feat: time-aware trade maturity classification

- Add _trade_maturity() helper: classifies trades by % of horizon elapsed
  (trop_tot <10%, debut 10-35%, mature 35-75%, fin_horizon >75%)
- Fix horizon_days fallback chain in log_trade_entries (default 30→90)
- journal.py: enrich each MTM trade with maturity dict + horizon_days
- reasoning.py: portfolio report segments trades by maturity; GPT-4o
  draws lessons only from matures (≥35% elapsed), never from trop_tot
- auto_cycle.py: 90d window, maturity-aware prompt with timing rules
- JournalDeBord.tsx: maturity badge with emoji, label, progress bar
  and day counter (Xj / Yj Z%) replacing plain days_held column

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
OpenSquared
2026-06-16 23:49:33 +02:00
parent 4bbcd7a3a6
commit 9075762dd5
5 changed files with 223 additions and 57 deletions

View File

@@ -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": "<alignement régime macro avec nos thèses ?>",
"winners_analysis": "<pourquoi ces trades ont marché — 2-3 phrases>",
"losers_analysis": "<pourquoi ces trades ont déçu — 2-3 phrases>",
"key_lessons": ["<leçon 1>", "<leçon 2>", "<leçon 3>"],
"blind_spots": "<ce que le scoring n'a pas bien capturé>",
"headline": "<1 phrase résumant la performance des trades matures>",
"regime_assessment": "<alignement régime macro avec nos thèses matures ?>",
"winners_analysis": "<pourquoi les trades matures gagnants ont marché — 2-3 phrases>",
"losers_analysis": "<pourquoi les trades matures perdants ont déçu — 2-3 phrases>",
"key_lessons": ["<leçon 1 tirée des matures>", "<leçon 2>", "<leçon 3>"],
"blind_spots": "<ce que le scoring n'a pas bien capturé sur les trades matures>",
"next_cycle_priorities": "<3 priorités pour le prochain cycle>",
"risk_watch": "<1-2 risques à surveiller>"
"risk_watch": "<1-2 risques à surveiller>",
"timing_note": "<observation sur les trades immatures — à surveiller mais pas à juger>"
}}"""
result = _chat(

View File

@@ -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(