Spaces:
Sleeping
Sleeping
| import os | |
| # Doit être défini AVANT tout import de tensorflow/keras/protobuf | |
| os.environ.setdefault('PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION', 'python') | |
| os.environ.setdefault('TF_CPP_MIN_LOG_LEVEL', '3') | |
| import dash | |
| from dash import dcc, html, Input, Output, State | |
| import yfinance as yf | |
| import pandas as pd | |
| from flask import jsonify, request as flask_request, Response, stream_with_context | |
| import requests | |
| from bs4 import BeautifulSoup | |
| from services.database import init_db | |
| import logging | |
| from concurrent.futures import ThreadPoolExecutor, as_completed | |
| # Charger les variables d'environnement depuis .env (chemin absolu) | |
| import os as _os | |
| try: | |
| from dotenv import load_dotenv | |
| _env_path = _os.path.join(_os.path.dirname(_os.path.abspath(__file__)), ".env") | |
| load_dotenv(dotenv_path=_env_path, override=True) | |
| except ImportError: | |
| pass # python-dotenv non installé, les env vars système seront utilisées | |
| # Supprimer les logs trop bavards | |
| logging.getLogger('werkzeug').setLevel(logging.ERROR) | |
| # === INIT DATABASE === | |
| init_db() | |
| # === INIT DASH === | |
| app = dash.Dash( | |
| __name__, | |
| use_pages=True, | |
| suppress_callback_exceptions=True, | |
| external_stylesheets=[ | |
| "https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css" | |
| ] | |
| ) | |
| app.title = "ENSIM - Predictions Boursieres" | |
| # Désactiver le cache navigateur pour les assets (force rechargement chatbot.js à chaque fois) | |
| app.server.config['SEND_FILE_MAX_AGE_DEFAULT'] = 0 | |
| # === TICKERS === | |
| TICKERS = { | |
| "BTC-USD": "BTC/USD", | |
| "ETH-USD": "ETH/USD", | |
| "^IXIC": "NASDAQ", | |
| "AAPL": "AAPL", | |
| "GOOGL": "GOOGL" | |
| } | |
| def fetch_ticker_data(): | |
| data = [] | |
| for symbol, label in TICKERS.items(): | |
| try: | |
| stock = yf.Ticker(symbol) | |
| hist = stock.history(period="1d", interval="1m") | |
| if len(hist) >= 2: | |
| current = hist['Close'].iloc[-1] | |
| prev = hist['Close'].iloc[-2] | |
| change = (current - prev) / prev * 100 | |
| change_str = f"up {change:.2f}%" if change > 0 else f"down {abs(change):.2f}%" | |
| change_class = "up" if change > 0 else "down" | |
| data.append({ | |
| "label": label, | |
| "value": f"{current:,.2f}", | |
| "change": change_str, | |
| "class": change_class | |
| }) | |
| else: | |
| data.append({ | |
| "label": label, | |
| "value": "N/A", | |
| "change": "down 0.0%", | |
| "class": "down" | |
| }) | |
| except Exception as e: | |
| print(f"Erreur ticker {symbol}: {e}") | |
| data.append({ | |
| "label": label, | |
| "value": "ERR", | |
| "change": "down 0.0%", | |
| "class": "down" | |
| }) | |
| return data | |
| # === LAYOUT === | |
| app.layout = html.Div([ | |
| # Background (z-index négatif) | |
| html.Div(className="trade-bg"), | |
| html.Div(className="grid-lines"), | |
| html.Div([html.Div(className="particle") for _ in range(40)]), | |
| # === COMPOSANTS CORE === | |
| dcc.Location(id="url", refresh=False), | |
| dcc.Store(id="session-store", storage_type="session"), | |
| dcc.Store(id="demo-seen-store", storage_type="session"), | |
| dcc.Interval(id="interval-component", interval=5*60*1000, n_intervals=0), | |
| # === TICKER EN HAUT (z-index: 3000) === | |
| html.Div(id="ticker-container"), | |
| # === NAVBAR (z-index: 2000) === | |
| html.Div(id="navbar-container"), | |
| # === PAGE CONTENT === | |
| dash.page_container, | |
| # === CHATBOT WIDGET === | |
| html.Button( | |
| html.I(className="fa-solid fa-robot"), | |
| id="chatbot-toggle", | |
| title="Assistant IA" | |
| ), | |
| html.Div( | |
| id="chatbot-panel", | |
| className="chatbot-hidden", | |
| children=[ | |
| # ── Header ────────────────────────────────────────── | |
| html.Div(id="chatbot-header", children=[ | |
| html.Div(className="chatbot-header-left", children=[ | |
| html.Div(className="chatbot-avatar", children=[ | |
| html.I(className="fa-solid fa-robot") | |
| ]), | |
| html.Div(className="chatbot-header-info", children=[ | |
| html.Span("Conseiller IA", className="chatbot-header-name"), | |
| html.Div(className="chatbot-header-status", children=[ | |
| html.Span(className="chatbot-status-dot"), | |
| html.Span("En ligne"), | |
| ]), | |
| ]), | |
| ]), | |
| html.Div(className="chatbot-header-actions", children=[ | |
| html.Button( | |
| html.I(className="fa-solid fa-clock-rotate-left"), | |
| id="chatbot-history-btn", | |
| title="Historique des conversations" | |
| ), | |
| html.Button( | |
| html.I(className="fa-solid fa-pen-to-square"), | |
| id="chatbot-new-btn", | |
| title="Nouvelle conversation" | |
| ), | |
| html.Button( | |
| html.I(className="fa-solid fa-xmark"), | |
| id="chatbot-close", | |
| title="Fermer" | |
| ), | |
| ]), | |
| ]), | |
| # ── Vue Chat (par défaut) ──────────────────────────── | |
| html.Div(id="chatbot-chat-view", children=[ | |
| html.Div(id="chatbot-messages", children=[ | |
| html.Div( | |
| id="chatbot-welcome", | |
| className="chatbot-msg chatbot-msg-bot", | |
| children=[ | |
| html.Div(className="chatbot-bubble", children=[ | |
| html.Strong("Bonjour !"), | |
| html.Br(), | |
| "Je suis votre assistant IA. Posez-moi vos questions sur la plateforme, les modèles de prédiction ou les actions disponibles." | |
| ]) | |
| ] | |
| ) | |
| ]), | |
| html.Div(id="chatbot-input-row", children=[ | |
| html.Button( | |
| html.I(className="fa-solid fa-microphone"), | |
| id="chatbot-mic", | |
| title="Parler" | |
| ), | |
| html.Textarea( | |
| id="chatbot-input", | |
| placeholder="Posez votre question ou parlez…", | |
| rows=1 | |
| ), | |
| html.Button( | |
| html.I(className="fa-solid fa-paper-plane"), | |
| id="chatbot-send", | |
| title="Envoyer" | |
| ), | |
| ]), | |
| ]), | |
| # ── Vue Historique (cachée par défaut) ─────────────── | |
| html.Div(id="chatbot-history-view", className="cb-view-hidden", children=[ | |
| html.Div(id="chatbot-history-header", children=[ | |
| html.Span("Conversations", className="cb-hist-title"), | |
| html.Button( | |
| [html.I(className="fa-solid fa-plus"), " Nouvelle"], | |
| id="chatbot-new-btn2", | |
| className="cb-new-btn" | |
| ), | |
| ]), | |
| html.Div(id="chatbot-history-list"), | |
| ]), | |
| ] | |
| ), | |
| ]) | |
| # === LISTE DES PAGES PROTÉGÉES === | |
| PROTECTED_PAGES = ["/actions_page", "/analysis", "/admin", "/mon-suivi", "/profil"] | |
| # === CALLBACK PRINCIPAL : NAVBAR + TICKER + PROTECTION === | |
| def update_layout(pathname, session): | |
| is_logged_in = session is not None | |
| is_home = pathname == "/" | |
| # === 1. CONSTRUCTION DE LA NAVBAR === | |
| def nav_cls(href): | |
| if href == "/": | |
| return "nav-link active" if pathname == "/" else "nav-link" | |
| return "nav-link active" if pathname.startswith(href) else "nav-link" | |
| nav_links = [ | |
| dcc.Link("Accueil", href="/", className=nav_cls("/")), | |
| dcc.Link("Témoignages", href="/temoignages", className=nav_cls("/temoignages")), | |
| ] | |
| if not is_logged_in: | |
| nav_links.append(dcc.Link("Demo", href="/demo", className=nav_cls("/demo"))) | |
| if is_logged_in: | |
| nav_links.extend([ | |
| dcc.Link("Marchés", href="/actions_page", className=nav_cls("/actions_page")), | |
| dcc.Link("Analyse", href="/analysis", className=nav_cls("/analysis")), | |
| dcc.Link("Mon Suivi", href="/mon-suivi", className=nav_cls("/mon-suivi")), | |
| dcc.Link("Mon Profil", href="/profil", className=nav_cls("/profil")), | |
| ]) | |
| if session and session.get("is_admin"): | |
| print(f"[ADMIN] Lien admin ajouté pour {session.get('email')}") | |
| nav_links.append(dcc.Link("Admin", href="/admin", className=nav_cls("/admin"))) | |
| nav_links.append(html.Button("Déconnexion", id="logout-btn", className="nav-link")) | |
| else: | |
| nav_links.extend([ | |
| dcc.Link("Connexion", href="/login", className=nav_cls("/login")), | |
| dcc.Link("Inscription", href="/signup", className=nav_cls("/signup")), | |
| ]) | |
| # Détermine la classe CSS de la navbar | |
| navbar_class = "navbar with-ticker" if is_home and is_logged_in else "navbar no-ticker" | |
| navbar = html.Div(className=navbar_class, children=[ | |
| html.Div(className="navbar-left", children=[ | |
| html.Img(src="/assets/logo.png", className="logo", alt="Logo") | |
| ]), | |
| html.Div(className="nav-links", children=nav_links) | |
| ]) | |
| # === 2. TICKER (seulement sur home ET connecté) === | |
| if is_home and is_logged_in: | |
| ticker = html.Div(className="ticker-wrap", children=[ | |
| html.Div(id="ticker-inner", className="ticker-inner") | |
| ]) | |
| interval_disabled = False | |
| else: | |
| ticker = "" | |
| interval_disabled = True | |
| return navbar, ticker, interval_disabled | |
| # === CALLBACK TICKER === | |
| def update_ticker(n): | |
| data = fetch_ticker_data() | |
| items = [ | |
| html.Div(className="ticker-item", children=[ | |
| html.Span(d["label"]), | |
| html.Span(className="ticker-value", children=d["value"]), | |
| html.Span(className=f"ticker-change {d['class']}", children=d["change"]) | |
| ]) | |
| for d in data | |
| ] | |
| ticker_set = html.Div(className="ticker-set", children=items) | |
| return [ticker_set, ticker_set] | |
| # === CALLBACK LOGOUT === | |
| # On retourne null → Dash écrit null dans sessionStorage lui-même | |
| # Puis setTimeout donne le temps à Dash de finir avant de recharger la page | |
| app.clientside_callback( | |
| """ | |
| function(n_clicks) { | |
| if (n_clicks && n_clicks > 0) { | |
| setTimeout(function() { window.location.href = '/'; }, 300); | |
| return null; | |
| } | |
| return window.dash_clientside.no_update; | |
| } | |
| """, | |
| Output("session-store", "data", allow_duplicate=True), | |
| Input("logout-btn", "n_clicks"), | |
| prevent_initial_call=True | |
| ) | |
| # === CALLBACK REDIRECTION PAGES PROTÉGÉES === | |
| def redirect_if_not_logged(pathname, session, demo_seen): | |
| # Pages protégées → login si non connecté | |
| if pathname in PROTECTED_PAGES and session is None: | |
| return "/login", dash.no_update | |
| # Première visite sur "/" sans compte → démo (une seule fois par session) | |
| if pathname == "/" and session is None and not demo_seen: | |
| return "/demo", True | |
| return dash.no_update, dash.no_update | |
| # === API OHLCV (yfinance → lightweight-charts) === | |
| _ALLOWED = {'AAPL', 'AMZN', 'BTC-USD', 'GOOGL', 'META', 'MSFT', 'NVDA', 'TSLA'} | |
| def api_ohlcv(symbol): | |
| if symbol not in _ALLOWED: | |
| return jsonify({'error': 'Symbol not allowed'}), 400 | |
| try: | |
| h = yf.Ticker(symbol).history(period='2y', interval='1d') | |
| if h.empty: | |
| return jsonify({'error': 'No data'}), 404 | |
| try: | |
| h.index = pd.to_datetime(h.index).tz_localize(None) | |
| except Exception: | |
| h.index = pd.to_datetime(h.index).tz_convert(None) | |
| candles = [ | |
| { | |
| 'time': str(idx.date()), | |
| 'open': round(float(row['Open']), 4), | |
| 'high': round(float(row['High']), 4), | |
| 'low': round(float(row['Low']), 4), | |
| 'close': round(float(row['Close']), 4), | |
| 'vol': int(row['Volume']), | |
| } | |
| for idx, row in h.iterrows() | |
| ] | |
| return jsonify({'symbol': symbol, 'candles': candles}) | |
| except Exception as e: | |
| return jsonify({'error': str(e)}), 500 | |
| # === API PREVIEW (Open Graph tags for article hover card) === | |
| _preview_cache = {} | |
| def api_preview(): | |
| url = flask_request.args.get('url', '') | |
| if not url or not url.startswith(('http://', 'https://')): | |
| return jsonify({'error': 'Invalid URL'}), 400 | |
| if url in _preview_cache: | |
| return jsonify(_preview_cache[url]) | |
| try: | |
| headers = {'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'} | |
| resp = requests.get(url, timeout=5, headers=headers, allow_redirects=True) | |
| soup = BeautifulSoup(resp.text, 'html.parser') | |
| def og(prop): | |
| tag = soup.find('meta', property=f'og:{prop}') | |
| return tag['content'].strip() if tag and tag.get('content') else None | |
| def meta_name(name): | |
| tag = soup.find('meta', attrs={'name': name}) | |
| return tag['content'].strip() if tag and tag.get('content') else None | |
| title = (og('title') or meta_name('title') or | |
| (soup.title.string.strip() if soup.title else '') or '') | |
| image = og('image') or meta_name('twitter:image') or '' | |
| description = og('description') or meta_name('description') or '' | |
| site_name = og('site_name') or '' | |
| result = { | |
| 'title': title[:200], | |
| 'image': image, | |
| 'description': description[:300], | |
| 'site_name': site_name, | |
| } | |
| if len(_preview_cache) < 500: | |
| _preview_cache[url] = result | |
| resp_json = jsonify(result) | |
| resp_json.headers['Cache-Control'] = 'max-age=3600' | |
| return resp_json | |
| except Exception as e: | |
| return jsonify({'error': str(e)}), 500 | |
| # === API PRICES (parallel fetch for all symbols) === | |
| def _fetch_price(sym): | |
| try: | |
| h = yf.Ticker(sym).history(period='5d', interval='1d') | |
| if h.empty: | |
| return sym, None | |
| try: | |
| h.index = pd.to_datetime(h.index).tz_localize(None) | |
| except Exception: | |
| h.index = pd.to_datetime(h.index).tz_convert(None) | |
| price = float(h['Close'].iloc[-1]) | |
| prev = float(h['Close'].iloc[-2]) if len(h) >= 2 else price | |
| return sym, {'price': round(price, 2), 'pct': round((price - prev) / prev * 100, 2)} | |
| except Exception: | |
| return sym, None | |
| def api_prices(): | |
| results = {} | |
| with ThreadPoolExecutor(max_workers=8) as executor: | |
| futures = {executor.submit(_fetch_price, sym): sym for sym in _ALLOWED} | |
| for future in as_completed(futures): | |
| sym, data = future.result() | |
| results[sym] = data | |
| return jsonify(results) | |
| # === API TRANSCRIPTION VOCALE (Groq Whisper) === | |
| def api_transcribe(): | |
| from groq import Groq | |
| audio_file = flask_request.files.get('audio') | |
| if not audio_file: | |
| return jsonify({'error': 'Aucun fichier audio'}), 400 | |
| api_key = os.environ.get("GROQ_API_KEY") | |
| if not api_key: | |
| return jsonify({'error': 'Clé API non configurée'}), 500 | |
| try: | |
| client = Groq(api_key=api_key) | |
| audio_bytes = audio_file.read() | |
| transcription = client.audio.transcriptions.create( | |
| file=("audio.webm", audio_bytes), | |
| model="whisper-large-v3-turbo", | |
| language="fr", | |
| response_format="text", | |
| ) | |
| return jsonify({'text': str(transcription).strip()}) | |
| except Exception as e: | |
| return jsonify({'error': str(e)}), 500 | |
| # === API INVESTISSEMENT CHATBOT === | |
| def api_chat_invest(): | |
| from services.database import get_connection | |
| data = flask_request.get_json(force=True, silent=True) or {} | |
| email = data.get('email', '').strip() | |
| symbol = data.get('symbol', '').strip().upper() | |
| model = data.get('model', 'sentiment').strip().lower() | |
| action = data.get('action', 'ACHETER').strip().upper() | |
| amount = float(data.get('amount', 0) or 0) | |
| print(f"[chat-invest] REÇU → email={email!r} symbol={symbol!r} model={model!r} action={action!r} amount={amount}") | |
| ALLOWED_SYMBOLS = {'AAPL','MSFT','TSLA','NVDA','GOOGL','AMZN','META','BTC-USD'} | |
| ALLOWED_MODELS = {'sentiment','lstm','transformer'} | |
| if not email or symbol not in ALLOWED_SYMBOLS or model not in ALLOWED_MODELS or amount <= 0: | |
| print(f"[chat-invest] VALIDATION ÉCHOUÉE — email={email!r} symbol={symbol!r} model={model!r} amount={amount}") | |
| return jsonify({'error': 'Données invalides', 'debug': {'email': email, 'symbol': symbol, 'model': model, 'amount': amount}}), 400 | |
| try: | |
| # Prix actuel | |
| h = yf.Ticker(symbol).history(period='2d', interval='1d') | |
| price = float(h['Close'].iloc[-1]) if not h.empty else 0.0 | |
| # Mapper action → directions compatibles avec Mon Suivi | |
| pred_dir = 'up' if action == 'ACHETER' else 'down' | |
| actual_dir = 'up' if action == 'ACHETER' else 'down' | |
| conn = get_connection() | |
| conn.execute(""" | |
| INSERT INTO user_trades | |
| (user_email, symbol, entry_price, quantity, | |
| prediction_direction, actual_direction, pnl, pnl_percentage, | |
| model_type, status) | |
| VALUES (?, ?, ?, ?, ?, ?, 0, 0, ?, 'open') | |
| """, (email, symbol, price, amount, pred_dir, actual_dir, model)) | |
| conn.commit() | |
| conn.close() | |
| print(f"[chat-invest] Trade sauvegardé: {email} | {symbol} | {model} | {action} | {amount}€ à {price}$") | |
| return jsonify({ | |
| 'success': True, | |
| 'symbol': symbol, | |
| 'price': round(price, 2), | |
| 'amount': amount, | |
| 'action': action, | |
| 'model': model, | |
| }) | |
| except Exception as e: | |
| print(f"[chat-invest] Erreur: {e}") | |
| return jsonify({'error': str(e)}), 500 | |
| # === API COMPARAISON MODÈLES PAR ACTION === | |
| def api_model_compare(): | |
| from services.database import get_connection | |
| email = flask_request.args.get('email', '').strip() | |
| symbol = flask_request.args.get('symbol', '').strip().upper() | |
| if not email or not symbol: | |
| return jsonify({'error': 'Missing params'}), 400 | |
| MODEL_LABELS = {'lstm': 'LSTM', 'transformer': 'Transformer', 'sentiment': 'Actualités'} | |
| try: | |
| conn = get_connection() | |
| # Résumé par modèle | |
| rows = conn.execute(""" | |
| SELECT model_type, | |
| COUNT(*) as trades, | |
| SUM(CASE WHEN pnl > 0 THEN 1 ELSE 0 END) as wins, | |
| COALESCE(SUM(pnl), 0) as total_pnl, | |
| COALESCE(AVG(pnl_percentage), 0) as avg_pct | |
| FROM user_trades | |
| WHERE user_email = ? AND symbol = ? AND status = 'closed' | |
| GROUP BY model_type | |
| """, (email, symbol)).fetchall() | |
| models = {} | |
| for model_type, trades, wins, total_pnl, avg_pct in rows: | |
| # Derniers trades de ce modèle | |
| recent = conn.execute(""" | |
| SELECT entry_date, prediction_direction, quantity, pnl, pnl_percentage | |
| FROM user_trades | |
| WHERE user_email = ? AND symbol = ? AND model_type = ? AND status = 'closed' | |
| ORDER BY entry_date DESC LIMIT 5 | |
| """, (email, symbol, model_type)).fetchall() | |
| models[model_type] = { | |
| 'label': MODEL_LABELS.get(model_type, model_type), | |
| 'trades': trades, | |
| 'wins': wins, | |
| 'losses': trades - wins, | |
| 'win_rate': round(wins / trades * 100, 1) if trades > 0 else 0.0, | |
| 'total_pnl': round(total_pnl, 2), | |
| 'avg_pct': round(avg_pct, 2), | |
| 'recent': [ | |
| { | |
| 'date': str(r[0])[:10], | |
| 'signal': r[1], | |
| 'amount': r[2] or 0, | |
| 'pnl': round(r[3] or 0, 2), | |
| 'pnl_pct': round(r[4] or 0, 2), | |
| } | |
| for r in recent | |
| ], | |
| } | |
| conn.close() | |
| best_model = None | |
| if models: | |
| best_model = max(models.items(), key=lambda x: x[1]['total_pnl'])[0] | |
| return jsonify({'symbol': symbol, 'models': models, 'best_model': best_model}) | |
| except Exception as e: | |
| print(f"[model-compare] Erreur: {e}") | |
| return jsonify({'error': str(e)}), 500 | |
| # === API COMPARAISON MODÈLES — SIMULATION BACKTESTS === | |
| def api_backtest_compare(): | |
| symbol = flask_request.args.get('symbol', '').strip().upper() | |
| if not symbol: | |
| return jsonify({'error': 'Missing symbol'}), 400 | |
| START = 500.0 | |
| DAYS = 180 | |
| def _run_lstm(): | |
| try: | |
| from pages.mon_suivi import _run_backtest_lstm | |
| return 'lstm', _run_backtest_lstm(symbol, start_amount=START, days=DAYS) | |
| except Exception as e: | |
| print(f"[backtest-compare] lstm {symbol}: {e}") | |
| return 'lstm', None | |
| def _run_transformer(): | |
| try: | |
| from services.transformer_service import predict_backtest as _bt | |
| return 'transformer', _bt(symbol, start_amount=START, days=DAYS) | |
| except Exception as e: | |
| print(f"[backtest-compare] transformer {symbol}: {e}") | |
| return 'transformer', None | |
| def _run_sentiment(): | |
| try: | |
| from pages.mon_suivi import _run_backtest | |
| return 'sentiment', _run_backtest(symbol, start_amount=START, days=DAYS) | |
| except Exception as e: | |
| print(f"[backtest-compare] sentiment {symbol}: {e}") | |
| return 'sentiment', None | |
| def _ds(arr, n=30): | |
| if not arr or len(arr) <= n: | |
| return [round(v, 2) for v in arr] | |
| step = len(arr) / n | |
| pts = [arr[int(i * step)] for i in range(n)] + [arr[-1]] | |
| return [round(v, 2) for v in pts] | |
| results = {} | |
| with ThreadPoolExecutor(max_workers=3) as ex: | |
| futures = [ex.submit(_run_lstm), ex.submit(_run_transformer), ex.submit(_run_sentiment)] | |
| for f in as_completed(futures): | |
| key, bt = f.result() | |
| if bt: | |
| results[key] = { | |
| 'label': {'lstm': 'LSTM', 'transformer': 'Transformer', 'sentiment': 'Actualités'}[key], | |
| 'start': START, | |
| 'final': bt['final_value'], | |
| 'return_pct': bt['total_return'], | |
| 'bh_final': round(bt['buy_hold'][-1], 2) if bt.get('buy_hold') else START, | |
| 'bh_return': bt['buy_hold_return'], | |
| 'n_trades': bt['n_trades'], | |
| 'wins': bt['wins'], | |
| 'losses': bt['losses'], | |
| 'win_rate': bt['win_rate'], | |
| 'start_date': bt['start_date'], | |
| 'series': _ds(bt.get('portfolio', [])), | |
| 'bh_series': _ds(bt.get('buy_hold', [])), | |
| } | |
| if not results: | |
| return jsonify({'error': f'Données insuffisantes pour {symbol}'}), 404 | |
| best = max(results.items(), key=lambda x: x[1]['return_pct'])[0] | |
| return jsonify({'symbol': symbol, 'models': results, 'best_model': best, 'start': START}) | |
| # === API DÉTECTION INVESTISSEMENT (post-streaming, fiable) === | |
| def api_chat_detect_invest(): | |
| """ | |
| Après le streaming, vérifie si la conversation contient un investissement à enregistrer. | |
| Retourne {"invest": {...}} ou {"invest": null}. | |
| Utilise un prompt dédié à l'extraction JSON — beaucoup plus fiable que le marqueur inline. | |
| """ | |
| import json as _json | |
| from groq import Groq | |
| data = flask_request.get_json(force=True, silent=True) or {} | |
| messages = data.get('messages', [])[-6:] # derniers messages seulement | |
| api_key = os.environ.get("GROQ_API_KEY") | |
| if not api_key: | |
| return jsonify({'invest': None}) | |
| EXTRACT_PROMPT = """Tu es un extracteur JSON. Analyse le dernier message de l'utilisateur et extrait les données d'un investissement à enregistrer. | |
| MAPPINGS OBLIGATOIRES — noms d'entreprises → symboles boursiers : | |
| Apple / AAPL → "AAPL" | |
| Microsoft / MSFT → "MSFT" | |
| Tesla / TSLA → "TSLA" | |
| Nvidia / NVDA → "NVDA" | |
| Google / Alphabet / GOOGL → "GOOGL" | |
| Amazon / AMZN → "AMZN" | |
| Meta / Facebook / META → "META" | |
| Bitcoin / BTC / crypto → "BTC-USD" | |
| MAPPINGS MODÈLES : | |
| LSTM / LST / lstm / réseau de neurones / prix → "lstm" | |
| Transformer / transformeur / hybride → "transformer" | |
| Sentiment / actualités / actualites / news → "sentiment" | |
| Si l'utilisateur dit qu'il a investi / mis de l'argent / suivi un conseil / acheté / vendu, extrais : | |
| {"symbol":"AAPL","model":"lstm","action":"ACHETER","amount":500} | |
| RÈGLES : | |
| - action = "ACHETER" si l'utilisateur a acheté ou suivi un conseil haussier, "VENDRE" sinon | |
| - amount = le montant en euros (nombre seul, ex: 500) | |
| - Réponds UNIQUEMENT avec le JSON brut, rien d'autre | |
| - Si une info manque ou si ce n'est PAS une intention d'enregistrement : réponds null""" | |
| try: | |
| client = Groq(api_key=api_key) | |
| resp = client.chat.completions.create( | |
| model="llama-3.3-70b-versatile", | |
| messages=[{"role": "system", "content": EXTRACT_PROMPT}] + messages, | |
| max_tokens=80, | |
| stream=False, | |
| temperature=0, | |
| ) | |
| raw = (resp.choices[0].message.content or "").strip() | |
| print(f"[detect-invest] raw='{raw}'") | |
| if raw == "null" or not raw.startswith("{"): | |
| return jsonify({"invest": None}) | |
| invest = _json.loads(raw) | |
| # Validation | |
| ALLOWED_SYMBOLS = {"AAPL","MSFT","TSLA","NVDA","GOOGL","AMZN","META","BTC-USD"} | |
| ALLOWED_MODELS = {"sentiment","lstm","transformer"} | |
| if (invest.get("symbol") in ALLOWED_SYMBOLS | |
| and invest.get("model","").lower() in ALLOWED_MODELS | |
| and invest.get("action") in {"ACHETER","VENDRE"} | |
| and float(invest.get("amount", 0) or 0) > 0): | |
| invest["model"] = invest["model"].lower() | |
| return jsonify({"invest": invest}) | |
| return jsonify({"invest": None}) | |
| except Exception as e: | |
| print(f"[detect-invest] erreur: {e}") | |
| return jsonify({"invest": None}) | |
| # === API CHATBOT (SSE streaming) === | |
| def api_chat(): | |
| from services.chat_service import stream_chat | |
| import json | |
| data = flask_request.get_json(force=True, silent=True) or {} | |
| messages = data.get('messages', []) | |
| # Validation basique | |
| if not isinstance(messages, list): | |
| return jsonify({'error': 'Invalid payload'}), 400 | |
| def generate(): | |
| try: | |
| for chunk in stream_chat(messages): | |
| payload = json.dumps({'text': chunk}, ensure_ascii=False) | |
| yield f"data: {payload}\n\n" | |
| except Exception as e: | |
| payload = json.dumps({'error': str(e)}, ensure_ascii=False) | |
| yield f"data: {payload}\n\n" | |
| finally: | |
| yield "data: [DONE]\n\n" | |
| return Response( | |
| stream_with_context(generate()), | |
| content_type='text/event-stream', | |
| headers={ | |
| 'Cache-Control': 'no-cache', | |
| 'X-Accel-Buffering': 'no', | |
| } | |
| ) | |
| # === LANCEMENT === | |
| if __name__ == "__main__": | |
| import os | |
| port = int(os.environ.get("PORT", 8050)) | |
| debug = os.environ.get("DEBUG", "true").lower() == "true" | |
| app.run(host="0.0.0.0", port=port, debug=debug) | |