diff --git a/backend/routers/journal.py b/backend/routers/journal.py index 57885cb..8e187fa 100644 --- a/backend/routers/journal.py +++ b/backend/routers/journal.py @@ -1,7 +1,7 @@ from fastapi import APIRouter from typing import Any, Dict, List import math -from services.database import get_macro_regime_history, get_geo_alert_history, get_trade_entry_prices, reset_journal_history, _fetch_live_prices +from services.database import get_macro_regime_history, get_geo_alert_history, get_trade_entry_prices, reset_journal_history, _fetch_live_prices, _trade_maturity def _sanitize(obj: Any) -> Any: @@ -66,12 +66,16 @@ def trade_mtm(days: int = 30): except Exception: pass + horizon = e.get("horizon_days") or 90 + maturity = _trade_maturity(days_held or 0, horizon) + result.append({ **e, "current_price": current_price, "pnl_pct": pnl_pct, "days_held": days_held, "direction": "bearish" if _is_bearish(e.get("strategy", "")) else "bullish", + "maturity": maturity, }) return _sanitize({"trades": result, "days": days, "tickers_fetched": len(current_prices)}) diff --git a/backend/routers/reasoning.py b/backend/routers/reasoning.py index be9a43c..948f651 100644 --- a/backend/routers/reasoning.py +++ b/backend/routers/reasoning.py @@ -21,6 +21,7 @@ from services.database import ( get_trade_entry_by_id, list_ai_reports, save_ai_report, + _trade_maturity, ) logger = logging.getLogger(__name__) @@ -234,36 +235,72 @@ def generate_portfolio_report(days: int = 90): from services.ai_analyzer import _chat - data = get_mtm_trades_with_traces(days=days, limit_movers=5) - winners = data["winners"] - losers = data["losers"] + from datetime import date as _date - winners_block = _trade_summary_block("TOP GAINS", winners) - losers_block = _trade_summary_block("TOP PERTES", losers) + data = get_mtm_trades_with_traces(days=days, limit_movers=10) + all_trades = data.get("all_trades", []) - avg_pnl = data.get("avg_pnl_pct") + # Classify every trade by maturity + def _enrich_maturity(t: dict) -> dict: + try: + dh = (_date.today() - _date.fromisoformat(t["entry_date"])).days + except Exception: + dh = 0 + return {**t, "days_held": dh, "maturity": _trade_maturity(dh, t.get("horizon_days") or 90)} + + enriched = [_enrich_maturity(t) for t in all_trades if t.get("pnl_pct") is not None] + trop_tot = [t for t in enriched if t["maturity"]["status"] == "trop_tot"] + en_cours = [t for t in enriched if t["maturity"]["status"] == "debut"] + matures = [t for t in enriched if t["maturity"]["status"] in ("mature", "fin_horizon")] + + # Sort by P&L for report + winners = sorted(matures, key=lambda t: t.get("pnl_pct", 0), reverse=True)[:5] + losers = sorted(matures, key=lambda t: t.get("pnl_pct", 0))[:5] + avg_pnl = (sum(t.get("pnl_pct", 0) for t in matures) / len(matures)) if matures else None avg_str = f"{avg_pnl:+.1f}%" if avg_pnl is not None else "N/A" + def _tline(t: dict) -> str: + mat = t["maturity"] + return (f" {t.get('underlying','?')} {t.get('strategy','?')} " + f"P&L={t.get('pnl_pct',0):+.1f}% [{mat['readable']}] " + f"score={t.get('score_at_entry','?')}") + + mature_wins = "\n".join(_tline(t) for t in winners) or " Aucun" + mature_loss = "\n".join(_tline(t) for t in losers) or " Aucun" + early_lines = "\n".join(_tline(t) for t in (trop_tot + en_cours)[:8]) or " Aucun" + prompt = f"""Tu es un stratège macro-géopolitique senior. Génère un rapport synthétique sur notre portefeuille options. +⚠️ RÈGLE FONDAMENTALE DE TIMING : +Nos trades sont des options de 30 à 90 jours. Un trade vieux de 3 jours n'apporte AUCUNE information sur sa performance finale. +Tu dois tirer des leçons UNIQUEMENT des trades MATURES (≥35% de l'horizon écoulé). +Les trades IMMATURES (< 35%) sont listés pour transparence — n'en tire aucune conclusion de performance. + ═══ STATISTIQUES GLOBALES ═══ -Période : {days} derniers jours -Trades total: {data['total_trades']} | Pricés: {data['priced_count']} | P&L moyen: {avg_str} +Période : {days}j | Total trades: {len(all_trades)} | Pricés: {data['priced_count']} +MATURES (signal fiable): {len(matures)} — P&L moyen: {avg_str} +IMMATURES (trop tôt): {len(trop_tot) + len(en_cours)} -═══ {winners_block} +═══ MATURES — TOP GAINS (signal fiable — tire des leçons ici) ═══ +{mature_wins} -═══ {losers_block} +═══ MATURES — TOP PERTES (signal fiable — tire des leçons ici) ═══ +{mature_loss} -Génère un rapport JSON structuré : +═══ EN COURS / IMMATURES (ne pas juger la performance) ═══ +{early_lines} + +Génère un rapport JSON structuré basé UNIQUEMENT sur les trades matures : {{ - "headline": "<1 phrase résumant la performance de la période>", - "regime_assessment": "", - "winners_analysis": "", - "losers_analysis": "", - "key_lessons": ["", "", ""], - "blind_spots": "", - "next_cycle_priorities": "<3 priorités concrètes pour améliorer les prochains cycles : patterns à surveiller, ajustements de scoring, régimes à anticiper>", - "risk_watch": "<1-2 risques macro-géopolitiques à surveiller de près qui pourraient impacter nos positions actuelles>" + "headline": "<1 phrase résumant la performance des trades matures>", + "regime_assessment": "", + "winners_analysis": "", + "losers_analysis": "", + "key_lessons": ["", "", ""], + "blind_spots": "", + "next_cycle_priorities": "<3 priorités concrètes pour les prochains cycles>", + "risk_watch": "<1-2 risques à surveiller>", + "timing_note": "" }}""" try: @@ -272,7 +309,7 @@ Génère un rapport JSON structuré : prompt, model="gpt-4o", json_mode=True, - max_tokens=1200, + max_tokens=1400, ) except Exception as e: logger.error(f"[PortfolioReport] GPT-4o call failed: {e}") @@ -282,9 +319,11 @@ Génère un rapport JSON structuré : raise HTTPException(503, "GPT-4o n'a pas retourné de réponse") stats = { - "total_trades": data["total_trades"], + "total_trades": len(all_trades), "priced_count": data["priced_count"], "avg_pnl_pct": avg_pnl, + "mature_count": len(matures), + "early_count": len(trop_tot) + len(en_cours), } report_id = save_ai_report( diff --git a/backend/services/auto_cycle.py b/backend/services/auto_cycle.py index 5ff8e50..f3b904b 100644 --- a/backend/services/auto_cycle.py +++ b/backend/services/auto_cycle.py @@ -503,60 +503,106 @@ def _auto_portfolio_snapshot(ai_key: str) -> None: os.environ["OPENAI_API_KEY"] = ai_key from services.database import ( - get_mtm_trades_with_traces, save_ai_report, get_latest_portfolio_lessons, + get_mtm_trades_with_traces, save_ai_report, _trade_maturity, ) + from datetime import date as _date - data = get_mtm_trades_with_traces(days=30, limit_movers=5) - winners = data.get("winners", []) - losers = data.get("losers", []) + data = get_mtm_trades_with_traces(days=90, limit_movers=10) + all_trades = data.get("all_trades", []) priced = data.get("priced_count", 0) - # Need at least 3 priced trades with actual movement to make analysis meaningful - meaningful = [ - t for t in (winners + losers) - if t.get("pnl_pct") is not None and abs(t.get("pnl_pct", 0)) > 0.05 - ] - if len(meaningful) < 3: + # Classify each trade by maturity + def _with_maturity(t: Dict) -> Dict: + try: + dh = (_date.today() - _date.fromisoformat(t["entry_date"])).days + except Exception: + dh = 0 + mat = _trade_maturity(dh, t.get("horizon_days") or 90) + return {**t, "days_held": dh, "maturity": mat} + + enriched = [_with_maturity(t) for t in all_trades if t.get("pnl_pct") is not None] + + trop_tot = [t for t in enriched if t["maturity"]["status"] == "trop_tot"] + en_cours = [t for t in enriched if t["maturity"]["status"] == "debut"] + matures = [t for t in enriched if t["maturity"]["status"] in ("mature", "fin_horizon")] + + # Only learn lessons from mature trades (weight > 0.9) + meaningful_mature = [t for t in matures if abs(t.get("pnl_pct", 0)) > 0.1] + + if len(meaningful_mature) < 2: logger.info( - f"[AutoSnapshot] Skipping GPT-4o report: only {len(meaningful)} trades " - f"with meaningful P&L movement (need ≥ 3)" + f"[AutoSnapshot] Skipping GPT-4o report: only {len(meaningful_mature)} mature trades " + f"with meaningful P&L (need ≥ 2). " + f"Immatures={len(trop_tot)}, en cours={len(en_cours)}, matures={len(matures)}" ) return - avg_pnl = data.get("avg_pnl_pct") + # Sort matures by P&L for the report + winners = sorted(matures, key=lambda t: t.get("pnl_pct", 0), reverse=True)[:5] + losers = sorted(matures, key=lambda t: t.get("pnl_pct", 0))[:5] + avg_pnl = (sum(t.get("pnl_pct", 0) for t in matures) / len(matures)) if matures else None + stats = { - "total_trades": data["total_trades"], + "total_trades": len(all_trades), "priced_count": priced, "avg_pnl_pct": avg_pnl, + "mature_count": len(matures), + "early_count": len(trop_tot) + len(en_cours), } - # Build prompt (reuse same logic as reasoning.py generate endpoint) - from routers.reasoning import _trade_summary_block, _bucket_summary, _rankings_summary + # Build maturity context blocks for the prompt + def _trade_line(t: Dict) -> str: + mat = t["maturity"] + return ( + f" {t.get('underlying','?')} {t.get('strategy','?')} " + f"P&L={t.get('pnl_pct',0):+.1f}% " + f"[{mat['readable']}] " + f"score_entrée={t.get('score_at_entry','?')}" + ) + + mature_wins_block = "\n".join(_trade_line(t) for t in winners) or " Aucun" + mature_losses_block = "\n".join(_trade_line(t) for t in losers) or " Aucun" + early_block = "\n".join( + f" {t.get('underlying','?')} {t.get('strategy','?')} " + f"P&L={t.get('pnl_pct',0):+.1f}% [{t['maturity']['readable']}]" + for t in (trop_tot + en_cours)[:6] + ) or " Aucun" + + avg_str = f"{avg_pnl:+.1f}%" if avg_pnl is not None else "N/A" from services.ai_analyzer import _chat - winners_block = _trade_summary_block("TOP GAINS", winners) - losers_block = _trade_summary_block("TOP PERTES", losers) - avg_str = f"{avg_pnl:+.1f}%" if avg_pnl is not None else "N/A" + prompt = f"""Tu es un stratège macro-géopolitique senior. Rapport post-cycle automatique. - prompt = f"""Tu es un stratège macro-géopolitique senior. Rapport synthétique post-cycle automatique. +⚠️ RÈGLE FONDAMENTALE DE TIMING : +Les options ont des horizons de 30-90 jours. Un trade de 3 jours ne dit RIEN sur sa performance finale. +Tu dois UNIQUEMENT tirer des leçons des trades MATURES (≥35% de l'horizon écoulé). +Les trades immatures sont listés pour information seulement — n'en tire AUCUNE conclusion de performance. ═══ STATISTIQUES GLOBALES ═══ -Période : 30 derniers jours | Trades total: {data['total_trades']} | Pricés: {priced} | P&L moyen: {avg_str} +Période analysée : 90 jours | Trades total: {len(all_trades)} | Pricés: {priced} +Trades MATURES (signal fiable): {len(matures)} | P&L moyen matures: {avg_str} +Trades IMMATURES (trop tôt pour juger): {len(trop_tot) + len(en_cours)} -═══ {winners_block} +═══ MATURES — TOP GAINS (signal fiable) ═══ +{mature_wins_block} -═══ {losers_block} +═══ MATURES — TOP PERTES (signal fiable) ═══ +{mature_losses_block} -Génère un rapport JSON : +═══ EN COURS — NE PAS ÉVALUER (trop tôt) ═══ +{early_block} + +Génère un rapport JSON basé UNIQUEMENT sur les trades matures : {{ - "headline": "<1 phrase résumant la performance>", - "regime_assessment": "", - "winners_analysis": "", - "losers_analysis": "", - "key_lessons": ["", "", ""], - "blind_spots": "", + "headline": "<1 phrase résumant la performance des trades matures>", + "regime_assessment": "", + "winners_analysis": "", + "losers_analysis": "", + "key_lessons": ["", "", ""], + "blind_spots": "", "next_cycle_priorities": "<3 priorités pour le prochain cycle>", - "risk_watch": "<1-2 risques à surveiller>" + "risk_watch": "<1-2 risques à surveiller>", + "timing_note": "" }}""" result = _chat( diff --git a/backend/services/database.py b/backend/services/database.py index c1b20d4..f1d357a 100644 --- a/backend/services/database.py +++ b/backend/services/database.py @@ -889,7 +889,13 @@ def log_trade_entries(run_id: str, scored_patterns: List[Dict[str, Any]], quotes ticker_key = underlying.upper() entry_price = price_map.get(ticker_key) - horizon = int(trade.get("horizon_days") or sp.get("horizon_days") or 30) + horizon = int( + trade.get("horizon_days") or + sp.get("horizon_days") or + sp.get("recommended_trade", {}).get("expiry_days") or + _orig.get("horizon_days") or + 90 + ) existing_row = conn.execute( "SELECT id FROM trade_entry_prices WHERE pattern_id=? AND underlying=? AND strategy=?", @@ -975,6 +981,48 @@ def get_cycle_run(run_id: str) -> Optional[Dict[str, Any]]: return dict(row) if row else None +def _trade_maturity(days_held: int, horizon_days: int) -> Dict[str, Any]: + """ + Classify a trade's maturity based on elapsed time vs planned horizon. + Returns status, label, weight (0-1 for lesson extraction), and color hint. + + Thresholds (percentage of horizon elapsed): + < 10% → trop_tot : P&L is pure noise, never evaluate + 10-35% → debut : early signal, very low weight + 35-75% → mature : reliable signal, full weight + > 75% → fin_horizon : approaching expiry, full weight + watch flag + """ + h = max(horizon_days or 90, 1) + d = max(days_held or 0, 0) + ratio = d / h + pct = round(ratio * 100, 1) + + if ratio < 0.10: + return { + "status": "trop_tot", "label": "Trop tôt", "emoji": "🕐", + "weight": 0.0, "color": "slate", "ratio_pct": pct, + "readable": f"{d}j / {h}j ({pct}% écoulé — bruit statistique)", + } + elif ratio < 0.35: + return { + "status": "debut", "label": "Début", "emoji": "📊", + "weight": 0.25, "color": "yellow", "ratio_pct": pct, + "readable": f"{d}j / {h}j ({pct}% écoulé — signal précoce)", + } + elif ratio < 0.75: + return { + "status": "mature", "label": "Signal fiable", "emoji": "✅", + "weight": 1.0, "color": "emerald", "ratio_pct": pct, + "readable": f"{d}j / {h}j ({pct}% écoulé — signal fiable)", + } + else: + return { + "status": "fin_horizon", "label": "Fin d'horizon", "emoji": "⏰", + "weight": 1.0, "color": "orange", "ratio_pct": pct, + "readable": f"{d}j / {h}j ({pct}% écoulé — surveiller de près)", + } + + def get_trade_entry_prices(days: int = 30) -> List[Dict[str, Any]]: conn = get_conn() rows = conn.execute( diff --git a/frontend/src/pages/JournalDeBord.tsx b/frontend/src/pages/JournalDeBord.tsx index 072e5cc..940dcb3 100644 --- a/frontend/src/pages/JournalDeBord.tsx +++ b/frontend/src/pages/JournalDeBord.tsx @@ -394,7 +394,7 @@ function TradeMtmSection({ days }: { days: number }) { Date Prix entrée Prix actuel - J + Maturité P&L th. @@ -451,8 +451,37 @@ function TradeMtmSection({ days }: { days: number }) { {t.current_price != null ? t.current_price.toFixed(2) : '—'} - - {t.days_held != null ? t.days_held : '—'} + + {t.maturity ? ( +
+ + {t.maturity.emoji} {t.maturity.label} + + + {t.days_held ?? 0}j / {t.horizon_days ?? 90}j ({t.maturity.ratio_pct}%) + +
+
+
+
+ ) : ( + + {t.days_held != null ? `${t.days_held}j` : '—'} + + )}