diff --git a/GeoOptions_Documentation_Complete.html b/GeoOptions_Documentation_Complete.html index 3746cdf..d6c34e5 100644 --- a/GeoOptions_Documentation_Complete.html +++ b/GeoOptions_Documentation_Complete.html @@ -109,10 +109,11 @@ li { margin-bottom: 4px; font-size: 9.5pt; } Phase 3 · Portfolio & Risk Phase 4 · IA Probabiliste Phase 5 · Cycle Context & Intelligence + Phase 6 · Specialist Desks & Calibration
- Version 5.0 · Juin 2026 · 5 Phases · FastAPI + React + SQLite + GPT-4o
- 19 pages · 50 signaux macro · IV Gate · Pattern Convergence · IBKR Ticket + Version 6.0 · Juin 2026 · 6 Phases · FastAPI + React + SQLite + GPT-4o
+ 23 pages · 50 signaux macro · IV Gate · Pattern Convergence · IBKR Ticket
@@ -180,6 +181,26 @@ li { margin-bottom: 4px; font-size: 9.5pt; }
  • 9. Guide Trader — Lire le Cockpit Page par Page
  • 10. Décisions de Conception
  • 11. Glossaire
  • +
  • 2c. Workflow Complet du Cycle — Graphe des Calculs
  • + +
  • 12. Specialist Desks — Configuration par Asset Class
  • + +
  • 13. Bibliothèque Patterns — Find Similar & Merge
  • + +
  • 14. Calibration Progressive — IA vs Observé
  • +
    @@ -188,7 +209,7 @@ li { margin-bottom: 4px; font-size: 9.5pt; }

    1. Vue d'Ensemble du Système

    1.1 Ce que fait le système

    -

    GeoOptions Intelligence est un cockpit de trading d'options à usage personnel, conçu pour un trader individuel opérant sur Interactive Brokers avec des positions de l'ordre de 1 000 € par trade et un horizon moyen de 3 mois. Le système fusionne en temps quasi-réel trois flux d'information hétérogènes — données de marché financières (prix, volatilité implicite, open interest), signaux géopolitiques (flux RSS de presse internationale, scores de tension par région) et analyse macroéconomique (régime macro dominant parmi 4 états : Expansion, Stagflation, Récession, Stress Géopolitique) — pour générer des suggestions de patterns de trading options avec probabilités de succès, tailles de position ajustées au risque, et contre-thèses d'invalidation. L'ensemble tourne sur un VPS Linux sous FastAPI (backend) et React (frontend), avec SQLite comme base de données persistante et GPT-4o comme moteur de raisonnement.

    +

    GeoOptions Intelligence est un cockpit de trading d'options à usage personnel, conçu pour un trader individuel opérant sur Interactive Brokers avec des positions de l'ordre de 1 000 € par trade et un horizon moyen de 3 mois. Le système fusionne en temps quasi-réel trois flux d'information hétérogènes — données de marché financières (prix, volatilité implicite, open interest), signaux géopolitiques (flux RSS de presse internationale, scores de tension par région) et analyse macroéconomique (régime macro dominant parmi 8 états : Goldilocks, Désinflation, Soft Landing, Reflation, Stagflation, Choc Inflationniste, Récession, Crise de Liquidité) — pour générer des suggestions de patterns de trading options avec probabilités de succès, tailles de position ajustées au risque, et contre-thèses d'invalidation. L'ensemble tourne sur un VPS Linux sous FastAPI (backend) et React (frontend), avec SQLite comme base de données persistante et GPT-4o comme moteur de raisonnement.

    1.2 Les 3 Principes Fondamentaux

    @@ -209,31 +230,153 @@ li { margin-bottom: 4px; font-size: 9.5pt; }

    1.3 Architecture Technique

    -
    -┌─────────────────────────────────────────────────────────────────┐ -│ FRONTEND (React + Vite) │ -│ Dashboard · Radar Géo · Marchés · Régime · Options Lab │ -│ Patterns · Portefeuille · Journal · Rapport IA · Super Ctx │ -│ Analytics · Analytics Avancées · Risk Dashboard · Config │ -└──────────────────────────┬──────────────────────────────────────┘ - │ HTTP / REST API - ▼ -┌─────────────────────────────────────────────────────────────────┐ -│ BACKEND (FastAPI / Python) │ -│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────────┐ │ -│ │ Scheduler │ │ AI Engine │ │ Risk Engine │ │ -│ │ APScheduler │ │ GPT-4o API │ │ Kelly · HHI · Cluster│ │ -│ │ 6h cycle │ │ 6 prompts │ │ Bayesian · K-Means │ │ -│ └──────┬───────┘ └──────┬───────┘ └──────────┬───────────┘ │ -│ │ │ │ │ -│ ┌──────▼─────────────────▼───────────────────────▼───────────┐ │ -│ │ SQLite Database (15+ tables) │ │ -│ └────────────────────────────────────────────────────────────┘ │ -└──────────────────────────────────────────────────────────────────┘ - │ │ │ - ▼ ▼ ▼ - yfinance API OpenAI API RSS Feeds - (prix, IV, OI) (GPT-4o) (Reuters, BBC, Al Jazeera) + + +
    + + +
    +
    ⬡ FRONTEND — React 18 · Vite · Tailwind CSS · Recharts
    +
    + Dashboard + Radar Géo + Journal + Markets + Macro Regime + Options Lab + Patterns + Portfolio + Cycle Report + Super Context + Analytics + Adv. Analytics + Risk Dashboard + VaR Analysis + Position History + Backtest + Calendar + Specialist Desks + System Logs + Configuration +
    +
    + + +
    HTTP / REST API
    + + +
    +
    ⚙ BACKEND — FastAPI · Python 3.11 · APScheduler
    +
    +
    + 🕐 +
    Scheduler
    +
    APScheduler · cycle 6h · elapsed-time aware
    +
    +
    + 🤖 +
    AI Engine
    +
    GPT-4o · 6 prompts · GPT-4o-mini résumés
    +
    +
    + ⚖️ +
    Risk Engine
    +
    Kelly · HHI · K-Means · Bayésien · VaR
    +
    +
    + 🔬 +
    Cycle Context
    +
    Decay · IVR · 50 macro · Tech · Price Discovery
    +
    +
    + 🏦 +
    Specialist Desks
    +
    7 desks · rapports · injection IA duale
    +
    +
    +
    + + +
    SQLite / aiosqlite
    + + +
    +
    🗄 BASE DE DONNÉES — SQLite 18+ Tables
    +
    + custom_patterns + trade_entry_prices + pattern_score_history + geo_news + macro_regime_history + system_logs + knowledge_base + iv_history + portfolio_snapshots + skipped_trades + pattern_embeddings + ai_reasoning_traces + specialist_reports + asset_class_configs + ai_call_logs + fred_data +
    +
    + + +
    ↑ ↑ ↑ ↑ ↑
    +
    +
    + 📈 +
    yfinance
    +
    Prix spot · IV · OI · 50 macro signaux · bootstrap 45j
    +
    +
    + 🧠 +
    OpenAI API
    +
    GPT-4o (scoring/suggest) · GPT-4o-mini (news/résumés/similarité)
    +
    +
    + 📰 +
    RSS Feeds
    +
    Reuters · AP · Al Jazeera · FT · Bloomberg · 7 flux
    +
    +
    + 🏛️ +
    IBKR
    +
    Portefeuille réel · Prix options live · Ticket auto-calculé
    +
    +
    + 🏦 +
    FRED API
    +
    CPI · NFP · Taux Fed · PCE · Chômage
    +
    +
    +
    @@ -431,6 +574,21 @@ final_relevance = clamp(base_relevance + ai_alignment, 0, 100)

    Ces données sont encodées dans le méga-prompt de l'Étape 5.

    +
    +
    Étape 4b — Chargement des Specialist Desks
    +

    Input : Table asset_class_configs (7 desks) + specialist_reports + report_desk_links

    +

    Traitement : Pour chaque desk actif, le système charge les fondamentaux, la sensibilité macro par régime, les seuils de prix significatifs/extrêmes, et les 5 prochains rapports planifiés triés par date et importance.

    +

    Output : Un bloc de contexte structuré par desk, injecté dans deux prompts distincts :

    + +
    +
    Pourquoi deux chemins distincts ?
    + Lors du scoring, l'IA connaît déjà l'asset class des patterns. L'injection ciblée suffit et coûte moins cher. Lors de la suggestion, l'IA doit explorer librement tous les marchés — injecter les 7 desks garantit qu'aucune opportunité n'est manquée par méconnaissance du contexte desk. +
    +
    +
    Étape 4 — Construction du Super Contexte

    Input : Base de données Knowledge Base (patterns historiques), trades des 90 derniers jours, insights récents avec scores de fiabilité

    @@ -1757,6 +1915,37 @@ Seuil : confidence > 0.75 → ALERTE INVALIDATION générée dans l'UI

    Onglet Options — Parameters : IV Gate thresholds (Alert / Extreme), exit defaults (target % / stop-loss %), indicateurs techniques à activer par horizon, skew threshold pour alertes directionnelles.

    Ne jamais modifier le modèle GPT en production sans tester sur un cycle complet. Ne jamais baisser le Kelly fraction en dessous de 1/4 (tailles trop petites pour couvrir les frais IBKR).

    +

    Pattern Lab (Options Lab) — Flux de Travail Détaillé

    +

    La page Patterns est le cœur opérationnel de GeoOptions. Voici le flux de travail typique d'un trader après chaque cycle :

    +
    +
    1. Lire le
    Cycle Report
    +
    +
    2. Ouvrir
    Patterns Explorer
    +
    +
    3. Filtrer par
    classe d'actif
    +
    +
    4. Trier par
    Score IA ↓
    +
    +
    5. Vérifier
    MaturityBadge
    +
    +
    6. Find Similar
    (si nouveau)
    +
    +
    7. Log ou
    Skip manuellement
    +
    + + + + + + + + + + + + +
    FonctionnalitéCe qu'elle faitQuand l'utiliser
    Filtres class d'actifFiltrage par forex/metals/agri/energy/indices/crypto/bondsTous les jours, pour ne voir que les patterns pertinents au marché actif
    Tri Score IA / Date / ProbabilitéReclasse les patterns par score du dernier cycle (desc), date de création, ou bayesian_win_rateAprès cycle — trier par Score IA pour voir quels patterns le système juge les plus opportuns aujourd'hui
    MaturityBadgeAffiche pour chaque pattern : source (Pure AI → Data-driven), poids calibration, WR bayésien, AI estimate vs calibréAvant de décider de logger un trade — savoir si le G dans l'EV vient de l'IA ou de données réelles
    Find Similar (ScanSearch)Appelle GPT-4o-mini pour comparer le pattern à tous les autres. Retourne : doublon à fusionner / contre-scénario / pattern uniqueAprès chaque cycle qui ajoute des nouveaux patterns — évite l'accumulation de quasi-doublons
    Merge (Fusionner)Fusion transactionnelle : union historical_instances, somme backtest hits, remapping de 6 tables enfantsQuand Find Similar retourne "merge_as_instance" — ne jamais fusionner des directions opposées
    Éditeur completModifier name, description, triggers, expected_move_pct, trades suggérés, historical_instancesPour corriger un pattern généré par l'IA qui contient des erreurs de libellé ou de paramètres
    Suggestion IA manuelleDéclenche le prompt suggest_patterns à la demande (hors cycle)Quand le marché évolue significativement en intra-journée et qu'on veut de nouvelles idées immédiatement
    Vue grille / listeGrille : aperçu rapide de nombreux patterns. Liste : détail complet avec toutes les métriques visiblesGrille pour le matin (scan rapide) · Liste pour la décision finale d'un trade
    +
    @@ -1854,15 +2043,625 @@ Seuil : confidence > 0.75 → ALERTE INVALIDATION générée dans l'UI Moniteur de Portefeuille IAAgent GPT-4o-mini (Step 7b du cycle) qui analyse l'état du portefeuille simulé après chaque log de trades. Produit un assessment global + liste d'actions (close / rebalance / monitor) + suggestion de rééquilibrage. Non-bloquant et persisté dans system_logs pour affichage dans le Journal. Concentration de ClassePourcentage d'une classe d'actif (energy, metals, agriculture, equities, indices, forex, rates) dans le portefeuille simulé total. Seuil d'alerte : > 35%. Affiché sous forme de barres horizontales colorées dans l'onglet Risque Sim. Price WarningBadge ambre sur un trade dans l'onglet Ouverts : NO DATA (ni prix d'entrée ni prix actuel disponible), NO ENTRY (prix d'entrée absent), NO LIVE (prix actuel non récupéré). Signale que le monitoring MtM est impossible pour ce trade. + Specialist DeskConfiguration par asset class dans GeoOptions : fondamentaux clés, sensibilité macro par régime, seuils de prix significatifs/extrêmes, et catalogue de rapports critiques (y compris non-calendrier). Injecté dans les deux prompts IA (scoring et suggestion) pour ancrer les recommandations dans la culture réelle de chaque desk. + Crédibilité ActuarielleMéthode de pondération w = n/(n+k) (k=5 dans GeoOptions) qui mesure la confiance accordée aux données observées vs l'estimation a priori. À n=5 trades : 50% observé. Inspiré de Bühlmann-Straub (actuariat). Utilisée pour blender l'estimated_move IA avec le rendement réellement observé. + Calibrated Expected MoveValeur blendée de l'expected_move_pct : (1−w)×ai_estimate + w×observed_avg_win_pct. Utilisée comme G dans l'EV dès que la crédibilité w > 10%. Permet de corriger progressivement le biais d'optimisme de l'IA avec les données réelles. + Find SimilarEndpoint GPT-4o-mini qui compare un pattern de la bibliothèque contre tous les autres et retourne : merge_as_instance (doublon → fusionner), counter_scenario (direction opposée, même régime → conserver les deux), new_pattern (pas de match structurel). + Pattern MergeFusion transactionnelle de deux patterns : union des historical_instances (dédup), somme des compteurs backtest, remapping de toutes les tables FK (scores, trades, traces IA, logs), suppression du pattern discardé. Irréversible — confirmation requise. + Barra de MaturitéBadge visuel sur chaque card PatternExplorer montrant le degré de calibration : label source (Pure AI → Data-driven), barre dégradée indiquant le poids observé %, et comparaison chiffrée IA estimate vs Calibré. + + + +
    + + +

    2c. Workflow Complet du Cycle — Pipeline de Calcul

    +

    Chaque étape du cycle automatique est documentée ici avec ses formules, données entrantes, et sorties — aucun calcul masqué, aucune boîte noire.

    + +

    2c.1 Vue Synthétique du Pipeline (8 Phases)

    + + + +
    + +
    +
    +
    +
    Collecte Parallèle des Données
    +
    Fetch simultané sur 6 sources. ThreadPoolExecutor 20 workers pour les 50 tickers macro.
    +
    + 📰 RSS 7 flux + 📈 yfinance 50 signaux + 🏦 FRED (CPI/NFP/taux) + 📅 Calendrier économique + 📊 IV Watchlist + OI + 🏛️ IBKR portefeuille réel +
    +
    +
    + +
    + +
    +
    +
    +
    Traitements & Calculs Intermédiaires
    +
    Formules déterministes — aucune IA impliquée à ce stade.
    +
    + ⏱ Decay news e^(−λt) + 📐 IVR = (IV−min)/(max−min) + 🎯 Régime macro 8 scénarios + 📉 Signaux blendés 1j/5j/10j + 🔍 Price Discovery multi-actifs + ⚡ Tech: RSI · BB · MAs + 🌐 Surface vol: SKEW/VVIX +
    +
    +
    + +
    + +
    +
    +
    +
    Chargement Specialist Desks (7 desks)
    +
    Fondamentaux + sensibilité macro + rapports à venir. Injecté dans les 2 prompts suivants.
    +
    + 💱 Forex + 🥇 Metals + 🌾 Agri + ⚡ Energy + 📊 Indices + ₿ Crypto + 📉 Bonds +
    +
    +
    + +
    + +
    +
    +
    +
    GPT-4o — Suggestion de Nouveaux Patterns
    +
    Méga-prompt injectant la totalité du contexte. Output : 4–6 patterns avec expected_move_pct et trades suggérés.
    +
    + 🤖 GPT-4o (1 appel) + macro + geo + news_decayed + 7 desks injectés + reliability_map + trade_mandate € + portfolio_lessons +
    +
    +
    + +
    + +
    +
    +
    +
    GPT-4o — Scoring de Tous les Patterns (4 Piliers)
    +
    Score 0–100 = P1(News 30) + P2(Calendrier 20) + P3(Prix 35) + P4(R/R 15). Score delta par trade : eff_score = base + delta.
    +
    + P1 — News & Géo (30pts) + P2 — Calendrier (20pts) + P3 — Prix multi-actifs (35pts) + P4 — Risk/Reward (15pts) + contra-signal check + macro_bias par asset class + desks ciblés injectés +
    +
    +
    + +
    + +
    +
    +
    +
    IV Gate — Filtre Dur Pré-Log
    +
    Règles déterministes sur IVR, skew, term structure. Verdict OK/WARN → continue. ALERT → skipped_trades (bloqué).
    +
    + IVR < 30 → Longs autorisés + IVR 30–60 → Spreads préférés + IVR 60–80 → Spreads seulement + IVR > 80 → Vente vol uniquement + Skew > 8% → Bear Spread forcé + Backwardation → no vol sell +
    +
    +
    + +
    + +
    +
    +
    +
    Profil de Risque + Calcul EV + Log
    +
    eff_score ≥ min_score ET exp_move ≥ min_gain → profil retenu → EV calculé → trade_entry_prices.
    +
    + p = eff_score / 100 + G = calibrated_move / 100 + ev_net = p×G − (1−p) + trade_score normalisé 0–100 + matched_profile stocké + → IBKR ticket auto-calculé +
    +
    +
    + +
    + +
    +
    +
    +
    Apprentissage Post-Cycle & Calibration
    +
    Mis à jour uniquement sur les trades "matures" (≥35% horizon écoulé) pour éviter de biaiser les stats avec des trades non résolus.
    +
    + 🎲 Bayésien Beta(1+w, 1+l) + 📊 Reliability = WR×log(n+1) + 🔬 Calibration w=n/(n+5) + 📈 Brier Score calibration IA + 🎯 K-Means clusters régimes + 🧬 Embeddings sémantiques +
    +
    +
    + +
    + +

    2c.2 Démonstration des Calculs — Simulation Complète

    +

    Simulation d'un trade Bull Call Spread WTI du cycle du 23 juin 2026. Chaque valeur est calculable à la main.

    + +
    + + +
    +
    + +
    +
    ① Decay Temporel des News
    +
    Atténuation exponentielle — demi-vie 12h
    +
    +
    +
    +
    +λ = ln(2) / 12 = 0.0578 h⁻¹ +decayed_score = impact_score × e−λ × age_heures + +Article OPEC — age : 8h | impact brut : 0.78 +→ decayed = 0.78 × e−0.0578 × 8 = 0.78 × 0.630 = 0.492 +
    + + + + +
    Article âgé de 8hfactor = 0.630−37% vs brut
    Article âgé de 24hfactor = 0.250−75%
    Article âgé de 48hfactor = 0.063−94% (plancher 0.30 appliqué)
    +
    Le plancher de 0.30 évite qu'un article majeur ne disparaisse complètement des 72h suivantes.
    +
    +
    + + +
    +
    + 🎯 +
    +
    ② Régime Macro — 8 Scénarios (50 signaux)
    +
    Scoring règle-basé + lissage Bayésien 25 jours
    +
    +
    +
    +
    +Signal blend (anti-bruit) : +signal_brent = 0.20 × Δ1j + 0.50 × Δ5j + 0.30 × Δ10j +→ Ex : +3.2% | +0.8% | -0.4% → blend = +0.92% (vs +3.2% brut) + +Score régime brut (exemple Stagflation) : + Brent↑↑ (+30) + Courbe plate (+10) + Or↑ (+15) + Cuivre↓ (+15) + Défensifs>Tech (+8) = 78/100 + +Lissage Bayésien (9 jours d'historique, w_prior=45%) : +score_lissé = 0.55 × 78 + 0.45 × 65 = 72.35 +
    + + + + + +
    Stagflation72 pts — DOMINANT ✓Brent↑, Or↑, courbe plate, cuivre↓
    Choc Inflationniste58 ptsÉcart <15pts → résistance au switch
    Récession43 ptsCourbe inversée mais VIX encore modéré
    Goldilocks21 ptsS&P200j OK mais VIX élevé
    +
    8 régimes : Goldilocks · Désinflation · Soft Landing · Reflation · Stagflation · Choc Inflationniste · Récession · Crise de Liquidité
    +
    +
    + + +
    +
    + 📐 +
    +
    ③ IV Rank & IV Gate
    +
    Positionnement de la vol actuelle dans sa plage annuelle → stratégie optimale
    +
    +
    +
    +
    +IVR = (IV_actuelle − IV_min_52s) / (IV_max_52s − IV_min_52s) × 100 + +WTI (OVX proxy) : IV=28%, min=18%, max=52% +IVR = (28 − 18) / (52 − 18) × 100 = 10/34 × 100 = 29.4% + +IV Gate → stratégie Bull Call Spread WTI : + IVR = 29% → zone "spreads légèrement préférés" → OK ✓ + (IVR > 60 → WARN | IVR > 80 → ALERT ✗ bloqué) +
    +
    L'IVR mesure à quelle hauteur est la vol actuelle dans sa plage 52 semaines — pas sa valeur absolue. Un IVR=80 sur XLE (IV=35%) est plus "cher" qu'un IVR=30 sur VIX (IV=28%).
    +
    +
    + + +
    +
    + 🎯 +
    +
    ④ Scoring GPT-4o — 4 Piliers (0–100)
    +
    Décomposition sur le pattern "Choc Offre Pétrolier WTI"
    +
    +
    +
    + + + + + + + + + + + + + + + + + + + + + + + + +
    P1 — News & Géo (max 30)
    geo (max 12)10 — 3 articles OPEC/Russie pertinents
    eco/macro (max 10)8 — Stagflation bullish+ énergie
    volume/récence (max 8)5 — 2 articles >6h (decay actif)
    P1 Total → 23/30

    P2 — Calendrier (max 20)
    banques centrales (max 10)6 — BCE dans 8j
    macro releases (max 10)7 — EIA dans 2j (catalyseur proche)
    P2 Total → 13/20

    P3 — Prix multi-actifs (max 35)
    énergie/commod (max 7)7 — WTI+2.3%, OVX=42 modéré
    taux & bonds (max 7)5 — TLT stable
    forex (max 7)4 — DXY neutre
    indices (max 7)4 — XLE+1.8%
    VIX/IV (max 7)5 — VIX=18, SKEW=122
    P3 Total → 25/35

    P4 — Risk/Reward (max 15)
    asymétrie R/R (max 10)8 — spread risque défini
    timing (max 5)4 — EIA dans 2j
    P4 Total → 12/15
    +
    +Score pattern = P1 + P2 + P3 + P4 = 23 + 13 + 25 + 12 = 73/100 +score_delta (Bull Call Spread) = +7 [meilleur trade du pattern, IVR=29 optimal spreads] +eff_score = 73 + 7 = 80/100 +
    +
    +
    + + +
    +
    + 📊 +
    +
    ⑤ Calcul EV avec Calibration Progressive
    +
    Pattern avec 3 trades matures (2 gagnants, avg win = +82%)
    +
    +
    +
    +
    +CALIBRATION (crédibilité actuarielle) +k = 5, n = 3 trades matures +w = 3 / (3+5) = 0.375 [37.5% poids observé] + +ai_estimate = 95% | observed_avg_win = 82% +calibrated_move = (1−0.375)×95% + 0.375×82% + = 59.4% + 30.8% = 90.2% + +CALCUL EV +p = eff_score / 100 = 80/100 = 0.80 +G = calibrated_move / 100 = 0.902 + +ev_gross = p × G = 0.80 × 0.902 = 0.722 (+72.2%) +ev_net = p × G − (1−p) = 0.722 − 0.20 = +0.522 ✓ positif +trade_score = 0.722/(0.722+0.20) × 100 = 78.3 / 100 +
    +
    💡 Sans calibration (pattern nouveau, w=0) : G=0.95, ev_net = 0.80×0.95−0.20 = +0.56. Avec calibration : +0.52. La calibration a réduit l'optimisme de l'IA de 7% — ce qui est sain.
    +
    +
    + + +
    +
    + 🎲 +
    +
    ⑥ Mise à Jour Bayésienne Post-Maturation
    +
    Prior neutre de Laplace → les données prennent progressivement le dessus
    +
    +
    +
    +
    +Distribution Beta : α = 1 + wins | β = 1 + losses + +État actuel : 3 trades matures, 2 gagnants +α = 1+2 = 3 | β = 1+1 = 2 +bayesian_win_rate = α/(α+β) = 3/5 = 60% +IC95 ≈ ±0.196 → intervalle crédible [40.4% ; 79.6%] (large → encore peu de données) + +Après 20 trades, 13 gagnants : +α=14, β=8 → WR = 14/22 = 63.6% +IC95 ≈ ±0.082 → [55.4% ; 71.8%] (serré → données consolidées) + +reliability_score = win_rate × log(n+1) + n=3, WR=60% → 0.60 × log(4) = 0.83 (insuffisant, seuil fiable = 1.5) + n=20, WR=64% → 0.64 × log(21) = 1.95 (fiable ✓) +
    +
    +
    + +
    + +
    +
    Zéro boîte noire — Traçabilité complète
    + Toutes les valeurs de ce workflow sont stockées en DB et consultables : le score 73/100 dans pattern_score_history, l'EV dans trade_entry_prices, les raisons du skipped dans skipped_trades, les traces GPT dans ai_reasoning_traces. La page Cycle Report restitue l'intégralité du raisonnement après chaque cycle. +
    + +
    + + +

    12. Specialist Desks — Configuration par Asset Class

    +

    Les Specialist Desks modélisent la réalité d'un hedge fund multi-stratégies où chaque desk (forex, metals, agri, énergie, indices, crypto, obligations) a une culture, des drivers et des rapports critiques spécifiques que le trader généraliste ne connaît pas par défaut.

    + +

    12.1 Architecture et Tables DB

    +

    Trois tables nouvelles créées en Phase 6 :

    + + + + + + + + +
    TableRôleColonnes clés
    asset_class_configsConfiguration par desk (7 desks)asset_class, display_name, icon, fundamentals (TEXT libre), macro_sensitivity (JSON), price_delta_thresholds (JSON)
    specialist_reportsCatalogue de rapports (manuel + auto)id, name, source, cadence, next_date, importance (1–3), notes
    report_desk_linksLiaison N:N reports ↔ desksreport_id, asset_class
    + +

    7 desks pré-configurés au démarrage :

    + + + + + + + + + + + +
    DeskDrivers fondamentaux (exemples)Rapports liés par défaut
    💱 ForexDivergence politique monétaire, différentiel de taux, flux carry trade, balance des paiementsFOMC, ECB, BOJ, NFP, CPI
    🥇 MetalsDollar index, tensions géopolitiques, demande EM, production minière, inflation réelleCOT, CPI
    🌾 AgriMétéo, USDA stocks, cycles de plantation, demande Chine, subventions gouvernementalesWASDE, COT, China PMI, USDA Export Inspections
    ⚡ EnergyOPEC+, inventaires pétrole, demande transport, géopolitique Moyen-Orient, USDEIA Petroleum, Baker Hughes Rig Count, COT
    📊 IndicesEarnings saison, Fed policy, rotation sectorielle, VIX, buybacksFOMC, NFP, CPI, Earnings
    ₿ CryptoRégulation, adoption institutionnelle, flux ETF, halving BTC, risque on/offVIX (proxy risk-off)
    📉 BondsPolitique monétaire, inflation anticipée, pente de courbe, qualité de créditFOMC, ECB, BOJ, CPI, NFP
    + +
    +
    Rapports non-calendrier
    + Contrairement à la page Institutional Reports (rapports auto-fetchés), les Specialist Desks accueillent des rapports entièrement manuels qui n'existent dans aucun calendrier économique standard. Exemple : le Cocoa Grinding Report (trimestriel, World Cocoa Foundation) n'est pas dans Bloomberg ni Reuters — il peut être ajouté manuellement avec une date prévisionnelle et lié au desk Agri. +
    + +

    12.2 Injection dans les Prompts IA

    +

    Le bloc Specialist Desks est injecté à deux endroits du pipeline :

    + + + + + + + +
    PromptAsset classes injectéesContenu par desk
    score_patterns_with_contextUniquement les asset classes présentes dans les patterns scorés ce cycleFondamentaux (≤300 chars), macro sensitivity (4 pairs régime→effet), price thresholds, 5 prochains rapports planifiés
    suggest_patterns_from_market_contextTous les 7 desks systématiquementIdentique — permet à l'IA de générer des suggestions sur tous les desks même si aucun pattern existant ne les couvre
    + +

    Format du bloc injecté dans le prompt (exemple desk Agri) :

    +
    ## SPECIALIST DESK — AGRI 🌾 +Key drivers: Météo, USDA stocks, cycles de plantation, demande Chine... +Regime sensitivity: Stagflation → bullish | Goldilocks → neutral | Récession → bearish | Disinflation → bearish +Price thresholds: significant_week=3% extreme_week=8% +Upcoming reports: + • WASDE (USDA) — 2026-07-11 [monthly] ⭐×3 + • USDA Export Inspections (USDA) — 2026-06-30 [weekly]
    + +
    + + +

    13. Bibliothèque Patterns — Find Similar & Merge

    +

    Après chaque cycle, l'IA peut ajouter des patterns qui recoupent des patterns existants. La feature Find Similar permet de détecter et fusionner ces doublons tout en préservant toutes les données associées.

    + +

    13.1 Analyse de Similarité GPT-4o-mini

    +

    Endpoint : POST /api/patterns/find-similar

    + + + + + + + + +
    RecommandationConditionAction proposée
    merge_as_instanceMême régime + même instrument + même directionBouton "Fusionner dans [X]" avec confirmation
    counter_scenarioMême régime + même instrument + direction opposéeAppliquer le regime_tag commun — les deux patterns restent
    new_patternRégime ou instrument différent, pas de match structurelBadge vert "Pattern unique" — rien à faire
    + +

    Règle critique : ne jamais fusionner des directions opposées (bullish WTI + bearish WTI = deux scénarios valides du même régime, pas un doublon).

    + +

    13.2 Fusion et Remapping Complet des Données

    +

    Endpoint : POST /api/patterns/merge — transaction atomique SQLite :

    + +
    1. Charger pattern_keep + pattern_discard +2. Fusionner historical_instances[] → union + dédup par (date, event) +3. merged_hits = keep.backtest_hits + discard.backtest_hits + merged_runs = keep.backtest_runs_count + discard.backtest_runs_count +4. merged_regime = keep.regime_tag OR discard.regime_tag +5. UPDATE pattern_score_history SET pattern_id = keep_id WHERE pattern_id = discard_id + UPDATE trade_entry_prices SET pattern_id = keep_id WHERE pattern_id = discard_id + UPDATE ai_reasoning_traces SET pattern_id = keep_id WHERE pattern_id = discard_id + UPDATE ai_call_logs SET pattern_id = keep_id WHERE pattern_id = discard_id + UPDATE skipped_trades SET pattern_id = keep_id WHERE pattern_id = discard_id + DELETE FROM pattern_embeddings WHERE pattern_id = discard_id (sera régénéré) +6. UPDATE custom_patterns SET ... WHERE id = keep_id (données fusionnées) +7. DELETE FROM custom_patterns WHERE id = discard_id
    + +
    +
    Intégrité garantie
    + Toutes les tables liées par foreign key logicielle sont remappées avant la suppression. L'Analytics, la Calibration et le Journal continuent de voir les données du pattern discardé, maintenant consolidées sous le pattern conservé. La calibration recalculera le nouveau poids de crédibilité au prochain cycle. +
    + +
    + + +

    14. Calibration Progressive — IA vs Observé

    +

    L'expected_move_pct est initialement une estimation de GPT-4o du rendement option (levier inclus) si la thèse est confirmée. Ce n'est pas un calcul de pricing options (pas de Black-Scholes). La calibration progressive remplace progressivement cette estimation par le rendement réellement observé sur les trades matures.

    + +

    14.1 Formule de Crédibilité et Blending

    +

    Modèle actuariel de crédibilité (Bühlmann-Straub simplifié) :

    + +
    k = 5 [constante de crédibilité] +n = nombre de trades matures (≥35% de l'horizon écoulé) + +w (poids observé) = n / (n + k) + +observed_avg_win_pct = moyenne(pnl_pct) sur les trades gagnants matures +calibrated_expected_move = (1 − w) × ai_estimate + w × observed_avg_win_pct + [seulement calculé si au moins 1 trade gagnant]
    + +

    Progression du poids observé :

    + + + + + + + + + + + +
    n trades maturesPoids observé (w)Source labelInterprétation
    00%Pure AI100% estimation IA, aucune donnée réelle
    117%Early dataLégère influence des données — encore majoritairement IA
    338%Early dataDonnées commencent à corriger l'IA
    550%MixedÉquilibre parfait IA / observé
    1067%MixedDonnées majoritaires
    2080%Data-drivenIA en fond de correction seulement
    5091%Data-drivenQuasi-entièrement ancré dans le réel
    + +
    +
    Rôle dans l'EV
    + Le calibrated_expected_move est le G dans ev_net = p×G − (1−p). Un G trop optimiste (IA pure sur pattern nouveau) surévalue l'EV. Avec 10 trades gagnants observés à 65% en moyenne là où l'IA estimait 95%, le calibré vaut 0.67×65 + 0.33×95 ≈ 75% — l'EV recalculé est 11% plus conservateur. Cela peut faire passer un trade de "loggé" à "skipped" si le profil exige gain ≥ 80%. +
    + +

    14.2 Affichage et Rapport de Cycle

    + +

    Card PatternExplorer — MaturityBadge :

    + + +

    Section Calibration dans le Rapport de Cycle :

    + + +

    Mise à jour automatique : update_bayesian_posteriors() est appelé à la fin de chaque cycle. Il recalcule simultanément les posteriors Beta(α,β) et les valeurs de calibration pour tous les patterns ayant des trades matures.

    + + + + + + +
    Nouvelle colonne custom_patternsTypeDescription
    calibrated_expected_moveREALValeur blendée utilisée comme G dans l'EV. NULL si aucun trade gagnant.
    calibration_weightREAL 0–1w = n/(n+5). Stocké pour affichage frontend sans recalcul.
    observed_avg_win_pctREALMoyenne des pnl_pct > 0 sur trades matures. NULL si aucun gain.

    diff --git a/backend/routers/specialist_desks.py b/backend/routers/specialist_desks.py index 3f30600..9df6b32 100644 --- a/backend/routers/specialist_desks.py +++ b/backend/routers/specialist_desks.py @@ -3,10 +3,13 @@ Specialist Desks — per asset-class fundamental configs + report catalogue. Prefix: /api/specialist-desks """ import uuid +import logging from typing import Optional, List from fastapi import APIRouter, HTTPException from pydantic import BaseModel +logger = logging.getLogger(__name__) + router = APIRouter(prefix="/api/specialist-desks", tags=["specialist-desks"]) @@ -45,6 +48,20 @@ class ReportUpsert(BaseModel): desks: List[str] = [] +class ReportResultUpdate(BaseModel): + actual_value: Optional[float] = None + consensus_estimate: Optional[float] = None + text_sentiment_score: Optional[float] = None + text_sentiment_label: Optional[str] = None + text_sentiment_summary: Optional[str] = None + + +class ScoreTextRequest(BaseModel): + text: str + report_name: str = "" + desk: str = "forex" + + # ── Configs ──────────────────────────────────────────────────────────────────── @router.get("/configs") @@ -142,3 +159,79 @@ def unlink_from_desk(report_id: str, asset_class: str): from services.database import unlink_report_desk unlink_report_desk(report_id, asset_class) return {"unlinked": report_id, "desk": asset_class} + + +# ── Surprise Index ───────────────────────────────────────────────────────────── + +@router.put("/reports/{report_id}/result") +def update_report_result(report_id: str, body: ReportResultUpdate): + from services.database import update_report_result as _upd + _upd( + report_id=report_id, + actual_value=body.actual_value, + consensus_estimate=body.consensus_estimate, + text_sentiment_score=body.text_sentiment_score, + text_sentiment_label=body.text_sentiment_label, + text_sentiment_summary=body.text_sentiment_summary, + ) + return {"saved": report_id} + + +# ── COT Data ─────────────────────────────────────────────────────────────────── + +@router.get("/cot") +def get_cot_data(): + from services.database import get_latest_cot_data + return get_latest_cot_data() + + +@router.post("/cot/fetch") +def fetch_cot(): + from services.cot_fetcher import fetch_all_cot + from services.database import save_cot_data + try: + data = fetch_all_cot() + if data: + saved = save_cot_data(data) + return {"fetched": len(data), "saved": saved} + return {"fetched": 0, "saved": 0, "warning": "No COT data returned"} + except Exception as e: + logger.error(f"COT fetch error: {e}") + raise HTTPException(500, str(e)) + + +# ── Forward Curves ───────────────────────────────────────────────────────────── + +@router.get("/forward-curves") +def get_forward_curves(): + from services.database import get_latest_forward_curves + return get_latest_forward_curves() + + +@router.post("/forward-curves/fetch") +def fetch_forward_curves_endpoint(): + from services.forward_curve import fetch_forward_curves + from services.database import save_forward_curves + try: + data = fetch_forward_curves() + if data: + saved = save_forward_curves(data) + return {"fetched": len(data), "saved": saved} + return {"fetched": 0, "saved": 0, "warning": "No curve data returned"} + except Exception as e: + logger.error(f"Forward curve fetch error: {e}") + raise HTTPException(500, str(e)) + + +# ── Hawk / Dove Text Scorer ──────────────────────────────────────────────────── + +@router.post("/score-text") +def score_text(body: ScoreTextRequest): + """Score a central bank / commodity report excerpt via GPT-4o-mini.""" + try: + from services.ai_analyzer import score_report_text + result = score_report_text(body.text, body.report_name, body.desk) + return result + except Exception as e: + logger.error(f"Text scoring error: {e}") + raise HTTPException(500, str(e)) diff --git a/backend/services/ai_analyzer.py b/backend/services/ai_analyzer.py index 59c888b..e084533 100644 --- a/backend/services/ai_analyzer.py +++ b/backend/services/ai_analyzer.py @@ -377,10 +377,21 @@ SCORE DISTRIBUTION RULE (MANDATORY): def _build_specialist_context_block(asset_classes: set) -> str: - """Build a SPECIALIST DESK CONTEXT block for the given asset classes.""" + """Build SPECIALIST DESK CONTEXT block including COT, forward curves, surprise scores.""" try: - from services.database import get_asset_class_config, get_desk_reports + from services.database import get_asset_class_config, get_desk_reports, get_latest_cot_data, get_latest_forward_curves from datetime import datetime as _dt + + # Fetch COT and forward curve data once + try: + all_cot = get_latest_cot_data() + except Exception: + all_cot = [] + try: + all_curves = get_latest_forward_curves() + except Exception: + all_curves = [] + blocks = [] for ac in sorted(asset_classes): cfg = get_asset_class_config(ac) @@ -388,16 +399,19 @@ def _build_specialist_context_block(asset_classes: set) -> str: continue reports = get_desk_reports(ac) lines = [f"\n## SPECIALIST DESK — {cfg['display_name'].upper()} {cfg.get('icon','')}"] + if cfg.get("fundamentals"): lines.append(f"Key drivers: {cfg['fundamentals'][:300]}") + # Macro sensitivity sensitivities = cfg.get("macro_sensitivity") or [] if sensitivities: sens_str = " | ".join( - f"{s.get('regime','?')} → {s.get('effect','?')}" + f"{s.get('regime','?')} -> {s.get('effect','?')}" for s in sensitivities[:4] ) lines.append(f"Regime sensitivity: {sens_str}") + # Price thresholds thresh = cfg.get("price_delta_thresholds") or {} if thresh: @@ -405,7 +419,30 @@ def _build_specialist_context_block(asset_classes: set) -> str: f"Price thresholds: significant_week={thresh.get('significant_week','?')}% " f"extreme_week={thresh.get('extreme_week','?')}%" ) - # Upcoming reports + + # COT positioning for this asset class + cot_ac = [c for c in all_cot if c.get("asset_class") == ac] + if cot_ac: + lines.append("COT Positioning (Money Managers net, % of OI):") + for c in cot_ac[:5]: + net_pct = c.get("net_pct_oi", 0) + chg = c.get("change_net", 0) + direction = "LONG" if net_pct > 5 else "SHORT" if net_pct < -5 else "NEUTRAL" + chg_str = f" (chg {'+' if chg >= 0 else ''}{chg:,} wk)" if chg else "" + lines.append(f" - {c['commodity']}: net={net_pct:+.1f}% OI [{direction}]{chg_str} -- {c.get('report_date','?')}") + + # Forward curves for this asset class + curves_ac = [c for c in all_curves if c.get("asset_class") == ac] + if curves_ac: + lines.append("Forward Curve Structure (spot vs +3M):") + for curve in curves_ac[:5]: + struct = curve.get("structure", "unknown") + slope = curve.get("slope_pct") + slope_str = f" ({slope:+.1f}%)" if slope is not None else "" + struct_label = struct.upper() + lines.append(f" - {curve['asset']}: {struct_label}{slope_str}") + + # Recent reports with surprise scores today = _dt.utcnow().strftime("%Y-%m-%d") upcoming = sorted( [r for r in reports if r.get("next_date") and r["next_date"] >= today], @@ -414,16 +451,85 @@ def _build_specialist_context_block(asset_classes: set) -> str: if upcoming: lines.append("Upcoming reports:") for r in upcoming: - lines.append( - f" • {r['name']} ({r['source']}) — {r['next_date']} [{r['cadence']}]" - + (f" ⭐×{r['importance']}" if r.get("importance", 1) >= 3 else "") - ) + line = f" - {r['name']} ({r['source']}) -- {r['next_date']} [{r['cadence']}]" + if r.get("importance", 1) >= 3: + line += " ***" + if r.get("consensus_estimate") is not None: + line += f" [consensus: {r['consensus_estimate']}]" + lines.append(line) + + # Recent releases with surprise scores + recent_releases = sorted( + [r for r in reports if r.get("last_date") and r.get("surprise_score") is not None], + key=lambda r: r.get("last_date", ""), + reverse=True + )[:3] + if recent_releases: + lines.append("Recent releases (surprise index = actual - consensus):") + for r in recent_releases: + surprise = r["surprise_score"] + label = r.get("text_sentiment_label", "") + label_str = f" [{label}]" if label else "" + sign = "+" if surprise >= 0 else "" + lines.append(f" - {r['name']}: surprise={sign}{surprise:.2f}{label_str} -- {r.get('last_date','?')}") + blocks.append("\n".join(lines)) return "\n".join(blocks) + "\n" if blocks else "" except Exception: return "" +def score_report_text(text: str, report_name: str = "", desk: str = "forex") -> Dict: + """Score a report/statement excerpt. Returns score (-1 to +1), label, summary, key_phrases.""" + if desk in ("forex", "bonds"): + dimension = "monetary policy stance: -1 = very dovish (rate cuts expected), 0 = neutral, +1 = very hawkish (rate hikes expected)" + examples = "Hawkish: 'inflation remains persistent', 'further tightening may be appropriate'. Dovish: 'inflation returning to target', 'downside risks dominate', 'easing cycle beginning'." + elif desk in ("energy", "metals", "agri"): + dimension = "commodity market outlook: -1 = very bearish (oversupply, demand weakness), 0 = neutral, +1 = very bullish (deficit, demand surge)" + examples = "Bullish: 'output cuts extended', 'crop failure', 'stockpiles at multi-year low'. Bearish: 'surplus expected', 'demand revision lower', 'record production'." + else: + dimension = "market sentiment: -1 = very bearish, 0 = neutral, +1 = very bullish" + examples = "" + + prompt = f"""You are a macro trading analyst. Analyze the following text and return ONLY valid JSON. + +Report: {report_name or 'Unknown'} +Desk: {desk} +Score dimension: {dimension} +{examples} + +TEXT TO ANALYZE: +{text[:3000]} + +Return JSON with exactly these fields: +{{ + "score": , + "label": <"very_hawkish" | "hawkish" | "neutral" | "dovish" | "very_dovish"> (for forex/bonds) + or <"very_bullish" | "bullish" | "neutral" | "bearish" | "very_bearish"> (for others), + "summary": <1-2 sentence summary of the key signal>, + "key_phrases": [], + "confidence": <"high" | "medium" | "low"> +}}""" + + try: + import openai as _oai + from services.database import get_config as _gc + api_key = _gc("openai_api_key") or "" + if not api_key: + return {"score": 0, "label": "neutral", "summary": "No API key configured", "key_phrases": [], "confidence": "low"} + client = _oai.OpenAI(api_key=api_key) + response = client.chat.completions.create( + model="gpt-4o-mini", + messages=[{"role": "user", "content": prompt}], + temperature=0.1, + response_format={"type": "json_object"}, + ) + import json + return json.loads(response.choices[0].message.content) + except Exception as e: + return {"score": 0, "label": "neutral", "summary": f"Error: {str(e)}", "key_phrases": [], "confidence": "low"} + + def score_patterns_with_context( patterns: List[Dict], recent_news: List[Dict], diff --git a/backend/services/auto_cycle.py b/backend/services/auto_cycle.py index 24181e5..d6ee89e 100644 --- a/backend/services/auto_cycle.py +++ b/backend/services/auto_cycle.py @@ -594,6 +594,21 @@ def run_cycle_once(trigger: str = "auto") -> Dict[str, Any]: except Exception as _ibe: logger.warning(f"[Cycle] Institutional block failed (non-blocking): {_ibe}") + # ── COT + Forward Curves (background refresh, non-blocking) ─────────────── + try: + from services.cot_fetcher import fetch_all_cot + from services.forward_curve import fetch_forward_curves + from services.database import save_cot_data, save_forward_curves as _sfc + cot_data = fetch_all_cot() + if cot_data: + save_cot_data(cot_data) + curve_data = fetch_forward_curves() + if curve_data: + _sfc(curve_data) + except Exception as _e: + import logging as _logging + _logging.getLogger(__name__).warning(f"COT/Curve refresh failed (non-blocking): {_e}") + suggestions = suggest_patterns_from_market_context( news, quotes, calendar, macro_regime=macro_regime, geo_score=geo_score_obj, portfolio_lessons=portfolio_lessons, diff --git a/backend/services/cot_fetcher.py b/backend/services/cot_fetcher.py index a6f6e02..237a724 100644 --- a/backend/services/cot_fetcher.py +++ b/backend/services/cot_fetcher.py @@ -1,6 +1,10 @@ """ CFTC Commitment of Traders (COT) weekly fetcher. Data from CFTC Socrata public API — no API key required. + +Two modes: + - fetch_cot_report(): legacy institutional_reports format (used by institutional_scheduler) + - fetch_all_cot(): flat list of per-commodity/asset COT entries (used by specialist desks / cot_data table) """ import logging import math @@ -192,3 +196,162 @@ def fetch_cot_report() -> Optional[Dict]: **signals, "ai_summary": ai_summary, } + + +# ── Specialist Desks flat COT feed (cot_data table) ────────────────────────── + +_DISAGG_URL = "https://publicreporting.cftc.gov/resource/72hh-3qpy.json" # disaggregated commodities +_LEGACY_FIN_URL = "https://publicreporting.cftc.gov/resource/gpe5-46if.json" # financial/forex + +_DISAGG_MARKETS = [ + ("CRUDE OIL, LIGHT SWEET - NYMEX", "WTI Crude", "energy"), + ("NATURAL GAS - NYMEX", "Natural Gas", "energy"), + ("GOLD - COMEX", "Gold", "metals"), + ("SILVER - COMEX", "Silver", "metals"), + ("COPPER- #1 - COMEX", "Copper", "metals"), + ("CORN - CBOT", "Corn", "agri"), + ("WHEAT - CBOT", "Wheat", "agri"), + ("SOYBEANS - CBOT", "Soybeans", "agri"), + ("SOYBEAN OIL - CBOT", "Soybean Oil", "agri"), + ("COCOA - ICE", "Cocoa", "agri"), + ("COFFEE C - ICE", "Coffee", "agri"), +] + +_FIN_MARKETS = [ + ("EURO FX - CME", "Euro (EUR/USD)", "forex"), + ("JAPANESE YEN - CME", "Japanese Yen", "forex"), + ("BRITISH POUND STERLING - CME", "British Pound", "forex"), + ("SWISS FRANC - CME", "Swiss Franc", "forex"), + ("U.S. DOLLAR INDEX - ICE FUTURES U.S.", "DXY Index", "forex"), + ("30-DAY FEDERAL FUNDS - CBOT", "Fed Funds", "bonds"), + ("U.S. TREASURY BONDS - CBOT", "US T-Bonds", "bonds"), + ("10-YEAR U.S. TREASURY NOTES - CBOT", "10Y T-Notes", "bonds"), +] + + +def _parse_int_flat(v) -> int: + try: + return int(str(v).replace(",", "").strip()) + except Exception: + return 0 + + +def _fetch_disaggregated_flat() -> List[Dict[str, Any]]: + """Fetch commodity COT (disaggregated) — MM long/short positions.""" + results = [] + market_names = [m[0] for m in _DISAGG_MARKETS] + name_map = {m[0]: (m[1], m[2]) for m in _DISAGG_MARKETS} + + quoted = ", ".join(f"'{n}'" for n in market_names) + params = { + "$select": "market_and_exchange_names,report_date_as_yyyy_mm_dd,m_money_positions_long_all,m_money_positions_short_all,open_interest_all,change_in_m_money_long_all,change_in_m_money_short_all", + "$where": f"market_and_exchange_names in ({quoted})", + "$order": "report_date_as_yyyy_mm_dd DESC", + "$limit": str(len(market_names) * 2), + } + try: + resp = requests.get(_DISAGG_URL, params=params, timeout=20) + resp.raise_for_status() + rows = resp.json() + except Exception as e: + logger.error(f"COT disaggregated fetch failed: {e}") + return [] + + seen: set = set() + for row in rows: + mkt = row.get("market_and_exchange_names", "") + if mkt not in name_map or mkt in seen: + continue + seen.add(mkt) + label, ac = name_map[mkt] + + mm_long = _parse_int_flat(row.get("m_money_positions_long_all", 0)) + mm_short = _parse_int_flat(row.get("m_money_positions_short_all", 0)) + oi = _parse_int_flat(row.get("open_interest_all", 0)) + net = mm_long - mm_short + chg_long = _parse_int_flat(row.get("change_in_m_money_long_all", 0)) + chg_short = _parse_int_flat(row.get("change_in_m_money_short_all", 0)) + change_net = chg_long - chg_short + net_pct_oi = round(net / oi * 100, 2) if oi else 0.0 + + results.append({ + "market_name": mkt, + "commodity": label, + "asset_class": ac, + "report_date": row.get("report_date_as_yyyy_mm_dd", "")[:10], + "mm_long": mm_long, + "mm_short": mm_short, + "open_interest": oi, + "net_position": net, + "net_pct_oi": net_pct_oi, + "change_net": change_net, + }) + + return results + + +def _fetch_financial_flat() -> List[Dict[str, Any]]: + """Fetch financial/forex COT (legacy) — Non-commercial long/short.""" + results = [] + market_names = [m[0] for m in _FIN_MARKETS] + name_map = {m[0]: (m[1], m[2]) for m in _FIN_MARKETS} + + quoted = ", ".join(f"'{n}'" for n in market_names) + params = { + "$select": "market_and_exchange_names,report_date_as_yyyy_mm_dd,noncomm_positions_long_all,noncomm_positions_short_all,open_interest_all,change_in_noncomm_long_all,change_in_noncomm_short_all", + "$where": f"market_and_exchange_names in ({quoted})", + "$order": "report_date_as_yyyy_mm_dd DESC", + "$limit": str(len(market_names) * 2), + } + try: + resp = requests.get(_LEGACY_FIN_URL, params=params, timeout=20) + resp.raise_for_status() + rows = resp.json() + except Exception as e: + logger.error(f"COT financial fetch failed: {e}") + return [] + + seen: set = set() + for row in rows: + mkt = row.get("market_and_exchange_names", "") + if mkt not in name_map or mkt in seen: + continue + seen.add(mkt) + label, ac = name_map[mkt] + + nc_long = _parse_int_flat(row.get("noncomm_positions_long_all", 0)) + nc_short = _parse_int_flat(row.get("noncomm_positions_short_all", 0)) + oi = _parse_int_flat(row.get("open_interest_all", 0)) + net = nc_long - nc_short + chg_long = _parse_int_flat(row.get("change_in_noncomm_long_all", 0)) + chg_short = _parse_int_flat(row.get("change_in_noncomm_short_all", 0)) + change_net = chg_long - chg_short + net_pct_oi = round(net / oi * 100, 2) if oi else 0.0 + + results.append({ + "market_name": mkt, + "commodity": label, + "asset_class": ac, + "report_date": row.get("report_date_as_yyyy_mm_dd", "")[:10], + "mm_long": nc_long, + "mm_short": nc_short, + "open_interest": oi, + "net_position": net, + "net_pct_oi": net_pct_oi, + "change_net": change_net, + }) + + return results + + +def fetch_all_cot() -> List[Dict[str, Any]]: + """Fetch both disaggregated (commodities) and financial (forex/bonds) COT data. + + Returns a flat list of per-market dicts suitable for save_cot_data(). + """ + logger.info("Fetching COT data from CFTC (specialist desks feed)...") + all_data: List[Dict[str, Any]] = [] + all_data.extend(_fetch_disaggregated_flat()) + all_data.extend(_fetch_financial_flat()) + logger.info(f"COT: fetched {len(all_data)} markets") + return all_data diff --git a/backend/services/database.py b/backend/services/database.py index 8b5a085..ac3a336 100644 --- a/backend/services/database.py +++ b/backend/services/database.py @@ -108,6 +108,32 @@ def init_db(): except Exception: pass + # Specialist Reports — surprise index + text sentiment columns + try: + c.execute("ALTER TABLE specialist_reports ADD COLUMN consensus_estimate REAL") + except Exception: + pass + try: + c.execute("ALTER TABLE specialist_reports ADD COLUMN actual_value REAL") + except Exception: + pass + try: + c.execute("ALTER TABLE specialist_reports ADD COLUMN surprise_score REAL") + except Exception: + pass + try: + c.execute("ALTER TABLE specialist_reports ADD COLUMN text_sentiment_score REAL") + except Exception: + pass + try: + c.execute("ALTER TABLE specialist_reports ADD COLUMN text_sentiment_label TEXT") + except Exception: + pass + try: + c.execute("ALTER TABLE specialist_reports ADD COLUMN text_sentiment_summary TEXT") + except Exception: + pass + # Pattern Lab — historical backtest runs c.execute("""CREATE TABLE IF NOT EXISTS backtest_lab_runs ( id TEXT PRIMARY KEY, @@ -677,6 +703,36 @@ def init_db(): PRIMARY KEY (report_id, asset_class) )""") + # ── COT & Forward Curve data ────────────────────────────────────────────── + c.execute("""CREATE TABLE IF NOT EXISTS cot_data ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + market_name TEXT NOT NULL, + commodity TEXT NOT NULL, + asset_class TEXT NOT NULL, + report_date TEXT NOT NULL, + mm_long INTEGER DEFAULT 0, + mm_short INTEGER DEFAULT 0, + open_interest INTEGER DEFAULT 0, + net_position INTEGER DEFAULT 0, + net_pct_oi REAL DEFAULT 0.0, + change_net INTEGER DEFAULT 0, + fetched_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(commodity, report_date) + )""") + + c.execute("""CREATE TABLE IF NOT EXISTS forward_curve_data ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + asset TEXT NOT NULL, + asset_class TEXT NOT NULL, + front_price REAL, + far_price REAL, + slope_pct REAL, + structure TEXT NOT NULL DEFAULT 'unknown', + months_spread INTEGER DEFAULT 3, + fetched_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(asset, DATE(fetched_at)) + )""") + # Seed desk defaults (idempotent) _desk_count = c.execute("SELECT COUNT(*) FROM asset_class_configs").fetchone()[0] if _desk_count == 0: @@ -4268,3 +4324,109 @@ def upsert_asset_class_config( return True finally: conn.close() + + +# ── COT Data ────────────────────────────────────────────────────────────────── + +def save_cot_data(entries: List[Dict[str, Any]]) -> int: + conn = get_conn() + try: + saved = 0 + for e in entries: + conn.execute("""INSERT INTO cot_data + (market_name, commodity, asset_class, report_date, mm_long, mm_short, + open_interest, net_position, net_pct_oi, change_net) + VALUES (?,?,?,?,?,?,?,?,?,?) + ON CONFLICT(commodity, report_date) DO UPDATE SET + mm_long=excluded.mm_long, mm_short=excluded.mm_short, + open_interest=excluded.open_interest, net_position=excluded.net_position, + net_pct_oi=excluded.net_pct_oi, change_net=excluded.change_net, + fetched_at=datetime('now')""", + (e["market_name"], e["commodity"], e["asset_class"], e["report_date"], + e.get("mm_long", 0), e.get("mm_short", 0), e.get("open_interest", 0), + e.get("net_position", 0), e.get("net_pct_oi", 0.0), e.get("change_net", 0))) + saved += 1 + conn.commit() + return saved + finally: + conn.close() + + +def get_latest_cot_data() -> List[Dict[str, Any]]: + conn = get_conn() + try: + rows = conn.execute(""" + SELECT * FROM cot_data + WHERE (commodity, report_date) IN ( + SELECT commodity, MAX(report_date) FROM cot_data GROUP BY commodity + ) + ORDER BY asset_class, net_pct_oi DESC""").fetchall() + return [dict(r) for r in rows] + finally: + conn.close() + + +# ── Forward Curve Data ──────────────────────────────────────────────────────── + +def save_forward_curves(entries: List[Dict[str, Any]]) -> int: + conn = get_conn() + try: + saved = 0 + for e in entries: + conn.execute("""INSERT INTO forward_curve_data + (asset, asset_class, front_price, far_price, slope_pct, structure, months_spread) + VALUES (?,?,?,?,?,?,?) + ON CONFLICT(asset, DATE(fetched_at)) DO UPDATE SET + front_price=excluded.front_price, far_price=excluded.far_price, + slope_pct=excluded.slope_pct, structure=excluded.structure, + fetched_at=datetime('now')""", + (e["asset"], e["asset_class"], e.get("front_price"), e.get("far_price"), + e.get("slope_pct"), e.get("structure", "unknown"), e.get("months_spread", 3))) + saved += 1 + conn.commit() + return saved + finally: + conn.close() + + +def get_latest_forward_curves() -> List[Dict[str, Any]]: + conn = get_conn() + try: + rows = conn.execute(""" + SELECT * FROM forward_curve_data + WHERE (asset, DATE(fetched_at)) IN ( + SELECT asset, MAX(DATE(fetched_at)) FROM forward_curve_data GROUP BY asset + ) + ORDER BY asset_class, asset""").fetchall() + return [dict(r) for r in rows] + finally: + conn.close() + + +# ── Surprise Index ──────────────────────────────────────────────────────────── + +def update_report_result( + report_id: str, + actual_value: Optional[float], + consensus_estimate: Optional[float], + text_sentiment_score: Optional[float] = None, + text_sentiment_label: Optional[str] = None, + text_sentiment_summary: Optional[str] = None, +) -> bool: + conn = get_conn() + try: + surprise = None + if actual_value is not None and consensus_estimate is not None: + surprise = round(actual_value - consensus_estimate, 4) + conn.execute("""UPDATE specialist_reports SET + actual_value=?, consensus_estimate=?, surprise_score=?, + text_sentiment_score=?, text_sentiment_label=?, text_sentiment_summary=?, + updated_at=datetime('now') + WHERE id=?""", + (actual_value, consensus_estimate, surprise, + text_sentiment_score, text_sentiment_label, text_sentiment_summary, + report_id)) + conn.commit() + return True + finally: + conn.close() diff --git a/backend/services/forward_curve.py b/backend/services/forward_curve.py new file mode 100644 index 0000000..13eb4e3 --- /dev/null +++ b/backend/services/forward_curve.py @@ -0,0 +1,95 @@ +""" +Forward Curve Service — contango / backwardation per commodity. +Uses yfinance to compare front-month vs near-dated futures contracts. +""" +import logging +from datetime import datetime, timedelta +from typing import List, Dict, Any, Optional + +logger = logging.getLogger(__name__) + +# Month codes for futures tickers (standard CME/NYMEX/CBOT convention) +_MONTH_CODES = {1:'F',2:'G',3:'H',4:'J',5:'K',6:'M',7:'N',8:'Q',9:'U',10:'V',11:'X',12:'Z'} + +# (base_ticker, label, asset_class, months_spread) +_CURVE_SPECS = [ + ("CL", "WTI Crude", "energy", 3), + ("NG", "Natural Gas", "energy", 3), + ("GC", "Gold", "metals", 3), + ("SI", "Silver", "metals", 3), + ("HG", "Copper", "metals", 3), + ("ZC", "Corn", "agri", 3), + ("ZW", "Wheat", "agri", 3), + ("ZS", "Soybeans", "agri", 3), +] + + +def _ticker_for_month(base: str, months_ahead: int) -> str: + target = datetime.now() + timedelta(days=months_ahead * 30) + code = _MONTH_CODES[target.month] + year = str(target.year)[-2:] + return f"{base}{code}{year}" + + +def _get_price(ticker: str) -> Optional[float]: + try: + import yfinance as yf + data = yf.download(ticker, period="3d", progress=False, auto_adjust=True) + if data.empty: + return None + close = data["Close"].dropna() + if close.empty: + return None + val = float(close.iloc[-1]) + # Handle multi-index DataFrames + if hasattr(val, '__iter__'): + val = list(val)[0] + return round(val, 4) if val and val > 0 else None + except Exception: + return None + + +def fetch_forward_curves() -> List[Dict[str, Any]]: + """Fetch front-month and far-month prices to compute curve slope.""" + results = [] + for base, label, ac, spread in _CURVE_SPECS: + front_ticker = f"{base}=F" + front_price = _get_price(front_ticker) + if not front_price: + logger.warning(f"Forward curve: no front-month price for {label} ({front_ticker})") + continue + + # Try M+spread, M+spread-1, M+spread+1 (some contract months are illiquid) + far_price = None + for offset in [0, -1, 1, -2, 2]: + far_ticker = _ticker_for_month(base, spread + offset) + far_price = _get_price(far_ticker) + if far_price: + break + + slope_pct = None + structure = "unknown" + if far_price: + slope_pct = round((far_price - front_price) / front_price * 100, 2) + if slope_pct > 0.15: + structure = "contango" + elif slope_pct < -0.15: + structure = "backwardation" + else: + structure = "flat" + + results.append({ + "asset": label, + "asset_class": ac, + "front_price": round(front_price, 2), + "far_price": round(far_price, 2) if far_price else None, + "slope_pct": slope_pct, + "structure": structure, + "months_spread": spread, + }) + logger.info( + f"Forward curve {label}: front={front_price:.2f} " + f"far={far_price} slope={slope_pct}% -> {structure}" + ) + + return results diff --git a/frontend/src/hooks/useApi.ts b/frontend/src/hooks/useApi.ts index 21bbfa9..1f64dbb 100644 --- a/frontend/src/hooks/useApi.ts +++ b/frontend/src/hooks/useApi.ts @@ -1252,3 +1252,107 @@ export const useUnlinkReportDesk = () => { }, }) } + +// ── COT / Forward Curves / Surprise Index / Hawk-Dove — new types ───────────── + +export interface CotEntry { + id: number + market_name: string + commodity: string + asset_class: string + report_date: string + mm_long: number + mm_short: number + open_interest: number + net_position: number + net_pct_oi: number + change_net: number + fetched_at: string +} + +export interface ForwardCurveEntry { + id: number + asset: string + asset_class: string + front_price: number | null + far_price: number | null + slope_pct: number | null + structure: 'contango' | 'backwardation' | 'flat' | 'unknown' + months_spread: number + fetched_at: string +} + +export interface TextSentimentResult { + score: number + label: string + summary: string + key_phrases: string[] + confidence: 'high' | 'medium' | 'low' +} + +export interface ReportResultUpdate { + actual_value?: number | null + consensus_estimate?: number | null + text_sentiment_score?: number | null + text_sentiment_label?: string | null + text_sentiment_summary?: string | null +} + +// ── COT Data ────────────────────────────────────────────────────────────────── + +export function useCotData() { + return useQuery({ + queryKey: ['specialist-desks', 'cot'], + queryFn: () => api.get('/specialist-desks/cot').then(r => r.data), + staleTime: 1000 * 60 * 60, // 1h + }) +} + +export function useFetchCot() { + const qc = useQueryClient() + return useMutation({ + mutationFn: () => api.post('/specialist-desks/cot/fetch').then(r => r.data), + onSuccess: () => qc.invalidateQueries({ queryKey: ['specialist-desks', 'cot'] }), + }) +} + +// ── Forward Curves ──────────────────────────────────────────────────────────── + +export function useForwardCurves() { + return useQuery({ + queryKey: ['specialist-desks', 'forward-curves'], + queryFn: () => api.get('/specialist-desks/forward-curves').then(r => r.data), + staleTime: 1000 * 60 * 60, + }) +} + +export function useFetchForwardCurves() { + const qc = useQueryClient() + return useMutation({ + mutationFn: () => api.post('/specialist-desks/forward-curves/fetch').then(r => r.data), + onSuccess: () => qc.invalidateQueries({ queryKey: ['specialist-desks', 'forward-curves'] }), + }) +} + +// ── Surprise Index ──────────────────────────────────────────────────────────── + +export function useUpdateReportResult() { + const qc = useQueryClient() + return useMutation({ + mutationFn: ({ id, body }: { id: string; body: ReportResultUpdate }) => + api.put(`/specialist-desks/reports/${id}/result`, body).then(r => r.data), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['specialist-reports'] }) + qc.invalidateQueries({ queryKey: ['desk-configs'] }) + }, + }) +} + +// ── Hawk/Dove Text Scorer ───────────────────────────────────────────────────── + +export function useScoreText() { + return useMutation({ + mutationFn: (body: { text: string; report_name: string; desk: string }) => + api.post('/specialist-desks/score-text', body).then(r => r.data) as Promise, + }) +} diff --git a/frontend/src/pages/SpecialistDesks.tsx b/frontend/src/pages/SpecialistDesks.tsx index 15dc30d..602fc27 100644 --- a/frontend/src/pages/SpecialistDesks.tsx +++ b/frontend/src/pages/SpecialistDesks.tsx @@ -4,12 +4,17 @@ import { useUpdateDeskConfig, useCreateSpecialistReport, useUpdateSpecialistReport, useDeleteSpecialistReport, useLinkReportDesk, useUnlinkReportDesk, + useCotData, useFetchCot, useForwardCurves, useFetchForwardCurves, + useUpdateReportResult, useScoreText, DeskConfig, SpecialistReport, MacroSensitivity, + TextSentimentResult, ReportResultUpdate, } from '../hooks/useApi' import { Users, FileText, Plus, Trash2, Save, X, Edit2, ChevronRight, Star, Link, ExternalLink, RefreshCw, AlertCircle, Calendar, BookOpen, + TrendingUp, TrendingDown, Minus, BarChart2, FlaskConical, + ArrowUp, ArrowDown, Activity, } from 'lucide-react' import clsx from 'clsx' @@ -230,6 +235,79 @@ function MacroSensitivityEditor({ ) } +// ── Surprise Index inline component ─────────────────────────────────────────── + +function SurpriseInput({ report, onSave }: { + report: any + onSave: (data: ReportResultUpdate) => void +}) { + const [consensus, setConsensus] = useState(report.consensus_estimate?.toString() ?? '') + const [actual, setActual] = useState(report.actual_value?.toString() ?? '') + const [open, setOpen] = useState(false) + + const surprise = actual !== '' && consensus !== '' + ? parseFloat(actual) - parseFloat(consensus) + : null + + return ( +
    + {!open && ( + + )} + {open && ( +
    +
    +
    +
    Consensus
    + setConsensus(e.target.value)} type="number" step="any" + placeholder="0.0" + className="w-full px-2 py-1 text-[10px] bg-dark-800 border border-slate-600/50 rounded text-slate-300 focus:outline-none focus:border-violet-500 font-mono" /> +
    +
    +
    Actual
    + setActual(e.target.value)} type="number" step="any" + placeholder="0.0" + className="w-full px-2 py-1 text-[10px] bg-dark-800 border border-slate-600/50 rounded text-slate-300 focus:outline-none focus:border-violet-500 font-mono" /> +
    + {surprise !== null && ( +
    +
    Surprise
    +
    0 ? 'text-emerald-400 border-emerald-800/40 bg-emerald-900/20' : + surprise < 0 ? 'text-red-400 border-red-800/40 bg-red-900/20' : + 'text-slate-400 border-slate-700/40')}> + {surprise >= 0 ? '+' : ''}{surprise.toFixed(3)} +
    +
    + )} +
    +
    + + +
    +
    + )} +
    + ) +} + // ── Main page ────────────────────────────────────────────────────────────────── export default function SpecialistDesks() { @@ -242,12 +320,22 @@ export default function SpecialistDesks() { const { mutate: linkReport } = useLinkReportDesk() const { mutate: unlinkReport } = useUnlinkReportDesk() + const { data: cotData = [], isLoading: cotLoading } = useCotData() + const { data: curveData = [], isLoading: curvesLoading } = useForwardCurves() + const { mutate: fetchCot, isPending: fetchingCot } = useFetchCot() + const { mutate: fetchCurves, isPending: fetchingCurves } = useFetchForwardCurves() + const { mutateAsync: updateReportResult } = useUpdateReportResult() + const { mutateAsync: scoreText, isPending: scoringText } = useScoreText() + const [scoreTextInput, setScoreTextInput] = useState('') + const [scoreTextResult, setScoreTextResult] = useState(null) + const [scoreReportName, setScoreReportName] = useState('') + const orderedDesks = DESK_ORDER .map(ac => desks.find(d => d.asset_class === ac)) .filter(Boolean) as DeskConfig[] const [activeDeskAc, setActiveDeskAc] = useState(DESK_ORDER[0]) - const [activeTab, setActiveTab] = useState<'config' | 'reports' | 'all-reports'>('config') + const [activeTab, setActiveTab] = useState<'config' | 'reports' | 'all-reports' | 'cot' | 'curves'>('config') // Desk edit state const [editFund, setEditFund] = useState('') @@ -362,10 +450,225 @@ export default function SpecialistDesks() { All Reports ({allReports.length}) + +
    {/* ── Right: desk detail / all reports ──────────────────────────────── */}
    + + {/* ══ COT VIEW ════════════════════════════════════════════ */} + {activeTab === 'cot' && ( +
    +
    +
    +

    + + COT Positioning +

    +

    CFTC Commitment of Traders — Money Manager net positions (% of Open Interest)

    +
    + +
    + + {cotLoading ? ( +
    + Loading… +
    + ) : cotData.length === 0 ? ( +
    + No COT data yet. Click "Refresh CFTC" to fetch the latest weekly report. +
    + ) : ( +
    + {(['energy', 'metals', 'agri', 'forex', 'bonds'] as const).map(ac => { + const entries = cotData.filter(c => c.asset_class === ac) + if (!entries.length) return null + const labels: Record = { energy: '⚡ Energy', metals: '🥇 Metals', agri: '🌾 Agri & Softs', forex: '💱 Forex', bonds: '📉 Bonds' } + const reportDate = entries[0]?.report_date + return ( +
    +
    + {labels[ac] || ac} + {reportDate && COT week of {reportDate}} +
    +
    + {entries.map(entry => { + const pct = entry.net_pct_oi + const isLong = pct > 5 + const isShort = pct < -5 + const barWidth = Math.min(Math.abs(pct), 50) // cap at 50% + const chg = entry.change_net + return ( +
    +
    {entry.commodity}
    + {/* Net position bar */} +
    +
    +
    +
    +
    + {pct > 0 ? ( +
    + ) : ( +
    + )} +
    + + {pct >= 0 ? '+' : ''}{pct.toFixed(1)}% + + + {isLong ? 'LONG' : isShort ? 'SHORT' : 'NEUTRAL'} + +
    + {/* Weekly change */} + {chg !== 0 && ( +
    0 ? 'text-emerald-400' : 'text-red-400')}> + {chg > 0 ? : } + {Math.abs(chg).toLocaleString()} +
    + )} + {/* Net position (contracts) */} +
    + {entry.net_position.toLocaleString()} net +
    +
    + ) + })} +
    +
    + ) + })} +

    + Source: CFTC Disaggregated Futures (commodities) + Legacy Financial (forex/bonds) — published weekly on Fridays +

    +
    + )} +
    + )} + + {/* ══ FORWARD CURVES VIEW ══════════════════════════════════ */} + {activeTab === 'curves' && ( +
    +
    +
    +

    + + Forward Curves +

    +

    Contango / Backwardation — spot vs +3M futures slope (yfinance)

    +
    + +
    + + {curvesLoading ? ( +
    + Loading… +
    + ) : curveData.length === 0 ? ( +
    + No forward curve data yet. Click "Refresh Curves" to fetch. +
    + ) : ( +
    + {(['energy', 'metals', 'agri'] as const).map(ac => { + const entries = curveData.filter(c => c.asset_class === ac) + if (!entries.length) return null + const labels: Record = { energy: '⚡ Energy', metals: '🥇 Metals', agri: '🌾 Agri & Softs' } + return ( +
    +
    + {labels[ac]} +
    +
    + {entries.map(e => { + const isContango = e.structure === 'contango' + const isBackward = e.structure === 'backwardation' + const slope = e.slope_pct + return ( +
    +
    +
    {e.asset}
    +
    + Front: {e.front_price?.toFixed(2) ?? '—'} + {e.far_price && ` | +3M: ${e.far_price.toFixed(2)}`} +
    +
    +
    +
    + {isContango ? + : isBackward ? + : } + {e.structure.toUpperCase()} +
    + {slope !== null && ( +
    0 ? 'text-sky-500' : slope < 0 ? 'text-orange-500' : 'text-slate-500' + )}> + {slope >= 0 ? '+' : ''}{slope.toFixed(2)}% +
    + )} +
    +
    + ) + })} +
    +
    + ) + })} +
    +
    +
    CONTANGO = futures prix > spot → marché normal (coût de stockage) → pénalise les positions longues au rollover
    +
    BACKWARDATION = futures prix < spot → pénurie physique immédiate → profite aux positions longues au rollover
    +
    +
    +
    + )} +
    + )} + {activeTab === 'all-reports' ? ( /* ══ ALL REPORTS VIEW ════════════════════════════════════════════ */
    @@ -560,6 +863,87 @@ export default function SpecialistDesks() { className="w-full px-3 py-2 text-xs bg-dark-700 border border-slate-600/50 rounded text-slate-300 focus:outline-none focus:border-violet-500 resize-none" />
    + + {/* ── Hawk/Dove Text Scorer (Forex & Bonds desks only) ── */} + {(activeDeskAc === 'forex' || activeDeskAc === 'bonds') && ( +
    +
    + + Hawk/Dove Text Scorer + — Paste a central bank statement to get an IA sentiment score +
    +
    + setScoreReportName(e.target.value)} + placeholder="Report name (e.g. FOMC Minutes June 2026)" + className="w-full px-3 py-2 text-xs bg-dark-700 border border-slate-600/50 rounded text-slate-200 focus:outline-none focus:border-violet-500" + /> +