feat: options technical agent — IV/skew/term structure validation per trade
- New options_technical_agent.py: rule engine (IVR, skew, term structure, flow)
+ GPT-4o narrative per trade; verdict OK/WARN/ALERT + fit_score
- options_trade_assessments table in DB for Journal badge persistence
- auto_cycle.py step 5.2: assess newly logged trades after log_trade_entries;
results embedded in cycle report
- suggest_patterns_from_market_context: +iv_context param + explicit IV→strategy
rules in prompt (IVR<30%→Long, 30-60%→Spread, >60%→no naked long, >80%→short)
- Pre-fetch iv_context at step 1.9 so suggestion step gets strategy rules
- reports.py: /api/reports/assessments/latest + /assessments/{run_id} endpoints
- RapportIA.tsx: "Validation Technique Options" section with per-trade IVBar,
VerdictBadge, issues list, GPT-4o analysis, optimal strategy suggestion
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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)}
|
||||
|
||||
@@ -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": <float 0-1, probabilité que ce trigger d'invalidation se réalise dans l'horizon>,
|
||||
"suggested_trades": [
|
||||
{{
|
||||
"strategy": "<Long Call|Long Put|Bull Call Spread|Bear Put Spread|Long Straddle>",
|
||||
"strategy": "<Long Call|Long Put|Bull Call Spread|Bear Put Spread|Long Straddle|Iron Condor|Short Strangle|Cash-Secured Put|Covered Call — respecter règles IVR>",
|
||||
"underlying": "<ticker Yahoo Finance>",
|
||||
"rationale": "<pourquoi ce trade dans ce contexte macro+géo>",
|
||||
"rationale": "<pourquoi ce trade dans ce contexte macro+géo+vol>",
|
||||
"asset_class": "<classe>",
|
||||
"expected_move_pct": <float, RENDEMENT OPTION en % pour CE trade si thèse confirmée. Long Call: 80-250%, Spread: 40-120%, Straddle: 60-180%.>
|
||||
}},
|
||||
{{
|
||||
"strategy": "<autre stratégie>",
|
||||
"strategy": "<autre stratégie respectant les règles IVR>",
|
||||
"underlying": "<ticker Yahoo Finance>",
|
||||
"rationale": "<rationale>",
|
||||
"asset_class": "<classe>",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
464
backend/services/options_technical_agent.py
Normal file
464
backend/services/options_technical_agent.py
Normal file
@@ -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": "<ticker>",
|
||||
"strategy": "<stratégie>",
|
||||
"technical_score": <0-100>,
|
||||
"analysis": "<3-4 phrases d'analyse technique précise: timing vol, skew, structure>",
|
||||
"when_to_enter": "<condition précise et mesurable pour un meilleur timing: ex 'Attendre IVR < 40%', 'Après event X'>"
|
||||
}}
|
||||
],
|
||||
"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é
|
||||
"""
|
||||
@@ -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<string, { label: string; color: string; emoji: string }> = {
|
||||
@@ -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 (
|
||||
<span className={clsx('inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[10px] font-bold border', m.bg, m.color)}>
|
||||
<Icon className="w-2.5 h-2.5" /> {verdict}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function IVBar({ ivRank }: { ivRank?: number | null }) {
|
||||
if (ivRank == null) return <span className="text-slate-600 text-[10px]">IVR —</span>
|
||||
const color = ivRank >= 60 ? 'bg-red-500' : ivRank >= 30 ? 'bg-yellow-500' : 'bg-emerald-500'
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-16 h-1 bg-slate-700 rounded-full overflow-hidden">
|
||||
<div className={clsx('h-full rounded-full', color)} style={{ width: `${Math.min(ivRank, 100)}%` }} />
|
||||
</div>
|
||||
<span className={clsx('text-[10px] font-mono', ivRank >= 60 ? 'text-red-400' : ivRank >= 30 ? 'text-yellow-400' : 'text-emerald-400')}>
|
||||
IVR {ivRank.toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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() {
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* Options Technical Agent */}
|
||||
{report.options_technical && (report.options_technical.assessments?.length > 0 || report.options_technical.global_assessment) && (
|
||||
<Section icon={<BarChart2 className="w-4 h-4" />} title="Validation Technique Options">
|
||||
{/* Global score + summary */}
|
||||
<div className="flex items-start gap-3 mb-4">
|
||||
{report.options_technical.global_score != null && (
|
||||
<div className="shrink-0 text-center">
|
||||
<div className={clsx('text-2xl font-bold font-mono',
|
||||
report.options_technical.global_score >= 70 ? 'text-emerald-400'
|
||||
: report.options_technical.global_score >= 45 ? 'text-yellow-400'
|
||||
: 'text-red-400'
|
||||
)}>
|
||||
{report.options_technical.global_score}
|
||||
</div>
|
||||
<div className="text-[9px] text-slate-600">Score vol</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
{report.options_technical.global_assessment && (
|
||||
<p className="text-xs text-slate-300 leading-relaxed">
|
||||
{report.options_technical.global_assessment}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex gap-3 mt-2 text-[10px]">
|
||||
{report.options_technical.n_ok > 0 && (
|
||||
<span className="text-emerald-400 flex items-center gap-0.5">
|
||||
<CheckCircle2 className="w-3 h-3" /> {report.options_technical.n_ok} OK
|
||||
</span>
|
||||
)}
|
||||
{report.options_technical.n_warn > 0 && (
|
||||
<span className="text-yellow-400 flex items-center gap-0.5">
|
||||
<AlertTriangle className="w-3 h-3" /> {report.options_technical.n_warn} WARN
|
||||
</span>
|
||||
)}
|
||||
{report.options_technical.n_alert > 0 && (
|
||||
<span className="text-red-400 flex items-center gap-0.5">
|
||||
<XCircle className="w-3 h-3" /> {report.options_technical.n_alert} ALERT
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Per-trade assessments */}
|
||||
<div className="space-y-3">
|
||||
{(report.options_technical.assessments as any[]).map((a: any, i: number) => (
|
||||
<div key={i} className={clsx(
|
||||
'rounded border px-3 py-2.5',
|
||||
VERDICT_META[a.verdict as 'OK' | 'WARN' | 'ALERT']?.bg ?? 'border-slate-700/30 bg-dark-700/40'
|
||||
)}>
|
||||
{/* Trade header */}
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<VerdictBadge verdict={a.verdict} />
|
||||
<span className="text-xs font-semibold text-slate-200">{a.ticker}</span>
|
||||
<span className="text-[10px] text-slate-500">{a.strategy}</span>
|
||||
<div className="ml-auto">
|
||||
<IVBar ivRank={a.iv_rank} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* IV data row */}
|
||||
<div className="flex gap-3 text-[10px] text-slate-500 mb-2">
|
||||
{a.skew_pct != null && (
|
||||
<span>Skew: <span className={clsx('font-mono', a.skew_pct > 5 ? 'text-orange-400' : 'text-slate-400')}>
|
||||
{a.skew_pct > 0 ? '+' : ''}{a.skew_pct.toFixed(1)}pts
|
||||
</span></span>
|
||||
)}
|
||||
{a.term_structure && (
|
||||
<span>Structure: <span className={clsx('font-mono',
|
||||
a.term_structure === 'backwardation' ? 'text-red-400'
|
||||
: a.term_structure === 'contango' ? 'text-emerald-400' : 'text-slate-400'
|
||||
)}>{a.term_structure}</span></span>
|
||||
)}
|
||||
{a.flow_bias && (
|
||||
<span>Flow: <span className="font-mono text-slate-400">{a.flow_bias}</span></span>
|
||||
)}
|
||||
<span className="ml-auto text-slate-600">Score: <span className="font-mono text-slate-400">{a.fit_score}</span></span>
|
||||
</div>
|
||||
|
||||
{/* Issues + positives */}
|
||||
{((a.issues ?? []).length > 0 || (a.positives ?? []).length > 0) && (
|
||||
<div className="space-y-0.5 mb-2">
|
||||
{(a.issues as string[]).map((issue: string, j: number) => (
|
||||
<div key={j} className="flex items-start gap-1.5 text-[10px] text-orange-300">
|
||||
<span className="shrink-0 mt-0.5">⚠</span> {issue}
|
||||
</div>
|
||||
))}
|
||||
{(a.positives as string[]).map((pos: string, j: number) => (
|
||||
<div key={j} className="flex items-start gap-1.5 text-[10px] text-emerald-400">
|
||||
<span className="shrink-0 mt-0.5">✓</span> {pos}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* GPT-4o analysis */}
|
||||
{a.analysis && (
|
||||
<p className="text-[11px] text-slate-400 leading-relaxed mb-1">{a.analysis}</p>
|
||||
)}
|
||||
|
||||
{/* Optimal strategy + when to enter */}
|
||||
<div className="flex items-start gap-3 mt-1.5 pt-1.5 border-t border-slate-700/20 text-[10px]">
|
||||
{a.optimal_strategy && a.optimal_strategy !== a.strategy && (
|
||||
<span className="text-slate-500">
|
||||
Stratégie optimale: <span className="text-blue-400 font-medium">{a.optimal_strategy}</span>
|
||||
</span>
|
||||
)}
|
||||
{a.when_to_enter && (
|
||||
<span className="text-slate-600 italic">{a.when_to_enter}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* Risk / portfolio monitor */}
|
||||
{report.portfolio_monitor && (
|
||||
<Section icon={<ShieldAlert className="w-4 h-4" />} title="Risque & Alertes portefeuille">
|
||||
|
||||
Reference in New Issue
Block a user