diff --git a/backend/routers/reports.py b/backend/routers/reports.py index ecfb8a8..caada2c 100644 --- a/backend/routers/reports.py +++ b/backend/routers/reports.py @@ -1,5 +1,8 @@ from fastapi import APIRouter, HTTPException -from services.database import get_cycle_reports, get_cycle_report, get_latest_cycle_report +from services.database import ( + get_cycle_reports, get_cycle_report, get_latest_cycle_report, + get_trade_assessments, get_latest_trade_assessments, +) router = APIRouter(prefix="/api/reports", tags=["reports"]) @@ -23,3 +26,15 @@ def cycle_report_detail(run_id: str): if not report: raise HTTPException(404, "Rapport de cycle introuvable") return report + + +@router.get("/assessments/latest") +def assessments_latest(limit: int = 20): + """Latest options technical assessments (one per trade, for Journal badges).""" + return {"assessments": get_latest_trade_assessments(limit)} + + +@router.get("/assessments/{run_id}") +def assessments_by_run(run_id: str): + """Options technical assessments for a specific cycle run.""" + return {"assessments": get_trade_assessments(run_id)} diff --git a/backend/services/ai_analyzer.py b/backend/services/ai_analyzer.py index 70bb0bd..7a9cb57 100644 --- a/backend/services/ai_analyzer.py +++ b/backend/services/ai_analyzer.py @@ -818,6 +818,7 @@ def suggest_patterns_from_market_context( geo_score: Optional[Dict] = None, portfolio_lessons: Optional[Dict] = None, reliability_map: Optional[Dict] = None, + iv_context: str = "", ) -> List[Dict]: """Ask GPT-4o to propose new patterns based on current geo/market + macro regime context.""" top_news = sorted(news, key=lambda x: x.get("impact_score", 0), reverse=True)[:12] @@ -923,8 +924,32 @@ Leçons clés : + "\n⚠️ Inspire-toi des patterns fiables. Évite de reproduire les patterns en bas de liste.\n" ) - user = f"""Tu es un stratège géopolitique et financier senior. -{macro_block}{geo_block}{lessons_block}{reliability_block} + iv_block = "" + if iv_context: + iv_block = f""" +{iv_context} + +## ⚠️ RÈGLES STRICTES IV → STRATÉGIE (OBLIGATOIRE pour chaque suggested_trade) +Le choix de stratégie doit tenir compte du COÛT de la volatilité implicite (IVR = IV Rank 52 semaines): + +| IVR | Stratégie AUTORISÉE | Stratégie INTERDITE | +|--------------|----------------------------------------------------------|------------------------------| +| < 30% (cheap)| Long Call, Long Put, Long Straddle | Iron Condor, Short Strangle | +| 30–60% (mod) | Bull Call Spread, Bear Put Spread | Long Straddle, Strangle naked| +| 60–80% (cher)| Bull/Bear Spread, Cash-Secured Put | Long Call/Put naked | +| > 80% (pic) | Iron Condor, Short Strangle, Covered Call, Cash-Secured Put | TOUTE option long naked | + +Règles supplémentaires: +- Skew put élevé (> 5 pts) → Long Put trop cher → préférer Bear Put Spread +- Term structure backwardation → ne pas vendre la vol (vendeur piégé) +- Si IVR inconnu → utiliser spreads débiteurs par défaut (neutre au coût de vol) +- Long Straddle UNIQUEMENT si IVR < 25% ET catalyseur clairement identifié + +⚠️ INTERDICTION: Ne jamais suggérer "Long Call" ou "Long Put" (naked) si IVR > 60% sur ce ticker. +""" + + user = f"""Tu es un stratège géopolitique et financier senior, expert en options. +{macro_block}{geo_block}{lessons_block}{reliability_block}{iv_block} ## Actualités géopolitiques du moment (triées par impact) {news_block} @@ -938,6 +963,8 @@ En analysant ce panorama, propose 4 à 6 NOUVEAUX patterns géopolitiques qui so Ne reprend pas les patterns classiques connus (Middle East Oil Spike, Gold Flight to Safety, etc.) — propose des patterns SPÉCIFIQUES au contexte actuel, cohérents avec le régime macro. +IMPORTANT — STRATÉGIE: Respecte ABSOLUMENT les règles IV→stratégie définies ci-dessus si l'IVR est connu. + IMPORTANT — CHAMP expected_move_pct: Ce champ représente le RENDEMENT OPTION ATTENDU en % (levier inclus), PAS le mouvement du sous-jacent. Raisonne: si le sous-jacent bouge de X% dans la direction attendue, combien gagne l'option en %? @@ -965,14 +992,14 @@ Retourne UNIQUEMENT ce JSON: "invalidation_probability": , "suggested_trades": [ {{ - "strategy": "", + "strategy": "", "underlying": "", - "rationale": "", + "rationale": "", "asset_class": "", "expected_move_pct": }}, {{ - "strategy": "", + "strategy": "", "underlying": "", "rationale": "", "asset_class": "", diff --git a/backend/services/auto_cycle.py b/backend/services/auto_cycle.py index 7d5276e..ae4eef7 100644 --- a/backend/services/auto_cycle.py +++ b/backend/services/auto_cycle.py @@ -300,6 +300,24 @@ def run_cycle_once(trigger: str = "auto") -> Dict[str, Any]: dominant = scenarios.get("dominant", "incertain") summary["dominant_regime"] = dominant + # ── Step 1.9: Pre-fetch IV context for strategy suggestion rules ────── + iv_context = "" + try: + from services.iv_engine import get_iv_context_for_prompt, IV_WATCHLIST + from services.database import get_mtm_trades_with_traces + _mtm_pre = get_mtm_trades_with_traces(days=90) + _trade_tickers_pre = list({ + (t.get("underlying") or "").upper() + for t in _mtm_pre.get("all_trades", []) + if t.get("underlying") + }) + _iv_tickers_pre = (_trade_tickers_pre + IV_WATCHLIST[:6])[:10] + iv_context = get_iv_context_for_prompt(_iv_tickers_pre) + if iv_context: + logger.info(f"[Cycle {run_id[:16]}] IV context pre-fetched for {len(_iv_tickers_pre)} tickers (suggestion step)") + except Exception as _e: + logger.warning(f"[Cycle] IV context pre-fetch failed (non-blocking): {_e}") + # ── Step 2: Suggest new patterns ────────────────────────────────────── logger.info(f"[Cycle {run_id[:16]}] Step 2: suggesting patterns") _reliability_map = {} @@ -317,6 +335,7 @@ def run_cycle_once(trigger: str = "auto") -> Dict[str, Any]: news, quotes, calendar, macro_regime=macro_regime, geo_score=geo_score_obj, portfolio_lessons=portfolio_lessons, reliability_map=_reliability_map or None, + iv_context=iv_context, ) except Exception as e: logger.warning(f"[Cycle] Suggestion step failed: {e}") @@ -389,8 +408,7 @@ def run_cycle_once(trigger: str = "auto") -> Dict[str, Any]: except Exception as _re: logger.warning(f"[Cycle] Risk cluster context failed (non-blocking): {_re}") - # ── Step 3.6: Collect IV context ───────────────────────────────────── - iv_context = "" + # ── Step 3.6: Refresh IV context (with full trade+watchlist scope) ────── try: from services.iv_engine import get_iv_context_for_prompt, IV_WATCHLIST # Collect underlyings from current trade journal + default watchlist @@ -521,8 +539,32 @@ def run_cycle_once(trigger: str = "auto") -> Dict[str, Any]: log_geo_alert(geo_score=geo_score_val, top_patterns=top_patterns_log, news_count=len(news), run_id=scoring_run_id) + _options_assessment = None log_trade_entries(run_id=scoring_run_id, scored_patterns=scored, quotes=quotes) + # ── Step 5.2: Options Technical Agent — validate newly logged trades ── + try: + from services.options_technical_agent import assess_logged_trades, save_assessments_to_db + _options_assessment = assess_logged_trades( + scoring_run_id=scoring_run_id, + scored=scored, + ai_key=ai_key, + ) + if _options_assessment and _options_assessment.get("assessments"): + save_assessments_to_db(_options_assessment["assessments"], scoring_run_id) + summary["options_assessment"] = { + "n_ok": _options_assessment.get("n_ok", 0), + "n_warn": _options_assessment.get("n_warn", 0), + "n_alert": _options_assessment.get("n_alert", 0), + "global_score": _options_assessment.get("global_score"), + } + logger.info( + f"[OptionsTech] Assessment done — OK={_options_assessment.get('n_ok')} " + f"WARN={_options_assessment.get('n_warn')} ALERT={_options_assessment.get('n_alert')}" + ) + except Exception as _ota: + logger.warning(f"[OptionsTech] Agent failed (non-blocking): {_ota}") + # ── Step 5.1: Portfolio monitor — conflict & concentration check ────── try: from services.portfolio_risk import analyze_simulation_portfolio @@ -651,6 +693,7 @@ def run_cycle_once(trigger: str = "auto") -> Dict[str, Any]: scoring_run_id=scoring_run_id, portfolio_monitor=summary.get("portfolio_monitor"), commentary=commentary, + options_assessment=_options_assessment, ) if _cycle_report: from services.database import save_cycle_report @@ -780,6 +823,7 @@ def _generate_cycle_report( scoring_run_id: str, portfolio_monitor: Optional[Dict], commentary: Optional[str], + options_assessment: Optional[Dict] = None, ) -> Optional[Dict]: """ Build the full cycle report dict: @@ -987,6 +1031,15 @@ Réponds en JSON avec ce schéma EXACT: "summary": (s.get("summary") or "")[:120], "key_catalyst": s.get("key_catalyst", "")} for s in sorted(scored, key=lambda x: -(x.get("score") or 0))[:5] ], + # Options Technical Agent assessment + "options_technical": { + "global_assessment": (options_assessment or {}).get("global_assessment", ""), + "global_score": (options_assessment or {}).get("global_score"), + "n_ok": (options_assessment or {}).get("n_ok", 0), + "n_warn": (options_assessment or {}).get("n_warn", 0), + "n_alert": (options_assessment or {}).get("n_alert", 0), + "assessments": (options_assessment or {}).get("assessments", []), + } if options_assessment is not None else None, } return report diff --git a/backend/services/database.py b/backend/services/database.py index d98c0ee..2d28183 100644 --- a/backend/services/database.py +++ b/backend/services/database.py @@ -461,6 +461,30 @@ def init_db(): except Exception: pass + c.execute("""CREATE TABLE IF NOT EXISTS options_trade_assessments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + run_id TEXT NOT NULL, + trade_id INTEGER, + ticker TEXT, + strategy TEXT, + assessed_at TEXT, + iv_rank REAL, + iv_current_pct REAL, + skew_pct REAL, + term_structure TEXT, + fit_score INTEGER, + verdict TEXT, + issues_json TEXT, + optimal_strategy TEXT, + analysis TEXT, + when_to_enter TEXT + )""") + try: + c.execute("CREATE INDEX IF NOT EXISTS idx_ota_run ON options_trade_assessments(run_id)") + c.execute("CREATE INDEX IF NOT EXISTS idx_ota_trade ON options_trade_assessments(trade_id)") + except Exception: + pass + try: c.execute("CREATE INDEX IF NOT EXISTS idx_kb_category ON knowledge_base(category, status)") c.execute("CREATE INDEX IF NOT EXISTS idx_rs_version ON reasoning_state(version DESC)") @@ -3255,3 +3279,46 @@ def get_latest_cycle_report() -> Optional[Dict[str, Any]]: return report except Exception: return None + + +def get_trade_assessments(run_id: str) -> List[Dict[str, Any]]: + import json as _json + conn = get_conn() + rows = conn.execute( + "SELECT * FROM options_trade_assessments WHERE run_id=? ORDER BY id", + (run_id,), + ).fetchall() + conn.close() + result = [] + for r in rows: + d = dict(r) + try: + d["issues"] = _json.loads(d.get("issues_json") or "[]") + except Exception: + d["issues"] = [] + result.append(d) + return result + + +def get_latest_trade_assessments(limit: int = 20) -> List[Dict[str, Any]]: + """Return most recent assessments (one per trade_id) — for Journal badges.""" + import json as _json + conn = get_conn() + rows = conn.execute( + """SELECT a.* FROM options_trade_assessments a + INNER JOIN (SELECT trade_id, MAX(id) as max_id FROM options_trade_assessments + WHERE trade_id IS NOT NULL GROUP BY trade_id) m + ON a.id = m.max_id + ORDER BY a.id DESC LIMIT ?""", + (limit,), + ).fetchall() + conn.close() + result = [] + for r in rows: + d = dict(r) + try: + d["issues"] = _json.loads(d.get("issues_json") or "[]") + except Exception: + d["issues"] = [] + result.append(d) + return result diff --git a/backend/services/options_technical_agent.py b/backend/services/options_technical_agent.py new file mode 100644 index 0000000..d93f4c4 --- /dev/null +++ b/backend/services/options_technical_agent.py @@ -0,0 +1,464 @@ +""" +Options Technical Agent — validates each logged trade from a pure options pricing perspective. + +Checks performed per trade: + 1. IV Rank / IV Percentile → is vol cheap or expensive? + 2. IV vs Historical Vol ratio → is options premium justified? + 3. Skew (put/call) → what is the market hedging against? + 4. Term Structure (contango/back) → front-month stress or calm? + 5. Options flow (call/put ratio) → smart money direction? + 6. Strategy / IV fit → is the strategy appropriate for current vol regime? + +Verdict: OK | WARN | ALERT +""" +import logging +from typing import Any, Dict, List, Optional + +logger = logging.getLogger(__name__) + +# ── IV regime → strategy mapping ─────────────────────────────────────────────── + +IV_STRATEGY_RULES = { + "long_vol": ["long call", "long put", "long straddle", "long strangle"], + "short_vol": ["short call", "short put", "short strangle", "iron condor", + "covered call", "cash secured put", "cash-secured put"], + "spread": ["bull call spread", "bear put spread", "bull put spread", + "bear call spread", "call spread", "put spread"], +} + +STRATEGY_REGIME = { + # IVR < 30 → buy vol (cheap) + "low": {"preferred": ["Long Call", "Long Put", "Long Straddle"], + "avoid": ["Iron Condor", "Short Strangle"]}, + # IVR 30-60 → spreads (moderate cost) + "mid": {"preferred": ["Bull Call Spread", "Bear Put Spread", "Bull Put Spread", "Bear Call Spread"], + "avoid": ["Long Straddle", "Long Strangle"]}, + # IVR > 60 → sell vol / use spreads (expensive) + "high": {"preferred": ["Bull Call Spread", "Bear Put Spread", "Cash-Secured Put", + "Iron Condor", "Covered Call", "Short Strangle"], + "avoid": ["Long Call", "Long Put", "Long Straddle", "Long Strangle"]}, +} + + +def _iv_regime(iv_rank: Optional[float]) -> str: + if iv_rank is None: + return "unknown" + if iv_rank < 30: + return "low" + if iv_rank < 60: + return "mid" + return "high" + + +def _infer_direction(strategy: str) -> str: + s = strategy.lower() + if any(k in s for k in ["call", "bull", "haussier"]): + return "bullish" + if any(k in s for k in ["put", "bear", "baissier"]): + return "bearish" + if any(k in s for k in ["straddle", "strangle", "iron condor"]): + return "neutral" + return "bullish" + + +def _optimal_strategy(direction: str, iv_rank: Optional[float]) -> str: + regime = _iv_regime(iv_rank) + if direction == "bullish": + return {"low": "Long Call", "mid": "Bull Call Spread", "high": "Cash-Secured Put ou Bull Put Spread"}.get(regime, "Bull Call Spread") + elif direction == "bearish": + return {"low": "Long Put", "mid": "Bear Put Spread", "high": "Covered Call ou Bear Call Spread"}.get(regime, "Bear Put Spread") + else: + return {"low": "Long Straddle", "mid": "Iron Condor (wing buy)", "high": "Iron Condor ou Short Strangle"}.get(regime, "Iron Condor") + + +def assess_strategy_fit( + strategy: str, + iv_rank: Optional[float], + iv_current_pct: Optional[float], + iv_min_52w: Optional[float], + iv_max_52w: Optional[float], + skew_pct: Optional[float], + term_structure: Optional[str], + flow_bias: Optional[str], +) -> Dict[str, Any]: + """Rule-based assessment of strategy vs options pricing environment.""" + issues: List[str] = [] + positives: List[str] = [] + score = 100 + s = strategy.lower() + + is_long_vol = any(k in s for k in IV_STRATEGY_RULES["long_vol"]) + is_short_vol = any(k in s for k in IV_STRATEGY_RULES["short_vol"]) + is_spread = any(k in s for k in IV_STRATEGY_RULES["spread"]) + direction = _infer_direction(strategy) + regime = _iv_regime(iv_rank) + + # ── Rule 1: IV Rank fit ────────────────────────────────────────────────── + if iv_rank is not None: + if is_long_vol: + if iv_rank >= 80: + issues.append( + f"IVR {iv_rank:.0f}% — achat de vol au pic annuel (IV crush quasi-certain). " + f"Min 52s={iv_min_52w:.1f}% | Max={iv_max_52w:.1f}%" + if iv_min_52w and iv_max_52w else + f"IVR {iv_rank:.0f}% — achat de vol au pic annuel, IV crush probable" + ) + score -= 45 + elif iv_rank >= 60: + issues.append(f"IVR {iv_rank:.0f}% — vol chère, un spread débiteur réduirait le coût de vega de ~40-60%") + score -= 25 + elif iv_rank <= 20: + positives.append(f"IVR {iv_rank:.0f}% — vol bon marché, timing idéal pour acheter des options") + score += 5 + elif iv_rank <= 35: + positives.append(f"IVR {iv_rank:.0f}% — vol modérément bon marché, stratégie long vol pertinente") + elif is_short_vol: + if iv_rank <= 25: + issues.append(f"IVR {iv_rank:.0f}% — prime collectée faible, risque/rendement défavorable pour vendeur") + score -= 25 + elif iv_rank >= 65: + positives.append(f"IVR {iv_rank:.0f}% — vol chère, timing favorable pour la vente de prime") + score += 10 + elif is_spread: + if iv_rank >= 50: + positives.append(f"IVR {iv_rank:.0f}% — spread adapté : coût vega réduit, convient au régime de vol élevée") + elif iv_rank <= 20: + issues.append(f"IVR {iv_rank:.0f}% — vol bon marché, option pure plus efficace qu'un spread (gain plafonné inutilement)") + score -= 10 + + # ── Rule 2: IV vs Historical Vol ratio ────────────────────────────────── + if iv_current_pct and iv_min_52w and iv_max_52w: + iv_range = iv_max_52w - iv_min_52w + if iv_range > 0: + iv_normalized = (iv_current_pct - iv_min_52w) / iv_range * 100 + # This is essentially IVR recalculated — use for extra context + if is_long_vol and iv_normalized >= 90: + issues.append( + f"IV actuelle {iv_current_pct:.1f}% vs range 52s [{iv_min_52w:.1f}%–{iv_max_52w:.1f}%] " + f"— dans le top 10% du range annuel" + ) + + # ── Rule 3: Skew ────────────────────────────────────────────────────────── + if skew_pct is not None: + if skew_pct > 8 and "put" in s and is_long_vol: + issues.append(f"Skew put élevé ({skew_pct:+.1f}pts) — protection déjà très chère, marché en mode hedge") + score -= 15 + elif skew_pct > 5: + issues.append(f"Skew put positif ({skew_pct:+.1f}pts) — demande de protection élevée, marché anxieux") + score -= 8 + elif skew_pct < -5 and "call" in s and is_long_vol: + issues.append(f"Skew call négatif ({skew_pct:+.1f}pts) — demande de calls élevée, options call chères relativement") + score -= 8 + elif abs(skew_pct) <= 3: + positives.append(f"Skew neutre ({skew_pct:+.1f}pts) — pas de biais de protection excessif dans le marché") + + # ── Rule 4: Term structure ──────────────────────────────────────────────── + if term_structure: + if term_structure == "backwardation" and is_short_vol: + issues.append("Term structure en backwardation — vol front-month > back, vendre la vol est risqué en période de stress") + score -= 20 + elif term_structure == "backwardation" and is_long_vol and "straddle" in s: + positives.append("Backwardation + long straddle : vol front-month élevée, catalyseur événementiel probable") + score += 8 + elif term_structure == "contango" and is_short_vol: + positives.append("Contango : vol croît avec le temps, calendar spread ou vente de vol front-month avantageuse") + score += 5 + elif term_structure == "contango" and is_long_vol: + issues.append("Contango : vol augmente avec l'échéance, payer le temps est coûteux pour les options longues") + score -= 5 + + # ── Rule 5: Options flow ────────────────────────────────────────────────── + if flow_bias: + if flow_bias == "bearish" and direction == "bullish": + issues.append("Flow options bearish (plus de puts achetés) alors que la stratégie est haussière — signal contra") + score -= 10 + elif flow_bias == "bullish" and direction == "bearish": + issues.append("Flow options bullish (plus de calls achetés) alors que la stratégie est baissière — signal contra") + score -= 10 + elif flow_bias == direction: + positives.append(f"Flow options aligné sur la direction ({flow_bias}) — confirmation par le smart money") + score += 5 + + score = max(0, min(100, score)) + verdict = "OK" if score >= 70 else "WARN" if score >= 45 else "ALERT" + + return { + "fit_score": score, + "verdict": verdict, + "issues": issues, + "positives": positives, + "direction_inferred": direction, + "iv_regime": regime, + "optimal_strategy": _optimal_strategy(direction, iv_rank), + } + + +# ── Main agent function ──────────────────────────────────────────────────────── + +def assess_logged_trades( + scoring_run_id: str, + scored: List[Dict], + ai_key: str, +) -> Optional[Dict]: + """ + For each trade logged in this cycle: + 1. Fetch IV snapshot (IVR, skew, term structure, flow) + 2. Run rule-based assessment + 3. GPT-4o narrative for all trades together + Returns dict with per-trade assessments + global assessment. + """ + import os + import json as _json + os.environ["OPENAI_API_KEY"] = ai_key + + # ── Get newly logged trades ─────────────────────────────────────────────── + try: + from services.database import get_conn + conn = get_conn() + rows = conn.execute( + """SELECT id, underlying, strategy, entry_date, score_at_entry, pattern_name, capital_invested + FROM trade_entry_prices WHERE run_id=? ORDER BY entry_date DESC""", + (scoring_run_id,), + ).fetchall() + conn.close() + trades = [dict(r) for r in rows] + except Exception as e: + logger.warning(f"[OptionsTech] Failed to fetch logged trades: {e}") + return None + + if not trades: + logger.info("[OptionsTech] No trades logged this cycle — skipping assessment") + return {"assessments": [], "global_assessment": "", "global_score": None} + + # ── Fetch IV snapshots per unique ticker ────────────────────────────────── + from services.iv_engine import get_full_iv_snapshot + iv_snapshots: Dict[str, Dict] = {} + unique_tickers = list({t["underlying"] for t in trades if t.get("underlying")}) + + for ticker in unique_tickers: + try: + iv_snapshots[ticker] = get_full_iv_snapshot(ticker) + logger.debug(f"[OptionsTech] IV snapshot fetched for {ticker}") + except Exception as e: + logger.debug(f"[OptionsTech] IV snapshot failed for {ticker}: {e}") + iv_snapshots[ticker] = {} + + # ── Rule-based assessment per trade ────────────────────────────────────── + assessments: List[Dict] = [] + for t in trades: + ticker = t.get("underlying") or "" + strategy = t.get("strategy") or "Long Call" + snap = iv_snapshots.get(ticker, {}) + + iv_rank = snap.get("iv_rank") + iv_current_pct = snap.get("iv_current_pct") + iv_min_52w = snap.get("iv_min_52w_pct") + iv_max_52w = snap.get("iv_max_52w_pct") + skew = snap.get("skew", {}) + skew_pct = skew.get("skew_pct") + term = snap.get("term_structure", {}) + term_structure = term.get("structure") + flow = snap.get("options_flow", {}) + flow_bias = flow.get("flow_bias") + call_put_ratio = flow.get("call_put_ratio") + iv_source = snap.get("iv_source", "none") + + fit = assess_strategy_fit( + strategy=strategy, + iv_rank=iv_rank, + iv_current_pct=iv_current_pct, + iv_min_52w=iv_min_52w, + iv_max_52w=iv_max_52w, + skew_pct=skew_pct, + term_structure=term_structure, + flow_bias=flow_bias, + ) + + assessments.append({ + "trade_id": t.get("id"), + "ticker": ticker, + "strategy": strategy, + "pattern_name": t.get("pattern_name", ""), + # IV data + "iv_rank": iv_rank, + "iv_current_pct": iv_current_pct, + "iv_min_52w_pct": iv_min_52w, + "iv_max_52w_pct": iv_max_52w, + "iv_source": iv_source, + "skew_pct": skew_pct, + "skew_interpretation": skew.get("interpretation"), + "term_structure": term_structure, + "term_iv_30d": term.get("iv_30d"), + "term_iv_90d": term.get("iv_90d"), + "flow_bias": flow_bias, + "call_put_ratio": call_put_ratio, + # Rule-based verdict + "fit_score": fit["fit_score"], + "verdict": fit["verdict"], + "issues": fit["issues"], + "positives": fit["positives"], + "iv_regime": fit["iv_regime"], + "direction_inferred": fit["direction_inferred"], + "optimal_strategy": fit["optimal_strategy"], + # GPT-4o analysis filled below + "analysis": "", + "when_to_enter": "", + }) + + # ── GPT-4o narrative (all trades together) ──────────────────────────────── + global_assessment = "" + global_score = None + try: + from services.ai_analyzer import _chat + + trades_for_prompt = [] + for a in assessments: + entry = { + "ticker": a["ticker"], + "strategy": a["strategy"], + "pattern": a["pattern_name"], + "iv_rank_pct": a["iv_rank"], + "iv_current_pct": a["iv_current_pct"], + "iv_range_52w": f"{a['iv_min_52w_pct']:.1f}%–{a['iv_max_52w_pct']:.1f}%" + if a["iv_min_52w_pct"] and a["iv_max_52w_pct"] else None, + "skew_pts": a["skew_pct"], + "skew_interp": a["skew_interpretation"], + "term_structure": a["term_structure"], + "iv_30d": a["term_iv_30d"], + "iv_90d": a["term_iv_90d"], + "flow_bias": a["flow_bias"], + "call_put_ratio": a["call_put_ratio"], + "rule_verdict": a["verdict"], + "rule_issues": a["issues"], + "rule_positives": a["positives"], + "optimal_strategy_suggested": a["optimal_strategy"], + } + trades_for_prompt.append(entry) + + prompt = f"""Tu es un trader d'options senior avec 20 ans d'expérience en market making et volatilité. +Analyse les trades ci-dessous qui viennent d'être loggés dans notre système. +Pour chaque trade, fournis une analyse technique options rigoureuse en utilisant TOUS les indicateurs disponibles. + +TRADES À ANALYSER: +{_json.dumps(trades_for_prompt, ensure_ascii=False, indent=2)} + +Pour chaque trade, analyse: +1. Le TIMING de vol (IVR, range 52s, IV/HV implicite) — est-ce le bon moment pour cette stratégie ? +2. La STRUCTURE (skew, term structure, flow) — que dit le marché options lui-même ? +3. La STRATÉGIE choisie — est-elle optimale pour ce régime de vol ? +4. Le PRICE d'ENTRÉE OPTIMAL — quelle condition améliorerait le timing ? + +Rappel règles d'or: +- IVR > 70% + achat d'option naked = payer la prime maximale = IV crush probable +- Skew put élevé = marché en mode protection, puts chers +- Backwardation = stress, ne pas vendre la vol +- Iron Condor / Short Strangle = uniquement IVR > 65% +- Long Straddle = uniquement si catalyseur + IVR < 30% + +Réponds en JSON EXACT: +{{ + "trade_assessments": [ + {{ + "ticker": "", + "strategy": "", + "technical_score": <0-100>, + "analysis": "<3-4 phrases d'analyse technique précise: timing vol, skew, structure>", + "when_to_enter": "" + }} + ], + "global_assessment": "<2-3 phrases sur la qualité technique globale des entrées de ce cycle>", + "global_score": <0-100, note technique globale du cycle d'entrées> +}}""" + + result = _chat( + "Tu es un expert options/volatilité. Analyse technique précise en JSON.", + prompt, + model="gpt-4o", + json_mode=True, + max_tokens=1200, + ) + + if result: + global_assessment = result.get("global_assessment", "") + global_score = result.get("global_score") + ai_assessments = {a["ticker"]: a for a in result.get("trade_assessments", [])} + for a in assessments: + ai = ai_assessments.get(a["ticker"], {}) + a["analysis"] = ai.get("analysis", "") + a["when_to_enter"] = ai.get("when_to_enter", "") + if ai.get("technical_score") is not None: + a["fit_score"] = int(ai["technical_score"]) + a["verdict"] = "OK" if a["fit_score"] >= 70 else "WARN" if a["fit_score"] >= 45 else "ALERT" + + logger.info(f"[OptionsTech] GPT-4o assessment complete — {len(assessments)} trades, global_score={global_score}") + + except Exception as e: + logger.warning(f"[OptionsTech] GPT-4o narrative failed: {e}") + + return { + "run_id": scoring_run_id, + "assessments": assessments, + "global_assessment": global_assessment, + "global_score": global_score, + "n_alert": sum(1 for a in assessments if a["verdict"] == "ALERT"), + "n_warn": sum(1 for a in assessments if a["verdict"] == "WARN"), + "n_ok": sum(1 for a in assessments if a["verdict"] == "OK"), + } + + +def save_assessments_to_db(assessments: List[Dict], run_id: str) -> None: + """Persist per-trade assessments for Journal badge display.""" + import json as _json + try: + from services.database import get_conn + conn = get_conn() + for a in assessments: + conn.execute( + """INSERT OR REPLACE INTO options_trade_assessments + (run_id, trade_id, ticker, strategy, assessed_at, + iv_rank, iv_current_pct, skew_pct, term_structure, + fit_score, verdict, issues_json, optimal_strategy, analysis, when_to_enter) + VALUES (?, ?, ?, ?, datetime('now'), ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + ( + run_id, + a.get("trade_id"), + a.get("ticker"), + a.get("strategy"), + a.get("iv_rank"), + a.get("iv_current_pct"), + a.get("skew_pct"), + a.get("term_structure"), + a.get("fit_score"), + a.get("verdict"), + _json.dumps(a.get("issues", []) + (["✓ " + p for p in a.get("positives", [])]), ensure_ascii=False), + a.get("optimal_strategy"), + a.get("analysis", ""), + a.get("when_to_enter", ""), + ), + ) + conn.commit() + conn.close() + except Exception as e: + logger.warning(f"[OptionsTech] DB save failed: {e}") + + +# ── IV-strategy injection string for AI prompts ──────────────────────────────── + +OPTIONS_STRATEGY_RULES = """ +## ⚠️ RÈGLES STRICTES IV → STRATÉGIE (à appliquer pour chaque trade suggéré) + +Le choix de stratégie doit tenir compte du COÛT de la volatilité implicite (IVR = IV Rank 52 semaines): + +| IVR | Stratégie AUTORISÉE | Stratégie INTERDITE | +|-----|---------------------|---------------------| +| < 30% (vol cheap) | Long Call, Long Put, Long Straddle | Iron Condor, Short Strangle | +| 30–60% (vol moderate) | Bull Call Spread, Bear Put Spread | Long Straddle, Long Strangle naked | +| 60–80% (vol chère) | Spreads débiteurs, Cash-Secured Put | Long Call naked, Long Put naked | +| > 80% (vol très chère)| Iron Condor, Short Strangle, Covered Call, Cash-Secured Put | TOUTE option long naked | + +Règles supplémentaires: +- Skew put élevé (> 5 pts) → éviter Long Put (trop cher), préférer Bear Put Spread +- Term structure backwardation → ne pas vendre de vol (vendeur piégé si vol monte encore) +- Si IVR inconnu → utiliser Bull Call Spread / Bear Put Spread par défaut (neutre au coût de vol) +- Long Straddle uniquement si: IVR < 25% ET catalyseur événementiel clairement identifié +""" diff --git a/frontend/src/pages/RapportIA.tsx b/frontend/src/pages/RapportIA.tsx index 1a31664..7b02a3e 100644 --- a/frontend/src/pages/RapportIA.tsx +++ b/frontend/src/pages/RapportIA.tsx @@ -4,6 +4,7 @@ import clsx from 'clsx' import { Brain, TrendingUp, TrendingDown, RefreshCw, ShieldAlert, GitCompare, Layers, Zap, BookOpen, Clock, ChevronRight, + CheckCircle2, AlertTriangle, XCircle, BarChart2, } from 'lucide-react' const SCENARIO_META: Record = { @@ -43,6 +44,37 @@ function Section({ icon, title, children }: { icon: React.ReactNode; title: stri ) } +const VERDICT_META = { + OK: { color: 'text-emerald-400', bg: 'bg-emerald-900/10 border-emerald-700/30', icon: CheckCircle2 }, + WARN: { color: 'text-yellow-400', bg: 'bg-yellow-900/10 border-yellow-700/30', icon: AlertTriangle }, + ALERT: { color: 'text-red-400', bg: 'bg-red-900/10 border-red-700/30', icon: XCircle }, +} + +function VerdictBadge({ verdict }: { verdict: 'OK' | 'WARN' | 'ALERT' }) { + const m = VERDICT_META[verdict] ?? VERDICT_META.WARN + const Icon = m.icon + return ( + + {verdict} + + ) +} + +function IVBar({ ivRank }: { ivRank?: number | null }) { + if (ivRank == null) return IVR — + const color = ivRank >= 60 ? 'bg-red-500' : ivRank >= 30 ? 'bg-yellow-500' : 'bg-emerald-500' + return ( +
+
+
+
+ = 60 ? 'text-red-400' : ivRank >= 30 ? 'text-yellow-400' : 'text-emerald-400')}> + IVR {ivRank.toFixed(0)}% + +
+ ) +} + function DeltaRow({ emoji, label, count, items, colorClass }: { emoji: string; label: string; count: number; items: any[]; colorClass: string; @@ -412,6 +444,123 @@ export default function RapportCycle() { )} + {/* Options Technical Agent */} + {report.options_technical && (report.options_technical.assessments?.length > 0 || report.options_technical.global_assessment) && ( +
} title="Validation Technique Options"> + {/* Global score + summary */} +
+ {report.options_technical.global_score != null && ( +
+
= 70 ? 'text-emerald-400' + : report.options_technical.global_score >= 45 ? 'text-yellow-400' + : 'text-red-400' + )}> + {report.options_technical.global_score} +
+
Score vol
+
+ )} +
+ {report.options_technical.global_assessment && ( +

+ {report.options_technical.global_assessment} +

+ )} +
+ {report.options_technical.n_ok > 0 && ( + + {report.options_technical.n_ok} OK + + )} + {report.options_technical.n_warn > 0 && ( + + {report.options_technical.n_warn} WARN + + )} + {report.options_technical.n_alert > 0 && ( + + {report.options_technical.n_alert} ALERT + + )} +
+
+
+ + {/* Per-trade assessments */} +
+ {(report.options_technical.assessments as any[]).map((a: any, i: number) => ( +
+ {/* Trade header */} +
+ + {a.ticker} + {a.strategy} +
+ +
+
+ + {/* IV data row */} +
+ {a.skew_pct != null && ( + Skew: 5 ? 'text-orange-400' : 'text-slate-400')}> + {a.skew_pct > 0 ? '+' : ''}{a.skew_pct.toFixed(1)}pts + + )} + {a.term_structure && ( + Structure: {a.term_structure} + )} + {a.flow_bias && ( + Flow: {a.flow_bias} + )} + Score: {a.fit_score} +
+ + {/* Issues + positives */} + {((a.issues ?? []).length > 0 || (a.positives ?? []).length > 0) && ( +
+ {(a.issues as string[]).map((issue: string, j: number) => ( +
+ {issue} +
+ ))} + {(a.positives as string[]).map((pos: string, j: number) => ( +
+ {pos} +
+ ))} +
+ )} + + {/* GPT-4o analysis */} + {a.analysis && ( +

{a.analysis}

+ )} + + {/* Optimal strategy + when to enter */} +
+ {a.optimal_strategy && a.optimal_strategy !== a.strategy && ( + + Stratégie optimale: {a.optimal_strategy} + + )} + {a.when_to_enter && ( + {a.when_to_enter} + )} +
+
+ ))} +
+
+ )} + {/* Risk / portfolio monitor */} {report.portfolio_monitor && (
} title="Risque & Alertes portefeuille">