commit d256b65d30f7744a476cc073ce936beb96728de3 Author: OpenSquared Date: Tue Jun 16 20:29:59 2026 +0200 Initial commit — GeoOptions Intelligence Cockpit v2.0 Stack: FastAPI + React/TypeScript + SQLite + GPT-4o Features: Radar géopolitique, Marchés, Régime Macro, Journal de Bord MTM, Rapport IA, Super Contexte (base de raisonnement évolutive), Boucle feedback IA. Deploy: Docker + docker-compose + nginx pour openfin.open-squared.tech Co-Authored-By: Claude Sonnet 4.6 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..eae5a07 --- /dev/null +++ b/.gitignore @@ -0,0 +1,54 @@ +# Python +__pycache__/ +*.py[cod] +*.pyo +*.pyd +*.so +*.egg +*.egg-info/ +dist/ +build/ +.eggs/ +venv/ +.venv/ +env/ +.env +*.env + +# Database — ne pas versionner les données de production +backend/data/*.db +backend/data/*.db-shm +backend/data/*.db-wal + +# Node / frontend +frontend/node_modules/ +frontend/dist/ +frontend/.vite/ +.npm/ + +# IDE +.vscode/settings.json +.idea/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db +desktop.ini + +# Claude Code settings (locaux, non partagés) +.claude/ + +# Fichiers de travail locaux +*.docx +docx_extract.txt + +# Logs +*.log +logs/ + +# Deploy secrets +deploy/.env +deploy/nginx/certs/ +deploy/nginx/certbot-webroot/ diff --git a/GUIDE.md b/GUIDE.md new file mode 100644 index 0000000..227e007 --- /dev/null +++ b/GUIDE.md @@ -0,0 +1,179 @@ +# GeoOptions Intelligence — Guide d'utilisation + +## Démarrage + +```powershell +powershell -ExecutionPolicy Bypass -File c:\DataS\OpenFin\start.ps1 +``` + +Le script tue les anciens processus, démarre le backend (port 8000) et le frontend (port 5173), puis ouvre automatiquement **http://localhost:5173** dans le navigateur. + +--- + +## Les 9 pages + +### 1. Cockpit (page d'accueil) + +Vue centrale de pilotage. S'actualise toutes les minutes. + +| Zone | Ce qu'elle montre | +|---|---| +| **Risque Géopolitique** | Score 0–100 calculé depuis les flux RSS en temps réel | +| **Cartographie risques** | Radar par catégorie (énergie, métaux, agriculture…) | +| **Patterns actifs** | Top 3 patterns déclenchés avec leur score de similarité | +| **Catalyseurs** | Prochains événements économiques importants | +| **Top 10 idées** | Idées de trades générées automatiquement (≈1000€, horizon 3 mois) | +| **Marchés** | Prix en direct : énergie, métaux, indices, forex | + +**Ajouter un trade depuis une idée** : cliquer sur **"+ Ajouter au portefeuille"** sur une carte idée. La position est créée automatiquement avec le prix Black-Scholes du moment comme référence d'entrée. + +**Top 10 IA** (bouton bleu, requiert clé OpenAI) : envoie le contexte géopolitique live à GPT-4o qui génère et classe les 10 meilleures opportunités du moment. + +--- + +### 2. Radar Géopolitique + +Analyse des flux d'information géopolitique. + +- **Flux RSS** : actualités classées par catégorie (conflit, énergie, santé, politique) +- **Patterns déclenchés** : liste complète avec score, actif ciblé et direction suggérée +- **Score de risque détaillé** : décomposition par sous-catégorie + +--- + +### 3. Marchés & Prix + +Cotations temps réel (délai 15 min via Yahoo Finance) pour les 28 instruments du watchlist : +- Énergie : USO, XLE, UNG, BNO, OIL +- Métaux : GLD, SLV, COPX, PPLT, GDX +- Agriculture : WEAT, CORN, SOYB, JO, NIB +- Indices : SPY, QQQ, IWM, EFA, EEM, VIX +- Actions : XOM, CVX, LMT, MOS, AA +- Forex : UUP, FXE, FXY, FXF, CYB + +Graphiques historiques disponibles par instrument (1j à 5 ans). + +--- + +### 4. Options Lab + +Pricer Black-Scholes interactif. + +1. Choisir un **sous-jacent** (ou le taper manuellement) +2. Sélectionner une **stratégie** : Long Call, Long Put, Bull Call Spread, Bear Put Spread, Long Straddle +3. Régler **strike**, **type**, **durée** et **quantité** +4. Cliquer **"Calculer"** → prix de l'option, Greeks (Δ Θ ν ρ), breakeven +5. **Courbe P&L** : visualisation du gain/perte selon le prix du sous-jacent à expiration + +La volatilité implicite (IV) est calculée automatiquement depuis l'historique de prix. + +--- + +### 5. Patterns + +Moteurs de détection géopolitique → signal de trade. + +**Patterns intégrés (8)** : Middle East → Oil, US Tariffs → Agriculture, Geo Risk → Gold, Fed Hawkish → USD, China → Copper, Ukraine → Wheat, NG Disruption, Pandemic → VIX. + +**Créer un pattern personnalisé** : +1. Cliquer **"Nouveau pattern"** +2. Remplir nom, description, mots-clés déclencheurs, actif ciblé, direction +3. **"Générer depuis un contexte"** (IA) : décrire librement la situation géopolitique → GPT-4o-mini génère le pattern complet +4. **"Évaluer avec IA"** : GPT-4o note le pattern sur 100 et indique ses forces/faiblesses +5. Sauvegarder → le pattern est actif immédiatement dans le Cockpit + +--- + +### 6. Portefeuille + +Suivi mark-to-market en temps réel de toutes les positions. + +**Ajouter une position manuellement** : +1. Cliquer **"Nouvelle position"** +2. Remplir : sous-jacent, stratégie, strike, type (call/put), prime payée, capital, durée +3. Si la prime est renseignée → le P&L est calculé par rapport à cette prime réelle +4. Si non → le prix Black-Scholes au moment de l'ajout sert de référence (P&L démarre à ~0€) + +**Lire une carte de position** : + +| Champ | Signification | +|---|---| +| **Prime payée / Capital investi** | Référence d'entrée (BS au moment de l'achat si pas de prime réelle) | +| **Valeur BS actuelle** | Prix Black-Scholes calculé avec le spot live | +| **Spot sous-jacent** | Prix du sous-jacent récupéré en temps réel | +| **Jours restants** | Temps avant expiration (rouge < 14j, orange < 30j) | +| **Δ Θ ν** | Greeks nets de la position | +| **Frais IB** | $0.65/contrat (min $1.00) simulés à l'entrée | +| **P&L €/%** | (Valeur actuelle) − (Référence entrée) − (Frais IB entrée) | + +**Clôturer une position** : cliquer "Clôturer la position", entrer la valeur de revente → le P&L net (frais sortie inclus) est calculé et la position passe dans l'historique. + +**Supprimer une position** : icône poubelle (positions ouvertes) ou hover sur la ligne (positions clôturées). + +**Courbe P&L** : onglet "Courbe P&L" → equity curve cumulée de tous les trades clôturés. + +--- + +### 7. Backtest + +Simulation historique d'une stratégie sur une période passée. + +1. Choisir un sous-jacent, une stratégie, un strike et une durée +2. Sélectionner la fenêtre historique (1 an, 2 ans…) +3. Lancer le backtest → résultats : win rate, P&L moyen, max drawdown, sharpe ratio + +--- + +### 8. Calendrier + +Événements économiques à surveiller : Fed, BCE, NFP, CPI, OPEC, résultats trimestriels. +Classés par importance (! moyen, !! élevé, !!! critique). + +--- + +### 9. Configuration + +**Sources d'information** : activer/désactiver les flux RSS et APIs externes (Reuters, AP, Al Jazeera, FT, Bloomberg, EIA, FRED, USDA, WHO…). + +**Clés API** : +- **OpenAI** : nécessaire pour le Top 10 IA, l'évaluation de patterns et l'analyse de discours +- **NewsAPI, EIA, FRED** : sources optionnelles pour enrichir les données + +Après avoir entré la clé OpenAI et cliqué Sauvegarder, le badge "IA GPT-4o active" apparaît dans la sidebar. + +--- + +## Workflow typique + +``` +1. Lancer start.ps1 +2. Cockpit → lire le score de risque géopolitique +3. Patterns actifs → comprendre quels signaux sont déclenchés +4. Top 10 IA → générer les meilleures idées du moment +5. Options Lab → affiner le strike et visualiser la courbe P&L +6. "+ Ajouter au portefeuille" → position créée avec référence BS +7. Portefeuille → suivre l'évolution quotidienne +8. À terme → clôturer la position et voir la courbe P&L cumulée +``` + +--- + +## Frais IB simulés + +| Nb contrats | Frais entrée | Frais sortie | Total | +|---|---|---|---| +| 1 | $1.00 | $1.00 | $2.00 | +| 3 | $1.95 | $1.95 | $3.90 | +| 5 | $3.25 | $3.25 | $6.50 | +| 10 | $6.50 | $6.50 | $13.00 | + +1 contrat = 100 actions sous-jacentes. La prime s'exprime en $/share, la valeur totale en $/share × 100. + +--- + +## Notes importantes + +- Les prix sont en **temps différé de 15 minutes** (Yahoo Finance gratuit) +- La volatilité implicite est calculée depuis **l'historique de prix** (pas le marché des options) +- Les P&L sont en **euros** par convention d'affichage mais les sous-jacents sont cotés en **dollars** +- Ce cockpit est un **outil de simulation et d'aide à la décision** — pas une interface de trading réel diff --git a/_start_backend.bat b/_start_backend.bat new file mode 100644 index 0000000..0f2bb3e --- /dev/null +++ b/_start_backend.bat @@ -0,0 +1,7 @@ +@echo off +title GeoOptions Backend +cd /d "C:\DataS\OpenFin\backend" +if not exist venv python -m venv venv +call venv\Scripts\activate.bat +pip install -r requirements.txt -q +python -m uvicorn main:app --host 0.0.0.0 --port 8000 --reload diff --git a/_start_frontend.bat b/_start_frontend.bat new file mode 100644 index 0000000..f45e5d9 --- /dev/null +++ b/_start_frontend.bat @@ -0,0 +1,5 @@ +@echo off +title GeoOptions Frontend +cd /d "C:\DataS\OpenFin\frontend" +if not exist node_modules npm install +npm run dev diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..afb3ea3 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,20 @@ +FROM python:3.11-slim + +WORKDIR /app + +# System deps for yfinance / pandas / scipy +RUN apt-get update && apt-get install -y --no-install-recommends \ + gcc g++ libffi-dev && \ + rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +# Persistent SQLite lives here (mounted as volume) +RUN mkdir -p data + +EXPOSE 8000 + +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"] diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000..f5568e6 --- /dev/null +++ b/backend/main.py @@ -0,0 +1,70 @@ +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from routers import market_data, geopolitical, options, backtest, ai, portfolio, config, patterns, journal, cycle as cycle_router, profiles as profiles_router, reasoning as reasoning_router, knowledge as knowledge_router +from services.database import init_db, get_config +import os +import uvicorn + +app = FastAPI( + title="GeoOptions Intelligence", + description="Geopolitical Options Trading Cockpit API", + version="2.0.0", +) + +app.add_middleware( + CORSMiddleware, + allow_origins=["http://localhost:5173", "http://localhost:3000", "http://127.0.0.1:5173"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + + +@app.on_event("startup") +def startup(): + init_db() + key = get_config("openai_api_key") or "" + if key: + os.environ["OPENAI_API_KEY"] = key + # Seed built-in patterns into DB (idempotent) + from services.geo_analyzer import GEO_PATTERNS + from services.database import seed_builtin_patterns + seed_builtin_patterns(GEO_PATTERNS) + # Start auto-cycle scheduler if enabled + from services.auto_cycle import start_scheduler + start_scheduler() + + +@app.on_event("shutdown") +def shutdown(): + from services.auto_cycle import stop_scheduler + stop_scheduler() + + +app.include_router(market_data.router) +app.include_router(geopolitical.router) +app.include_router(options.router) +app.include_router(backtest.router) +app.include_router(ai.router) +app.include_router(portfolio.router) +app.include_router(config.router) +app.include_router(patterns.router) +app.include_router(journal.router) +app.include_router(cycle_router.router) +app.include_router(profiles_router.router) +app.include_router(reasoning_router.router) +app.include_router(knowledge_router.router) + + +@app.get("/") +def root(): + return {"app": "GeoOptions Intelligence Cockpit", "version": "2.0.0", "docs": "/docs"} + + +@app.get("/api/health") +def health(): + return {"status": "ok", "version": "2.0.0"} + + +if __name__ == "__main__": + uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True) diff --git a/backend/models/__init__.py b/backend/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/models/schemas.py b/backend/models/schemas.py new file mode 100644 index 0000000..bac689b --- /dev/null +++ b/backend/models/schemas.py @@ -0,0 +1,155 @@ +from pydantic import BaseModel +from typing import Optional, List, Dict, Any +from datetime import datetime +from enum import Enum + + +class AssetClass(str, Enum): + ENERGY = "energy" + METALS = "metals" + AGRICULTURE = "agriculture" + EQUITIES = "equities" + INDICES = "indices" + FOREX = "forex" + CRYPTO = "crypto" + RATES = "rates" + + +class RiskLevel(str, Enum): + LOW = "low" + MEDIUM = "medium" + HIGH = "high" + EXTREME = "extreme" + + +class GeopoliticalCategory(str, Enum): + MILITARY = "military" + SANCTIONS = "sanctions" + ELECTIONS = "elections" + NATURAL_DISASTER = "natural_disaster" + HEALTH_CRISIS = "health_crisis" + RESOURCE_SCARCITY = "resource_scarcity" + TRADE_WAR = "trade_war" + ENERGY_CRISIS = "energy_crisis" + POLITICAL_SPEECH = "political_speech" + FINANCIAL_CRISIS = "financial_crisis" + + +class GeoEvent(BaseModel): + id: str + title: str + summary: str + category: GeopoliticalCategory + date: datetime + source: str + impact_score: float # -1.0 to 1.0 + asset_impacts: Dict[AssetClass, float] # impact per asset class + tags: List[str] + is_processed: bool = False + + +class MarketQuote(BaseModel): + symbol: str + name: str + price: float + change: float + change_pct: float + volume: int + iv: Optional[float] = None # implied volatility + asset_class: AssetClass + timestamp: datetime + + +class OptionsContract(BaseModel): + symbol: str + underlying: str + expiry: str + strike: float + option_type: str # call / put + bid: float + ask: float + last: float + volume: int + open_interest: int + iv: float + delta: float + gamma: float + theta: float + vega: float + rho: float + + +class TradeIdea(BaseModel): + id: str + title: str + rationale: str + asset_class: AssetClass + underlying: str + strategy: str # e.g. "Bull Call Spread", "Long Put", "Straddle" + legs: List[Dict[str, Any]] + max_loss: float + max_gain: Optional[float] + breakeven: List[float] + horizon_days: int + confidence: float # 0-100 + geo_trigger: Optional[str] + risk_level: RiskLevel + capital_required: float + created_at: datetime + + +class BacktestParams(BaseModel): + start_date: str + end_date: str + strategy: str + underlying: str + geo_filters: Optional[List[GeopoliticalCategory]] = None + capital: float = 1000.0 + + +class BacktestResult(BaseModel): + params: BacktestParams + trades: List[Dict[str, Any]] + total_return: float + win_rate: float + max_drawdown: float + sharpe_ratio: float + profit_factor: float + equity_curve: List[Dict[str, Any]] + + +class EconomicEvent(BaseModel): + id: str + title: str + country: str + date: datetime + importance: str # low / medium / high + previous: Optional[str] + forecast: Optional[str] + actual: Optional[str] + asset_impact: List[AssetClass] + + +class PortfolioPosition(BaseModel): + id: str + trade_idea_id: Optional[str] + symbol: str + strategy: str + entry_date: datetime + expiry: str + legs: List[Dict[str, Any]] + capital_invested: float + current_value: float + pnl: float + pnl_pct: float + status: str # open / closed / expired + + +class GeoPatternMatch(BaseModel): + pattern_id: str + description: str + historical_date: datetime + current_similarity: float + historical_outcome: str + suggested_trades: List[str] + asset_class: AssetClass diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..429cf19 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,20 @@ +fastapi==0.115.0 +uvicorn[standard]==0.30.6 +pydantic==2.9.2 +python-dotenv==1.0.1 +yfinance>=1.4.1 +pandas==2.2.3 +numpy==2.1.2 +scipy==1.14.1 +httpx==0.27.2 +aiohttp==3.10.10 +sqlalchemy==2.0.36 +aiosqlite==0.20.0 +beautifulsoup4==4.12.3 +feedparser==6.0.11 +anthropic==0.36.2 +openai>=1.30.0 +python-multipart==0.0.12 +apscheduler==3.10.4 +pytz==2024.2 +ta==0.11.0 diff --git a/backend/routers/__init__.py b/backend/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/routers/ai.py b/backend/routers/ai.py new file mode 100644 index 0000000..2531260 --- /dev/null +++ b/backend/routers/ai.py @@ -0,0 +1,297 @@ +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel +from typing import Optional, List, Dict, Any +import json +from services.ai_analyzer import ( + analyze_speech, evaluate_pattern, suggest_pattern_from_context, + rank_trade_ideas, analyze_news_item, get_client, score_patterns_with_context, + suggest_patterns_from_market_context, ai_score_news_batch, + DEFAULT_ANALYSIS_TEMPLATE, _chat, +) +from services.data_fetcher import fetch_geo_news, get_all_quotes +from services.geo_analyzer import compute_geo_risk_score, match_patterns +from services.database import get_config, get_custom_patterns, get_analysis_config, save_pattern_scores, get_pattern_scores, get_score_deltas, compute_pattern_similarity, log_geo_alert, log_trade_entries + +router = APIRouter(prefix="/api/ai", tags=["ai"]) + + +def require_ai(): + key = get_config("openai_api_key") or "" + if not key: + raise HTTPException(400, "Clé OpenAI non configurée — aller dans Config") + import os + os.environ["OPENAI_API_KEY"] = key + + +class SpeechRequest(BaseModel): + text: str + speaker: Optional[str] = "" + + +class PatternEvalRequest(BaseModel): + pattern: Dict[str, Any] + + +class PatternSuggestRequest(BaseModel): + context: str + + +class NewsAnalyzeRequest(BaseModel): + title: str + summary: str + + +class ScorePatternsRequest(BaseModel): + top_n: Optional[int] = None # override config default + category_filter: Optional[str] = None # "all"|"energy"|"metals"|"agriculture"|"forex"|"indices"|"equities" + template: Optional[str] = None # override config template + + +@router.get("/status") +def ai_status(): + key = get_config("openai_api_key") or "" + return { + "enabled": bool(key), + "key_configured": bool(key), + "model": "gpt-4o", + "fast_model": "gpt-4o-mini", + } + + +@router.post("/analyze-speech") +def analyze_speech_endpoint(req: SpeechRequest): + require_ai() + return analyze_speech(req.text, req.speaker) + + +@router.post("/analyze-news") +def analyze_news_endpoint(req: NewsAnalyzeRequest): + require_ai() + return analyze_news_item(req.title, req.summary) + + +@router.post("/evaluate-pattern") +def evaluate_pattern_endpoint(req: PatternEvalRequest): + require_ai() + return evaluate_pattern(req.pattern) + + +@router.post("/suggest-pattern") +def suggest_pattern_endpoint(req: PatternSuggestRequest): + require_ai() + return suggest_pattern_from_context(req.context) + + +@router.get("/top-ideas") +def top_ideas(): + require_ai() + from routers.geopolitical import _news_cache + news = _news_cache.get("data") or fetch_geo_news() + matches = match_patterns(news) + geo_score = compute_geo_risk_score(news) + ideas = rank_trade_ideas(matches, geo_score, news, {}) + return {"ideas": ideas, "count": len(ideas)} + + +@router.post("/score-patterns") +def score_patterns(req: ScorePatternsRequest): + """Score all patterns with rich context (news + prices + IV) via GPT-4o.""" + require_ai() + + # Load config defaults + cfg = get_analysis_config() + top_n = req.top_n or cfg.get("top_n", 10) + category_filter = req.category_filter or cfg.get("category_filter", "all") + template = req.template or cfg.get("template") or DEFAULT_ANALYSIS_TEMPLATE + + # Gather context + from routers.geopolitical import _news_cache + news = _news_cache.get("data") or fetch_geo_news() + + # AI-rescore news: get accurate impact magnitudes + directional signals per asset class + # This enables contra-signal detection in pattern scoring (e.g. peace deal → oil bearish) + news = ai_score_news_batch(news) + _news_cache["data"] = news # propagate AI-enriched scores back to news feed + + geo_score = compute_geo_risk_score(news) + quotes = get_all_quotes() + + # Macro regime context (use cached value from /api/market/macro-regime if available) + from routers.market_data import _macro_cache + macro_regime = _macro_cache.get("data") + if not macro_regime: + from services.data_fetcher import get_macro_gauges, score_macro_scenarios + gauges = get_macro_gauges() + macro_regime = {"gauges": gauges, "scenarios": score_macro_scenarios(gauges)} + + # All patterns from DB (builtin-seeded + custom) + all_patterns = get_custom_patterns() + + # Score all active patterns + scored = score_patterns_with_context( + patterns=all_patterns, + recent_news=news, + quotes_by_class=quotes, + geo_score=geo_score, + template=template, + top_n=len(all_patterns), + category_filter=None, + macro_regime=macro_regime, + ) + + # Persist scores for later retrieval + run_id = save_pattern_scores(scored, meta={"geo_score": geo_score.get("score"), "total": len(scored)}) + + # Journal de Bord — log geo alert + trade entry prices at this moment + top_patterns_for_log = sorted( + [{"pattern_id": sp.get("pattern_id"), "name": sp.get("geo_trigger"), "score": sp.get("score", 0)} + for sp in scored if sp.get("score", 0) > 0], + key=lambda x: -x["score"] + )[:10] + log_geo_alert( + geo_score=int(geo_score.get("score") or 0), + top_patterns=top_patterns_for_log, + news_count=len(news), + run_id=run_id, + ) + log_trade_entries(run_id=run_id, scored_patterns=scored, quotes=quotes) + + return { + "scored_patterns": scored, + "count": len(scored), + "top_n": top_n, + "category_filter": category_filter, + "geo_score": geo_score.get("score"), + } + + +@router.get("/last-scores") +def last_scores(): + """Return last persisted AI pattern scores with inter-run score deltas.""" + data = get_pattern_scores() + deltas = get_score_deltas() + scored = data.get("scores", []) + for sp in scored: + pid = sp.get("pattern_id", "") + sp["score_trend"] = deltas.get(pid) # None = first run, int = change vs previous run + return { + "scored_patterns": scored, + "scored_at": data.get("scored_at"), + "meta": data.get("meta", {}), + "count": len(scored), + } + + +@router.get("/pattern-similarity") +def pattern_similarity(): + """Return pairs of patterns with high keyword overlap (Jaccard >= 0.25).""" + patterns = get_custom_patterns() + pairs = compute_pattern_similarity(patterns, threshold=0.25) + return {"pairs": pairs, "count": len(pairs)} + + +@router.post("/suggest-new-patterns") +def suggest_new_patterns(): + """Ask GPT-4o to propose new patterns from current geo/market context (no text input needed).""" + require_ai() + from routers.geopolitical import _news_cache + news = _news_cache.get("data") or fetch_geo_news() + quotes = get_all_quotes() + from services.data_fetcher import get_economic_calendar + calendar = get_economic_calendar() + + # Macro regime context + from routers.market_data import _macro_cache + macro_regime = _macro_cache.get("data") + if not macro_regime: + from services.data_fetcher import get_macro_gauges, score_macro_scenarios + gauges = get_macro_gauges() + macro_regime = {"gauges": gauges, "scenarios": score_macro_scenarios(gauges)} + + # Geo risk score for additional context + geo_score = compute_geo_risk_score(news) + + patterns = suggest_patterns_from_market_context(news, quotes, calendar, macro_regime=macro_regime, geo_score=geo_score) + return {"suggested_patterns": patterns, "count": len(patterns)} + + +@router.get("/analysis-template") +def get_template(): + cfg = get_analysis_config() + return { + "template": cfg.get("template") or DEFAULT_ANALYSIS_TEMPLATE, + "top_n": cfg.get("top_n", 10), + "category_filter": cfg.get("category_filter", "all"), + } + + +class MacroNarrationRequest(BaseModel): + macro_regime: Dict[str, Any] + + +@router.post("/macro-narration") +def macro_narration(req: MacroNarrationRequest): + """Ask GPT-4o to narrate the current macro regime for the MacroRegime page.""" + require_ai() + sc = req.macro_regime.get("scenarios", {}) + gauges = req.macro_regime.get("gauges", {}) + dom = sc.get("dominant", "incertain") + scores = sc.get("scores", {}) + reasons = sc.get("reasons", {}) + + def gv(key: str) -> str: + v = gauges.get(key, {}).get("value") + return str(round(v, 3)) if v is not None else "N/A" + + def gc(key: str) -> str: + v = gauges.get(key, {}).get("change_pct") + return f"{v:+.2f}%" if v is not None else "N/A" + + user = f"""Tu es un stratège macro senior utilisant un framework à 3 axes : Inflation / Croissance / Liquidité. + +Scénario dominant: {dom.upper()} +Scores des 8 scénarios: {json.dumps(scores, ensure_ascii=False)} +Raisons du scénario dominant: {json.dumps(reasons.get(dom, []), ensure_ascii=False)} + +AXE INFLATION (énergie + taux réels): +- Brent (var J-1): {gc('brent')} +- Gaz naturel (var J-1): {gc('ng')} +- TIPS ETF (var J-1): {gc('tips')} + +AXE CROISSANCE (cycle réel): +- Cuivre (var J-1): {gc('copper')} — "Dr Copper" +- S&P vs 200j MA: {gv('spx_vs_200d')}% +- Russell vs S&P (breadth): {gv('iwm_spx_ratio')} pts% +- Industriels XLI (proxy ISM, var J-1): {gc('xli')} + +AXE LIQUIDITÉ / STRESS (conditions financières): +- VIX: {gv('vix')} +- Pente 10Y–3M: {gv('slope_10y3m')}% (négatif = récession probable) +- HYG spreads HY (var J-1): {gc('hyg')} +- LQD spreads IG (var J-1): {gc('lqd')} +- Dollar DXY (var J-1): {gc('dxy')} + +SIGNAUX DÉRIVÉS: +- Ratio Or/Cuivre: {gv('gold_copper_ratio')} (>700 = peur, <500 = expansion) +- Or (var J-1): {gc('gold')} +- IEF Trésor (var J-1): {gc('ief')} + +Donne une analyse narrative COURTE (5-7 phrases) en français pour un trader options: +1. Confirme le régime dominant et ses 2-3 signaux les plus forts +2. Identifie 1-2 compteurs en contradiction ou tension (signal ambigu) +3. Évalue si la LIQUIDITÉ confirme ou contredit le régime inflation/croissance +4. Cite les 2-3 classes d'actifs les plus favorisées/défavorisées +5. Donne 1 biais tactique concret options pour les prochains jours + +Réponds UNIQUEMENT en JSON: {{"narration": ""}}""" + + result = _chat( + "Tu es un stratège macro senior. Analyse concise et actionnable pour traders options. JSON uniquement.", + user, + model="gpt-4o", + json_mode=True, + max_tokens=600, + ) + if not result: + return {"narration": "IA non disponible — vérifier la clé OpenAI."} + return {"narration": result.get("narration", "")} diff --git a/backend/routers/backtest.py b/backend/routers/backtest.py new file mode 100644 index 0000000..65bd060 --- /dev/null +++ b/backend/routers/backtest.py @@ -0,0 +1,126 @@ +from fastapi import APIRouter +from pydantic import BaseModel +from typing import Optional, List +import yfinance as yf +import numpy as np +import pandas as pd +from datetime import datetime +from services.options_pricer import black_scholes + +router = APIRouter(prefix="/api/backtest", tags=["backtest"]) + + +class BacktestRequest(BaseModel): + symbol: str + start_date: str + end_date: str + strategy: str # "long_call" | "long_put" | "bull_call_spread" | "bear_put_spread" | "straddle" + strike_offset_pct: float = 0.05 # e.g. 5% OTM + expiry_days: int = 90 + capital: float = 1000.0 + geo_filter: Optional[str] = None # optional pattern id to filter + + +@router.post("/run") +def run_backtest(req: BacktestRequest): + try: + ticker = yf.Ticker(req.symbol) + hist = ticker.history(start=req.start_date, end=req.end_date, interval="1d") + if hist.empty or len(hist) < 20: + return {"error": "Insufficient data for the period"} + + hist = hist.reset_index() + returns = np.log(hist["Close"] / hist["Close"].shift(1)).dropna() + + trades = [] + equity = [req.capital] + capital = req.capital + r = 0.05 + T_open = req.expiry_days / 365 + + step = max(1, req.expiry_days // 3) + for i in range(0, len(hist) - req.expiry_days, step): + row = hist.iloc[i] + S = float(row["Close"]) + date_str = str(row["Date"])[:10] + + sigma_window = returns.iloc[max(0, i - 30):i] + if len(sigma_window) < 5: + continue + sigma = float(sigma_window.std() * np.sqrt(252)) + if sigma < 0.01: + sigma = 0.20 + + if req.strategy in ["long_call", "bull_call_spread"]: + K = S * (1 + req.strike_offset_pct) + else: + K = S * (1 - req.strike_offset_pct) + + result = black_scholes(S, K, T_open, r, sigma, "call" if "call" in req.strategy else "put") + premium = result["price"] + contracts = max(1, int((capital * 0.1) / (premium * 100))) + cost = contracts * premium * 100 + + expiry_idx = min(i + req.expiry_days, len(hist) - 1) + S_expiry = float(hist.iloc[expiry_idx]["Close"]) + date_expiry = str(hist.iloc[expiry_idx]["Date"])[:10] + + if req.strategy in ["long_call", "bull_call_spread"]: + intrinsic = max(0, S_expiry - K) + else: + intrinsic = max(0, K - S_expiry) + + pnl = (intrinsic - premium) * contracts * 100 + capital += pnl + equity.append(round(capital, 2)) + + trades.append({ + "entry_date": date_str, + "exit_date": date_expiry, + "strategy": req.strategy, + "S_entry": round(S, 2), + "K": round(K, 2), + "premium": round(premium, 4), + "contracts": contracts, + "cost": round(cost, 2), + "S_expiry": round(S_expiry, 2), + "intrinsic": round(intrinsic, 4), + "pnl": round(pnl, 2), + "capital": round(capital, 2), + }) + + if not trades: + return {"error": "No trades generated"} + + wins = [t for t in trades if t["pnl"] > 0] + losses = [t for t in trades if t["pnl"] <= 0] + total_pnl = sum(t["pnl"] for t in trades) + gross_profit = sum(t["pnl"] for t in wins) if wins else 0 + gross_loss = abs(sum(t["pnl"] for t in losses)) if losses else 1 + + eq = np.array(equity) + peak = np.maximum.accumulate(eq) + drawdown = (eq - peak) / peak + max_dd = float(drawdown.min()) * 100 + + equity_curve = [{"index": i, "capital": v} for i, v in enumerate(equity)] + + return { + "symbol": req.symbol, + "strategy": req.strategy, + "period": f"{req.start_date} → {req.end_date}", + "total_trades": len(trades), + "wins": len(wins), + "losses": len(losses), + "win_rate": round(len(wins) / len(trades) * 100, 1) if trades else 0, + "total_pnl": round(total_pnl, 2), + "total_return_pct": round((capital - req.capital) / req.capital * 100, 2), + "max_drawdown_pct": round(max_dd, 2), + "profit_factor": round(gross_profit / gross_loss, 2) if gross_loss else 0, + "final_capital": round(capital, 2), + "equity_curve": equity_curve, + "trades": trades[-20:], + } + + except Exception as e: + return {"error": str(e)} diff --git a/backend/routers/config.py b/backend/routers/config.py new file mode 100644 index 0000000..f4d2c50 --- /dev/null +++ b/backend/routers/config.py @@ -0,0 +1,91 @@ +from fastapi import APIRouter +from pydantic import BaseModel +from typing import Dict, Any, Optional +import os +from services.database import get_all_config, set_config, get_sources, update_sources, get_analysis_config, save_analysis_config + +router = APIRouter(prefix="/api/config", tags=["config"]) + + +class ApiKeysRequest(BaseModel): + openai_api_key: Optional[str] = None + newsapi_key: Optional[str] = None + eia_api_key: Optional[str] = None + fred_api_key: Optional[str] = None + + +class SourcesRequest(BaseModel): + sources: Dict[str, Any] + + +class SettingsRequest(BaseModel): + ai_enabled: Optional[str] = None + ai_auto_rescore: Optional[str] = None + + +class AnalysisConfigRequest(BaseModel): + top_n: Optional[int] = None + category_filter: Optional[str] = None + template: Optional[str] = None + + +@router.get("/") +def get_config_all(): + return get_all_config() + + +@router.get("/sources") +def list_sources(): + return get_sources() + + +@router.put("/sources") +def update_sources_endpoint(req: SourcesRequest): + update_sources(req.sources) + return {"status": "ok", "updated": len(req.sources)} + + +@router.put("/api-keys") +def update_api_keys(req: ApiKeysRequest): + updated = [] + if req.openai_api_key is not None: + set_config("openai_api_key", req.openai_api_key) + os.environ["OPENAI_API_KEY"] = req.openai_api_key + updated.append("openai") + if req.newsapi_key is not None: + set_config("newsapi_key", req.newsapi_key) + updated.append("newsapi") + if req.eia_api_key is not None: + set_config("eia_api_key", req.eia_api_key) + updated.append("eia") + if req.fred_api_key is not None: + set_config("fred_api_key", req.fred_api_key) + updated.append("fred") + return {"status": "ok", "updated": updated} + + +@router.put("/settings") +def update_settings(req: SettingsRequest): + if req.ai_enabled is not None: + set_config("ai_enabled", req.ai_enabled) + if req.ai_auto_rescore is not None: + set_config("ai_auto_rescore", req.ai_auto_rescore) + return {"status": "ok"} + + +@router.get("/analysis") +def get_analysis_config_endpoint(): + return get_analysis_config() + + +@router.put("/analysis") +def save_analysis_config_endpoint(req: AnalysisConfigRequest): + current = get_analysis_config() + if req.top_n is not None: + current["top_n"] = req.top_n + if req.category_filter is not None: + current["category_filter"] = req.category_filter + if req.template is not None: + current["template"] = req.template + save_analysis_config(current) + return {"status": "ok", "config": current} diff --git a/backend/routers/cycle.py b/backend/routers/cycle.py new file mode 100644 index 0000000..6299c69 --- /dev/null +++ b/backend/routers/cycle.py @@ -0,0 +1,74 @@ +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel +from typing import Optional +from services.database import get_cycle_runs, get_cycle_run, set_config, get_config +from services.auto_cycle import get_status, trigger_manual, restart_scheduler + +router = APIRouter(prefix="/api/cycle", tags=["cycle"]) + + +@router.get("/status") +def cycle_status(): + """Current scheduler state + last cycle summary.""" + return get_status() + + +@router.get("/history") +def cycle_history(limit: int = 20): + """List recent cycle runs.""" + runs = get_cycle_runs(limit=limit) + # Parse commentary JSON for frontend + import json + for r in runs: + if r.get("commentary"): + try: + r["commentary_parsed"] = json.loads(r["commentary"]) + except Exception: + r["commentary_parsed"] = {"commentary": r["commentary"]} + return {"runs": runs, "count": len(runs)} + + +@router.post("/trigger") +def trigger_cycle(): + """Manually trigger one cycle immediately (non-blocking).""" + key = get_config("openai_api_key") or "" + if not key: + raise HTTPException(400, "Clé OpenAI non configurée") + trigger_manual() + return {"triggered": True, "message": "Cycle lancé en arrière-plan"} + + +class CycleConfigRequest(BaseModel): + enabled: Optional[bool] = None + interval_hours: Optional[float] = None + similarity_threshold: Optional[float] = None + min_ev_threshold: Optional[float] = None + min_score_threshold: Optional[int] = None + + +@router.post("/config") +def update_cycle_config(req: CycleConfigRequest): + """Update auto-cycle + filter configuration and restart scheduler.""" + if req.enabled is not None: + set_config("auto_cycle_enabled", "true" if req.enabled else "false") + if req.interval_hours is not None: + if not (0.5 <= req.interval_hours <= 24): + raise HTTPException(400, "interval_hours must be between 0.5 and 24") + set_config("auto_cycle_hours", str(req.interval_hours)) + if req.similarity_threshold is not None: + if not (0.0 <= req.similarity_threshold <= 1.0): + raise HTTPException(400, "similarity_threshold must be between 0 and 1") + set_config("auto_cycle_similarity_threshold", str(req.similarity_threshold)) + if req.min_ev_threshold is not None: + if not (0.0 <= req.min_ev_threshold <= 1.0): + raise HTTPException(400, "min_ev_threshold must be between 0 and 1") + set_config("min_ev_threshold", str(req.min_ev_threshold)) + if req.min_score_threshold is not None: + if not (0 <= req.min_score_threshold <= 100): + raise HTTPException(400, "min_score_threshold must be between 0 and 100") + set_config("min_score_threshold", str(req.min_score_threshold)) + + # Restart scheduler to pick up changes + restart_scheduler() + + return get_status() diff --git a/backend/routers/geopolitical.py b/backend/routers/geopolitical.py new file mode 100644 index 0000000..aa76dbf --- /dev/null +++ b/backend/routers/geopolitical.py @@ -0,0 +1,72 @@ +from fastapi import APIRouter, Query +from typing import Optional, List +from services.data_fetcher import fetch_geo_news, get_economic_calendar +from services.geo_analyzer import compute_geo_risk_score, match_patterns, generate_trade_ideas, compute_pattern_relevance +from services.database import get_custom_patterns +from datetime import datetime, timezone, timedelta + +router = APIRouter(prefix="/api/geo", tags=["geopolitical"]) + +_news_cache: dict = {"data": [], "ts": 0} + + +@router.get("/news") +def geo_news(force_refresh: bool = False): + import time + now = time.time() + if not force_refresh and _news_cache["data"] and (now - _news_cache["ts"]) < 3600: + return _news_cache["data"] + news = fetch_geo_news() + _news_cache["data"] = news + _news_cache["ts"] = now + return news + + +@router.get("/risk-score") +def risk_score(): + news = _news_cache["data"] or fetch_geo_news() + return compute_geo_risk_score(news) + + +@router.get("/pattern-matches") +def pattern_matches(): + news = _news_cache["data"] or fetch_geo_news() + all_patterns = get_custom_patterns() + return match_patterns(news, patterns=all_patterns) + + +@router.get("/pattern-relevance") +def pattern_relevance(days: int = 2): + """Return ALL active patterns with news-keyword relevance over the last N days.""" + all_news = _news_cache["data"] or fetch_geo_news() + # Filter news to last N days + if days > 0: + cutoff = datetime.now(timezone.utc) - timedelta(days=days) + recent: list = [] + for n in all_news: + try: + from email.utils import parsedate_to_datetime + d = parsedate_to_datetime(str(n.get("date", ""))) + if d >= cutoff: + recent.append(n) + except Exception: + recent.append(n) + news = recent if recent else all_news + else: + news = all_news + all_patterns = get_custom_patterns() + return compute_pattern_relevance(news, patterns=all_patterns) + + +@router.get("/trade-ideas") +def trade_ideas(): + news = _news_cache["data"] or fetch_geo_news() + all_patterns = get_custom_patterns() + matches = match_patterns(news, patterns=all_patterns) + geo_score = compute_geo_risk_score(news) + return generate_trade_ideas(matches, geo_score) + + +@router.get("/calendar") +def calendar(): + return get_economic_calendar() diff --git a/backend/routers/journal.py b/backend/routers/journal.py new file mode 100644 index 0000000..57885cb --- /dev/null +++ b/backend/routers/journal.py @@ -0,0 +1,112 @@ +from fastapi import APIRouter +from typing import Any, Dict, List +import math +from services.database import get_macro_regime_history, get_geo_alert_history, get_trade_entry_prices, reset_journal_history, _fetch_live_prices + + +def _sanitize(obj: Any) -> Any: + """Replace NaN/Inf with None recursively for JSON compliance.""" + if isinstance(obj, dict): + return {k: _sanitize(v) for k, v in obj.items()} + if isinstance(obj, list): + return [_sanitize(v) for v in obj] + if isinstance(obj, float) and (math.isnan(obj) or math.isinf(obj)): + return None + return obj + +router = APIRouter(prefix="/api/journal", tags=["journal"]) + +# Bearish strategies — P&L is inverted (profit when price falls) +_BEARISH_KEYWORDS = {"bear", "put", "short", "sell", "vente", "baissier"} + + +def _is_bearish(strategy: str) -> bool: + s = (strategy or "").lower() + return any(kw in s for kw in _BEARISH_KEYWORDS) + + +@router.get("/macro-history") +def macro_history(days: int = 15): + """Macro regime snapshots for the last N days.""" + return _sanitize({"history": get_macro_regime_history(days), "days": days}) + + +@router.get("/geo-history") +def geo_history(days: int = 30): + """Geo alert score history for the last N days.""" + return {"history": get_geo_alert_history(days), "days": days} + + + + +@router.get("/trade-mtm") +def trade_mtm(days: int = 30): + """ + Mark-to-market for all logged trade suggestions. + Enriches with live prices via shared _fetch_live_prices utility. + """ + entries = get_trade_entry_prices(days) + tickers_needed = list({(e.get("underlying") or "").upper() for e in entries if e.get("underlying")}) + current_prices = _fetch_live_prices(tickers_needed, timeout=20) + + from datetime import date as _date + result: List[Dict[str, Any]] = [] + for e in entries: + ticker = (e.get("underlying") or "").upper() + entry_price = e.get("entry_price") + current_price = current_prices.get(ticker) + pnl_pct = None + if entry_price and current_price and entry_price > 0: + raw_pnl = (current_price - entry_price) / entry_price * 100 + pnl_pct = round(-raw_pnl if _is_bearish(e.get("strategy", "")) else raw_pnl, 2) + + days_held = None + try: + days_held = (_date.today() - _date.fromisoformat(e["entry_date"])).days + except Exception: + pass + + result.append({ + **e, + "current_price": current_price, + "pnl_pct": pnl_pct, + "days_held": days_held, + "direction": "bearish" if _is_bearish(e.get("strategy", "")) else "bullish", + }) + + return _sanitize({"trades": result, "days": days, "tickers_fetched": len(current_prices)}) + + +@router.delete("/reset") +def reset_journal(): + """Truncate all journal history (trades, macro, geo, cycles). Irreversible.""" + reset_journal_history() + return {"reset": True, "message": "Journal de bord réinitialisé"} + + +@router.get("/summary") +def journal_summary(): + """Quick stats for the Journal de Bord header.""" + macro = get_macro_regime_history(15) + geo = get_geo_alert_history(30) + trades = get_trade_entry_prices(30) + + # Detect regime transitions (consecutive different dominants) + transitions = [] + for i in range(1, len(macro)): + if macro[i - 1]["dominant"] != macro[i]["dominant"]: + transitions.append({ + "from": macro[i]["dominant"], + "to": macro[i - 1]["dominant"], + "at": macro[i - 1]["timestamp"], + }) + + return { + "macro_snapshots": len(macro), + "regime_transitions": transitions[:5], + "current_dominant": macro[0]["dominant"] if macro else None, + "geo_alerts": len(geo), + "avg_geo_score": round(sum(g["geo_score"] for g in geo) / len(geo), 1) if geo else None, + "max_geo_score": max((g["geo_score"] for g in geo), default=None), + "trade_entries_logged": len(trades), + } diff --git a/backend/routers/knowledge.py b/backend/routers/knowledge.py new file mode 100644 index 0000000..e31ae14 --- /dev/null +++ b/backend/routers/knowledge.py @@ -0,0 +1,309 @@ +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel +from typing import Any, Dict, List, Optional +import json +import os + +from services.database import ( + get_kb_entries, get_all_kb_entries, save_kb_entry, update_kb_entry_status, + get_latest_reasoning_state, get_reasoning_history, get_reasoning_state_by_id, + save_reasoning_state, list_ai_reports, get_mtm_trades_with_traces, +) + +router = APIRouter(prefix="/api/knowledge", tags=["knowledge"]) + + +def _build_synthesis_prompt(reports: List[Dict], trades: List[Dict], kb_entries: List[Dict]): + """Build the GPT-4o synthesis prompt from all accumulated data.""" + now_str = __import__("datetime").datetime.utcnow().strftime("%Y-%m-%d %H:%M") + + # Portfolio reports summary + reports_block = "" + for r in reports[:10]: + rpt = r.get("report") or {} + stats = r.get("stats") or {} + date = r.get("created_at", "")[:16] + headline = rpt.get("headline", "") + winners = rpt.get("winners_analysis", "") + losers = rpt.get("losers_analysis", "") + lessons = rpt.get("key_lessons", []) + blind = rpt.get("blind_spots", "") + next_p = rpt.get("next_cycle_priorities", "") + lessons_str = " | ".join(lessons) if isinstance(lessons, list) else str(lessons) + reports_block += f""" +--- Rapport du {date} --- +Headline: {headline} +Stats: {stats} +Gagnants: {winners[:300]} +Perdants: {losers[:300]} +Leçons clés: {lessons_str[:400]} +Angles morts: {blind[:200]} +Priorités cycle suivant: {next_p[:200]} +""" + + # Trade history + winners = [t for t in trades if (t.get("pnl_pct") or 0) > 0.5] + losers = [t for t in trades if (t.get("pnl_pct") or 0) < -0.5] + neutral = [t for t in trades if t not in winners and t not in losers] + + def trade_line(t): + return (f"{t.get('underlying','?')} {t.get('strategy','?')} " + f"P&L={t.get('pnl_pct',0):.2f}% score={t.get('latest_score','?')} " + f"regime={t.get('macro_regime','?')}") + + trades_block = f""" +Gagnants ({len(winners)}): {' | '.join(trade_line(t) for t in winners[:8])} +Perdants ({len(losers)}): {' | '.join(trade_line(t) for t in losers[:8])} +Neutres ({len(neutral)}): {len(neutral)} trades sans signal fort +""" + + # Existing KB + kb_block = "" + if kb_entries: + by_cat: Dict[str, List] = {} + for e in kb_entries: + cat = e.get("category", "général") + by_cat.setdefault(cat, []).append(e) + for cat, items in by_cat.items(): + kb_block += f"\n[{cat.upper()}]\n" + for item in items[:5]: + kb_block += f" - [{item['confidence']}%] {item['title']}: {item['content'][:150]}\n" + + system = """Tu es l'intelligence analytique centrale d'un système de trading d'options géopolitiques. +Tu dois synthétiser TOUT l'historique disponible pour produire un document de raisonnement évolutif. +Ce document sera utilisé comme contexte enrichi pour tous les prochains cycles d'analyse. +Réponds UNIQUEMENT en JSON valide selon le schéma spécifié.""" + + user = f"""Date: {now_str} + +=== HISTORIQUE DES RAPPORTS DE PERFORMANCE ({len(reports)} rapports) === +{reports_block} + +=== HISTORIQUE DES TRADES ({len(trades)} trades) === +{trades_block} + +=== BASE DE CONNAISSANCES EXISTANTE === +{kb_block if kb_block else "Aucune entrée existante — première synthèse."} + +=== MISSION === +Produis un JSON avec ces champs: + +{{ + "narrative": "Un texte narratif riche (500-800 mots) qui décrit l'état actuel du raisonnement du système, les patterns qui fonctionnent, les erreurs récurrentes, les corrélations géopolitiques/macro identifiées, les régimes qui favorisent nos stratégies, et les priorités d'amélioration. C'est le 'cerveau' du système.", + + "regime_insights": [ + {{"regime": "nom du régime macro", "observation": "ce qu'on sait de ce régime", "confidence": 0-100, "trade_count": N}} + ], + + "pattern_insights": [ + {{"pattern": "nom du pattern", "observation": "performance et conditions", "confidence": 0-100, "win_rate_pct": 0-100}} + ], + + "macro_correlations": [ + {{"trigger": "événement géopolitique/macro", "market_reaction": "réaction observée", "reliability": "haute/moyenne/faible"}} + ], + + "recurring_mistakes": [ + {{"mistake": "description de l'erreur", "frequency": "souvent/parfois", "mitigation": "comment l'éviter"}} + ], + + "strengths": ["point fort 1", "point fort 2"], + + "blind_spots": ["angle mort 1", "angle mort 2"], + + "strategic_priorities": ["priorité 1", "priorité 2", "priorité 3"], + + "risk_parameters": {{ + "avoid_when": ["condition 1", "condition 2"], + "prefer_when": ["condition 1", "condition 2"] + }} +}}""" + + return system, user + + +@router.get("/state") +def get_state(): + """Latest synthesized reasoning state.""" + state = get_latest_reasoning_state() + return {"state": state} + + +@router.get("/history") +def get_history(limit: int = 10): + """List of reasoning state versions.""" + return {"history": get_reasoning_history(limit)} + + +@router.get("/history/{state_id}") +def get_state_version(state_id: int): + state = get_reasoning_state_by_id(state_id) + if not state: + raise HTTPException(404, "Version introuvable") + return {"state": state} + + +@router.get("/entries") +def list_entries(status: str = "all"): + if status == "all": + entries = get_all_kb_entries() + else: + entries = get_kb_entries(status) + by_cat: Dict[str, List] = {} + for e in entries: + by_cat.setdefault(e.get("category", "général"), []).append(e) + return {"entries": entries, "by_category": by_cat, "total": len(entries)} + + +class KbEntryIn(BaseModel): + category: str + title: str + content: str + confidence: int = 50 + tags: str = "" + existing_id: Optional[int] = None + + +@router.post("/entries") +def add_entry(body: KbEntryIn): + entry_id = save_kb_entry( + category=body.category, + title=body.title, + content=body.content, + confidence=body.confidence, + tags=body.tags, + existing_id=body.existing_id, + ) + return {"id": entry_id} + + +@router.patch("/entries/{entry_id}/status") +def patch_entry_status(entry_id: int, body: Dict[str, str]): + status = body.get("status", "active") + if status not in ("active", "tentative", "invalidated"): + raise HTTPException(400, "status must be active | tentative | invalidated") + update_kb_entry_status(entry_id, status) + return {"id": entry_id, "status": status} + + +@router.post("/synthesize") +async def synthesize(): + """Run GPT-4o synthesis over all historical data and save new reasoning state.""" + ai_key = os.environ.get("OPENAI_API_KEY", "") + if not ai_key: + raise HTTPException(400, "OpenAI API key not configured") + + import openai + client = openai.OpenAI(api_key=ai_key) + + reports = list_ai_reports(limit=10) + mtm_data = get_mtm_trades_with_traces(days=90) + trades = mtm_data.get("all_trades", []) if isinstance(mtm_data, dict) else [] + kb_entries = get_all_kb_entries() + + system_msg, user_msg = _build_synthesis_prompt(reports, trades, kb_entries) + + try: + resp = client.chat.completions.create( + model="gpt-4o", + messages=[ + {"role": "system", "content": system_msg}, + {"role": "user", "content": user_msg}, + ], + temperature=0.3, + max_tokens=2500, + response_format={"type": "json_object"}, + ) + raw = resp.choices[0].message.content or "{}" + synthesis = json.loads(raw) + except Exception as e: + raise HTTPException(500, f"GPT-4o error: {e}") + + narrative = synthesis.pop("narrative", "Synthèse non disponible.") + + state_id = save_reasoning_state( + narrative=narrative, + synthesis=synthesis, + sources_count=len(reports) + len(trades), + reports_used=len(reports), + trades_analyzed=len(trades), + ) + + # Persist KB entries from synthesis + for regime in synthesis.get("regime_insights", []): + if regime.get("observation"): + save_kb_entry( + category="régimes", + title=f"Régime: {regime.get('regime', '?')}", + content=regime.get("observation", ""), + confidence=regime.get("confidence", 50), + tags="auto-synth", + ) + + for pattern in synthesis.get("pattern_insights", []): + if pattern.get("observation"): + save_kb_entry( + category="patterns", + title=f"Pattern: {pattern.get('pattern', '?')}", + content=pattern.get("observation", ""), + confidence=pattern.get("confidence", 50), + tags="auto-synth", + ) + + for mistake in synthesis.get("recurring_mistakes", []): + if mistake.get("mistake"): + save_kb_entry( + category="erreurs", + title=mistake.get("mistake", "")[:80], + content=f"{mistake.get('mistake','')} → {mistake.get('mitigation','')}", + confidence=70, + tags="auto-synth", + ) + + return { + "state_id": state_id, + "narrative_preview": narrative[:200], + "kb_entries_added": ( + len(synthesis.get("regime_insights", [])) + + len(synthesis.get("pattern_insights", [])) + + len(synthesis.get("recurring_mistakes", [])) + ), + "sources": {"reports": len(reports), "trades": len(trades)}, + } + + +@router.get("/context-for-cycle") +def context_for_cycle(): + """Compact context to inject into AI cycle prompts.""" + state = get_latest_reasoning_state() + if not state: + return {"available": False, "context": ""} + + synthesis = state.get("synthesis") or {} + narrative = state.get("narrative", "") + + priorities = synthesis.get("strategic_priorities", []) + avoid = synthesis.get("risk_parameters", {}).get("avoid_when", []) + prefer = synthesis.get("risk_parameters", {}).get("prefer_when", []) + mistakes = [m.get("mistake", "") for m in synthesis.get("recurring_mistakes", [])[:3]] + strengths = synthesis.get("strengths", []) + + context = f"""=== SUPER CONTEXTE — BASE DE RAISONNEMENT ({state.get('created_at','')[:16]}) === +{narrative[:600]} + +PRIORITÉS STRATÉGIQUES: {' | '.join(priorities[:3])} +ERREURS À ÉVITER: {' | '.join(mistakes)} +PRÉFÉRER QUAND: {' | '.join(prefer[:2])} +ÉVITER QUAND: {' | '.join(avoid[:2])} +FORCES: {' | '.join(strengths[:2])} +""" + return { + "available": True, + "context": context, + "version": state.get("version"), + "created_at": state.get("created_at"), + "sources": { + "reports_used": state.get("reports_used"), + "trades_analyzed": state.get("trades_analyzed"), + }, + } diff --git a/backend/routers/market_data.py b/backend/routers/market_data.py new file mode 100644 index 0000000..a4f9d94 --- /dev/null +++ b/backend/routers/market_data.py @@ -0,0 +1,77 @@ +from fastapi import APIRouter, Query +from typing import Optional, Dict, Any +from datetime import datetime +from services.data_fetcher import get_all_quotes, get_historical, compute_historical_iv, WATCHLIST + +_macro_cache: Dict[str, Any] = {} + +router = APIRouter(prefix="/api/market", tags=["market"]) + + +@router.get("/quotes") +def quotes_all(): + return get_all_quotes() + + +@router.get("/quote/{symbol}") +def quote_single(symbol: str): + from services.data_fetcher import get_quote + return get_quote(symbol) + + +@router.get("/history/{symbol}") +def history( + symbol: str, + period: str = Query("1y", description="1d,5d,1mo,3mo,6mo,1y,2y,5y"), + interval: str = Query("1d", description="1m,5m,15m,1h,1d,1wk,1mo"), +): + return get_historical(symbol, period, interval) + + +@router.get("/iv/{symbol}") +def implied_vol(symbol: str, window: int = 30): + iv = compute_historical_iv(symbol, window) + return {"symbol": symbol, "iv": iv, "window": window} + + +@router.get("/watchlist") +def watchlist(): + return WATCHLIST + + +@router.get("/macro-regime") +def macro_regime(force: bool = False): + """Macro gauge values + 5-scenario scoring. Cached 15 min.""" + from services.data_fetcher import get_macro_gauges, score_macro_scenarios + now = datetime.utcnow() + if not force and _macro_cache.get("data") and _macro_cache.get("ts"): + age = (now - _macro_cache["ts"]).total_seconds() + if age < 900: + return {**_macro_cache["data"], "cached": True, "cache_age_sec": int(age)} + gauges = get_macro_gauges() + scenarios = score_macro_scenarios(gauges) + result: Dict[str, Any] = { + "gauges": gauges, + "scenarios": scenarios, + "fetched_at": now.isoformat(), + "cached": False, + } + _macro_cache["data"] = result + _macro_cache["ts"] = now + + if force: + # Build a compact gauge summary (key → value + change_pct) for the journal + gauges_summary = { + k: {"value": v.get("value"), "change_pct": v.get("change_pct"), "label": v.get("label")} + for k, v in gauges.items() + if v.get("value") is not None or v.get("change_pct") is not None + } + from services.database import log_macro_regime + log_macro_regime( + dominant=scenarios.get("dominant", "incertain"), + scores=scenarios.get("scores", {}), + reasons=scenarios.get("reasons", {}), + gauges_summary=gauges_summary, + ) + + return result diff --git a/backend/routers/options.py b/backend/routers/options.py new file mode 100644 index 0000000..a787d02 --- /dev/null +++ b/backend/routers/options.py @@ -0,0 +1,111 @@ +from fastapi import APIRouter, Query +from typing import Optional +from services.options_pricer import ( + black_scholes, compute_pnl_curve, bull_call_spread, + bear_put_spread, long_straddle, implied_vol_surface +) +from services.data_fetcher import get_quote, compute_historical_iv + +router = APIRouter(prefix="/api/options", tags=["options"]) + + +@router.get("/price") +def price_option( + symbol: str = Query(...), + strike: float = Query(...), + expiry_days: int = Query(90), + option_type: str = Query("call"), + rate: float = Query(0.05), +): + q = get_quote(symbol) + S = q["price"] if q and "price" in q else strike + sigma = compute_historical_iv(symbol) + T = expiry_days / 365 + result = black_scholes(S, strike, T, rate, sigma, option_type) + result["underlying_price"] = S + result["sigma"] = sigma + return result + + +@router.get("/pnl-curve") +def pnl_curve( + symbol: str = Query(...), + strike: float = Query(...), + expiry_days: int = Query(90), + option_type: str = Query("call"), + quantity: int = Query(1), + premium_paid: float = Query(...), + rate: float = Query(0.05), +): + q = get_quote(symbol) + S = q["price"] if q and "price" in q else strike + sigma = compute_historical_iv(symbol) + T = expiry_days / 365 + return compute_pnl_curve(S, strike, T, rate, sigma, option_type, quantity, premium_paid) + + +@router.get("/strategy/bull-call-spread") +def bull_spread( + symbol: str = Query(...), + strike_low: float = Query(...), + strike_high: float = Query(...), + expiry_days: int = Query(90), + rate: float = Query(0.05), +): + q = get_quote(symbol) + S = q["price"] if q and "price" in q else strike_low + sigma = compute_historical_iv(symbol) + T = expiry_days / 365 + result = bull_call_spread(S, strike_low, strike_high, T, rate, sigma) + result["underlying_price"] = S + result["sigma"] = sigma + return result + + +@router.get("/strategy/bear-put-spread") +def bear_spread( + symbol: str = Query(...), + strike_high: float = Query(...), + strike_low: float = Query(...), + expiry_days: int = Query(90), + rate: float = Query(0.05), +): + q = get_quote(symbol) + S = q["price"] if q and "price" in q else strike_high + sigma = compute_historical_iv(symbol) + T = expiry_days / 365 + result = bear_put_spread(S, strike_high, strike_low, T, rate, sigma) + result["underlying_price"] = S + result["sigma"] = sigma + return result + + +@router.get("/strategy/straddle") +def straddle( + symbol: str = Query(...), + strike: float = Query(...), + expiry_days: int = Query(90), + rate: float = Query(0.05), +): + q = get_quote(symbol) + S = q["price"] if q and "price" in q else strike + sigma = compute_historical_iv(symbol) + T = expiry_days / 365 + result = long_straddle(S, strike, T, rate, sigma) + result["underlying_price"] = S + result["sigma"] = sigma + return result + + +@router.get("/iv-surface") +def iv_surface( + symbol: str = Query(...), + rate: float = Query(0.05), +): + q = get_quote(symbol) + S = q["price"] if q and "price" in q else 100.0 + sigma = compute_historical_iv(symbol) + strikes_pct = [0.80, 0.85, 0.90, 0.95, 1.00, 1.05, 1.10, 1.15, 1.20] + expiries = [7, 14, 30, 60, 90, 180] + surface = implied_vol_surface(S, strikes_pct, expiries, rate, sigma) + return {"symbol": symbol, "spot": S, "surface": surface} diff --git a/backend/routers/patterns.py b/backend/routers/patterns.py new file mode 100644 index 0000000..5953762 --- /dev/null +++ b/backend/routers/patterns.py @@ -0,0 +1,70 @@ +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel +from typing import Optional, List, Dict, Any +from services.database import ( + save_custom_pattern, get_custom_patterns, delete_custom_pattern, toggle_pattern_active +) + +router = APIRouter(prefix="/api/patterns", tags=["patterns"]) + + +class PatternRequest(BaseModel): + id: Optional[str] = None + name: str + description: str + triggers: List[str] + keywords: List[str] + historical_instances: Optional[List[Dict[str, Any]]] = [] + suggested_trades: Optional[List[Dict[str, Any]]] = [] + asset_class: str + expected_move_pct: float + probability: float + horizon_days: int + ai_quality_score: Optional[int] = None + ai_evaluation: Optional[Dict[str, Any]] = None + source: Optional[str] = "custom" + + +@router.get("/all") +def list_all(): + """Return all active patterns from DB (builtin + custom).""" + return get_custom_patterns() + + +@router.get("/builtin") +def list_builtin(): + return [p for p in get_custom_patterns() if p.get("source") == "builtin"] + + +@router.get("/custom") +def list_custom(): + return [p for p in get_custom_patterns() if p.get("source") != "builtin"] + + +@router.post("/custom") +def create_pattern(req: PatternRequest): + data = req.model_dump() + data["source"] = "custom" + pat_id = save_custom_pattern(data) + return {"id": pat_id, "status": "saved"} + + +@router.put("/custom/{pat_id}") +def update_pattern(pat_id: str, req: PatternRequest): + data = req.model_dump() + data["id"] = pat_id + save_custom_pattern(data) + return {"id": pat_id, "status": "updated"} + + +@router.delete("/custom/{pat_id}") +def delete_pattern(pat_id: str): + delete_custom_pattern(pat_id) + return {"status": "deleted"} + + +@router.put("/toggle/{pat_id}") +def toggle_pattern(pat_id: str): + """Enable or disable a pattern (works for builtin and custom).""" + new_state = toggle_pattern_active(pat_id) + return {"id": pat_id, "is_active": new_state} diff --git a/backend/routers/portfolio.py b/backend/routers/portfolio.py new file mode 100644 index 0000000..4f57771 --- /dev/null +++ b/backend/routers/portfolio.py @@ -0,0 +1,298 @@ +from fastapi import APIRouter, HTTPException +import traceback as tb_mod +from pydantic import BaseModel +from typing import Optional, List, Dict, Any +from datetime import datetime, date, timedelta +from services.database import ( + add_position, get_positions, close_position, + update_position_notes, compute_ib_fees +) +from services.data_fetcher import get_quote +from services.options_pricer import black_scholes +from services.data_fetcher import compute_historical_iv +import math + +router = APIRouter(prefix="/api/portfolio", tags=["portfolio"]) + + +class AddPositionRequest(BaseModel): + title: str + underlying: str + strategy: str + asset_class: Optional[str] = "indices" + entry_date: Optional[str] = None + expiry_date: Optional[str] = None + expiry_days: Optional[int] = 90 + legs: List[Dict[str, Any]] + capital_invested: float + entry_underlying_price: Optional[float] = None + geo_trigger: Optional[str] = "" + rationale: Optional[str] = "" + notes: Optional[str] = "" + + +class ClosePositionRequest(BaseModel): + close_value: float + + +class NotesRequest(BaseModel): + notes: str + + +def mark_to_market(pos: Dict[str, Any]) -> Dict[str, Any]: + """Compute current value of a position using live prices + Black-Scholes.""" + underlying = pos["underlying"] + q = get_quote(underlying) + S = (q.get("price") if q else None) or pos.get("entry_underlying_price") or 100.0 + + legs = pos.get("legs", []) + if not legs: + return {**pos, "current_value": pos["capital_invested"], "pnl": 0, "pnl_pct": 0, + "current_underlying": S, "greeks": {}} + + # Compute days to expiry + expiry_date = pos.get("expiry_date") or "" + if expiry_date: + try: + exp = datetime.strptime(expiry_date[:10], "%Y-%m-%d").date() + T = max(0.001, (exp - date.today()).days / 365) + except Exception: + T = max(0.001, (pos.get("expiry_days", 90) - 30) / 365) + else: + entry = datetime.strptime(pos["entry_date"][:10], "%Y-%m-%d").date() + days_elapsed = (date.today() - entry).days + T = max(0.001, (pos.get("expiry_days", 90) - days_elapsed) / 365) + + from services.data_fetcher import compute_historical_iv as get_iv + sigma = get_iv(underlying) + r = 0.05 + + total_current_value = 0.0 + total_entry_value = 0.0 + net_delta = 0.0 + net_theta = 0.0 + net_vega = 0.0 + entry_from_legs = False + + # T at entry (full original duration) — used to reprice legs at entry if premium_paid not stored + S_entry = float(pos.get("entry_underlying_price") or S) + entry_date_str = pos.get("entry_date", "") + if expiry_date and entry_date_str: + try: + exp_dt = datetime.strptime(expiry_date[:10], "%Y-%m-%d").date() + entry_dt = datetime.strptime(entry_date_str[:10], "%Y-%m-%d").date() + T_entry = max(0.001, (exp_dt - entry_dt).days / 365) + except Exception: + T_entry = max(0.001, pos.get("expiry_days", 90) / 365) + else: + T_entry = max(0.001, pos.get("expiry_days", 90) / 365) + + for leg in legs: + K = leg.get("strike") or S + K_entry = leg.get("strike") or S_entry + opt_type = leg.get("option_type", "call") + qty = leg.get("quantity", 1) + sign = 1 if leg.get("position", "long") == "long" else -1 + bs = black_scholes(S, K, T, r, sigma, opt_type) + leg_value = bs["price"] * qty * 100 * sign + total_current_value += leg_value + net_delta += bs["delta"] * qty * sign + net_theta += bs["theta"] * qty * sign + net_vega += bs["vega"] * qty * sign + if leg.get("premium_paid") is not None: + total_entry_value += leg["premium_paid"] * qty * 100 * sign + entry_from_legs = True + else: + # No stored premium: reprice at entry conditions for a consistent PnL baseline + bs_entry = black_scholes(S_entry, K_entry, T_entry, r, sigma, opt_type) + total_entry_value += bs_entry["price"] * qty * 100 * sign + + # Entry reference: always from legs (either stored premium or BS at entry conditions) + ib_entry = pos.get("ib_fees_entry", 0) + entry_ref = total_entry_value if total_entry_value != 0 else pos["capital_invested"] + pnl = total_current_value - entry_ref - ib_entry + pnl_pct = (pnl / max(abs(entry_ref), 1) * 100) if entry_ref else 0 + + return { + **pos, + "current_underlying": round(S, 4), + "current_value": round(total_current_value, 2), + "entry_ref": round(entry_ref, 2), + "pnl": round(pnl, 2), + "pnl_pct": round(pnl_pct, 2), + "days_remaining": max(0, int(T * 365)), + "sigma_used": round(sigma, 4), + "greeks": { + "net_delta": round(net_delta, 4), + "net_theta": round(net_theta, 4), + "net_vega": round(net_vega, 4), + }, + } + + +@router.get("/positions") +def list_positions(status: str = "open"): + positions = get_positions(status) + if status == "open": + return [mark_to_market(p) for p in positions] + return positions + + +@router.get("/summary") +def portfolio_summary(): + open_pos = get_positions("open") + closed_pos = get_positions("closed") + + marked = [mark_to_market(p) for p in open_pos] + total_invested = sum(p["capital_invested"] for p in open_pos) + total_current = sum(p.get("current_value", p["capital_invested"]) for p in marked) + total_pnl = sum(p.get("pnl", 0) for p in marked) + total_fees = sum(p.get("ib_fees_entry", 0) for p in open_pos) + + realized_pnl = 0.0 + for p in closed_pos: + if p.get("close_value") is not None: + realized_pnl += (p["close_value"] - p["capital_invested"] + - p.get("ib_fees_entry", 0) - p.get("ib_fees_exit", 0)) + + return { + "open_positions": len(open_pos), + "closed_positions": len(closed_pos), + "total_invested": round(total_invested, 2), + "total_current_value": round(total_current, 2), + "unrealized_pnl": round(total_pnl, 2), + "unrealized_pnl_pct": round(total_pnl / total_invested * 100, 2) if total_invested else 0, + "realized_pnl": round(realized_pnl, 2), + "total_fees_paid": round(total_fees, 2), + "net_pnl": round(total_pnl + realized_pnl, 2), + } + + +TICKER_HINTS: Dict[str, str] = { + # Indices + "s&p 500": "^GSPC", "sp500": "^GSPC", "s&p500": "^GSPC", "spx": "^GSPC", + "nasdaq": "^NDX", "nasdaq 100": "^NDX", "ndx": "^NDX", "qqq": "QQQ", + "dow jones": "^DJI", "djia": "^DJI", + "vix": "^VIX", + "euro stoxx": "^STOXX50E", "stoxx50": "^STOXX50E", + "nikkei": "^N225", + # Metals + "gold": "GC=F", "or": "GC=F", "gold futures": "GC=F", + "silver": "SI=F", "argent": "SI=F", + "copper": "HG=F", "cuivre": "HG=F", + "platinum": "PL=F", "platine": "PL=F", + # Energy + "wti": "CL=F", "crude oil": "CL=F", "pétrole": "CL=F", "crude": "CL=F", + "brent": "BZ=F", + "natural gas": "NG=F", "gaz naturel": "NG=F", "natgas": "NG=F", + # Agriculture + "corn": "ZC=F", "maïs": "ZC=F", + "wheat": "ZW=F", "blé": "ZW=F", + "soybean": "ZS=F", "soja": "ZS=F", + "coffee": "KC=F", "café": "KC=F", + # Forex — yfinance format: {BASE}{QUOTE}=X + "eurusd": "EURUSD=X", "eur/usd": "EURUSD=X", "euro": "EURUSD=X", + "usdjpy": "USDJPY=X", "usd/jpy": "USDJPY=X", + "gbpusd": "GBPUSD=X", "gbp/usd": "GBPUSD=X", + "usdchf": "USDCHF=X", "usd/chf": "USDCHF=X", + "usdcnh": "USDCNH=X", "usd/cnh": "USDCNH=X", + "usdcny": "USDCNY=X", "usd/cny": "USDCNY=X", "cny": "USDCNY=X", + "usdrub": "USDRUB=X", "usd/rub": "USDRUB=X", + "usdtry": "USDTRY=X", "usd/try": "USDTRY=X", + "usdmxn": "USDMXN=X", "usd/mxn": "USDMXN=X", + "audusd": "AUDUSD=X", "aud/usd": "AUDUSD=X", + "dxy": "DX-Y.NYB", "dollar index": "DX-Y.NYB", +} + + +@router.post("/add") +def add_pos(req: AddPositionRequest): + import traceback + try: + data = req.model_dump() + + # Normalize common names to yfinance tickers + raw = req.underlying.strip() + normalized = TICKER_HINTS.get(raw.lower(), raw) + data["underlying"] = normalized + + # Fetch live underlying price + q = get_quote(normalized) + S = q.get("price") if q else None + if not S: + hint = TICKER_HINTS.get(raw.lower()) + tip = f" Essayez '{hint}'." if hint else " Utilisez le symbole Yahoo Finance (ex: ^GSPC pour S&P 500, GC=F pour Or, CL=F pour WTI)." + raise HTTPException(status_code=422, detail=f"Ticker '{raw}' introuvable sur Yahoo Finance.{tip}") + if not data.get("entry_underlying_price"): + data["entry_underlying_price"] = S + + # Auto-fill entry date and expiry + if not data.get("entry_date"): + data["entry_date"] = datetime.utcnow().isoformat()[:10] + if not data.get("expiry_date") and data.get("expiry_days"): + data["expiry_date"] = (date.today() + timedelta(days=data["expiry_days"])).isoformat() + + # Auto-price legs that have no premium_paid using BS at entry + # This ensures P&L starts at ~0 on day 1 (tracking change from entry, not vs. budget) + if S and data.get("legs"): + sigma = compute_historical_iv(req.underlying) + T = max(0.001, data.get("expiry_days", 90) / 365) + r = 0.05 + for leg in data["legs"]: + if leg.get("premium_paid") is None: + K = leg.get("strike") or S # ATM if no explicit strike + if not leg.get("strike"): + leg["strike"] = round(S, 2) + opt_type = leg.get("option_type", "call") + bs = black_scholes(S, K, T, r, sigma, opt_type) + leg["premium_paid"] = round(bs["price"], 4) + + pos_id = add_position(data) + return {"id": pos_id, "status": "added"} + except HTTPException: + raise + except Exception as e: + tb = traceback.format_exc() + raise HTTPException(status_code=500, detail=f"{str(e)}\n\n{tb}") + + +@router.post("/close/{pos_id}") +def close_pos(pos_id: str, req: ClosePositionRequest): + return close_position(pos_id, req.close_value) + + +@router.delete("/{pos_id}") +def delete_pos(pos_id: str): + from services.database import get_conn + conn = get_conn() + conn.execute("DELETE FROM portfolio WHERE id=?", (pos_id,)) + conn.commit() + conn.close() + return {"status": "deleted", "id": pos_id} + + +@router.patch("/notes/{pos_id}") +def update_notes(pos_id: str, req: NotesRequest): + update_position_notes(pos_id, req.notes) + return {"status": "ok"} + + +@router.get("/pnl-history") +def pnl_history(): + """Equity curve from closed positions.""" + closed = get_positions("closed") + closed_sorted = sorted(closed, key=lambda p: p.get("close_date", "")) + curve = [] + cumulative = 0.0 + for p in closed_sorted: + if p.get("close_value") is not None: + pnl = (p["close_value"] - p["capital_invested"] + - p.get("ib_fees_entry", 0) - p.get("ib_fees_exit", 0)) + cumulative += pnl + curve.append({ + "date": p.get("close_date", ""), + "pnl": round(pnl, 2), + "cumulative": round(cumulative, 2), + "title": p.get("title", p.get("underlying", "")), + }) + return curve diff --git a/backend/routers/profiles.py b/backend/routers/profiles.py new file mode 100644 index 0000000..0a0ddd4 --- /dev/null +++ b/backend/routers/profiles.py @@ -0,0 +1,88 @@ +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel +from typing import Optional +from services.database import get_risk_profiles, upsert_risk_profile, delete_risk_profile, _compute_trade_score + +router = APIRouter(prefix="/api/profiles", tags=["profiles"]) + + +class RiskProfileRequest(BaseModel): + id: Optional[int] = None + name: str + min_score: int + min_gain_pct: float + color: Optional[str] = "#3b82f6" + enabled: Optional[bool] = True + sort_order: Optional[int] = 0 + + +@router.get("") +def list_profiles(): + """List all risk profiles ordered by sort_order.""" + profiles = get_risk_profiles() + # Annotate each profile with the EV breakeven info + result = [] + for p in profiles: + # At the exact frontier: score = min_score, gain = min_gain_pct + _, ev_net, trade_score = _compute_trade_score(p["min_score"], p["min_gain_pct"]) + result.append({ + **p, + "ev_net_at_frontier": round(ev_net, 3), + "trade_score_at_frontier": trade_score, + }) + return {"profiles": result} + + +@router.post("") +def create_profile(req: RiskProfileRequest): + """Create a new risk profile.""" + if not (0 <= req.min_score <= 100): + raise HTTPException(400, "min_score must be between 0 and 100") + if req.min_gain_pct < 0: + raise HTTPException(400, "min_gain_pct must be >= 0") + pid = upsert_risk_profile(req.model_dump()) + profiles = get_risk_profiles() + return {"id": pid, "profiles": profiles} + + +@router.put("/{profile_id}") +def update_profile(profile_id: int, req: RiskProfileRequest): + """Update an existing risk profile.""" + if not (0 <= req.min_score <= 100): + raise HTTPException(400, "min_score must be between 0 and 100") + data = req.model_dump() + data["id"] = profile_id + upsert_risk_profile(data) + return {"profiles": get_risk_profiles()} + + +@router.delete("/{profile_id}") +def remove_profile(profile_id: int): + """Delete a risk profile.""" + profiles = get_risk_profiles() + if len([p for p in profiles if p["enabled"]]) <= 1: + # Allow deletion but warn + pass + delete_risk_profile(profile_id) + return {"profiles": get_risk_profiles()} + + +@router.get("/preview") +def preview_score(score: int = 50, gain_pct: float = 100.0): + """ + Preview the trade metrics for a given (score, gain_pct) pair. + Useful for the Config UI slider simulation. + """ + ev_gross, ev_net, trade_score = _compute_trade_score(score, gain_pct) + profiles = get_risk_profiles(enabled_only=True) + from services.database import _matches_profile + matched = _matches_profile(score, gain_pct, profiles) + return { + "score": score, + "gain_pct": gain_pct, + "ev_gross": ev_gross, + "ev_net": ev_net, + "trade_score": trade_score, + "matched_profile": matched, + "accepted": matched is not None, + } diff --git a/backend/routers/reasoning.py b/backend/routers/reasoning.py new file mode 100644 index 0000000..be9a43c --- /dev/null +++ b/backend/routers/reasoning.py @@ -0,0 +1,332 @@ +""" +AI Reasoning Traces — store and query the full reasoning chain behind each trade. + +Endpoints: + GET /api/reasoning/postmortem/{trade_id} — reasoning chain (no GPT call) + POST /api/reasoning/postmortem/{trade_id}/analyze — GPT-4o post-mortem analysis +""" +import json +import logging +import os + +from fastapi import APIRouter, HTTPException + +from services.database import ( + get_config, + get_ai_report, + get_mtm_trades_with_traces, + get_pattern_scoring_history, + get_scoring_trace, + get_suggestion_trace, + get_trade_entry_by_id, + list_ai_reports, + save_ai_report, +) + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/api/reasoning", tags=["reasoning"]) + + +# ── Helpers ─────────────────────────────────────────────────────────────────── + +def _bucket_summary(buckets: list) -> str: + lines = [] + for b in buckets: + pct = round(b.get("score", 0) / b.get("max", 1) * 100) if b.get("max") else 0 + lines.append(f" {b.get('label', b.get('id'))}: {b.get('score')}/{b.get('max')} ({pct}%) — {(b.get('comment') or '')[:90]}") + return "\n".join(lines) + + +def _rankings_summary(rankings: list) -> str: + lines = [] + for r in rankings: + delta = r.get("score_delta", 0) + sign = "+" if delta >= 0 else "" + lines.append(f" {r.get('underlying')} {r.get('strategy')} — delta {sign}{delta} | {(r.get('rationale') or '')[:80]}") + return "\n".join(lines) + + +# ── Endpoints ───────────────────────────────────────────────────────────────── + +@router.get("/postmortem/{trade_id}") +def get_postmortem(trade_id: int): + """ + Return the full AI reasoning chain for a logged trade: + - why the pattern was suggested (suggestion trace) + - why it was scored at that level (scoring trace with pillar breakdown) + - score evolution across cycles (trend) + """ + trade = get_trade_entry_by_id(trade_id) + if not trade: + raise HTTPException(404, f"Trade {trade_id} not found") + + scoring_trace = get_scoring_trace(trade["run_id"], trade["pattern_id"]) + suggestion_trace = get_suggestion_trace(trade["pattern_id"]) + score_history = get_pattern_scoring_history(trade["pattern_id"], limit=8) + + return { + "trade": trade, + "scoring_context": scoring_trace, + "suggestion_context": suggestion_trace, + "score_history": [ + { + "run_id": t["run_id"], + "created_at": t["created_at"], + "score": t["output"].get("score"), + "key_catalyst": t["output"].get("key_catalyst"), + "macro_dominant": t["macro_dominant"], + "geo_score": t["geo_score"], + "summary": t["output"].get("summary"), + } + for t in score_history + ], + } + + +@router.post("/postmortem/{trade_id}/analyze") +def analyze_postmortem(trade_id: int): + """ + Ask GPT-4o to explain why a trade did/didn't work based on the full reasoning chain. + Returns a structured analysis with diagnostic, lessons, and next-cycle recommendations. + """ + ai_key = get_config("openai_api_key") or "" + if not ai_key: + raise HTTPException(400, "Clé OpenAI non configurée") + os.environ["OPENAI_API_KEY"] = ai_key + + from services.ai_analyzer import _chat + + trade = get_trade_entry_by_id(trade_id) + if not trade: + raise HTTPException(404, f"Trade {trade_id} not found") + + scoring_trace = get_scoring_trace(trade["run_id"], trade["pattern_id"]) + suggestion_trace = get_suggestion_trace(trade["pattern_id"]) + score_history = get_pattern_scoring_history(trade["pattern_id"], limit=5) + + scoring_out = scoring_trace["output"] if scoring_trace else {} + scoring_ctx = scoring_trace["input_context"] if scoring_trace else {} + suggestion_out = suggestion_trace["output"] if suggestion_trace else {} + + buckets_text = _bucket_summary(scoring_out.get("buckets", [])) + rankings_text = _rankings_summary(scoring_out.get("trade_rankings", [])) + + score_trend = " → ".join( + f"{t['output'].get('score', '?')}/100 ({t['macro_dominant'] or '?'} régime, géo {t['geo_score'] or '?'})" + for t in reversed(score_history) + ) + + prompt = f"""Tu es un stratège macro-géopolitique senior qui analyse le post-mortem d'un trade options. + +═══ TRADE ANALYSÉ ═══ +Pattern : {trade.get("pattern_name")} +Instrument : {trade.get("underlying")} — {trade.get("strategy")} +Entrée : {trade.get("entry_date")} @ {trade.get("entry_price") or "N/A"} +Score entrée: {trade.get("score_at_entry")}/100 | Trade Score: {trade.get("trade_score") or "N/A"} | EV nette: {trade.get("ev_net") or "N/A"} +Profil : {trade.get("matched_profile")} | Gain prévu: {trade.get("expected_move_pct") or "N/A"}% + +═══ CONTEXTE AU MOMENT DU SCORING ═══ +Régime macro : {scoring_trace.get("macro_dominant") if scoring_trace else "N/A"} | Biais asset: {scoring_ctx.get("asset_bias", "N/A")} +Scores macro : {json.dumps(scoring_ctx.get("macro_scores", {}), ensure_ascii=False)} +Risque géo : {scoring_trace.get("geo_score") if scoring_trace else "N/A"}/100 +Gain prévu : {scoring_ctx.get("expected_move_pct") or "N/A"}% + +═══ POURQUOI CE PATTERN A ÉTÉ CRÉÉ ═══ +{suggestion_out.get("macro_fit") or "N/A"} +{suggestion_out.get("description") or ""} + +═══ SCORE DÉTAILLÉ PAR PILIER ═══ +Score global : {scoring_out.get("score", 0)}/100 (confiance {scoring_out.get("confidence", 0)}%) +{buckets_text or "Non disponible"} +Catalyseur clé : {scoring_out.get("key_catalyst") or "N/A"} +Synthèse : {scoring_out.get("summary") or "N/A"} +Contra-signal fort : {"OUI" if scoring_out.get("has_strong_contra") else "non"} + +═══ CLASSEMENT DES TRADES AU SCORING ═══ +{rankings_text or "Non disponible"} + +═══ ÉVOLUTION DU SCORE DANS LE TEMPS ═══ +{score_trend or "Premier scoring — pas d'historique"} + +Analyse ce trade en JSON : +{{ + "diagnostic": "<2-3 phrases: qu'explique la performance (bonne ou mauvaise) de ce trade ?>", + "what_worked": "", + "what_missed": "", + "regime_alignment": "", + "contra_assessment": "", + "lesson": "<1 règle précise à retenir pour scorer ce type de pattern plus finement>", + "next_cycle": "" +}}""" + + try: + result = _chat( + "Tu es un stratège macro-géopolitique senior. Post-mortem concis et actionnable. JSON uniquement.", + prompt, + model="gpt-4o", + json_mode=True, + max_tokens=900, + ) + except Exception as e: + logger.error(f"[Postmortem] GPT-4o call failed: {e}") + raise HTTPException(503, "GPT-4o indisponible") + + if not result: + raise HTTPException(503, "GPT-4o n'a pas retourné de réponse") + + return { + "trade_id": trade_id, + "trade": trade, + "scoring_context": scoring_trace, + "suggestion_context": suggestion_trace, + "analysis": result, + } + + +# ── Portfolio AI Report ──────────────────────────────────────────────────────── + +def _trade_summary_block(label: str, trades: list) -> str: + if not trades: + return f"{label} : aucun trade pricé" + lines = [f"{label} :"] + for t in trades: + pnl = t.get("pnl_pct") + sc = t.get("scoring_context") or {} + sc_out = sc.get("output", {}) if isinstance(sc, dict) else {} + sg = t.get("suggestion_context") or {} + sg_out = sg.get("output", {}) if isinstance(sg, dict) else {} + macro = sc.get("macro_dominant") if isinstance(sc, dict) else "?" + geo = sc.get("geo_score") if isinstance(sc, dict) else "?" + catalyst = sc_out.get("key_catalyst") or "N/A" + macro_fit = sg_out.get("macro_fit") or sg_out.get("description") or "N/A" + trend = " → ".join(str(s) for s in (t.get("score_trend") or [])) or "N/A" + buckets = sc_out.get("buckets", []) + weak = [b.get("label", b.get("id", "")) for b in buckets if b.get("max") and b.get("score", 0) / b["max"] < 0.4] + lines.append( + f" • {t.get('pattern_name')} | {t.get('underlying')} {t.get('strategy')}" + f" | P&L {'+' if (pnl or 0) >= 0 else ''}{(pnl or 0):.1f}%" + f" | Score entrée {t.get('score_at_entry')}/100 | Régime {macro} | Géo {geo}" + f"\n Thèse : {macro_fit[:120]}" + f"\n Catalyseur : {catalyst}" + f"\n Trend score : {trend}" + + (f"\n Piliers faibles : {', '.join(weak)}" if weak else "") + ) + return "\n".join(lines) + + +@router.get("/portfolio-report") +def get_portfolio_report_data(days: int = 90): + """Return raw MTM + traces data (no GPT-4o call) for the report page.""" + data = get_mtm_trades_with_traces(days=days, limit_movers=5) + return data + + +@router.post("/portfolio-report/generate") +def generate_portfolio_report(days: int = 90): + """ + Generate a GPT-4o AI report: key highlights, explanations for top movers, + macro regime assessment, and actionable next-cycle recommendations. + """ + ai_key = get_config("openai_api_key") or "" + if not ai_key: + raise HTTPException(400, "Clé OpenAI non configurée") + os.environ["OPENAI_API_KEY"] = ai_key + + from services.ai_analyzer import _chat + + data = get_mtm_trades_with_traces(days=days, limit_movers=5) + winners = data["winners"] + losers = data["losers"] + + winners_block = _trade_summary_block("TOP GAINS", winners) + losers_block = _trade_summary_block("TOP PERTES", losers) + + avg_pnl = data.get("avg_pnl_pct") + avg_str = f"{avg_pnl:+.1f}%" if avg_pnl is not None else "N/A" + + prompt = f"""Tu es un stratège macro-géopolitique senior. Génère un rapport synthétique sur notre portefeuille options. + +═══ STATISTIQUES GLOBALES ═══ +Période : {days} derniers jours +Trades total: {data['total_trades']} | Pricés: {data['priced_count']} | P&L moyen: {avg_str} + +═══ {winners_block} + +═══ {losers_block} + +Génère un rapport JSON structuré : +{{ + "headline": "<1 phrase résumant la performance de la période>", + "regime_assessment": "", + "winners_analysis": "", + "losers_analysis": "", + "key_lessons": ["", "", ""], + "blind_spots": "", + "next_cycle_priorities": "<3 priorités concrètes pour améliorer les prochains cycles : patterns à surveiller, ajustements de scoring, régimes à anticiper>", + "risk_watch": "<1-2 risques macro-géopolitiques à surveiller de près qui pourraient impacter nos positions actuelles>" +}}""" + + try: + result = _chat( + "Tu es un stratège macro senior. Rapport synthétique et actionnable. JSON uniquement.", + prompt, + model="gpt-4o", + json_mode=True, + max_tokens=1200, + ) + except Exception as e: + logger.error(f"[PortfolioReport] GPT-4o call failed: {e}") + raise HTTPException(503, "GPT-4o indisponible") + + if not result: + raise HTTPException(503, "GPT-4o n'a pas retourné de réponse") + + stats = { + "total_trades": data["total_trades"], + "priced_count": data["priced_count"], + "avg_pnl_pct": avg_pnl, + } + + report_id = save_ai_report( + days=days, + stats=stats, + winners=winners, + losers=losers, + report=result, + ) + + return { + "id": report_id, + "days": days, + "stats": stats, + "winners": winners, + "losers": losers, + "report": result, + } + + +@router.get("/reports") +def list_reports(report_type: str = "portfolio", limit: int = 20): + """List archived AI reports (newest first), summary only.""" + reports = list_ai_reports(report_type=report_type, limit=limit) + return { + "reports": [ + { + "id": r["id"], + "days": r["days"], + "created_at": r["created_at"], + "stats": r["stats"], + "headline": r["report"].get("headline", ""), + } + for r in reports + ] + } + + +@router.get("/reports/{report_id}") +def get_report(report_id: int): + """Retrieve a full archived AI report by ID.""" + report = get_ai_report(report_id) + if not report: + raise HTTPException(404, f"Report {report_id} not found") + return report diff --git a/backend/services/__init__.py b/backend/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/services/ai_analyzer.py b/backend/services/ai_analyzer.py new file mode 100644 index 0000000..8f368c1 --- /dev/null +++ b/backend/services/ai_analyzer.py @@ -0,0 +1,934 @@ +""" +AI analysis engine using OpenAI GPT-4o. +Tasks: news scoring, speech analysis, pattern evaluation, trade idea ranking. +""" +from openai import OpenAI +from typing import Optional, List, Dict, Any +import json +import os + +_client: Optional[OpenAI] = None + + +def get_client() -> Optional[OpenAI]: + global _client + key = os.environ.get("OPENAI_API_KEY", "") + if not key: + return None + if _client is None or _client.api_key != key: + _client = OpenAI(api_key=key) + return _client + + +def _chat(system: str, user: str, model: str = "gpt-4o-mini", json_mode: bool = True, max_tokens: int = 1500) -> Optional[Dict]: + client = get_client() + if not client: + return None + kwargs: Dict[str, Any] = { + "model": model, + "messages": [{"role": "system", "content": system}, {"role": "user", "content": user}], + "temperature": 0.2, + "max_tokens": max_tokens, + } + if json_mode: + kwargs["response_format"] = {"type": "json_object"} + resp = client.chat.completions.create(**kwargs) + content = resp.choices[0].message.content + if json_mode: + return json.loads(content) + return {"text": content} + + +# ── News / Article Analysis ─────────────────────────────────────────────────── + +SYSTEM_NEWS = """Tu es un analyste financier géopolitique senior spécialisé en options. +Tu analyses des actualités et identifies leur impact potentiel sur les marchés financiers. +Réponds UNIQUEMENT en JSON selon le schéma demandé. Sois précis et concis.""" + +def analyze_news_item(title: str, summary: str) -> Dict[str, Any]: + """Classify and score a single news item with GPT.""" + user = f"""Analyse cet article géopolitique/économique: +Titre: {title} +Résumé: {summary} + +Retourne ce JSON: +{{ + "category": "military|sanctions|elections|natural_disaster|health_crisis|resource_scarcity|trade_war|energy|political_speech|financial_crisis|general", + "impact_score": , + "direction": "bullish|bearish|neutral|volatile", + "affected_assets": {{ + "energy": , + "metals": , + "agriculture": , + "indices": , + "forex": + }}, + "key_entities": [], + "horizon": "immediate|days|weeks|months", + "reasoning": "<1 phrase expliquant l'impact>" +}}""" + result = _chat(SYSTEM_NEWS, user) + if not result: + return {"category": "general", "impact_score": 0.1, "direction": "neutral", + "affected_assets": {}, "key_entities": [], "horizon": "days", "reasoning": "AI non disponible"} + return result + + +# ── Speech / Text Analysis (Trump, Powell, etc.) ───────────────────────────── + +SYSTEM_SPEECH = """Tu es un analyste quantitatif géopolitique. Tu décodes les discours et déclarations +de personnalités politiques/économiques pour identifier des opportunités de trading en options. +Tu te spécialises dans: discours Trump (tarifs, énergie, dollar), Powell/Fed (taux), +leaders géopolitiques (sanctions, guerres, ressources). Réponds en JSON uniquement.""" + +def analyze_speech(text: str, speaker: str = "") -> Dict[str, Any]: + """Deep analysis of a speech/statement for trading signals.""" + user = f"""Analyse cette déclaration{'de ' + speaker if speaker else ''} pour des signaux de trading: + +--- +{text[:3000]} +--- + +Retourne ce JSON: +{{ + "speaker_identified": "", + "tone": "hawkish|dovish|aggressive|conciliatory|ambiguous", + "key_statements": [], + "market_signals": [ + {{ + "asset": "", + "direction": "up|down|volatile", + "magnitude": "low|medium|high|extreme", + "reasoning": "", + "timeframe": "" + }} + ], + "options_opportunities": [ + {{ + "underlying": "", + "strategy": "Long Call|Long Put|Bull Call Spread|Bear Put Spread|Long Straddle", + "strike_guidance": "", + "expiry_guidance": "<30j|60j|90j>", + "rationale": "", + "confidence": , + "capital_1000eur": "" + }} + ], + "risk_level": "low|medium|high|extreme", + "geo_pattern_triggered": "", + "summary": "<2-3 phrases de synthèse pour un trader>" +}}""" + result = _chat(SYSTEM_SPEECH, user, model="gpt-4o") + if not result: + return {"error": "OpenAI non disponible — vérifier la clé API"} + return result + + +# ── Pattern Evaluation & Creation ──────────────────────────────────────────── + +SYSTEM_PATTERN = """Tu es un expert en analyse géopolitique quantitative et en trading d'options. +Tu évalues et améliores des patterns géopolitiques pour un système de trading algorithmique. +Tes évaluations se basent sur des faits historiques vérifiables. Réponds en JSON.""" + +def evaluate_pattern(pattern: Dict[str, Any]) -> Dict[str, Any]: + """AI evaluation of a user-defined pattern.""" + user = f"""Évalue ce pattern géopolitique de trading: + +{json.dumps(pattern, ensure_ascii=False, indent=2)} + +Retourne ce JSON: +{{ + "quality_score": , + "validity": "excellent|good|fair|poor", + "strengths": [], + "weaknesses": [], + "suggested_improvements": {{ + "additional_keywords": [], + "additional_triggers": [], + "probability_estimate": , + "expected_move_revision": , + "horizon_revision": + }}, + "historical_validation": [ + {{ + "date": "", + "event": "<événement réel qui confirme le pattern>", + "outcome": "" + }} + ], + "counter_scenarios": [<2-3 scénarios qui invalideraient ce pattern>], + "overall_recommendation": "", + "risk_warnings": [] +}}""" + result = _chat(SYSTEM_PATTERN, user, model="gpt-4o") + if not result: + return {"error": "OpenAI non disponible", "quality_score": 0} + return result + + +def suggest_pattern_from_context(context: str) -> Dict[str, Any]: + """AI creates a pattern structure from a free-text context description.""" + user = f"""Un trader décrit ce contexte géopolitique et veut créer un pattern de trading: + +"{context}" + +Génère un pattern complet en JSON: +{{ + "id": "P_USER_<3 lettres aléatoires>", + "name": "", + "description": "", + "triggers": [], + "keywords": [<10-15 mots-clés anglais pour détecter ce pattern dans les news>], + "historical_instances": [ + {{"date": "", "event": "<événement réel>", "outcome": ""}} + ], + "suggested_trades": [ + {{"strategy": "", "underlying": "", "rationale": ""}} + ], + "asset_class": "", + "expected_move_pct": , + "probability": , + "horizon_days": , + "confidence_in_pattern": , + "caveats": [] +}}""" + result = _chat(SYSTEM_PATTERN, user, model="gpt-4o") + if not result: + return {"error": "OpenAI non disponible"} + return result + + +# ── Top 10 Trade Ideas Ranking ──────────────────────────────────────────────── + +SYSTEM_RANKING = """Tu es un gestionnaire de portefeuille spécialisé en options. +Tu dois sélectionner et classer les 10 meilleures opportunités de trading options +pour un capital de ~1000€ avec horizon 3 mois, en intégrant le contexte géopolitique actuel. +Privilégie: risque/rendement optimal, liquidité des options, clarté du catalyseur. Réponds en JSON.""" + +def rank_trade_ideas( + pattern_matches: List[Dict], + geo_score: Dict, + recent_news: List[Dict], + market_quotes: Dict, +) -> List[Dict[str, Any]]: + """Generate and rank top 10 trade ideas using GPT-4o.""" + + context = { + "geo_risk_score": geo_score.get("score", 50), + "geo_risk_level": geo_score.get("level", "medium"), + "top_risks": geo_score.get("top_risks", []), + "active_patterns": [ + {"name": p["name"], "similarity": p["similarity"], + "asset_class": p["asset_class"], "expected_move": p["expected_move_pct"]} + for p in pattern_matches[:5] + ], + "top_news": [ + {"title": n["title"], "category": n["category"], "impact": n["impact_score"]} + for n in recent_news[:10] + ], + } + + user = f"""Contexte géopolitique et marché actuel: +{json.dumps(context, ensure_ascii=False, indent=2)} + +Génère les 10 meilleures idées de trades en options pour 1000€ / horizon 3 mois. +Diversifie les classes d'actifs. Inclus au moins: 2 énergie, 1 métal, 1 agri, 2 indices/actions, 1 forex. + +Retourne ce JSON: +{{ + "ideas": [ + {{ + "rank": <1-10>, + "title": "", + "underlying": "", + "strategy": "Long Call|Long Put|Bull Call Spread|Bear Put Spread|Long Straddle|Long Strangle", + "asset_class": "", + "rationale": "", + "geo_trigger": "", + "strike_guidance": "", + "expiry_days": , + "expected_move_pct": , + "max_loss_eur": , + "target_gain_eur": , + "confidence": , + "risk_level": "low|medium|high|extreme", + "timing": "", + "invalidation": "" + }} + ], + "portfolio_note": "", + "current_bias": "bullish|bearish|neutral|volatile", + "key_risk": "" +}}""" + + result = _chat(SYSTEM_RANKING, user, model="gpt-4o") + if not result: + return [] + return result.get("ideas", []) + + +# ── Pattern Scoring with Rich Context ──────────────────────────────────────── + +DEFAULT_ANALYSIS_TEMPLATE = """Pour chaque pattern, note chaque sous-pilier ET fournis un commentaire 1-2 phrases en français. +Score total = somme exacte des 4 piliers (0-100). + +PILIER 1 — ACTUALITÉS & GÉO-CONTEXTE (30 pts max) + 1a. News géopolitiques (0-12): pertinence des événements récents vs keywords/triggers du pattern + 1b. News macro/économiques (0-10): données macro, publications éco, politiques monétaires/fiscales + 1c. Volume & récence signal (0-8) : nb de sources indépendantes, fraîcheur (<48h = max), cohérence + +PILIER 2 — CALENDRIER ÉCONOMIQUE (20 pts max) + 2a. Banques centrales (0-10): décisions FOMC/BCE/BoJ/BoE à venir, minutes, discours membres + 2b. Publications macro (0-10): CPI, NFP, PIB, PMI, rapport OPEC — alignement avec le pattern + +PILIER 3 — SIGNAUX DE PRIX (35 pts max) + 3a. Taux & Obligations (0-7): mouvements yields, courbe de taux, spreads crédit + 3b. Énergie & Matières prem. (0-7): or, pétrole, gaz, cuivre, blé — direction et momentum + 3c. Forex (0-7): USD index, EUR/USD, paires émergentes — cohérence avec pattern + 3d. Actions & Indices (0-7): SPX, NDX, rotation sectorielle, breadth, sentiment + 3e. Volatilité (VIX/IV) (0-7): régime de vol, coût options, skew — favorable à la stratégie ? + +PILIER 4 — RISQUE / RÉCOMPENSE (15 pts max) + 4a. Asymétrie R/R (0-10): ratio gain potentiel / prime payée / perte max pour ~1000€ + 4b. Timing d'entrée (0-5) : qualité du point d'entrée vs analogues historiques du pattern + +Règles: score total = 1a+1b+1c+2a+2b+3a+3b+3c+3d+3e+4a+4b; ne pas dépasser les max; commenter chaque sous-pilier. + +⚠️ RÈGLE ANTI-BIAIS DIRECTIONNELLE (IMPÉRATIVE): +- "expected_direction" indique si le pattern attend une hausse ou une baisse. +- "contra_signals" liste les news AI-scorées qui CONTREDISENT cette direction. +- "has_strong_contra": true = le contexte actuel ANNULE ou INVERSE la thèse du pattern. +→ Si has_strong_contra=true: score total ≤ 40/100 ; sous-pilier 1a geo ≤ 3/12. +→ Si resolution=true dans contra_signals (accord/cessez-le-feu résolvant le conflit trigger): 1a geo = 0-2/12. +→ Indique toujours dans "summary" si le signal est [SUPPORTING], [NEUTRAL] ou [CONTRA].""" + +SYSTEM_SCORER = """Tu es un gestionnaire de portefeuille senior spécialisé en options géopolitiques. +Tu analyses des patterns géopolitiques avec leur contexte marché enrichi (news, prix, IV) pour identifier +les meilleures opportunités de trading options (~1000€, horizon 3 mois). +Tu es rigoureux, quantitatif et pragmatique. Réponds UNIQUEMENT en JSON valide.""" + + +def score_patterns_with_context( + patterns: List[Dict], + recent_news: List[Dict], + quotes_by_class: Dict, + geo_score: Dict, + template: str = None, + top_n: int = 10, + category_filter: str = None, + macro_regime: Optional[Dict] = None, + portfolio_lessons: Optional[Dict] = None, +) -> List[Dict[str, Any]]: + """Score all patterns with rich context (news, prices, IV) using GPT-4o.""" + if not get_client(): + return [] + + scoring_template = template or DEFAULT_ANALYSIS_TEMPLATE + + # Flatten quotes to symbol -> data dict for fast lookup + quotes_flat: Dict[str, Dict] = {} + for cls_quotes in quotes_by_class.values(): + for q in cls_quotes: + quotes_flat[q.get("symbol", "")] = q + + # Build per-pattern context blocks + pattern_blocks = [] + for pat in patterns: + if category_filter and category_filter != "all": + if pat.get("asset_class") != category_filter: + # also check suggested trades + trade_classes = [t.get("asset_class", "") for t in pat.get("suggested_trades", [])] + if category_filter not in trade_classes: + continue + + # Filter news relevant to this pattern + keywords = [kw.lower() for kw in pat.get("keywords", [])] + relevant_news = [] + for n in recent_news[:50]: + text = (n.get("title", "") + " " + n.get("summary", "")).lower() + if any(kw in text for kw in keywords): + relevant_news.append({ + "title": n.get("title", ""), + "date": n.get("published", "")[:10], + "source": n.get("source", ""), + "impact": n.get("impact_score", 0), + }) + if len(relevant_news) >= 4: + break + + # Market data for each suggested underlying + market_data = {} + for trade in pat.get("suggested_trades", []): + sym = trade.get("underlying", "") + if sym and sym in quotes_flat: + q = quotes_flat[sym] + from services.data_fetcher import compute_historical_iv + try: + iv = compute_historical_iv(sym) + except Exception: + iv = None + market_data[sym] = { + "price": q.get("price"), + "change_1d_pct": q.get("change_pct"), + "iv_pct": round(iv * 100, 1) if iv else None, + "name": q.get("name", sym), + } + + # Detect contra-signals: AI-scored news that contradicts this pattern's direction + expected_up = pat.get("expected_move_pct", 0) > 0 + asset_cls = pat.get("asset_class", "") + _dir_field = {"energy": "ai_dir_energy", "metals": "ai_dir_metals"}.get(asset_cls, "ai_dir_indices") + + contra_signals = [] + for n in recent_news[:25]: + if not n.get("ai_scored"): + continue + ai_dir = n.get(_dir_field, "neutral") + impact = float(n.get("impact_score") or 0) + is_contra = (expected_up and ai_dir == "bearish") or (not expected_up and ai_dir == "bullish") + if is_contra and impact >= 0.35: + contra_signals.append({ + "title": (n.get("title") or "")[:100], + "impact": round(impact, 2), + "direction": ai_dir, + "resolution": n.get("ai_resolution", False), + "insight": n.get("ai_insight", ""), + }) + has_strong_contra = any(c["impact"] >= 0.55 or c.get("resolution") for c in contra_signals) + + # Macro regime context for this pattern + macro_ctx = None + if macro_regime: + scenarios = macro_regime.get("scenarios", {}) + gauges = macro_regime.get("gauges", {}) + dominant = scenarios.get("dominant", "incertain") + asset_bias = scenarios.get("asset_bias", {}) + pat_cls = pat.get("asset_class", "") + bias_for_class = asset_bias.get(dominant, {}).get(pat_cls, "neutral") if dominant != "incertain" else "neutral" + macro_ctx = { + "dominant_scenario": dominant, + "scenario_scores": scenarios.get("scores", {}), + "asset_class_bias": bias_for_class, + "vix": gauges.get("vix", {}).get("value"), + "yield_slope_pct": gauges.get("slope_10y3m", {}).get("value"), + "gold_copper_ratio": gauges.get("gold_copper_ratio", {}).get("value"), + "brent_1d_pct": gauges.get("brent", {}).get("change_pct"), + "spx_vs_200d_pct": gauges.get("spx_vs_200d", {}).get("value"), + } + + pattern_blocks.append({ + "id": pat.get("id", pat.get("pattern_id", "")), + "name": pat.get("name", ""), + "description": pat.get("description", ""), + "asset_class": pat.get("asset_class", ""), + "triggers": pat.get("triggers", []), + "historical_instances": pat.get("historical_instances", [])[:2], + "suggested_trades": pat.get("suggested_trades", []), + "expected_move_pct": pat.get("expected_move_pct", 0), + "expected_direction": "hausse" if expected_up else "baisse", + "horizon_days": pat.get("horizon_days", 90), + "relevant_news_count": len(relevant_news), + "relevant_news": relevant_news, + "market_data": market_data, + "contra_signals": contra_signals[:3], + "has_strong_contra": has_strong_contra, + "macro_regime": macro_ctx, + }) + + if not pattern_blocks: + return [] + + macro_section = "" + if macro_regime: + sc = macro_regime.get("scenarios", {}) + dom = sc.get("dominant", "incertain") + sc_scores = sc.get("scores", {}) + macro_section = f""" +RÉGIME MACRO ACTUEL (30 compteurs agrégés): +- Scénario dominant: {dom.upper()} | Scores: {json.dumps(sc_scores, ensure_ascii=False)} +- Instruction: Intègre ce régime dans les piliers prix (3a taux, 3b énergie, 3d indices, 3e VIX). + Chaque pattern reçoit un champ "macro_regime.asset_class_bias" indiquant la compatibilité + (bullish+/bullish/neutral/bearish/bearish+/defensive) de sa classe d'actif avec le scénario dominant. + → "bullish+" = conditions très favorables pour ce pattern → majore 3b ou 3d selon la classe + → "bearish" ou "bearish+" = conditions défavorables → minore 3b ou 3d + Indique dans "summary": [GOLDILOCKS|STAGFLATION|RÉCESSION|DÉSINFLATION|CRISE] + [SUPPORTING|NEUTRAL|CONTRA] +""" + + user = f"""CONTEXTE GLOBAL: +- Score risque géopolitique: {geo_score.get('score', 50)}/100 ({geo_score.get('level', 'medium')}) +- Top risques: {geo_score.get('top_risks', [])} +{macro_section} +TEMPLATE DE NOTATION: +{scoring_template} + +PATTERNS À SCORER ({len(pattern_blocks)} patterns): +{json.dumps(pattern_blocks, ensure_ascii=False, indent=2)} + +Pour chacun des {len(pattern_blocks)} patterns, score chaque sous-pilier + commente en français. +Le champ "score" = somme exacte de tous les sous-piliers. + +⚠️ TRADE RANKINGS (OBLIGATOIRE): Un pattern peut avoir plusieurs suggested_trades (ex: Long Call WTI + Bull Spread XLE). +Ces trades ne méritent PAS tous le même score. Pour chaque pattern, remplis "trade_rankings" en: +- classant les trades du meilleur (rank 1) au moins bon +- assignant un "score_delta" entre -20 et +20 (ex: +10 pour le meilleur, 0 pour la moyenne, -8 pour le moins bon) +- expliquant en 1 phrase pourquoi chaque trade est au-dessus/en-dessous de la moyenne du pattern +- la somme des score_delta doit être ≈ 0 (les trades se compensent par rapport au score pattern) + +Retourne UNIQUEMENT ce JSON valide: +{{ + "scored_patterns": [ + {{ + "pattern_id": "", + "score": , + "confidence": , + "buckets": [ + {{ + "id": "actualites", + "label": "Actualités & Géo-contexte", + "score": <0-30>, + "max": 30, + "comment": "", + "subs": [ + {{"id": "geo", "label": "News géopolitiques", "score": <0-12>, "max": 12, "comment": "<1-2 phrases>"}}, + {{"id": "eco", "label": "News macro/éco", "score": <0-10>, "max": 10, "comment": "<1-2 phrases>"}}, + {{"id": "flux", "label": "Volume & récence", "score": <0-8>, "max": 8, "comment": "<1-2 phrases>"}} + ] + }}, + {{ + "id": "calendrier", + "label": "Calendrier économique", + "score": <0-20>, + "max": 20, + "comment": "", + "subs": [ + {{"id": "banques", "label": "Banques centrales", "score": <0-10>, "max": 10, "comment": "<1-2 phrases>"}}, + {{"id": "macro_cal", "label": "Publications macro", "score": <0-10>, "max": 10, "comment": "<1-2 phrases>"}} + ] + }}, + {{ + "id": "prix", + "label": "Signaux de prix", + "score": <0-35>, + "max": 35, + "comment": "", + "subs": [ + {{"id": "taux", "label": "Taux & Obligations", "score": <0-7>, "max": 7, "comment": "<1-2 phrases>"}}, + {{"id": "energie", "label": "Énergie & Matières", "score": <0-7>, "max": 7, "comment": "<1-2 phrases>"}}, + {{"id": "forex_sig", "label": "Forex", "score": <0-7>, "max": 7, "comment": "<1-2 phrases>"}}, + {{"id": "actions", "label": "Actions & Indices", "score": <0-7>, "max": 7, "comment": "<1-2 phrases>"}}, + {{"id": "vix", "label": "Volatilité (VIX/IV)", "score": <0-7>, "max": 7, "comment": "<1-2 phrases>"}} + ] + }}, + {{ + "id": "rr", + "label": "Risque / Récompense", + "score": <0-15>, + "max": 15, + "comment": "", + "subs": [ + {{"id": "asymetrie", "label": "Asymétrie R/R", "score": <0-10>, "max": 10, "comment": "<1-2 phrases>"}}, + {{"id": "timing_rr", "label": "Timing d'entrée", "score": <0-5>, "max": 5, "comment": "<1-2 phrases>"}} + ] + }} + ], + "key_catalyst": "", + "recommended_trade": {{ + "underlying": "", + "strategy": "", + "strike_guidance": "", + "expiry_days": , + "rationale": "", + "target_gain_eur": , + "max_loss_eur": , + "timing_note": "", + "invalidation": "" + }}, + "asset_class": "", + "geo_trigger": "", + "summary": "", + "trade_rankings": [ + {{ + "underlying": "", + "strategy": "", + "rank": <1-N>, + "score_delta": , + "rationale": "<1 phrase: pourquoi ce trade mérite plus/moins que les autres du même pattern>", + "expected_move_pct": + }} + ] + }} + ], + "analysis_meta": {{ + "patterns_analyzed": , + "top_bias": "bullish|bearish|neutral|volatile", + "key_risk": "" + }} +}}""" + + # Extract the return-schema portion from `user` so batches use the identical full schema + # (includes bucket id/label/max definitions that GPT-4o needs to populate correctly) + _return_schema = user.split("Retourne UNIQUEMENT ce JSON valide:\n", 1)[1] + + # Build lessons feedback block for the scorer + lessons_header = "" + if portfolio_lessons: + lessons = portfolio_lessons.get("key_lessons") or [] + super_ctx = portfolio_lessons.get("super_context", "") + priorities = portfolio_lessons.get("strategic_priorities", []) + mistakes = portfolio_lessons.get("recurring_mistakes", []) + super_scoring_block = "" + if super_ctx: + super_scoring_block = f""" +🧠 SUPER CONTEXTE (base de raisonnement accumulée) : +{super_ctx[:400]} +Priorités: {' | '.join(str(p) for p in priorities[:2])} +Erreurs à éviter: {' | '.join(str(m) for m in mistakes[:2])} +""" + lessons_header = f""" +{super_scoring_block} +RETOUR DE PERFORMANCE (rapport du {portfolio_lessons.get('created_at','?')[:10]}) : +Bilan global : {portfolio_lessons.get('headline', '')[:150]} +Angles morts détectés : {portfolio_lessons.get('blind_spots', '')[:150]} +Priorités : {portfolio_lessons.get('next_cycle_priorities', '')[:150]} +Leçons : {' | '.join(str(l)[:80] for l in lessons[:3])} +⚠️ Tiens compte du Super Contexte et de ces leçons pour ajuster les scores et les commentaires par pilier. + +""" + + # Build the per-batch prompt template (static parts) + prompt_header = f"""CONTEXTE GLOBAL: +- Score risque géopolitique: {geo_score.get('score', 50)}/100 ({geo_score.get('level', 'medium')}) +- Top risques: {geo_score.get('top_risks', [])} +{macro_section}{lessons_header} +TEMPLATE DE NOTATION: +{scoring_template} + +""" + + import logging as _logging + _scorer_log = _logging.getLogger(__name__) + + def _score_batch(batch: list) -> list: + ids = [p.get("id", "?") for p in batch] + _scorer_log.info(f"[Scorer] Batch of {len(batch)} patterns: {ids}") + batch_user = ( + prompt_header + + f"PATTERNS À SCORER ({len(batch)} patterns):\n" + + json.dumps(batch, ensure_ascii=False, indent=2) + + f"\n\n⚠️ OBLIGATOIRE: Tu dois retourner EXACTEMENT {len(batch)} objets dans scored_patterns — un pour CHAQUE pattern de la liste, SANS EXCEPTION. Même si un pattern a score=0 (non pertinent actuellement), il doit figurer dans la liste.\n\n" + + f"Pour chacun des {len(batch)} patterns, score chaque sous-pilier + commente en français.\n" + + "Le champ \"score\" = somme exacte de tous les sous-piliers.\n\n" + + "⚠️ TRADE RANKINGS (OBLIGATOIRE): Un pattern peut avoir plusieurs suggested_trades (ex: Long Call WTI + Bull Spread XLE).\n" + + "Ces trades ne méritent PAS tous le même score. Pour chaque pattern, remplis \"trade_rankings\" en:\n" + + "- classant les trades du meilleur (rank 1) au moins bon\n" + + "- assignant un \"score_delta\" entre -20 et +20 (ex: +10 pour le meilleur, 0 pour la moyenne, -8 pour le moins bon)\n" + + "- expliquant en 1 phrase pourquoi chaque trade est au-dessus/en-dessous de la moyenne du pattern\n" + + "- la somme des score_delta doit être ≈ 0 (les trades se compensent par rapport au score pattern)\n\n" + + "Retourne UNIQUEMENT ce JSON valide:\n" + + _return_schema + ) + try: + res = _chat(SYSTEM_SCORER, batch_user, model="gpt-4o", json_mode=True, max_tokens=12000) + except Exception as e: + _scorer_log.error(f"[Scorer] GPT-4o call failed for batch {ids}: {e}") + res = None + scored = res.get("scored_patterns", []) if res else [] + _scorer_log.info(f"[Scorer] Batch returned {len(scored)} scored_patterns (expected {len(batch)})") + # Guarantee every pattern in the batch has an entry — prevents silent drops on truncation + scored_ids = {str(s.get("pattern_id", "")) for s in scored} + for p in batch: + if str(p.get("id", "")) not in scored_ids: + _scorer_log.warning(f"[Scorer] Pattern id='{p.get('id')}' name='{p.get('name')}' missing from GPT-4o response — adding stub score=0") + scored.append({ + "pattern_id": p["id"], + "score": 0, + "confidence": 0, + "buckets": [], + "key_catalyst": "Non pertinent dans le contexte actuel", + "recommended_trade": {}, + "asset_class": p.get("asset_class", ""), + "geo_trigger": p.get("name", ""), + "summary": "[CONTRA] Pattern non pertinent dans le contexte actuel.", + "trade_rankings": [], + }) + return scored + + BATCH_SIZE = 4 # 4 patterns × ~800 tokens output = ~3200 tokens, safely within gpt-4o limits + + # Score all batches in parallel + from concurrent.futures import ThreadPoolExecutor, as_completed + batches = [pattern_blocks[i:i+BATCH_SIZE] for i in range(0, len(pattern_blocks), BATCH_SIZE)] + _scorer_log.info(f"[Scorer] Scoring {len(pattern_blocks)} patterns in {len(batches)} batches of max {BATCH_SIZE}") + all_scored = [] + with ThreadPoolExecutor(max_workers=min(len(batches), 4)) as executor: + futures = [executor.submit(_score_batch, b) for b in batches] + for future in as_completed(futures): + try: + results = future.result() + all_scored.extend(results) + except Exception as e: + _scorer_log.error(f"[Scorer] Batch future raised: {e}") + + # Hardcoded max values per bucket id — used as fallback if GPT-4o omits the max field + _BUCKET_MAX = {"actualites": 30, "calendrier": 20, "prix": 35, "rr": 15} + _SUB_MAX = { + "geo": 12, "eco": 10, "flux": 8, + "banques": 10, "macro_cal": 10, + "taux": 7, "energie": 7, "forex_sig": 7, "actions": 7, "vix": 7, + "asymetrie": 10, "timing_rr": 5, + } + + # Normalize bucket scores and recompute total from sub-buckets + for p in all_scored: + if p.get("buckets"): + total = 0 + for b in p["buckets"]: + bid = b.get("id", "") + b_max = int(b.get("max") or _BUCKET_MAX.get(bid, 30)) + sub_sum = 0 + for sub in b.get("subs", []): + sid = sub.get("id", "") + s_max = int(sub.get("max") or _SUB_MAX.get(sid, 10)) + sub["score"] = max(0, min(int(sub.get("score") or 0), s_max)) + sub["max"] = s_max # ensure max is always set for frontend display + sub_sum += sub["score"] + b["score"] = max(0, min(int(b.get("score") or sub_sum), b_max)) + b["max"] = b_max # ensure max is always set for frontend display + total += b["score"] + p["score"] = min(total, 100) + + all_scored.sort(key=lambda x: x.get("score", 0), reverse=True) + return all_scored[:top_n] + + +# ── Suggest new patterns from live market context ───────────────────────────── + +def suggest_patterns_from_market_context( + news: List[Dict], + quotes_by_class: Dict[str, List[Dict]], + calendar: List[Dict], + macro_regime: Optional[Dict] = None, + geo_score: Optional[Dict] = None, + portfolio_lessons: Optional[Dict] = None, +) -> 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] + news_block = "\n".join([ + f"- [{n.get('source','')}] {n.get('title','')} (impact {n.get('impact_score',0):.2f})" + for n in top_news + ]) + + market_lines = [] + for cls, qs in quotes_by_class.items(): + for q in qs[:3]: + if q.get("price"): + market_lines.append(f" {cls} | {q.get('name', q['symbol'])}: {q['price']} ({q.get('change_pct', 0):+.1f}%)") + market_block = "\n".join(market_lines) + + cal_block = "\n".join([ + f"- {e.get('date','')} [{e.get('importance','')}] {e.get('title','')}" + for e in (calendar or [])[:8] + ]) + + # Macro regime block + macro_block = "" + if macro_regime: + sc = macro_regime.get("scenarios", {}) + gauges = macro_regime.get("gauges", {}) + dominant = sc.get("dominant", "incertain") + scores = sc.get("scores", {}) + asset_bias = sc.get("asset_bias", {}).get(dominant, {}) + reasons = sc.get("reasons", {}).get(dominant, []) + vix = gauges.get("vix", {}).get("value") + slope = gauges.get("slope_10y3m", {}).get("value") + gold_cu = gauges.get("gold_copper_ratio", {}).get("value") + spx_200 = gauges.get("spx_vs_200d", {}).get("value") + brent_chg = gauges.get("brent", {}).get("change_pct") + bias_lines = "\n".join([f" - {cls}: {b}" for cls, b in asset_bias.items()]) + brent_str = f"{brent_chg:+.2f}%" if brent_chg is not None else "N/A" + macro_block = f""" +## Régime macro actuel (30 compteurs institutionnels) +- Scénario dominant: {dominant.upper()} | Scores: {json.dumps(scores, ensure_ascii=False)} +- Signaux clés: {', '.join(reasons[:4])} +- Compteurs: VIX={vix} | Pente 10Y-3M={slope}% | Or/Cuivre={gold_cu} | SPX vs 200j={spx_200}% | Brent J-1={brent_str} +- Biais par classe d'actif (scénario {dominant.upper()}): +{bias_lines} + +⚠️ CONTRAINTE: Les patterns proposés doivent être COHÉRENTS avec ce régime macro. +- Favorise les patterns dont l'asset_class a un biais "bullish" ou "bullish+" dans le régime actuel. +- Évite les patterns haussiers sur des classes "bearish" ou "bearish+" sauf si un catalyseur géopolitique exceptionnel le justifie. +- Chaque pattern doit expliquer dans "macro_fit" pourquoi il est compatible (ou en tension) avec le régime {dominant.upper()}. +""" + + geo_block = "" + if geo_score: + geo_block = f"\n## Risque géopolitique global\n- Score: {geo_score.get('score', 50)}/100 ({geo_score.get('level', 'medium')})\n- Top risques: {', '.join(str(r) for r in geo_score.get('top_risks', [])[:3])}\n" + + lessons_block = "" + if portfolio_lessons: + super_ctx = portfolio_lessons.get("super_context", "") + priorities = portfolio_lessons.get("strategic_priorities", []) + mistakes = portfolio_lessons.get("recurring_mistakes", []) + lessons = portfolio_lessons.get("key_lessons") or [] + super_block = "" + if super_ctx: + super_block = f""" +## 🧠 SUPER CONTEXTE — Base de raisonnement accumulée +{super_ctx[:600]} +Priorités stratégiques: {' | '.join(str(p) for p in priorities[:3])} +Erreurs récurrentes à éviter: {' | '.join(str(m) for m in mistakes[:3])} +""" + lessons_block = f""" +{super_block} +## ⚡ RETOUR DE PERFORMANCE — cycles précédents (rapport du {portfolio_lessons.get('created_at','?')[:10]}) +Performance globale : {portfolio_lessons.get('headline', '')} +Pourquoi les gains : {portfolio_lessons.get('winners_analysis', '')[:200]} +Pourquoi les pertes : {portfolio_lessons.get('losers_analysis', '')[:200]} +Angles morts détectés : {portfolio_lessons.get('blind_spots', '')[:150]} +Priorités identifiées : {portfolio_lessons.get('next_cycle_priorities', '')[:200]} +Leçons clés : +{chr(10).join(f' - {l}' for l in lessons[:4])} + +⚠️ CONSIGNE : Tiens compte de ce retour de performance et du Super Contexte pour proposer des patterns MIEUX CIBLÉS. +Évite les erreurs identifiées dans les pertes. Privilégie les types de thèses qui ont fonctionné. +""" + + user = f"""Tu es un stratège géopolitique et financier senior. +{macro_block}{geo_block}{lessons_block} +## Actualités géopolitiques du moment (triées par impact) +{news_block} + +## Prix des marchés (variation J-1) +{market_block} + +## Calendrier économique à venir +{cal_block} + +En analysant ce panorama, propose 4 à 6 NOUVEAUX patterns géopolitiques qui sont en train d'émerger RIGHT NOW et qui méritent d'être surveillés pour des opportunités d'options. + +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 — 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 %? +- Long Call ATM (delta ~0.5, 30-90j): sous-jacent +5% → option +60 à +150% +- Long Call OTM (delta ~0.25): sous-jacent +8% → option +100 à +300% +- Bull Call Spread: sous-jacent +5% → spread +50 à +120% (plafonné) +- Long Straddle: mouvement ±10% → option +80 à +200% +Exemples réalistes: Long Call énergie sur catalyseur fort → 80-200%. Spread défensif → 40-100%. + +Retourne UNIQUEMENT ce JSON: +{{ + "patterns": [ + {{ + "name": "", + "description": "", + "macro_fit": "<1-2 phrases: pourquoi ce pattern est cohérent ou en tension avec le régime macro actuel, et quel catalyseur géopolitique le justifie>", + "triggers": ["", ""], + "keywords": ["", "", ""], + "asset_class": "", + "expected_move_pct": , + "probability": , + "horizon_days": , + "suggested_trades": [ + {{ + "strategy": "", + "underlying": "", + "rationale": "", + "asset_class": "", + "expected_move_pct": + }}, + {{ + "strategy": "", + "underlying": "", + "rationale": "", + "asset_class": "", + "expected_move_pct": + }} + ] + }} + ] +}}""" + + result = _chat(SYSTEM_SCORER, user, model="gpt-4o", json_mode=True, max_tokens=4000) + if not result: + return [] + return result.get("patterns", []) + + +# ── AI news batch scoring: impact magnitude + directional signals ───────────── + +def ai_score_news_batch(news_items: List[Dict]) -> List[Dict]: + """Score news items with AI: accurate impact + per-asset directional signal. + Adds ai_dir_energy/metals/indices, ai_resolution, ai_insight, ai_scored fields. + Called before pattern scoring so contra-signals can be detected. + """ + if not get_client() or not news_items: + return news_items + + to_score = [n for n in news_items[:20] if not n.get("ai_scored")] + if not to_score: + return news_items + + compact = [ + {"i": idx, "t": n.get("title", ""), "s": (n.get("summary", "") or "")[:150]} + for idx, n in enumerate(to_score) + ] + + user = f"""Score these geopolitical news items for TRUE market impact. + +CRITICAL: Resolution events (peace deals, ceasefires, truces, agreements ending conflicts) +have HIGH impact (0.7-0.9) but are BEARISH for oil/energy and BEARISH for safe-haven patterns. + +Items: {json.dumps(compact, ensure_ascii=False)} + +For each item return: +- impact_score: 0.0-1.0 real magnitude (resolution = high, sports/culture = low) +- dir_energy: "bullish"|"bearish"|"neutral" (for oil/gas/energy) +- dir_metals: "bullish"|"bearish"|"neutral" (for gold/silver/copper) +- dir_indices: "bullish"|"bearish"|"neutral" (risk-on vs risk-off) +- resolution: true if this is a de-escalation/peace/deal that REDUCES a prior conflict +- insight: "<1 short French sentence on main market effect>" + +JSON: {{"items": [{{"i":,"impact_score":,"dir_energy":"...","dir_metals":"...","dir_indices":"...","resolution":,"insight":"..."}}]}}""" + + result = _chat(SYSTEM_NEWS, user, model="gpt-4o-mini", json_mode=True, max_tokens=2000) + if not result: + return news_items + + scored_map = {s["i"]: s for s in result.get("items", [])} + for idx, n in enumerate(to_score): + s = scored_map.get(idx) + if s: + n["impact_score"] = max(0.0, min(1.0, float(s.get("impact_score") or n.get("impact_score", 0.1)))) + n["ai_dir_energy"] = s.get("dir_energy", "neutral") + n["ai_dir_metals"] = s.get("dir_metals", "neutral") + n["ai_dir_indices"] = s.get("dir_indices", "neutral") + n["ai_resolution"] = bool(s.get("resolution", False)) + n["ai_insight"] = s.get("insight", "") + n["ai_scored"] = True + + return news_items + + +# ── Re-score news batch with AI ─────────────────────────────────────────────── + +def ai_rescore_news(news_items: List[Dict]) -> List[Dict]: + """Batch re-score news items using AI for better classification.""" + if not get_client() or not news_items: + return news_items + rescored = [] + for item in news_items[:20]: + try: + ai = analyze_news_item(item.get("title", ""), item.get("summary", "")) + item["ai_category"] = ai.get("category", item.get("category")) + item["ai_impact"] = ai.get("impact_score", item.get("impact_score")) + item["ai_direction"] = ai.get("direction", "neutral") + item["ai_reasoning"] = ai.get("reasoning", "") + item["ai_entities"] = ai.get("key_entities", []) + if ai.get("affected_assets"): + item["asset_impacts"] = ai["affected_assets"] + except Exception: + pass + rescored.append(item) + return rescored diff --git a/backend/services/auto_cycle.py b/backend/services/auto_cycle.py new file mode 100644 index 0000000..4691ccf --- /dev/null +++ b/backend/services/auto_cycle.py @@ -0,0 +1,697 @@ +""" +Auto-cycle orchestration — runs every N hours (configurable). + +Cycle steps: + 1. Fetch current context (news, quotes, macro, geo) + 2. Ask GPT-4o to suggest new patterns + 3. Filter: keep only those with Jaccard keyword similarity < threshold vs existing + 4. Save filtered patterns to DB + 5. Score ALL patterns (existing + new) + 6. Log: pattern scores, trade entry prices, geo alert, macro snapshot + 7. Generate GPT-4o commentary: why are top/bottom trades performing this way? + 8. Update cycle_runs with results + commentary +""" +import logging +import threading +import uuid +from datetime import datetime +from typing import Any, Dict, List, Optional + +logger = logging.getLogger(__name__) + +# ── Global scheduler state ──────────────────────────────────────────────────── + +_stop_event = threading.Event() +_cycle_thread: Optional[threading.Thread] = None +_cycle_lock = threading.Lock() # prevents concurrent cycles +_current_status: Dict[str, Any] = { + "running": False, + "last_run_id": None, + "last_run_at": None, + "next_run_at": None, + "enabled": False, + "interval_hours": 3, +} + + +# ── Helpers ─────────────────────────────────────────────────────────────────── + +def _jaccard(a: List[str], b: List[str]) -> float: + sa = {x.lower() for x in (a or [])} + sb = {x.lower() for x in (b or [])} + if not sa and not sb: + return 0.0 + union = sa | sb + return len(sa & sb) / len(union) if union else 0.0 + + +def _max_similarity_vs_existing(candidate_kws: List[str], existing: List[Dict]) -> float: + return max((_jaccard(candidate_kws, p.get("keywords") or []) for p in existing), default=0.0) + + +# ── Core cycle logic ────────────────────────────────────────────────────────── + +def run_cycle_once(trigger: str = "auto") -> Dict[str, Any]: + """ + Execute one full auto-cycle. Thread-safe (skips if already running). + Returns a summary dict. + """ + if not _cycle_lock.acquire(blocking=False): + logger.info("Auto-cycle skipped — another cycle is already running") + return {"skipped": True, "reason": "already_running"} + + run_id = datetime.utcnow().isoformat() + summary: Dict[str, Any] = { + "run_id": run_id, + "trigger": trigger, + "patterns_suggested": 0, + "patterns_added": 0, + "patterns_scored": 0, + "geo_score": None, + "dominant_regime": None, + "commentary": None, + "status": "error", + } + + try: + from services.database import ( + get_config, get_custom_patterns, save_custom_pattern, + save_pattern_scores, log_macro_regime, log_geo_alert, log_trade_entries, + add_cycle_run, update_cycle_run, save_reasoning_trace, + get_latest_portfolio_lessons, + ) + from services.data_fetcher import fetch_geo_news, get_all_quotes, get_macro_gauges, score_macro_scenarios + from services.geo_analyzer import compute_geo_risk_score + from services.ai_analyzer import ( + suggest_patterns_from_market_context, score_patterns_with_context, + ai_score_news_batch, _chat, DEFAULT_ANALYSIS_TEMPLATE, + ) + + # Check AI key + ai_key = get_config("openai_api_key") or "" + if not ai_key: + logger.warning("Auto-cycle: no OpenAI key configured, skipping AI steps") + return {**summary, "status": "no_ai_key"} + + import os + os.environ["OPENAI_API_KEY"] = ai_key + + sim_threshold = float(get_config("auto_cycle_similarity_threshold") or "0.30") + + add_cycle_run(run_id, trigger=trigger) + _current_status["running"] = True + _current_status["last_run_id"] = run_id + + # ── Step 0: Load portfolio lessons + Super Contexte ────────────────── + portfolio_lessons = get_latest_portfolio_lessons() + + # Load Super Contexte (accumulated knowledge base) + try: + from services.database import get_latest_reasoning_state + _reasoning_state = get_latest_reasoning_state() + if _reasoning_state: + if portfolio_lessons is None: + portfolio_lessons = {} + portfolio_lessons["super_context"] = ( + f"[Super Contexte v{_reasoning_state.get('version',1)} " + f"du {(_reasoning_state.get('created_at','')[:16])}]\n" + + _reasoning_state.get("narrative", "")[:800] + ) + _synth = _reasoning_state.get("synthesis") or {} + portfolio_lessons["strategic_priorities"] = _synth.get("strategic_priorities", []) + portfolio_lessons["recurring_mistakes"] = [ + m.get("mistake", "") for m in _synth.get("recurring_mistakes", [])[:3] + ] + logger.info( + f"[Cycle {run_id[:16]}] Super Contexte v{_reasoning_state.get('version')} loaded " + f"({_reasoning_state.get('reports_used',0)} rapports, " + f"{_reasoning_state.get('trades_analyzed',0)} trades)" + ) + except Exception as _e: + logger.warning(f"[Cycle] Could not load Super Contexte: {_e}") + + if portfolio_lessons: + age_hours = 0 + try: + from datetime import datetime as _dt + created = _dt.fromisoformat(portfolio_lessons["created_at"]) + age_hours = (_dt.utcnow() - created).total_seconds() / 3600 + except Exception: + pass + logger.info( + f"[Cycle {run_id[:16]}] Portfolio lessons loaded " + f"(report from {portfolio_lessons.get('created_at','?')[:10]}, " + f"{age_hours:.0f}h ago, avg_pnl={portfolio_lessons['stats'].get('avg_pnl_pct')}%)" + ) + else: + logger.info(f"[Cycle {run_id[:16]}] No portfolio report yet — cycle runs without performance feedback") + + # ── Step 1: Fetch context ───────────────────────────────────────────── + logger.info(f"[Cycle {run_id[:16]}] Step 1: fetching context") + from routers.geopolitical import _news_cache # type: ignore + news = _news_cache.get("data") or fetch_geo_news() + news = ai_score_news_batch(news) + _news_cache["data"] = news + + geo_score_obj = compute_geo_risk_score(news) + geo_score_val = int(geo_score_obj.get("score") or 0) + summary["geo_score"] = geo_score_val + + quotes = get_all_quotes() + + gauges = get_macro_gauges() + scenarios = score_macro_scenarios(gauges) + macro_regime = {"gauges": gauges, "scenarios": scenarios} + dominant = scenarios.get("dominant", "incertain") + summary["dominant_regime"] = dominant + + # ── Step 2: Suggest new patterns ────────────────────────────────────── + logger.info(f"[Cycle {run_id[:16]}] Step 2: suggesting patterns") + try: + from services.data_fetcher import get_economic_calendar + calendar = get_economic_calendar() + suggestions = suggest_patterns_from_market_context( + news, quotes, calendar, macro_regime=macro_regime, geo_score=geo_score_obj, + portfolio_lessons=portfolio_lessons, + ) + except Exception as e: + logger.warning(f"[Cycle] Suggestion step failed: {e}") + suggestions = [] + + summary["patterns_suggested"] = len(suggestions) + logger.info(f"[Cycle {run_id[:16]}] Suggested {len(suggestions)} patterns from AI") + + # ── Step 3: Filter by similarity ────────────────────────────────────── + existing = get_custom_patterns() + logger.info(f"[Cycle {run_id[:16]}] Step 3: {len(existing)} existing patterns, threshold={sim_threshold}") + added_count = 0 + for s in suggestions: + kws = s.get("keywords") or [] + sim = _max_similarity_vs_existing(kws, existing) + if sim < sim_threshold: + # Capture returned ID so the pattern has a valid id for scoring + assigned_id = save_custom_pattern(s) + s["id"] = assigned_id + existing.append(s) + added_count += 1 + logger.info(f"[Cycle] Added pattern '{s.get('name')}' id={assigned_id} (sim={sim:.2f})") + # ── Save suggestion reasoning trace ─────────────────────────── + _top_news_ctx = [ + {"title": n.get("title", "")[:120], "impact": round(float(n.get("impact_score") or 0), 2), "source": n.get("source", "")} + for n in sorted(news, key=lambda x: -(float(x.get("impact_score") or 0)))[:8] + ] + save_reasoning_trace( + run_id=run_id, + trace_type="suggestion", + pattern_id=assigned_id, + input_context={ + "geo_score": geo_score_val, + "macro_dominant": dominant, + "macro_scores": scenarios.get("scores", {}), + "top_news": _top_news_ctx, + "cycle_run_id": run_id, + }, + output={ + "name": s.get("name"), + "description": s.get("description"), + "macro_fit": s.get("macro_fit"), + "expected_move_pct": s.get("expected_move_pct"), + "probability": s.get("probability"), + "horizon_days": s.get("horizon_days"), + "suggested_trades": s.get("suggested_trades", []), + "keywords": s.get("keywords", []), + "triggers": s.get("triggers", []), + }, + reasoning_summary=(s.get("macro_fit") or s.get("description") or "")[:300], + geo_score=geo_score_val, + macro_dominant=dominant, + ) + else: + logger.debug(f"[Cycle] Filtered '{s.get('name')}' — sim={sim:.2f} >= {sim_threshold}") + + summary["patterns_added"] = added_count + + # ── Step 4: Score ALL patterns ──────────────────────────────────────── + # Verify all patterns have IDs before scoring (guard against stale data) + patterns_with_id = [p for p in existing if p.get("id")] + patterns_without_id = [p.get("name", "?") for p in existing if not p.get("id")] + if patterns_without_id: + logger.warning(f"[Cycle] {len(patterns_without_id)} patterns have no id, skipping: {patterns_without_id}") + + logger.info(f"[Cycle {run_id[:16]}] Step 4: scoring {len(patterns_with_id)} patterns (of {len(existing)} total)") + template = get_config("analysis_template") or DEFAULT_ANALYSIS_TEMPLATE + try: + scored = score_patterns_with_context( + patterns=patterns_with_id, + recent_news=news, + quotes_by_class=quotes, + geo_score=geo_score_obj, + template=template, + top_n=len(patterns_with_id), + category_filter=None, + macro_regime=macro_regime, + portfolio_lessons=portfolio_lessons, + ) + scored_with_id = [s for s in scored if s.get("pattern_id")] + scored_without_id = [s for s in scored if not s.get("pattern_id")] + if scored_without_id: + logger.warning(f"[Cycle] {len(scored_without_id)} scored results have no pattern_id — they will NOT be saved to history") + logger.info(f"[Cycle {run_id[:16]}] Scoring returned {len(scored)} results ({len(scored_with_id)} with valid id)") + except Exception as e: + logger.error(f"[Cycle] Scoring failed: {e}", exc_info=True) + scored = [] + + summary["patterns_scored"] = len(scored) + + # ── Enrich scored patterns with original data not in GPT-4o response ─ + # GPT-4o scoring doesn't return expected_move_pct or suggested_trades — + # copy from the original pattern so log_trade_entries can use them. + _pmap = {p.get("id"): p for p in patterns_with_id} + for s in scored: + orig = _pmap.get(s.get("pattern_id", ""), {}) + if orig: + if not s.get("expected_move_pct") and orig.get("expected_move_pct"): + s["expected_move_pct"] = orig["expected_move_pct"] + logger.debug(f"[Cycle] Enriched '{orig.get('name')}' expected_move_pct={orig['expected_move_pct']}") + if not s.get("trade_rankings") and not s.get("suggested_trades"): + s["suggested_trades"] = orig.get("suggested_trades", []) + + # ── Step 5: Log everything ──────────────────────────────────────────── + logger.info(f"[Cycle {run_id[:16]}] Step 5: logging") + scoring_run_id = save_pattern_scores(scored, meta={ + "geo_score": geo_score_val, + "total": len(scored), + "cycle_run_id": run_id, + "trigger": trigger, + }) + + # ── Save scoring reasoning traces (one per scored pattern) ──────────── + _macro_scores_ctx = scenarios.get("scores", {}) + _asset_bias_ctx = scenarios.get("asset_bias", {}).get(dominant, {}) + for sp in scored: + pid = sp.get("pattern_id", "") + if not pid: + continue + orig = _pmap.get(pid, {}) + save_reasoning_trace( + run_id=scoring_run_id, + trace_type="scoring", + pattern_id=pid, + input_context={ + "geo_score": geo_score_val, + "macro_dominant": dominant, + "macro_scores": _macro_scores_ctx, + "asset_class": sp.get("asset_class") or orig.get("asset_class"), + "asset_bias": _asset_bias_ctx.get(sp.get("asset_class") or orig.get("asset_class", ""), "neutral"), + "expected_move_pct": sp.get("expected_move_pct") or orig.get("expected_move_pct"), + "cycle_run_id": run_id, + }, + output={ + "score": sp.get("score"), + "confidence": sp.get("confidence"), + "buckets": sp.get("buckets", []), + "key_catalyst": sp.get("key_catalyst"), + "summary": sp.get("summary"), + "trade_rankings": sp.get("trade_rankings", []), + "recommended_trade": sp.get("recommended_trade", {}), + "has_strong_contra": sp.get("has_strong_contra", False), + "geo_trigger": sp.get("geo_trigger"), + }, + reasoning_summary=((sp.get("key_catalyst") or "") + " | " + (sp.get("summary") or ""))[:400], + geo_score=geo_score_val, + macro_dominant=dominant, + ) + logger.info(f"[Cycle {run_id[:16]}] Saved {len([s for s in scored if s.get('pattern_id')])} reasoning traces") + + top_patterns_log = sorted( + [{"pattern_id": sp.get("pattern_id"), "name": sp.get("geo_trigger"), "score": sp.get("score", 0)} + for sp in scored if sp.get("score", 0) > 0], + key=lambda x: -x["score"] + )[:10] + + log_geo_alert(geo_score=geo_score_val, top_patterns=top_patterns_log, + news_count=len(news), run_id=scoring_run_id) + + log_trade_entries(run_id=scoring_run_id, scored_patterns=scored, quotes=quotes) + + gauges_summary = { + k: {"value": v.get("value"), "change_pct": v.get("change_pct"), "label": v.get("label")} + for k, v in gauges.items() + if v.get("value") is not None or v.get("change_pct") is not None + } + log_macro_regime(dominant=dominant, scores=scenarios.get("scores", {}), + reasons=scenarios.get("reasons", {}), gauges_summary=gauges_summary) + + # Update macro cache so the UI sees fresh data immediately + from routers.market_data import _macro_cache # type: ignore + import datetime as _dt + _macro_cache["data"] = {"gauges": gauges, "scenarios": scenarios, + "fetched_at": datetime.utcnow().isoformat(), "cached": False} + _macro_cache["ts"] = _dt.datetime.utcnow() + + # ── Step 6: GPT-4o cycle commentary ────────────────────────────────── + logger.info(f"[Cycle {run_id[:16]}] Step 6: generating commentary") + commentary = _generate_cycle_commentary( + scored=scored, dominant=dominant, scenarios=scenarios, + geo_score_val=geo_score_val, news=news, gauges=gauges, + ) + # Attach lessons metadata to commentary so UI can display it + if commentary and portfolio_lessons: + try: + import json as _json + c = _json.loads(commentary) if isinstance(commentary, str) else commentary + c["lessons_from_report"] = portfolio_lessons.get("created_at", "")[:16].replace("T", " ") + c["lessons_headline"] = portfolio_lessons.get("headline", "")[:100] + commentary = _json.dumps(c, ensure_ascii=False) + except Exception: + pass + summary["commentary"] = commentary + + # ── Finalize ────────────────────────────────────────────────────────── + summary["status"] = "completed" + update_cycle_run( + run_id, + completed_at=datetime.utcnow().isoformat(), + patterns_suggested=summary["patterns_suggested"], + patterns_added=summary["patterns_added"], + patterns_scored=summary["patterns_scored"], + geo_score=geo_score_val, + dominant_regime=dominant, + commentary=commentary, + status="completed", + ) + _current_status["last_run_at"] = datetime.utcnow().isoformat() + logger.info(f"[Cycle {run_id[:16]}] Completed — {added_count} new patterns, {len(scored)} scored") + + # ── Step 7: Auto portfolio snapshot ────────────────────────────────── + # Generate (or refresh) the portfolio report so the NEXT cycle has + # fresh performance lessons. Runs in background to not block the cycle. + import threading + threading.Thread( + target=_auto_portfolio_snapshot, + args=(ai_key,), + daemon=True, + name=f"portfolio-snapshot-{run_id[:8]}", + ).start() + + except Exception as e: + logger.error(f"[Cycle {run_id[:16]}] Fatal error: {e}", exc_info=True) + try: + from services.database import update_cycle_run + update_cycle_run(run_id, status="error", completed_at=datetime.utcnow().isoformat()) + except Exception: + pass + finally: + _current_status["running"] = False + _cycle_lock.release() + + return summary + + +def _generate_cycle_commentary( + scored: List[Dict], dominant: str, scenarios: Dict, + geo_score_val: int, news: List[Dict], gauges: Dict, +) -> Optional[str]: + """Ask GPT-4o to explain current performance of top/bottom patterns.""" + try: + from services.database import get_trade_entry_prices + from services.ai_analyzer import _chat + + # Get recent trade P&L for context (last 7 days) + entries = get_trade_entry_prices(7) + trade_summary = [] + for e in entries[:15]: + trade_summary.append({ + "pattern": e.get("pattern_name", ""), + "underlying": e.get("underlying", ""), + "strategy": e.get("strategy", ""), + "score_at_entry": e.get("score_at_entry", 0), + "entry_date": e.get("entry_date", ""), + }) + + # Top 5 scored patterns now + top_scored = sorted(scored, key=lambda x: -(x.get("score") or 0))[:5] + top_scored_summary = [ + {"name": s.get("geo_trigger"), "score": s.get("score"), "summary": s.get("summary", "")[:120]} + for s in top_scored + ] + + # Top recent news headlines + top_news = [{"title": n.get("title", ""), "impact": n.get("impact_score", 0)} + for n in sorted(news, key=lambda x: -(x.get("impact_score") or 0))[:5]] + + import json + + def gv(key: str) -> str: + v = gauges.get(key, {}).get("value") + return str(round(v, 2)) if v is not None else "N/A" + + def gc(key: str) -> str: + v = gauges.get(key, {}).get("change_pct") + return f"{v:+.2f}%" if v is not None else "N/A" + + prompt = f"""Tu es un stratège macro-géopolitique senior qui analyse la performance de notre système de détection de patterns. + +CONTEXTE DU CYCLE (maintenant): +- Régime dominant: {dominant.upper()} (score: {scenarios.get('scores', {}).get(dominant, 0)}%) +- Score risque géopolitique: {geo_score_val}/100 +- VIX: {gv('vix')} | Pente 10Y-3M: {gv('slope_10y3m')}% | DXY: {gc('dxy')} | Brent: {gc('brent')} +- Cuivre: {gc('copper')} | Or: {gc('gold')} | S&P vs 200j: {gv('spx_vs_200d')}% + +TOP 5 PATTERNS ACTUELLEMENT LES MIEUX SCORÉS: +{json.dumps(top_scored_summary, ensure_ascii=False, indent=2)} + +TRADES LOGUÉS CES 7 DERNIERS JOURS: +{json.dumps(trade_summary, ensure_ascii=False, indent=2)} + +NEWS GÉOPOLITIQUES À FORT IMPACT: +{json.dumps(top_news, ensure_ascii=False, indent=2)} + +Écris un COMMENTAIRE DE CYCLE concis (4-6 phrases) pour un trader options: +1. Le régime macro confirme-t-il les patterns qui scorent le mieux ? +2. Y a-t-il des news ou événements qui expliquent un écart avec nos prévisions ? +3. Quels patterns/trades méritent attention (confirmation ou invalidation) ? +4. Une recommandation tactique pour le prochain cycle (3h) + +Réponds UNIQUEMENT en JSON: {{"commentary": "", "key_risk": "", "top_pattern": ""}}""" + + result = _chat( + "Tu es un stratège macro senior. Analyse concise et actionnable. JSON uniquement.", + prompt, + model="gpt-4o", + json_mode=True, + max_tokens=500, + ) + if result and result.get("commentary"): + return json.dumps(result, ensure_ascii=False) + except Exception as e: + logger.warning(f"[Cycle] Commentary generation failed: {e}") + return None + + +# ── Auto portfolio snapshot ─────────────────────────────────────────────────── + +def _auto_portfolio_snapshot(ai_key: str) -> None: + """ + Called in a background thread at the end of each cycle. + Fetches live prices, checks if enough trades are priced (P&L ≠ 0), + and if so generates a GPT-4o portfolio report so the NEXT cycle has + fresh performance lessons. Skipped silently if not enough data. + """ + try: + import os + os.environ["OPENAI_API_KEY"] = ai_key + + from services.database import ( + get_mtm_trades_with_traces, save_ai_report, get_latest_portfolio_lessons, + ) + + data = get_mtm_trades_with_traces(days=30, limit_movers=5) + winners = data.get("winners", []) + losers = data.get("losers", []) + priced = data.get("priced_count", 0) + + # Need at least 3 priced trades with actual movement to make analysis meaningful + meaningful = [ + t for t in (winners + losers) + if t.get("pnl_pct") is not None and abs(t.get("pnl_pct", 0)) > 0.05 + ] + if len(meaningful) < 3: + logger.info( + f"[AutoSnapshot] Skipping GPT-4o report: only {len(meaningful)} trades " + f"with meaningful P&L movement (need ≥ 3)" + ) + return + + avg_pnl = data.get("avg_pnl_pct") + stats = { + "total_trades": data["total_trades"], + "priced_count": priced, + "avg_pnl_pct": avg_pnl, + } + + # Build prompt (reuse same logic as reasoning.py generate endpoint) + from routers.reasoning import _trade_summary_block, _bucket_summary, _rankings_summary + from services.ai_analyzer import _chat + + winners_block = _trade_summary_block("TOP GAINS", winners) + losers_block = _trade_summary_block("TOP PERTES", losers) + avg_str = f"{avg_pnl:+.1f}%" if avg_pnl is not None else "N/A" + + prompt = f"""Tu es un stratège macro-géopolitique senior. Rapport synthétique post-cycle automatique. + +═══ STATISTIQUES GLOBALES ═══ +Période : 30 derniers jours | Trades total: {data['total_trades']} | Pricés: {priced} | P&L moyen: {avg_str} + +═══ {winners_block} + +═══ {losers_block} + +Génère un rapport JSON : +{{ + "headline": "<1 phrase résumant la performance>", + "regime_assessment": "", + "winners_analysis": "", + "losers_analysis": "", + "key_lessons": ["", "", ""], + "blind_spots": "", + "next_cycle_priorities": "<3 priorités pour le prochain cycle>", + "risk_watch": "<1-2 risques à surveiller>" +}}""" + + result = _chat( + "Tu es un stratège macro senior. Rapport post-cycle concis. JSON uniquement.", + prompt, + model="gpt-4o", + json_mode=True, + max_tokens=800, + ) + if not result: + logger.warning("[AutoSnapshot] GPT-4o returned empty response") + return + + report_id = save_ai_report( + days=30, stats=stats, winners=winners, losers=losers, report=result, + report_type="portfolio", + ) + logger.info( + f"[AutoSnapshot] Portfolio report #{report_id} saved automatically " + f"({len(meaningful)} meaningful trades, avg P&L {avg_str})" + ) + except Exception as e: + logger.error(f"[AutoSnapshot] Failed: {e}", exc_info=True) + + +# ── Scheduler ───────────────────────────────────────────────────────────────── + +def _scheduler_loop(stop_event: threading.Event): + """Background loop that runs the cycle at the configured interval.""" + import time + from services.database import get_config + + while not stop_event.is_set(): + try: + interval_hours = float(get_config("auto_cycle_hours") or "3") + except Exception: + interval_hours = 3.0 + + _current_status["interval_hours"] = interval_hours + next_run = datetime.utcnow().isoformat() + _current_status["next_run_at"] = next_run + logger.info(f"[Scheduler] Next cycle in {interval_hours}h") + + # Wait for the interval (or until stop is signalled) + stop_event.wait(timeout=interval_hours * 3600) + + if stop_event.is_set(): + break + + # Check if still enabled + try: + enabled = (get_config("auto_cycle_enabled") or "false").lower() == "true" + except Exception: + enabled = False + + if enabled: + logger.info("[Scheduler] Running scheduled auto-cycle") + try: + run_cycle_once(trigger="auto") + except Exception as e: + logger.error(f"[Scheduler] Cycle error: {e}", exc_info=True) + + +def start_scheduler(): + """Start the background scheduler thread if auto_cycle is enabled.""" + global _cycle_thread, _stop_event + from services.database import get_config + + enabled = (get_config("auto_cycle_enabled") or "false").lower() == "true" + _current_status["enabled"] = enabled + + if not enabled: + logger.info("[Scheduler] Auto-cycle disabled — skipping scheduler start") + return + + if _cycle_thread and _cycle_thread.is_alive(): + logger.info("[Scheduler] Already running") + return + + _stop_event = threading.Event() + _cycle_thread = threading.Thread( + target=_scheduler_loop, + args=(_stop_event,), + daemon=True, + name="auto-cycle-scheduler", + ) + _cycle_thread.start() + logger.info("[Scheduler] Auto-cycle scheduler started") + + +def stop_scheduler(): + """Stop the background scheduler thread.""" + global _cycle_thread + _stop_event.set() + if _cycle_thread: + _cycle_thread.join(timeout=5) + _current_status["enabled"] = False + logger.info("[Scheduler] Stopped") + + +def restart_scheduler(): + """Restart the scheduler — call after config changes.""" + stop_scheduler() + _stop_event.clear() + start_scheduler() + + +def trigger_manual(): + """Run one cycle immediately in a background thread (non-blocking).""" + t = threading.Thread(target=run_cycle_once, args=("manual",), daemon=True, name="auto-cycle-manual") + t.start() + return t + + +def get_status() -> Dict[str, Any]: + from services.database import get_config, get_cycle_runs + try: + interval_hours = float(get_config("auto_cycle_hours") or "3") + enabled = (get_config("auto_cycle_enabled") or "false").lower() == "true" + sim_threshold = float(get_config("auto_cycle_similarity_threshold") or "0.30") + min_ev = float(get_config("min_ev_threshold") or "0.0") + min_score = int(get_config("min_score_threshold") or "0") + except Exception: + interval_hours, enabled, sim_threshold, min_ev, min_score = 3.0, False, 0.30, 0.0, 0 + + recent = get_cycle_runs(limit=1) + last = recent[0] if recent else None + + return { + **_current_status, + "enabled": enabled, + "interval_hours": interval_hours, + "similarity_threshold": sim_threshold, + "min_ev_threshold": min_ev, + "min_score_threshold": min_score, + "last_cycle": last, + "scheduler_alive": bool(_cycle_thread and _cycle_thread.is_alive()), + } diff --git a/backend/services/data_fetcher.py b/backend/services/data_fetcher.py new file mode 100644 index 0000000..92bdd3a --- /dev/null +++ b/backend/services/data_fetcher.py @@ -0,0 +1,593 @@ +""" +Market data fetcher using yfinance + free public APIs. +All functions are async-compatible where possible. +""" +import yfinance as yf +import pandas as pd +import numpy as np +from datetime import datetime, timedelta +from typing import Dict, List, Optional, Any +import feedparser +import httpx +import asyncio + + +# ── Watchlist by asset class ────────────────────────────────────────────────── +WATCHLIST: Dict[str, List[Dict[str, str]]] = { + "energy": [ + {"symbol": "CL=F", "name": "WTI Crude Oil", "currency": "USD"}, + {"symbol": "BZ=F", "name": "Brent Crude Oil", "currency": "USD"}, + {"symbol": "NG=F", "name": "Natural Gas", "currency": "USD"}, + {"symbol": "XLE", "name": "Energy ETF (XLE)", "currency": "USD"}, + {"symbol": "UNG", "name": "US Natural Gas ETF", "currency": "USD"}, + ], + "metals": [ + {"symbol": "GC=F", "name": "Gold Futures", "currency": "USD"}, + {"symbol": "SI=F", "name": "Silver Futures", "currency": "USD"}, + {"symbol": "HG=F", "name": "Copper Futures", "currency": "USD"}, + {"symbol": "PL=F", "name": "Platinum Futures", "currency": "USD"}, + {"symbol": "GDX", "name": "Gold Miners ETF", "currency": "USD"}, + ], + "agriculture": [ + {"symbol": "ZC=F", "name": "Corn Futures", "currency": "USD"}, + {"symbol": "ZW=F", "name": "Wheat Futures", "currency": "USD"}, + {"symbol": "ZS=F", "name": "Soybean Futures", "currency": "USD"}, + {"symbol": "KC=F", "name": "Coffee Futures", "currency": "USD"}, + {"symbol": "SB=F", "name": "Sugar #11 Futures", "currency": "USD"}, + ], + "indices": [ + {"symbol": "^GSPC", "name": "S&P 500", "currency": "USD"}, + {"symbol": "^NDX", "name": "NASDAQ 100", "currency": "USD"}, + {"symbol": "^DJI", "name": "Dow Jones", "currency": "USD"}, + {"symbol": "^STOXX50E", "name": "Euro Stoxx 50", "currency": "EUR"}, + {"symbol": "^N225", "name": "Nikkei 225", "currency": "JPY"}, + {"symbol": "^VIX", "name": "VIX Volatility", "currency": "USD"}, + ], + "equities": [ + {"symbol": "XOM", "name": "Exxon Mobil", "currency": "USD"}, + {"symbol": "CVX", "name": "Chevron", "currency": "USD"}, + {"symbol": "LMT", "name": "Lockheed Martin", "currency": "USD"}, + {"symbol": "RTX", "name": "Raytheon", "currency": "USD"}, + {"symbol": "BA", "name": "Boeing", "currency": "USD"}, + ], + "forex": [ + {"symbol": "EURUSD=X", "name": "EUR/USD", "currency": "USD"}, + {"symbol": "USDJPY=X", "name": "USD/JPY", "currency": "JPY"}, + {"symbol": "GBP=X", "name": "GBP/USD", "currency": "USD"}, + {"symbol": "USDCHF=X", "name": "USD/CHF", "currency": "CHF"}, + {"symbol": "UUP", "name": "US Dollar ETF (UUP)", "currency": "USD"}, + ], +} + + +def get_quote(symbol: str) -> Optional[Dict[str, Any]]: + for period in ("5d", "1mo"): + try: + ticker = yf.Ticker(symbol) + hist = ticker.history(period=period, interval="1d", auto_adjust=True) + if hist.empty: + continue + # Drop rows where Close is NaN + hist = hist.dropna(subset=["Close"]) + if hist.empty: + continue + price = float(hist["Close"].iloc[-1]) + prev = float(hist["Close"].iloc[-2]) if len(hist) > 1 else price + change = price - prev + change_pct = (change / prev * 100) if prev else 0 + return { + "symbol": symbol, + "price": round(price, 4), + "change": round(change, 4), + "change_pct": round(change_pct, 2), + "volume": int(hist["Volume"].iloc[-1]) if "Volume" in hist.columns else 0, + "timestamp": datetime.utcnow().isoformat(), + } + except Exception: + continue + return {"symbol": symbol, "price": None, "error": "no data"} + + +def get_all_quotes() -> Dict[str, List[Dict[str, Any]]]: + result = {} + for asset_class, assets in WATCHLIST.items(): + quotes = [] + for asset in assets: + q = get_quote(asset["symbol"]) + if q: + q["name"] = asset["name"] + q["asset_class"] = asset_class + quotes.append(q) + result[asset_class] = quotes + return result + + +def get_historical(symbol: str, period: str = "1y", interval: str = "1d") -> List[Dict[str, Any]]: + try: + from urllib.parse import unquote + symbol = unquote(symbol) + ticker = yf.Ticker(symbol) + hist = ticker.history(period=period, interval=interval) + if hist.empty: + return [] + hist = hist.reset_index() + records = [] + for _, row in hist.iterrows(): + records.append({ + "date": row["Date"].isoformat() if hasattr(row["Date"], "isoformat") else str(row["Date"]), + "open": round(float(row["Open"]), 4), + "high": round(float(row["High"]), 4), + "low": round(float(row["Low"]), 4), + "close": round(float(row["Close"]), 4), + "volume": int(row["Volume"]) if "Volume" in row else 0, + }) + return records + except Exception as e: + return [] + + +def compute_historical_iv(symbol: str, window: int = 30) -> float: + """Estimate realized vol as proxy for IV when options data unavailable.""" + try: + ticker = yf.Ticker(symbol) + hist = ticker.history(period="3mo", interval="1d") + if len(hist) < 10: + return 0.25 + returns = np.log(hist["Close"] / hist["Close"].shift(1)).dropna() + return float(returns.rolling(window).std().iloc[-1] * np.sqrt(252)) + except Exception: + return 0.25 + + +# ── News feeds ──────────────────────────────────────────────────────────────── +GEO_RSS_FEEDS = [ + {"name": "Reuters World", "url": "https://feeds.reuters.com/reuters/worldNews"}, + {"name": "Reuters Business", "url": "https://feeds.reuters.com/reuters/businessNews"}, + {"name": "Reuters Commodities", "url": "https://feeds.reuters.com/reuters/USenergyNews"}, + {"name": "AP Top News", "url": "https://feeds.apnews.com/rss/apf-topnews"}, + {"name": "Al Jazeera", "url": "https://www.aljazeera.com/xml/rss/all.xml"}, + {"name": "Financial Times", "url": "https://www.ft.com/rss/home"}, + {"name": "Bloomberg Markets", "url": "https://feeds.bloomberg.com/markets/news.rss"}, +] + +GEO_KEYWORDS = { + "military": ["war", "attack", "missile", "troops", "conflict", "invasion", "airstrike", "NATO", "ceasefire"], + "energy": ["OPEC", "oil production", "gas pipeline", "LNG", "energy sanctions", "crude", "petroleum"], + "sanctions": ["sanctions", "embargo", "tariff", "trade ban", "export control", "blacklist"], + "elections": ["election", "poll", "vote", "presidency", "referendum", "coup"], + "natural_disaster": ["earthquake", "hurricane", "flood", "drought", "wildfire", "tsunami", "volcano"], + "health_crisis": ["pandemic", "outbreak", "epidemic", "WHO", "virus", "quarantine", "lockdown"], + "resource_scarcity": ["shortage", "supply chain", "famine", "water crisis", "food security", "rare earth"], + "trade_war": ["trade war", "tariff", "WTO", "dumping", "protectionism", "trade deal"], + "political_speech": ["Trump", "Biden", "Xi Jinping", "Putin", "Macron", "Zelensky", "Fed", "ECB"], +} + + +def fetch_geo_news() -> List[Dict[str, Any]]: + news = [] + for feed_info in GEO_RSS_FEEDS: + try: + feed = feedparser.parse(feed_info["url"]) + for entry in feed.entries[:10]: + title = entry.get("title", "") + summary = entry.get("summary", entry.get("description", "")) + published = entry.get("published", "") + link = entry.get("link", "") + category = classify_news(title + " " + summary) + impact = estimate_impact(title + " " + summary, category) + news.append({ + "id": link, + "title": title, + "summary": summary[:300], + "source": feed_info["name"], + "category": category, + "impact_score": impact, + "asset_impacts": compute_asset_impacts(category, impact), + "date": published, + "tags": extract_tags(title + " " + summary), + "url": link, + }) + except Exception: + pass + return news[:50] + + +def classify_news(text: str) -> str: + text_lower = text.lower() + scores = {} + for cat, keywords in GEO_KEYWORDS.items(): + scores[cat] = sum(1 for kw in keywords if kw.lower() in text_lower) + best = max(scores, key=scores.get) + return best if scores[best] > 0 else "general" + + +def estimate_impact(text: str, category: str) -> float: + high_impact = ["attack", "invasion", "collapse", "crisis", "war", "ban", "default", "Trump", "Fed", + "ceasefire", "truce", "nuclear", "coup", "massacre", "bombed", "strike"] + medium_impact = ["tension", "sanctions", "shortage", "election", "rate", "OPEC", + "peace", "deal", "agreement", "accord", "treaty", "negotiation"] + text_lower = text.lower() + score = 0.1 + for word in high_impact: + if word.lower() in text_lower: + score += 0.2 + for word in medium_impact: + if word.lower() in text_lower: + score += 0.1 + return min(1.0, round(score, 2)) + + +def compute_asset_impacts(category: str, impact: float) -> Dict[str, float]: + impact_map = { + "military": {"energy": 0.8, "metals": 0.6, "forex": 0.4, "indices": -0.5, "agriculture": 0.3}, + "energy": {"energy": 0.9, "metals": 0.2, "forex": 0.3, "indices": -0.3}, + "sanctions": {"forex": 0.6, "energy": 0.5, "metals": 0.3, "indices": -0.4}, + "elections": {"forex": 0.7, "indices": 0.4, "equities": 0.3}, + "natural_disaster": {"agriculture": 0.8, "energy": 0.4, "indices": -0.3}, + "health_crisis": {"indices": -0.8, "agriculture": 0.5, "metals": 0.4}, + "resource_scarcity": {"agriculture": 0.9, "metals": 0.7, "energy": 0.5}, + "trade_war": {"indices": -0.6, "forex": 0.5, "agriculture": -0.3}, + "political_speech": {"forex": 0.5, "indices": 0.4, "energy": 0.3}, + } + base = impact_map.get(category, {}) + return {k: round(v * impact, 3) for k, v in base.items()} + + +def extract_tags(text: str) -> List[str]: + all_tags = [ + "Trump", "Russia", "Ukraine", "China", "Iran", "Israel", "Gaza", "NATO", + "OPEC", "Fed", "ECB", "Biden", "Xi", "Putin", "Zelensky", "Macron", + "oil", "gold", "wheat", "dollar", "yuan", "euro", "S&P", "VIX", + ] + return [tag for tag in all_tags if tag.lower() in text.lower()] + + +# ── Economic calendar (using free Trading Economics RSS or static) ──────────── +def get_economic_calendar() -> List[Dict[str, Any]]: + """Return next 30 days of major economic events (static + scraped).""" + from datetime import date, timedelta + today = date.today() + events = [ + {"title": "US Non-Farm Payrolls", "country": "US", "importance": "high", + "date": (today + timedelta(days=(4 - today.weekday()) % 7 + 7)).isoformat(), + "asset_impact": ["indices", "forex", "rates"]}, + {"title": "US CPI (Consumer Price Index)", "country": "US", "importance": "high", + "date": (today + timedelta(days=12)).isoformat(), + "asset_impact": ["indices", "forex", "metals"]}, + {"title": "FOMC Meeting / Fed Rate Decision", "country": "US", "importance": "high", + "date": (today + timedelta(days=18)).isoformat(), + "asset_impact": ["indices", "forex", "metals", "energy"]}, + {"title": "ECB Rate Decision", "country": "EU", "importance": "high", + "date": (today + timedelta(days=20)).isoformat(), + "asset_impact": ["forex", "indices"]}, + {"title": "US GDP (Preliminary)", "country": "US", "importance": "high", + "date": (today + timedelta(days=25)).isoformat(), + "asset_impact": ["indices", "forex"]}, + {"title": "OPEC+ Meeting", "country": "Global", "importance": "high", + "date": (today + timedelta(days=14)).isoformat(), + "asset_impact": ["energy"]}, + {"title": "US Crude Oil Inventories (EIA)", "country": "US", "importance": "medium", + "date": (today + timedelta(days=3)).isoformat(), + "asset_impact": ["energy"]}, + {"title": "EU Inflation (CPI)", "country": "EU", "importance": "medium", + "date": (today + timedelta(days=8)).isoformat(), + "asset_impact": ["forex", "indices"]}, + {"title": "China Trade Balance", "country": "CN", "importance": "medium", + "date": (today + timedelta(days=10)).isoformat(), + "asset_impact": ["metals", "agriculture", "forex"]}, + {"title": "US Unemployment Claims", "country": "US", "importance": "medium", + "date": (today + timedelta(days=2)).isoformat(), + "asset_impact": ["forex", "indices"]}, + {"title": "USDA Crop Report", "country": "US", "importance": "medium", + "date": (today + timedelta(days=6)).isoformat(), + "asset_impact": ["agriculture"]}, + {"title": "G7 Summit", "country": "Global", "importance": "high", + "date": (today + timedelta(days=30)).isoformat(), + "asset_impact": ["forex", "indices", "metals"]}, + ] + return sorted(events, key=lambda x: x["date"]) + + +# ── Macro Gauges & Scenario Scoring ────────────────────────────────────────── + +MACRO_GAUGE_CONFIG = [ + # (id, label, ticker, unit, bloc) + ("dxy", "Dollar DXY", "DX-Y.NYB", "index", "liquidite"), + ("us10y", "UST 10Y", "^TNX", "%", "liquidite"), + ("us3m", "UST 3M", "^IRX", "%", "liquidite"), + ("tips", "TIPS ETF", "TIP", "$", "liquidite"), + ("vix", "VIX", "^VIX", "pts", "credit"), + ("hyg", "HY Bonds (HYG)", "HYG", "$", "credit"), + ("lqd", "IG Bonds (LQD)", "LQD", "$", "credit"), + ("ief", "Trésor 7-10Y (IEF)", "IEF", "$", "credit"), + ("brent", "Brent", "BZ=F", "$", "energie"), + ("ng", "Gaz naturel", "NG=F", "$", "energie"), + ("gold", "Or", "GC=F", "$", "metaux"), + ("copper", "Cuivre", "HG=F", "$/lb", "metaux"), + ("spx", "S&P 500", "^GSPC", "pts", "croissance"), + ("iwm", "Russell 2000", "IWM", "$", "croissance"), + ("xli", "Industriels XLI", "XLI", "$", "croissance"), +] + +SCENARIO_META = { + "goldilocks": {"label": "Goldilocks", "color": "#10b981", "emoji": "🟢"}, + "desinflation": {"label": "Désinflation / Baisse taux","color": "#3b82f6", "emoji": "🔵"}, + "soft_landing": {"label": "Soft Landing", "color": "#06b6d4", "emoji": "🔷"}, + "reflation": {"label": "Reflation", "color": "#f97316", "emoji": "🟠"}, + "stagflation": {"label": "Stagflation", "color": "#f59e0b", "emoji": "🟡"}, + "inflation_shock": {"label": "Choc Inflationniste", "color": "#dc2626", "emoji": "🔥"}, + "recession": {"label": "Récession", "color": "#ef4444", "emoji": "🔴"}, + "crise_liquidite": {"label": "Crise de liquidité", "color": "#7c3aed", "emoji": "🟣"}, +} + +SCENARIO_ASSET_BIAS = { + "goldilocks": {"energy": "neutral", "metals": "bullish", "indices": "bullish+", "equities": "bullish+", "forex": "neutral", "agriculture": "neutral"}, + "desinflation": {"energy": "bearish", "metals": "bullish+", "indices": "bullish+", "equities": "bullish", "forex": "neutral", "agriculture": "neutral"}, + "soft_landing": {"energy": "neutral", "metals": "bullish", "indices": "bullish+", "equities": "bullish", "forex": "neutral", "agriculture": "neutral"}, + "reflation": {"energy": "bullish+", "metals": "bullish+", "indices": "bullish", "equities": "bullish+", "forex": "neutral", "agriculture": "bullish+"}, + "stagflation": {"energy": "bullish+", "metals": "bullish", "indices": "bearish", "equities": "bearish", "forex": "defensive", "agriculture": "bullish"}, + "inflation_shock": {"energy": "bullish+", "metals": "bullish+", "indices": "bearish", "equities": "bearish", "forex": "defensive", "agriculture": "bullish+"}, + "recession": {"energy": "bearish", "metals": "neutral", "indices": "bearish+", "equities": "bearish+", "forex": "defensive", "agriculture": "neutral"}, + "crise_liquidite": {"energy": "neutral", "metals": "bullish+", "indices": "bearish+", "equities": "bearish+", "forex": "defensive", "agriculture": "neutral"}, +} + + +def get_macro_gauges() -> Dict[str, Any]: + """Fetch macro gauges from yfinance in parallel and compute derived metrics.""" + from concurrent.futures import ThreadPoolExecutor, as_completed + + raw: Dict[str, Any] = {} + with ThreadPoolExecutor(max_workers=min(len(MACRO_GAUGE_CONFIG), 12)) as exe: + futures = { + exe.submit(get_quote, ticker): (gid, label, ticker, unit, bloc) + for gid, label, ticker, unit, bloc in MACRO_GAUGE_CONFIG + } + for fut in as_completed(futures): + gid, label, ticker, unit, bloc = futures[fut] + try: + q = fut.result() + except Exception: + q = None + raw[gid] = { + "id": gid, "label": label, "ticker": ticker, + "value": q.get("price") if q else None, + "change_pct": q.get("change_pct") if q else None, + "unit": unit, "bloc": bloc, + } + + # Normalize Treasury yields (yfinance sometimes returns 10x the actual %) + for yid in ("us10y", "us3m"): + v = raw[yid]["value"] + if v is not None and v > 20: + raw[yid]["value"] = round(v / 10, 3) + + # Derived: yield curve slope 10Y – 3M (% pts; negative = inverted) + v10 = raw["us10y"]["value"] + v3m = raw["us3m"]["value"] + slope = round(v10 - v3m, 3) if (v10 is not None and v3m is not None) else None + raw["slope_10y3m"] = { + "id": "slope_10y3m", "label": "Pente 10Y–3M", "ticker": None, + "value": slope, "change_pct": None, "unit": "% pts", "bloc": "liquidite", + "note": ("inversée ⚠️" if slope is not None and slope < 0 + else ("plate" if slope is not None and slope < 0.5 else "normale")), + } + + # Derived: Gold / Copper ratio (oz gold / lb copper; >700 = fear, <500 = growth) + gv = raw["gold"]["value"] + cv = raw["copper"]["value"] + gcr = round(gv / cv, 1) if (gv and cv) else None + raw["gold_copper_ratio"] = { + "id": "gold_copper_ratio", "label": "Ratio Or/Cuivre", + "ticker": None, "value": gcr, "change_pct": None, "unit": "ratio", "bloc": "derive", + "note": ("peur/récession" if gcr and gcr > 700 else ("neutre" if gcr and gcr > 550 else "croissance")), + } + + # Derived: S&P 500 % above/below 200-day MA + try: + spx_hist = get_historical("^GSPC", period="1y", interval="1d") + closes = [h["close"] for h in spx_hist if h.get("close")] + if len(closes) >= 50: + n = min(200, len(closes)) + ma = sum(closes[-n:]) / n + vs200 = round((closes[-1] - ma) / ma * 100, 2) + else: + vs200 = None + except Exception: + vs200 = None + raw["spx_vs_200d"] = { + "id": "spx_vs_200d", "label": "S&P vs 200j MA", + "ticker": None, "value": vs200, "change_pct": None, "unit": "%", "bloc": "derive", + "note": ("bull market" if vs200 is not None and vs200 > 5 + else ("au-dessus" if vs200 is not None and vs200 > 0 + else ("en-dessous ⚠️" if vs200 is not None else None))), + } + + # Derived: Russell 2000 vs S&P 500 relative daily performance + # Positive = small caps outperforming (risk-on breadth); negative = large cap defensiveness + iwm_c = raw.get("iwm", {}).get("change_pct") or 0.0 + spx_c_val = raw.get("spx", {}).get("change_pct") or 0.0 + rel_perf = round(iwm_c - spx_c_val, 2) + raw["iwm_spx_ratio"] = { + "id": "iwm_spx_ratio", "label": "Russell vs S&P (perf. rel.)", + "ticker": None, "value": rel_perf, "change_pct": None, "unit": "pts%", "bloc": "derive", + "note": ("small caps > large (risk-on)" if rel_perf > 0.2 + else ("parité" if rel_perf > -0.2 else "large caps dominants (défensif)")), + } + + return _sanitize_floats(raw) + + +def _sanitize_floats(obj: Any) -> Any: + """Recursively replace NaN/Inf floats with None so json.dumps never crashes.""" + import math + if isinstance(obj, dict): + return {k: _sanitize_floats(v) for k, v in obj.items()} + if isinstance(obj, list): + return [_sanitize_floats(v) for v in obj] + if isinstance(obj, float) and (math.isnan(obj) or math.isinf(obj)): + return None + return obj + + +def score_macro_scenarios(gauges: Dict[str, Any]) -> Dict[str, Any]: + """Rule-based scoring of the 5 macro regimes (0-100 each) from live gauge values.""" + def gv(k): return gauges.get(k, {}).get("value") + def gc(k): return gauges.get(k, {}).get("change_pct") or 0.0 + + vix = gv("vix") or 20.0 + slope = gv("slope_10y3m") + gcr = gv("gold_copper_ratio") + vs200 = gv("spx_vs_200d") + brent_c = gc("brent") + ng_c = gc("ng") + gold_c = gc("gold") + copper_c = gc("copper") + hyg_c = gc("hyg") + lqd_c = gc("lqd") + ief_c = gc("ief") + dxy_c = gc("dxy") + iwm_c = gc("iwm") + xli_c = gc("xli") + rel_perf = gv("iwm_spx_ratio") or 0.0 # Russell vs S&P relative perf + + scores: Dict[str, int] = {} + reasons: Dict[str, List[str]] = {} + + # GOLDILOCKS — croissance + faible volatilité + crédit serré + s = 0; r: List[str] = [] + if vix < 15: s += 30; r.append("VIX<15") + elif vix < 18: s += 20; r.append("VIX<18") + elif vix < 22: s += 10 + if slope is not None: + if slope > 1.0: s += 20; r.append("Courbe +1%pt") + elif slope > 0.3: s += 10; r.append("Courbe légèrement positive") + if gcr is not None: + if gcr < 500: s += 20; r.append(f"Or/Cu {gcr} (croissance)") + elif gcr < 600: s += 10 + if hyg_c > 0.2: s += 15; r.append("HYG↑ (crédit OK)") + elif hyg_c > 0: s += 5 + if vs200 is not None: + if vs200 > 5: s += 15; r.append(f"S&P+{vs200}% vs 200j") + elif vs200 > 0: s += 7 + if copper_c > 0.5: s += 10; r.append("Cuivre↑") + scores["goldilocks"] = min(100, s); reasons["goldilocks"] = r + + # DÉSINFLATION / BAISSE DE TAUX + s = 0; r = [] + if brent_c < -1.0: s += 25; r.append("Brent↓↓ (désinflationniste)") + elif brent_c < 0: s += 10 + if ng_c < -1.0: s += 10; r.append("Gaz↓") + if ief_c > 0.2: s += 20; r.append("IEF↑ (taux longs baissent)") + elif ief_c > 0: s += 10 + if vix < 20: s += 15; r.append("VIX<20") + if vs200 is not None and vs200 > 0: s += 20; r.append("S&P au-dessus 200j") + if hyg_c > 0: s += 10; r.append("HYG↑") + if gold_c > 0 and brent_c < 0: s += 10; r.append("Or↑+Brent↓ (taux réels ↓)") + scores["desinflation"] = min(100, s); reasons["desinflation"] = r + + # STAGFLATION — inflation + croissance faible + s = 0; r = [] + if brent_c > 2.0: s += 30; r.append("Brent↑↑") + elif brent_c > 0.5: s += 15; r.append("Brent↑") + if ng_c > 2.0: s += 15; r.append("Gaz↑↑") + elif ng_c > 0.5: s += 7 + if slope is not None: + if slope < 0: s += 20; r.append("Courbe inversée") + elif slope < 0.3: s += 10; r.append("Courbe plate") + if gold_c > 0.5: s += 15; r.append("Or↑ (protection inflation)") + if copper_c < 0: s += 15; r.append("Cuivre↓ (demande faible)") + if vix > 18: s += 10; r.append("VIX élevé") + scores["stagflation"] = min(100, s); reasons["stagflation"] = r + + # RÉCESSION + s = 0; r = [] + if slope is not None: + if slope < -0.5: s += 30; r.append("Courbe fortement inversée") + elif slope < 0: s += 15; r.append("Courbe inversée") + if gcr is not None: + if gcr > 750: s += 25; r.append(f"Or/Cu {gcr} (peur)") + elif gcr > 650: s += 10 + if vix > 28: s += 25; r.append("VIX>28") + elif vix > 22: s += 12 + if copper_c < -1.5: s += 20; r.append("Cuivre↓↓") + elif copper_c < -0.5: s += 8 + if hyg_c < -0.5: s += 15; r.append("HYG↓ (spreads s'écartent)") + elif hyg_c < 0: s += 5 + if gold_c > 0.3: s += 10; r.append("Or↑ (refuge)") + scores["recession"] = min(100, s); reasons["recession"] = r + + # CRISE DE LIQUIDITÉ + s = 0; r = [] + if vix > 35: s += 35; r.append("VIX>35 (panique)") + elif vix > 28: s += 20; r.append("VIX>28") + elif vix > 22: s += 8 + if hyg_c < -1.5: s += 35; r.append("HYG↓↓ (crise crédit)") + elif hyg_c < -0.5: s += 15 + if lqd_c < -0.5: s += 10; r.append("IG↓ (spreads s'écartent)") + if vs200 is not None: + if vs200 < -10: s += 25; r.append("S&P<200j -10%") + elif vs200 < -3: s += 10 + if gold_c > 1.0 and copper_c < -1.0: s += 20; r.append("Or↑+Cuivre↓ (fuite sécurité)") + if dxy_c > 1.0: s += 15; r.append("Dollar↑↑") + if ief_c > 0.5: s += 10; r.append("Obligations souveraines↑↑") + scores["crise_liquidite"] = min(100, s); reasons["crise_liquidite"] = r + + # REFLATION — croissance accélère + inflation remonte (cuivre, énergie, small caps explosent) + s = 0; r = [] + if copper_c > 1.5: s += 25; r.append("Cuivre↑↑ (Dr Copper = croissance)") + elif copper_c > 0.5: s += 12; r.append("Cuivre↑") + if xli_c > 0.8: s += 20; r.append("Industriels↑↑ (activité mfg forte)") + elif xli_c > 0.2: s += 10; r.append("Industriels↑") + if brent_c > 1.5: s += 15; r.append("Brent↑ (reflation énergie)") + elif brent_c > 0.3: s += 6 + if vs200 is not None and vs200 > 8: s += 20; r.append(f"S&P+{vs200}% vs 200j (bull fort)") + elif vs200 is not None and vs200 > 3: s += 10 + if slope is not None and slope > 1.0: s += 15; r.append("Courbe pentue (anticipation croissance)") + elif slope is not None and slope > 0.3: s += 6 + if rel_perf > 0.3: s += 10; r.append("Small caps > large (risk-on large)") + elif rel_perf > 0: s += 4 + if vix < 18: s += 5 + scores["reflation"] = min(100, s); reasons["reflation"] = r + + # SOFT LANDING — croissance positive + inflation en repli, pas encore basse + # Intermédiaire entre Goldilocks (idéal) et Désinflation (taux baissent fortement) + s = 0; r = [] + if vs200 is not None and vs200 > 0: s += 20; r.append("S&P > MA200 (croissance intacte)") + if brent_c < -0.5 and brent_c > -3: s += 20; r.append("Brent légèrement ↓ (désinflation graduelle)") + elif brent_c < 0: s += 8 + if vix < 20: s += 15; r.append("VIX<20 (pas de stress)") + if hyg_c > 0: s += 12; r.append("HYG↑ (crédit solide)") + if lqd_c > 0: s += 8; r.append("IG↑ (spreads IG calmes)") + if slope is not None and slope > 0: s += 10; r.append("Courbe non-inversée") + if xli_c > 0: s += 8; r.append("Industriels positifs") + if copper_c > 0: s += 5; r.append("Cuivre stable") + if ief_c > 0 and brent_c < 0: s += 7; r.append("Taux baissent + énergie recule") + scores["soft_landing"] = min(100, s); reasons["soft_landing"] = r + + # CHOC INFLATIONNISTE — spike énergie/supply soudain (guerre, OPEC, sécheresse) + # Différent de Stagflation : c'est un choc externe aigu, pas un régime durable + s = 0; r = [] + if brent_c > 4.0: s += 40; r.append("Brent↑↑↑ (choc énergie majeur)") + elif brent_c > 2.0: s += 25; r.append("Brent↑↑") + elif brent_c > 0.8: s += 10 + if ng_c > 4.0: s += 20; r.append("Gaz↑↑↑ (choc supply gaz)") + elif ng_c > 2.0: s += 12; r.append("Gaz↑↑") + if gold_c > 1.0: s += 20; r.append("Or↑↑ (refuge inflation/géo)") + elif gold_c > 0.3: s += 8; r.append("Or↑") + if vix > 22: s += 15; r.append("VIX↑ (stress montant)") + elif vix > 18: s += 5 + if copper_c < -0.5: s += 8; r.append("Cuivre↓ (demand destruction)") + if ief_c < -0.2: s += 8; r.append("Trésor↓ (taux longs remontent)") + scores["inflation_shock"] = min(100, s); reasons["inflation_shock"] = r + + ranked = sorted(scores.items(), key=lambda x: x[1], reverse=True) + dominant = ranked[0][0] if ranked[0][1] > 20 else "incertain" + + return { + "scores": scores, + "ranked": [[k, v] for k, v in ranked], + "dominant": dominant, + "reasons": reasons, + "meta": SCENARIO_META, + "asset_bias": SCENARIO_ASSET_BIAS, + } diff --git a/backend/services/database.py b/backend/services/database.py new file mode 100644 index 0000000..c1b20d4 --- /dev/null +++ b/backend/services/database.py @@ -0,0 +1,1405 @@ +""" +SQLite persistence layer for portfolio positions, custom patterns, and config. +""" +import sqlite3 +import json +import os +from datetime import datetime +from typing import List, Dict, Any, Optional + +DB_PATH = os.path.join(os.path.dirname(__file__), "..", "data", "geooptions.db") + + +def get_conn() -> sqlite3.Connection: + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + return conn + + +def init_db(): + os.makedirs(os.path.dirname(DB_PATH), exist_ok=True) + conn = get_conn() + c = conn.cursor() + + c.execute("""CREATE TABLE IF NOT EXISTS portfolio ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + underlying TEXT NOT NULL, + strategy TEXT NOT NULL, + asset_class TEXT, + entry_date TEXT NOT NULL, + expiry_date TEXT, + expiry_days INTEGER, + legs TEXT NOT NULL, + capital_invested REAL NOT NULL, + entry_underlying_price REAL, + geo_trigger TEXT, + rationale TEXT, + status TEXT DEFAULT 'open', + close_date TEXT, + close_value REAL, + notes TEXT, + ib_fees_entry REAL DEFAULT 0, + ib_fees_exit REAL DEFAULT 0, + created_at TEXT DEFAULT (datetime('now')) + )""") + + c.execute("""CREATE TABLE IF NOT EXISTS custom_patterns ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT, + triggers TEXT, + keywords TEXT, + historical_instances TEXT, + suggested_trades TEXT, + asset_class TEXT, + expected_move_pct REAL, + probability REAL, + horizon_days INTEGER, + ai_quality_score INTEGER, + ai_evaluation TEXT, + source TEXT DEFAULT 'custom', + is_active INTEGER DEFAULT 1, + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT DEFAULT (datetime('now')) + )""") + # Migration: add source column if not present + try: + c.execute("ALTER TABLE custom_patterns ADD COLUMN source TEXT DEFAULT 'custom'") + except Exception: + pass + + c.execute("""CREATE TABLE IF NOT EXISTS config ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TEXT DEFAULT (datetime('now')) + )""") + + c.execute("""CREATE TABLE IF NOT EXISTS pattern_score_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + run_id TEXT NOT NULL, + pattern_id TEXT NOT NULL, + score INTEGER, + confidence INTEGER, + summary TEXT, + scored_at TEXT NOT NULL + )""") + try: + c.execute("CREATE INDEX IF NOT EXISTS idx_psh_pattern ON pattern_score_history(pattern_id, scored_at DESC)") + except Exception: + pass + + c.execute("""CREATE TABLE IF NOT EXISTS cycle_runs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + run_id TEXT NOT NULL UNIQUE, + started_at TEXT NOT NULL, + completed_at TEXT, + trigger TEXT DEFAULT 'auto', + patterns_suggested INTEGER DEFAULT 0, + patterns_added INTEGER DEFAULT 0, + patterns_scored INTEGER DEFAULT 0, + geo_score INTEGER, + dominant_regime TEXT, + commentary TEXT, + status TEXT DEFAULT 'running' + )""") + try: + c.execute("CREATE INDEX IF NOT EXISTS idx_cr_started ON cycle_runs(started_at DESC)") + except Exception: + pass + + c.execute("""CREATE TABLE IF NOT EXISTS macro_regime_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + timestamp TEXT NOT NULL, + dominant TEXT NOT NULL, + scores_json TEXT NOT NULL, + reasons_json TEXT NOT NULL, + gauges_summary_json TEXT NOT NULL DEFAULT '{}' + )""") + try: + c.execute("CREATE INDEX IF NOT EXISTS idx_mrh_ts ON macro_regime_history(timestamp DESC)") + except Exception: + pass + + c.execute("""CREATE TABLE IF NOT EXISTS geo_alert_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + timestamp TEXT NOT NULL, + run_id TEXT NOT NULL, + geo_score INTEGER NOT NULL, + top_patterns_json TEXT NOT NULL DEFAULT '[]', + news_count INTEGER DEFAULT 0 + )""") + try: + c.execute("CREATE INDEX IF NOT EXISTS idx_gah_ts ON geo_alert_history(timestamp DESC)") + except Exception: + pass + + c.execute("""CREATE TABLE IF NOT EXISTS trade_entry_prices ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + run_id TEXT NOT NULL, + pattern_id TEXT NOT NULL, + pattern_name TEXT, + underlying TEXT NOT NULL, + strategy TEXT, + entry_price REAL, + entry_date TEXT NOT NULL, + score_at_entry INTEGER DEFAULT 0, + latest_score INTEGER, + expected_move_pct REAL, + horizon_days INTEGER DEFAULT 30, + ev_at_entry REAL, + ev_net REAL, + trade_score REAL, + matched_profile TEXT, + last_seen_at TEXT + )""") + for col, definition in [ + ("latest_score", "INTEGER"), + ("ev_at_entry", "REAL"), + ("ev_net", "REAL"), + ("trade_score", "REAL"), + ("matched_profile", "TEXT"), + ("last_seen_at", "TEXT"), + ]: + try: + c.execute(f"ALTER TABLE trade_entry_prices ADD COLUMN {col} {definition}") + except Exception: + pass + try: + c.execute("CREATE INDEX IF NOT EXISTS idx_tep_date ON trade_entry_prices(entry_date DESC)") + except Exception: + pass + + c.execute("""CREATE TABLE IF NOT EXISTS risk_profiles ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + min_score INTEGER NOT NULL DEFAULT 0, + min_gain_pct REAL NOT NULL DEFAULT 0, + color TEXT DEFAULT '#3b82f6', + enabled INTEGER DEFAULT 1, + sort_order INTEGER DEFAULT 0, + created_at TEXT DEFAULT (datetime('now')) + )""") + + # Default risk profiles (seed only if table is empty) + existing_profiles = c.execute("SELECT COUNT(*) FROM risk_profiles").fetchone()[0] + if existing_profiles == 0: + default_profiles = [ + ("Conservateur", 50, 100.0, "#22c55e", 1, 0), + ("Équilibré", 30, 250.0, "#3b82f6", 1, 1), + ("Agressif", 15, 600.0, "#ef4444", 1, 2), + ] + c.executemany( + "INSERT INTO risk_profiles (name, min_score, min_gain_pct, color, enabled, sort_order) VALUES (?,?,?,?,?,?)", + default_profiles + ) + + # Default config + defaults = { + "openai_api_key": "", + "newsapi_key": "", + "eia_api_key": "", + "fred_api_key": "", + "sources": json.dumps({ + "reuters_world": {"enabled": True, "url": "https://feeds.reuters.com/reuters/worldNews", "name": "Reuters World"}, + "reuters_business": {"enabled": True, "url": "https://feeds.reuters.com/reuters/businessNews", "name": "Reuters Business"}, + "reuters_energy": {"enabled": True, "url": "https://feeds.reuters.com/reuters/USenergyNews", "name": "Reuters Commodities"}, + "ap_top": {"enabled": True, "url": "https://feeds.apnews.com/rss/apf-topnews", "name": "AP Top News"}, + "aljazeera": {"enabled": True, "url": "https://www.aljazeera.com/xml/rss/all.xml", "name": "Al Jazeera"}, + "ft": {"enabled": False, "url": "https://www.ft.com/rss/home", "name": "Financial Times"}, + "bloomberg": {"enabled": False, "url": "https://feeds.bloomberg.com/markets/news.rss", "name": "Bloomberg Markets"}, + "newsapi": {"enabled": False, "url": "", "name": "NewsAPI (clé requise)", "requires_key": "newsapi_key"}, + "gdelt": {"enabled": False, "url": "https://api.gdeltproject.org/api/v2/doc/doc?query=geopolitics&mode=artlist&format=json", "name": "GDELT Project (gratuit)"}, + "eia": {"enabled": False, "url": "", "name": "EIA Energy (clé requise)", "requires_key": "eia_api_key"}, + "fred": {"enabled": False, "url": "", "name": "FRED Macro Fed (clé requise)", "requires_key": "fred_api_key"}, + "usda": {"enabled": False, "url": "https://apps.fas.usda.gov/psdonline/api/psd/crops", "name": "USDA Agriculture (gratuit)"}, + "who": {"enabled": False, "url": "https://www.who.int/rss-feeds/news-english.xml", "name": "WHO Santé (gratuit)"}, + "emdat": {"enabled": False, "url": "", "name": "EM-DAT Catastrophes (inscription requise)"}, + "twitter_trump": {"enabled": False, "url": "", "name": "X/Twitter Trump (API payante)"}, + }), + "ai_enabled": "false", + "ai_auto_rescore": "false", + "auto_cycle_enabled": "false", + "auto_cycle_hours": "3", + "auto_cycle_similarity_threshold": "0.30", + "min_ev_threshold": "0.0", + "min_score_threshold": "0", + } + for k, v in defaults.items(): + c.execute("INSERT OR IGNORE INTO config (key, value) VALUES (?, ?)", (k, v)) + + c.execute("""CREATE TABLE IF NOT EXISTS ai_reasoning_traces ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + run_id TEXT NOT NULL, + trace_type TEXT NOT NULL, + pattern_id TEXT, + input_context_json TEXT DEFAULT '{}', + output_json TEXT DEFAULT '{}', + reasoning_summary TEXT, + geo_score INTEGER, + macro_dominant TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + )""") + try: + c.execute("CREATE INDEX IF NOT EXISTS idx_art_run ON ai_reasoning_traces(run_id)") + c.execute("CREATE INDEX IF NOT EXISTS idx_art_pattern ON ai_reasoning_traces(pattern_id, trace_type)") + except Exception: + pass + + c.execute("""CREATE TABLE IF NOT EXISTS ai_reports ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + report_type TEXT NOT NULL DEFAULT 'portfolio', + days INTEGER NOT NULL DEFAULT 30, + stats_json TEXT DEFAULT '{}', + winners_json TEXT DEFAULT '[]', + losers_json TEXT DEFAULT '[]', + report_json TEXT DEFAULT '{}', + created_at TEXT NOT NULL DEFAULT (datetime('now')) + )""") + try: + c.execute("CREATE INDEX IF NOT EXISTS idx_reports_type_date ON ai_reports(report_type, created_at)") + except Exception: + pass + + c.execute("""CREATE TABLE IF NOT EXISTS knowledge_base ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + category TEXT NOT NULL, + title TEXT NOT NULL, + content TEXT NOT NULL, + confidence INTEGER DEFAULT 50, + confirmation_count INTEGER DEFAULT 1, + status TEXT DEFAULT 'active', + tags TEXT DEFAULT '', + first_seen_at TEXT NOT NULL DEFAULT (datetime('now')), + last_confirmed_at TEXT NOT NULL DEFAULT (datetime('now')) + )""") + + c.execute("""CREATE TABLE IF NOT EXISTS reasoning_state ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + version INTEGER NOT NULL DEFAULT 1, + narrative TEXT NOT NULL, + synthesis_json TEXT DEFAULT '{}', + sources_count INTEGER DEFAULT 0, + reports_used INTEGER DEFAULT 0, + trades_analyzed INTEGER DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + )""") + 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)") + except Exception: + pass + + conn.commit() + conn.close() + + +# ── Config ──────────────────────────────────────────────────────────────────── + +def get_config(key: str) -> Optional[str]: + conn = get_conn() + row = conn.execute("SELECT value FROM config WHERE key=?", (key,)).fetchone() + conn.close() + return row["value"] if row else None + + +def set_config(key: str, value: str): + conn = get_conn() + conn.execute( + "INSERT OR REPLACE INTO config (key, value, updated_at) VALUES (?, ?, datetime('now'))", + (key, value) + ) + conn.commit() + conn.close() + if key == "openai_api_key": + os.environ["OPENAI_API_KEY"] = value + + +def get_all_config() -> Dict[str, str]: + conn = get_conn() + rows = conn.execute("SELECT key, value FROM config").fetchall() + conn.close() + result = {r["key"]: r["value"] for r in rows} + if "openai_api_key" in result and result["openai_api_key"]: + result["openai_api_key_set"] = True + result["openai_api_key"] = "***" + return result + + +def get_sources() -> Dict[str, Any]: + raw = get_config("sources") + return json.loads(raw) if raw else {} + + +def update_sources(sources: Dict[str, Any]): + set_config("sources", json.dumps(sources)) + + +def save_pattern_scores(scores: List[Dict[str, Any]], meta: Dict[str, Any] = None) -> str: + """Persist last AI scores and append a history snapshot. Returns run_id.""" + from datetime import datetime as _dt + run_id = _dt.utcnow().isoformat() + data = {"scores": scores, "meta": meta or {}, "scored_at": run_id, "run_id": run_id} + set_config("last_pattern_scores", json.dumps(data)) + + conn = get_conn() + for sp in scores: + pid = sp.get("pattern_id", "") + if pid: + conn.execute( + "INSERT INTO pattern_score_history (run_id, pattern_id, score, confidence, summary, scored_at) VALUES (?,?,?,?,?,?)", + (run_id, pid, sp.get("score"), sp.get("confidence"), sp.get("summary", ""), run_id), + ) + # Keep only the last 30 runs + conn.execute("""DELETE FROM pattern_score_history WHERE run_id NOT IN ( + SELECT DISTINCT run_id FROM pattern_score_history ORDER BY scored_at DESC LIMIT 30 + )""") + conn.commit() + conn.close() + return run_id + + +def get_pattern_scores() -> Dict[str, Any]: + """Return last persisted AI scores.""" + raw = get_config("last_pattern_scores") + if raw: + try: + return json.loads(raw) + except Exception: + pass + return {"scores": [], "meta": {}, "scored_at": None} + + +def get_score_deltas() -> Dict[str, int]: + """Compute score change per pattern between the two most recent scoring runs.""" + conn = get_conn() + runs = conn.execute( + "SELECT DISTINCT run_id FROM pattern_score_history ORDER BY scored_at DESC LIMIT 2" + ).fetchall() + if len(runs) < 2: + conn.close() + return {} + latest_run, prev_run = runs[0]["run_id"], runs[1]["run_id"] + latest = {r["pattern_id"]: r["score"] for r in + conn.execute("SELECT pattern_id, score FROM pattern_score_history WHERE run_id=?", (latest_run,)).fetchall()} + prev = {r["pattern_id"]: r["score"] for r in + conn.execute("SELECT pattern_id, score FROM pattern_score_history WHERE run_id=?", (prev_run,)).fetchall()} + conn.close() + return { + pid: score - prev[pid] + for pid, score in latest.items() + if pid in prev and score is not None and prev[pid] is not None + } + + +def get_score_history(pattern_id: str, limit: int = 10) -> List[Dict[str, Any]]: + """Return the last N score snapshots for a given pattern.""" + conn = get_conn() + rows = conn.execute( + "SELECT score, confidence, summary, scored_at FROM pattern_score_history WHERE pattern_id=? ORDER BY scored_at DESC LIMIT ?", + (pattern_id, limit), + ).fetchall() + conn.close() + return [dict(r) for r in rows] + + +def compute_pattern_similarity(patterns: List[Dict[str, Any]], threshold: float = 0.25) -> List[Dict[str, Any]]: + """Return pairs of patterns with Jaccard keyword similarity above threshold.""" + results = [] + for i, p1 in enumerate(patterns): + kw1 = set(kw.lower() for kw in (p1.get("keywords") or [])) + for j, p2 in enumerate(patterns): + if i >= j: + continue + kw2 = set(kw.lower() for kw in (p2.get("keywords") or [])) + if not kw1 or not kw2: + continue + common = kw1 & kw2 + union = kw1 | kw2 + sim = len(common) / len(union) if union else 0.0 + if sim >= threshold: + results.append({ + "id_a": p1.get("id"), "name_a": p1.get("name"), + "id_b": p2.get("id"), "name_b": p2.get("name"), + "similarity": round(sim, 2), + "common_keywords": sorted(common)[:8], + }) + return sorted(results, key=lambda x: -x["similarity"]) + + +def get_analysis_config() -> Dict[str, Any]: + """Return the AI analysis config: template, top_n, category_filter.""" + raw = get_config("analysis_config") + if raw: + try: + return json.loads(raw) + except Exception: + pass + return {"top_n": 10, "category_filter": "all", "template": None} + + +def save_analysis_config(cfg: Dict[str, Any]): + set_config("analysis_config", json.dumps(cfg)) + + +# ── Portfolio ───────────────────────────────────────────────────────────────── + +IB_OPTIONS_FEE_PER_CONTRACT = 0.65 +IB_MIN_FEE = 1.0 + + +def compute_ib_fees(num_contracts: int) -> float: + return max(IB_MIN_FEE, num_contracts * IB_OPTIONS_FEE_PER_CONTRACT) + + +def add_position(pos: Dict[str, Any]) -> str: + import uuid + pos_id = pos.get("id") or f"POS-{uuid.uuid4().hex[:8].upper()}" + legs = pos.get("legs", []) + num_contracts = sum(abs(leg.get("quantity", 1)) for leg in legs) + ib_fees = compute_ib_fees(num_contracts) + + conn = get_conn() + conn.execute("""INSERT INTO portfolio ( + id, title, underlying, strategy, asset_class, entry_date, expiry_date, + expiry_days, legs, capital_invested, entry_underlying_price, + geo_trigger, rationale, status, notes, ib_fees_entry + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,'open',?,?)""", ( + pos_id, + pos.get("title", pos.get("underlying", "")), + pos.get("underlying", ""), + pos.get("strategy", ""), + pos.get("asset_class", ""), + pos.get("entry_date", datetime.utcnow().isoformat()[:10]), + pos.get("expiry_date", ""), + pos.get("expiry_days", 90), + json.dumps(legs), + pos.get("capital_invested", 1000.0), + pos.get("entry_underlying_price"), + pos.get("geo_trigger", ""), + pos.get("rationale", ""), + pos.get("notes", ""), + ib_fees, + )) + conn.commit() + conn.close() + return pos_id + + +def get_positions(status: str = "open") -> List[Dict[str, Any]]: + conn = get_conn() + rows = conn.execute( + "SELECT * FROM portfolio WHERE status=? ORDER BY created_at DESC", + (status,) + ).fetchall() + conn.close() + result = [] + for r in rows: + d = dict(r) + d["legs"] = json.loads(d.get("legs", "[]")) + result.append(d) + return result + + +def close_position(pos_id: str, close_value: float) -> Dict[str, Any]: + conn = get_conn() + pos = conn.execute("SELECT * FROM portfolio WHERE id=?", (pos_id,)).fetchone() + if not pos: + conn.close() + return {"error": "Position non trouvée"} + legs = json.loads(pos["legs"]) + num_contracts = sum(abs(leg.get("quantity", 1)) for leg in legs) + ib_exit = compute_ib_fees(num_contracts) + conn.execute("""UPDATE portfolio SET status='closed', close_date=?, close_value=?, + ib_fees_exit=? WHERE id=?""", + (datetime.utcnow().isoformat()[:10], close_value, ib_exit, pos_id)) + conn.commit() + pnl = close_value - pos["capital_invested"] - pos["ib_fees_entry"] - ib_exit + conn.close() + return {"id": pos_id, "close_value": close_value, "ib_fees_exit": ib_exit, "pnl": pnl} + + +def update_position_notes(pos_id: str, notes: str): + conn = get_conn() + conn.execute("UPDATE portfolio SET notes=? WHERE id=?", (notes, pos_id)) + conn.commit() + conn.close() + + +# ── Custom Patterns ──────────────────────────────────────────────────────────── + +def seed_builtin_patterns(builtin_patterns: List[Dict[str, Any]]): + """Seed built-in patterns into DB (idempotent — skips existing IDs).""" + conn = get_conn() + existing = {r[0] for r in conn.execute("SELECT id FROM custom_patterns").fetchall()} + for p in builtin_patterns: + if p["id"] not in existing: + conn.execute("""INSERT INTO custom_patterns ( + id, name, description, triggers, keywords, historical_instances, + suggested_trades, asset_class, expected_move_pct, probability, + horizon_days, source, is_active, updated_at + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,'builtin',1,datetime('now'))""", ( + p["id"], + p.get("name", ""), + p.get("description", ""), + json.dumps(p.get("triggers", [])), + json.dumps(p.get("keywords", [])), + json.dumps(p.get("historical_instances", [])), + json.dumps(p.get("suggested_trades", [])), + p.get("asset_class", "indices"), + p.get("expected_move_pct", 0), + p.get("probability", 0.5), + p.get("horizon_days", 30), + )) + conn.commit() + conn.close() + + +def save_custom_pattern(pattern: Dict[str, Any]) -> str: + import uuid + pat_id = pattern.get("id") or f"P_USER_{uuid.uuid4().hex[:6].upper()}" + source = pattern.get("source", "custom") + conn = get_conn() + conn.execute("""INSERT OR REPLACE INTO custom_patterns ( + id, name, description, triggers, keywords, historical_instances, + suggested_trades, asset_class, expected_move_pct, probability, + horizon_days, ai_quality_score, ai_evaluation, source, is_active, updated_at + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,1,datetime('now'))""", ( + pat_id, + pattern.get("name", ""), + pattern.get("description", ""), + json.dumps(pattern.get("triggers", [])), + json.dumps(pattern.get("keywords", [])), + json.dumps(pattern.get("historical_instances", [])), + json.dumps(pattern.get("suggested_trades", [])), + pattern.get("asset_class", "indices"), + pattern.get("expected_move_pct", 0), + pattern.get("probability", 0.5), + pattern.get("horizon_days", 30), + pattern.get("ai_quality_score"), + json.dumps(pattern.get("ai_evaluation", {})), + source, + )) + conn.commit() + conn.close() + return pat_id + + +def get_custom_patterns() -> List[Dict[str, Any]]: + conn = get_conn() + rows = conn.execute( + "SELECT * FROM custom_patterns WHERE is_active=1 ORDER BY source DESC, created_at DESC" + ).fetchall() + conn.close() + result = [] + for r in rows: + d = dict(r) + for f in ["triggers", "keywords", "historical_instances", "suggested_trades", "ai_evaluation"]: + d[f] = json.loads(d.get(f) or "[]") + result.append(d) + return result + + +def toggle_pattern_active(pat_id: str) -> bool: + """Toggle is_active for a pattern. Returns the new state.""" + conn = get_conn() + row = conn.execute("SELECT is_active FROM custom_patterns WHERE id=?", (pat_id,)).fetchone() + if not row: + conn.close() + return False + new_state = 0 if row["is_active"] else 1 + conn.execute("UPDATE custom_patterns SET is_active=? WHERE id=?", (new_state, pat_id)) + conn.commit() + conn.close() + return bool(new_state) + + +def delete_custom_pattern(pat_id: str): + conn = get_conn() + conn.execute("UPDATE custom_patterns SET is_active=0 WHERE id=?", (pat_id,)) + conn.commit() + conn.close() + + +# ── Risk Profiles ───────────────────────────────────────────────────────────── + +def get_risk_profiles(enabled_only: bool = False) -> List[Dict[str, Any]]: + conn = get_conn() + q = "SELECT * FROM risk_profiles" + if enabled_only: + q += " WHERE enabled=1" + q += " ORDER BY sort_order ASC, id ASC" + rows = conn.execute(q).fetchall() + conn.close() + return [dict(r) for r in rows] + + +def upsert_risk_profile(profile: Dict[str, Any]) -> int: + conn = get_conn() + pid = profile.get("id") + if pid: + conn.execute("""UPDATE risk_profiles + SET name=?, min_score=?, min_gain_pct=?, color=?, enabled=?, sort_order=? + WHERE id=?""", ( + profile["name"], int(profile["min_score"]), float(profile["min_gain_pct"]), + profile.get("color", "#3b82f6"), 1 if profile.get("enabled", True) else 0, + int(profile.get("sort_order", 0)), pid, + )) + else: + cur = conn.execute("""INSERT INTO risk_profiles (name, min_score, min_gain_pct, color, enabled, sort_order) + VALUES (?,?,?,?,?,?)""", ( + profile["name"], int(profile["min_score"]), float(profile["min_gain_pct"]), + profile.get("color", "#3b82f6"), 1 if profile.get("enabled", True) else 0, + int(profile.get("sort_order", 0)), + )) + pid = cur.lastrowid + conn.commit() + conn.close() + return pid + + +def delete_risk_profile(profile_id: int): + conn = get_conn() + conn.execute("DELETE FROM risk_profiles WHERE id=?", (profile_id,)) + conn.commit() + conn.close() + + +def _compute_trade_score(score: int, gain_pct: float) -> tuple[float, float, float]: + """ + Returns (ev_gross, ev_net, trade_score) for a (score, gain_pct) pair. + - ev_gross = p × G (raw expected multiple) + - ev_net = p × G - (1-p) (net EV assuming total loss if wrong) + - trade_score = p×G / (p×G + (1-p)) × 100 (normalized 0-100) + """ + p = max(0.0, min(1.0, score / 100)) + G = abs(gain_pct) / 100 + ev_gross = round(p * G, 4) + ev_net = round(p * G - (1 - p), 4) + denom = p * G + (1 - p) + trade_score = round((p * G / denom * 100) if denom > 0 else 0.0, 1) + return ev_gross, ev_net, trade_score + + +def _matches_profile(score: int, gain_pct: float, profiles: List[Dict[str, Any]]) -> Optional[str]: + """Return the name of the first enabled profile this trade satisfies, or None.""" + for prof in profiles: + if not prof.get("enabled", True): + continue + if score >= prof["min_score"] and gain_pct >= prof["min_gain_pct"]: + return prof["name"] + return None + + +# ── Journal de Bord ──────────────────────────────────────────────────────────── + +def log_macro_regime(dominant: str, scores: Dict[str, Any], reasons: Dict[str, Any], gauges_summary: Dict[str, Any]): + """Append a macro regime snapshot. Keeps last 90 days.""" + conn = get_conn() + conn.execute("""INSERT INTO macro_regime_history + (timestamp, dominant, scores_json, reasons_json, gauges_summary_json) + VALUES (datetime('now'), ?, ?, ?, ?)""", + (dominant, json.dumps(scores), json.dumps(reasons), json.dumps(gauges_summary))) + conn.execute("""DELETE FROM macro_regime_history WHERE timestamp < datetime('now', '-90 days')""") + conn.commit() + conn.close() + + +def get_macro_regime_history(days: int = 15) -> List[Dict[str, Any]]: + conn = get_conn() + rows = conn.execute( + "SELECT * FROM macro_regime_history WHERE timestamp >= datetime('now', ?) ORDER BY timestamp DESC", + (f"-{days} days",) + ).fetchall() + conn.close() + result = [] + for r in rows: + d = dict(r) + d["scores"] = json.loads(d.pop("scores_json", "{}")) + d["reasons"] = json.loads(d.pop("reasons_json", "{}")) + d["gauges_summary"] = json.loads(d.pop("gauges_summary_json", "{}")) + result.append(d) + return result + + +def log_geo_alert(geo_score: int, top_patterns: List[Dict[str, Any]], news_count: int, run_id: str): + """Append a geo alert snapshot tied to a scoring run.""" + conn = get_conn() + conn.execute("""INSERT INTO geo_alert_history + (timestamp, run_id, geo_score, top_patterns_json, news_count) + VALUES (datetime('now'), ?, ?, ?, ?)""", + (run_id, geo_score, json.dumps(top_patterns[:10]), news_count)) + conn.execute("DELETE FROM geo_alert_history WHERE timestamp < datetime('now', '-90 days')") + conn.commit() + conn.close() + + +def get_geo_alert_history(days: int = 30) -> List[Dict[str, Any]]: + conn = get_conn() + rows = conn.execute( + "SELECT * FROM geo_alert_history WHERE timestamp >= datetime('now', ?) ORDER BY timestamp DESC", + (f"-{days} days",) + ).fetchall() + conn.close() + result = [] + for r in rows: + d = dict(r) + d["top_patterns"] = json.loads(d.pop("top_patterns_json", "[]")) + result.append(d) + return result + + +def _normalize_yf_ticker(ticker: str) -> str: + """Normalize ticker for yfinance. + - USD/KRW → USDKRW=X (slash-format forex pairs from GPT-4o) + - USDKRW → USDKRW=X (bare 6-char alphabetic forex pairs) + - CL=F, SPY, etc. → unchanged + """ + t = ticker.upper().strip() + if '/' in t: + parts = t.split('/') + if len(parts) == 2 and all(p.isalpha() and len(p) >= 2 for p in parts): + return parts[0] + parts[1] + '=X' + return t + if len(t) == 6 and t.isalpha(): + return t + "=X" + return t + + +def log_trade_entries(run_id: str, scored_patterns: List[Dict[str, Any]], quotes: Dict[str, Any]): + """ + For each scored pattern's trade_rankings, record entry price if the trade + passes at least one enabled risk profile (min_score + min_gain_pct pair). + Deduplicates: one row per (pattern_id, underlying, strategy). + Falls back to yfinance for tickers not found in the quotes snapshot. + """ + import logging as _logging + _log = _logging.getLogger(__name__) + profiles = get_risk_profiles(enabled_only=True) + _log.info(f"[TradeLog] run_id={run_id} scored_patterns={len(scored_patterns)} profiles={len(profiles)}") + + # Load original patterns as fallback for expected_move_pct + # (GPT-4o scored output doesn't include this field) + _orig_patterns = {p.get("id", ""): p for p in get_custom_patterns()} + + # Build price map from pre-fetched quotes + price_map: Dict[str, float] = {} + for asset_class, items in quotes.items(): + if isinstance(items, list): + for item in items: + if item.get("ticker") and item.get("price") is not None: + price_map[item["ticker"].upper()] = float(item["price"]) + elif isinstance(items, dict): + for ticker, item in items.items(): + if isinstance(item, dict) and item.get("price") is not None: + price_map[ticker.upper()] = float(item["price"]) + + # Collect tickers NOT already in price_map for yfinance fallback + tickers_to_fetch: set = set() + for sp in scored_patterns: + for trade in sp.get("trade_rankings") or sp.get("suggested_trades", []): + t = (trade.get("underlying") or trade.get("ticker", "")).upper() + if t and t not in price_map: + tickers_to_fetch.add(t) + + if tickers_to_fetch: + _log.info(f"[TradeLog] yfinance fallback for {len(tickers_to_fetch)} tickers: {sorted(tickers_to_fetch)}") + try: + import yfinance as yf + import pandas as pd + from concurrent.futures import ThreadPoolExecutor, as_completed + + def _fetch(ticker: str): + normalized = _normalize_yf_ticker(ticker) + try: + # Use yf.download() — fresh HTTP request, no in-process Ticker cache + for kwargs in [ + {"period": "1d", "interval": "5m"}, + {"period": "5d", "interval": "1d"}, + ]: + df = yf.download(normalized, progress=False, auto_adjust=True, **kwargs) + if df.empty: + continue + if isinstance(df.columns, pd.MultiIndex): + df.columns = df.columns.get_level_values(0) + if "Close" not in df.columns: + continue + close = df["Close"].dropna() + if close.empty: + continue + price = float(close.iloc[-1]) + if price > 0: + _log.debug(f"[TradeLog] {ticker} → {normalized} = {price}") + return ticker, price + except Exception as e: + _log.warning(f"[TradeLog] Failed to fetch '{ticker}' (normalized='{normalized}'): {e}") + return ticker, None + + with ThreadPoolExecutor(max_workers=min(len(tickers_to_fetch), 10)) as ex: + for fut in as_completed({ex.submit(_fetch, t): t for t in tickers_to_fetch}, timeout=20): + try: + tk, price = fut.result() + if price is not None: + price_map[tk] = price + except Exception: + pass + except Exception as e: + _log.error(f"[TradeLog] yfinance fallback failed: {e}") + + conn = get_conn() + today = datetime.utcnow().isoformat()[:10] + now_ts = datetime.utcnow().isoformat() + + inserted_count = 0 + updated_count = 0 + skipped_no_profile = 0 + + for sp in scored_patterns: + pid = sp.get("pattern_id", "") + pattern_name = sp.get("geo_trigger") or sp.get("pattern_name") or pid + base_score = int(sp.get("score") or 0) + _orig = _orig_patterns.get(pid, {}) + + for trade in sp.get("trade_rankings") or sp.get("suggested_trades", []): + underlying = trade.get("underlying") or trade.get("ticker", "") + if not underlying: + continue + strategy = trade.get("strategy") or trade.get("trade_type", "") + + delta = int(trade.get("score_delta") or 0) + eff_score = max(0, min(100, base_score + delta)) + # Fallback chain: trade field → scored sp field → auto_cycle enrichment → original DB pattern + exp_move = abs(float( + trade.get("expected_move_pct") or + sp.get("expected_move_pct") or + _orig.get("expected_move_pct") or + 0 + )) + if exp_move == 0: + _log.warning(f"[TradeLog] Pattern '{pattern_name}' trade {underlying} has expected_move_pct=0 — all profiles with min_gain_pct>0 will fail") + + # Check if this trade passes any enabled risk profile + matched = _matches_profile(eff_score, exp_move, profiles) + if matched is None: + skipped_no_profile += 1 + _log.debug(f"[TradeLog] SKIP {underlying} score={eff_score} gain={exp_move:.0f}% — no profile match") + continue + + ev_gross, ev_net, trade_score = _compute_trade_score(eff_score, exp_move) + + ticker_key = underlying.upper() + entry_price = price_map.get(ticker_key) + horizon = int(trade.get("horizon_days") or sp.get("horizon_days") or 30) + + existing_row = conn.execute( + "SELECT id FROM trade_entry_prices WHERE pattern_id=? AND underlying=? AND strategy=?", + (pid, ticker_key, strategy) + ).fetchone() + + if existing_row: + conn.execute( + "UPDATE trade_entry_prices SET latest_score=?, trade_score=?, last_seen_at=? WHERE id=?", + (eff_score, trade_score, now_ts, existing_row["id"]) + ) + updated_count += 1 + else: + conn.execute("""INSERT INTO trade_entry_prices + (run_id, pattern_id, pattern_name, underlying, strategy, + entry_price, entry_date, score_at_entry, latest_score, + expected_move_pct, horizon_days, ev_at_entry, ev_net, + trade_score, matched_profile, last_seen_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", ( + run_id, pid, pattern_name, ticker_key, strategy, + entry_price, today, eff_score, eff_score, + exp_move, horizon, ev_gross, ev_net, + trade_score, matched, now_ts, + )) + inserted_count += 1 + _log.info(f"[TradeLog] NEW trade: pattern='{pattern_name}' {underlying} {strategy} score={eff_score} gain={exp_move:.0f}% profile='{matched}' price={entry_price}") + + _log.info(f"[TradeLog] Done — inserted={inserted_count} updated={updated_count} skipped_no_profile={skipped_no_profile}") + conn.execute("DELETE FROM trade_entry_prices WHERE entry_date < date('now', '-90 days')") + conn.commit() + conn.close() + + +def reset_journal_history(): + """Truncate all journal history tables for a clean slate.""" + conn = get_conn() + conn.execute("DELETE FROM trade_entry_prices") + conn.execute("DELETE FROM macro_regime_history") + conn.execute("DELETE FROM geo_alert_history") + conn.execute("DELETE FROM cycle_runs") + conn.commit() + conn.close() + + +def add_cycle_run(run_id: str, trigger: str = "auto") -> None: + conn = get_conn() + conn.execute( + "INSERT OR IGNORE INTO cycle_runs (run_id, started_at, trigger, status) VALUES (?, datetime('now'), ?, 'running')", + (run_id, trigger) + ) + conn.commit() + conn.close() + + +def update_cycle_run(run_id: str, **fields) -> None: + if not fields: + return + allowed = {"completed_at", "patterns_suggested", "patterns_added", "patterns_scored", + "geo_score", "dominant_regime", "commentary", "status"} + sets = ", ".join(f"{k}=?" for k in fields if k in allowed) + vals = [v for k, v in fields.items() if k in allowed] + if not sets: + return + conn = get_conn() + conn.execute(f"UPDATE cycle_runs SET {sets} WHERE run_id=?", vals + [run_id]) + conn.commit() + conn.close() + + +def get_cycle_runs(limit: int = 30) -> List[Dict[str, Any]]: + conn = get_conn() + rows = conn.execute( + "SELECT * FROM cycle_runs ORDER BY started_at DESC LIMIT ?", (limit,) + ).fetchall() + conn.close() + return [dict(r) for r in rows] + + +def get_cycle_run(run_id: str) -> Optional[Dict[str, Any]]: + conn = get_conn() + row = conn.execute("SELECT * FROM cycle_runs WHERE run_id=?", (run_id,)).fetchone() + conn.close() + return dict(row) if row else None + + +def get_trade_entry_prices(days: int = 30) -> List[Dict[str, Any]]: + conn = get_conn() + rows = conn.execute( + """SELECT * FROM trade_entry_prices + WHERE entry_date >= date('now', ?) + ORDER BY entry_date DESC, score_at_entry DESC""", + (f"-{days} days",) + ).fetchall() + conn.close() + return [dict(r) for r in rows] + + +def get_trade_entry_by_id(trade_id: int) -> Optional[Dict[str, Any]]: + conn = get_conn() + row = conn.execute("SELECT * FROM trade_entry_prices WHERE id=?", (trade_id,)).fetchone() + conn.close() + return dict(row) if row else None + + +# ── AI Reasoning Traces ─────────────────────────────────────────────────────── + +def save_reasoning_trace( + run_id: str, + trace_type: str, + pattern_id: str = None, + input_context: Dict[str, Any] = None, + output: Dict[str, Any] = None, + reasoning_summary: str = None, + geo_score: int = None, + macro_dominant: str = None, +) -> int: + """Persist one AI reasoning step. Returns the new row id.""" + conn = get_conn() + cur = conn.execute( + """INSERT INTO ai_reasoning_traces + (run_id, trace_type, pattern_id, input_context_json, output_json, + reasoning_summary, geo_score, macro_dominant) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)""", + ( + run_id, trace_type, pattern_id, + json.dumps(input_context or {}, ensure_ascii=False, default=str), + json.dumps(output or {}, ensure_ascii=False, default=str), + reasoning_summary, geo_score, macro_dominant, + ), + ) + row_id = cur.lastrowid + conn.commit() + conn.close() + return row_id + + +def _parse_trace(row) -> Dict[str, Any]: + """Deserialize a reasoning trace row.""" + d = dict(row) + for field in ("input_context_json", "output_json"): + try: + d[field.replace("_json", "")] = json.loads(d.get(field) or "{}") + except Exception: + d[field.replace("_json", "")] = {} + return d + + +def get_scoring_trace(run_id: str, pattern_id: str) -> Optional[Dict[str, Any]]: + """Return the scoring trace for a given (run_id, pattern_id) pair.""" + conn = get_conn() + row = conn.execute( + "SELECT * FROM ai_reasoning_traces WHERE run_id=? AND pattern_id=? AND trace_type='scoring'", + (run_id, pattern_id), + ).fetchone() + conn.close() + return _parse_trace(row) if row else None + + +def get_suggestion_trace(pattern_id: str) -> Optional[Dict[str, Any]]: + """Return the original suggestion trace for a pattern (first one ever).""" + conn = get_conn() + row = conn.execute( + """SELECT * FROM ai_reasoning_traces + WHERE pattern_id=? AND trace_type='suggestion' + ORDER BY created_at ASC LIMIT 1""", + (pattern_id,), + ).fetchone() + conn.close() + return _parse_trace(row) if row else None + + +def get_pattern_scoring_history(pattern_id: str, limit: int = 10) -> List[Dict[str, Any]]: + """All scoring traces for a pattern across cycles — for trend analysis.""" + conn = get_conn() + rows = conn.execute( + """SELECT * FROM ai_reasoning_traces + WHERE pattern_id=? AND trace_type='scoring' + ORDER BY created_at DESC LIMIT ?""", + (pattern_id, limit), + ).fetchall() + conn.close() + return [_parse_trace(r) for r in rows] + + +_BEARISH_KEYWORDS = {"bear", "put", "short", "sell", "vente", "baissier"} + + +def _fetch_live_prices(tickers: List[str], timeout: int = 20) -> Dict[str, Optional[float]]: + """ + Fetch current prices for a list of tickers using yf.download(). + Shared by journal MTM and portfolio report so both get consistent live data. + """ + result: Dict[str, Optional[float]] = {t: None for t in tickers} + if not tickers: + return result + try: + import yfinance as yf + import pandas as pd + from concurrent.futures import ThreadPoolExecutor, as_completed + + def _one(ticker: str) -> tuple: + for kwargs in [ + {"period": "1d", "interval": "5m"}, + {"period": "5d", "interval": "1d"}, + ]: + try: + df = yf.download(ticker, progress=False, auto_adjust=True, **kwargs) + if df.empty: + continue + if isinstance(df.columns, pd.MultiIndex): + df.columns = df.columns.get_level_values(0) + if "Close" not in df.columns: + continue + close = df["Close"].dropna() + if close.empty: + continue + price = float(close.iloc[-1]) + if price > 0: + return ticker, price + except Exception: + continue + return ticker, None + + with ThreadPoolExecutor(max_workers=min(len(tickers), 10)) as ex: + futs = {ex.submit(_one, t): t for t in tickers} + from concurrent.futures import as_completed as _ac + for fut in _ac(futs, timeout=timeout): + try: + tk, price = fut.result() + result[tk] = price + except Exception: + pass + except Exception: + pass + return result + + +def get_mtm_trades_with_traces(days: int = 90, limit_movers: int = 5) -> Dict[str, Any]: + """ + Return all MTM trades with live prices and reasoning traces for the + top winners and losers (by pnl_pct). Used by the AI portfolio report. + """ + from datetime import timedelta + cutoff_date = (datetime.utcnow() - timedelta(days=days)).strftime("%Y-%m-%d") + + conn = get_conn() + rows = conn.execute( + """SELECT * FROM trade_entry_prices + WHERE entry_date >= ? + ORDER BY entry_date DESC""", + (cutoff_date,), + ).fetchall() + conn.close() + + all_trades = [dict(r) for r in rows] + + # Fetch live prices for all unique tickers + tickers = list({(t.get("underlying") or "").upper() for t in all_trades if t.get("underlying")}) + live_prices = _fetch_live_prices(tickers, timeout=25) + + # Enrich trades with live price + pnl_pct + def _with_pnl(trade: Dict) -> Dict: + ticker = (trade.get("underlying") or "").upper() + entry = trade.get("entry_price") + current = live_prices.get(ticker) + pnl_pct = None + if entry and current and entry > 0: + raw = (current - entry) / entry * 100 + strategy = trade.get("strategy", "").lower() + bearish = any(k in strategy for k in _BEARISH_KEYWORDS) + pnl_pct = round(-raw if bearish else raw, 2) + return {**trade, "current_price": current, "pnl_pct": pnl_pct} + + enriched = [_with_pnl(t) for t in all_trades] + priced = [t for t in enriched if t.get("pnl_pct") is not None] + winners = sorted(priced, key=lambda t: t.get("pnl_pct", 0), reverse=True)[:limit_movers] + losers = sorted(priced, key=lambda t: t.get("pnl_pct", 0))[:limit_movers] + + def _with_traces(trade: Dict) -> Dict: + pid = trade.get("pattern_id", "") + run_id = trade.get("run_id", "") + sc = get_scoring_trace(run_id, pid) if run_id and pid else None + sg = get_suggestion_trace(pid) if pid else None + history = get_pattern_scoring_history(pid, limit=5) if pid else [] + return { + **trade, + "scoring_context": sc, + "suggestion_context": sg, + "score_history_count": len(history), + "score_trend": [h["output"].get("score") for h in reversed(history)] if history else [], + } + + return { + "total_trades": len(all_trades), + "priced_count": len(priced), + "avg_pnl_pct": (sum(t.get("pnl_pct", 0) for t in priced) / len(priced)) if priced else None, + "winners": [_with_traces(t) for t in winners], + "losers": [_with_traces(t) for t in losers], + "all_trades": enriched, + } + + +def save_ai_report( + days: int, + stats: Dict[str, Any], + winners: List[Dict], + losers: List[Dict], + report: Dict[str, Any], + report_type: str = "portfolio", +) -> int: + conn = get_conn() + cur = conn.execute( + """INSERT INTO ai_reports + (report_type, days, stats_json, winners_json, losers_json, report_json) + VALUES (?, ?, ?, ?, ?, ?)""", + ( + report_type, days, + json.dumps(stats, ensure_ascii=False, default=str), + json.dumps(winners, ensure_ascii=False, default=str), + json.dumps(losers, ensure_ascii=False, default=str), + json.dumps(report, ensure_ascii=False, default=str), + ), + ) + row_id = cur.lastrowid + conn.commit() + conn.close() + return row_id + + +def _parse_report(row) -> Dict[str, Any]: + d = dict(row) + for field in ("stats_json", "winners_json", "losers_json", "report_json"): + key = field.replace("_json", "") + try: + d[key] = json.loads(d.get(field) or "{}") + except Exception: + d[key] = {} + return d + + +def get_latest_portfolio_lessons() -> Optional[Dict[str, Any]]: + """ + Return the key lessons from the most recent portfolio report. + Used by auto_cycle to inject feedback into the next AI cycle's prompts. + Returns None if no report exists yet. + """ + conn = get_conn() + row = conn.execute( + """SELECT report_json, stats_json, created_at, days + FROM ai_reports WHERE report_type='portfolio' + ORDER BY created_at DESC LIMIT 1""" + ).fetchone() + conn.close() + if not row: + return None + try: + report = json.loads(row["report_json"] or "{}") + stats = json.loads(row["stats_json"] or "{}") + except Exception: + return None + if not report: + return None + return { + "created_at": row["created_at"], + "days": row["days"], + "stats": stats, + "headline": report.get("headline", ""), + "winners_analysis": report.get("winners_analysis", ""), + "losers_analysis": report.get("losers_analysis", ""), + "key_lessons": report.get("key_lessons", []), + "blind_spots": report.get("blind_spots", ""), + "next_cycle_priorities": report.get("next_cycle_priorities", ""), + "risk_watch": report.get("risk_watch", ""), + } + + +def list_ai_reports(report_type: str = "portfolio", limit: int = 20) -> List[Dict[str, Any]]: + conn = get_conn() + rows = conn.execute( + """SELECT id, report_type, days, stats_json, report_json, created_at + FROM ai_reports + WHERE report_type=? + ORDER BY created_at DESC LIMIT ?""", + (report_type, limit), + ).fetchall() + conn.close() + result = [] + for row in rows: + d = dict(row) + for field in ("stats_json", "report_json"): + key = field.replace("_json", "") + try: + d[key] = json.loads(d.get(field) or "{}") + except Exception: + d[key] = {} + result.append(d) + return result + + +def get_ai_report(report_id: int) -> Optional[Dict[str, Any]]: + conn = get_conn() + row = conn.execute("SELECT * FROM ai_reports WHERE id=?", (report_id,)).fetchone() + conn.close() + return _parse_report(row) if row else None + + +# ── Knowledge Base ───────────────────────────────────────────────────────────── + +def save_kb_entry(category: str, title: str, content: str, confidence: int = 50, + tags: str = "", existing_id: Optional[int] = None) -> int: + conn = get_conn() + now = datetime.utcnow().isoformat() + if existing_id: + conn.execute("""UPDATE knowledge_base SET content=?, confidence=?, tags=?, + last_confirmed_at=?, confirmation_count=confirmation_count+1 + WHERE id=?""", (content, confidence, tags, now, existing_id)) + conn.commit() + conn.close() + return existing_id + cur = conn.execute("""INSERT INTO knowledge_base + (category, title, content, confidence, confirmation_count, status, tags, first_seen_at, last_confirmed_at) + VALUES (?,?,?,?,1,'active',?,?,?)""", + (category, title, content, confidence, tags, now, now)) + new_id = cur.lastrowid + conn.commit() + conn.close() + return new_id + + +def get_kb_entries(status: str = "active") -> List[Dict[str, Any]]: + conn = get_conn() + rows = conn.execute( + "SELECT * FROM knowledge_base WHERE status=? ORDER BY confidence DESC, last_confirmed_at DESC", + (status,) + ).fetchall() + conn.close() + return [dict(r) for r in rows] + + +def get_all_kb_entries() -> List[Dict[str, Any]]: + conn = get_conn() + rows = conn.execute( + "SELECT * FROM knowledge_base ORDER BY confidence DESC, last_confirmed_at DESC" + ).fetchall() + conn.close() + return [dict(r) for r in rows] + + +def update_kb_entry_status(entry_id: int, status: str): + conn = get_conn() + conn.execute("UPDATE knowledge_base SET status=? WHERE id=?", (status, entry_id)) + conn.commit() + conn.close() + + +# ── Reasoning State ──────────────────────────────────────────────────────────── + +def save_reasoning_state(narrative: str, synthesis: Dict[str, Any], + sources_count: int = 0, reports_used: int = 0, + trades_analyzed: int = 0) -> int: + conn = get_conn() + cur_row = conn.execute( + "SELECT COALESCE(MAX(version), 0) as v FROM reasoning_state" + ).fetchone() + next_version = (cur_row["v"] if cur_row else 0) + 1 + cur = conn.execute("""INSERT INTO reasoning_state + (version, narrative, synthesis_json, sources_count, reports_used, trades_analyzed, created_at) + VALUES (?,?,?,?,?,?,datetime('now'))""", + (next_version, narrative, json.dumps(synthesis), sources_count, reports_used, trades_analyzed)) + new_id = cur.lastrowid + conn.commit() + conn.close() + return new_id + + +def get_latest_reasoning_state() -> Optional[Dict[str, Any]]: + conn = get_conn() + row = conn.execute( + "SELECT * FROM reasoning_state ORDER BY version DESC LIMIT 1" + ).fetchone() + conn.close() + if not row: + return None + d = dict(row) + try: + d["synthesis"] = json.loads(d.get("synthesis_json") or "{}") + except Exception: + d["synthesis"] = {} + return d + + +def get_reasoning_history(limit: int = 10) -> List[Dict[str, Any]]: + conn = get_conn() + rows = conn.execute( + "SELECT id, version, sources_count, reports_used, trades_analyzed, created_at FROM reasoning_state ORDER BY version DESC LIMIT ?", + (limit,) + ).fetchall() + conn.close() + return [dict(r) for r in rows] + + +def get_reasoning_state_by_id(state_id: int) -> Optional[Dict[str, Any]]: + conn = get_conn() + row = conn.execute("SELECT * FROM reasoning_state WHERE id=?", (state_id,)).fetchone() + conn.close() + if not row: + return None + d = dict(row) + try: + d["synthesis"] = json.loads(d.get("synthesis_json") or "{}") + except Exception: + d["synthesis"] = {} + return d diff --git a/backend/services/geo_analyzer.py b/backend/services/geo_analyzer.py new file mode 100644 index 0000000..622ea5f --- /dev/null +++ b/backend/services/geo_analyzer.py @@ -0,0 +1,348 @@ +""" +Geopolitical pattern engine. +Scores current events against historical templates and generates trade signals. +""" +from datetime import datetime, timedelta +from typing import List, Dict, Any, Optional +import json + + +# ── Historical geopolitical pattern library ─────────────────────────────────── +GEO_PATTERNS = [ + { + "id": "P001", + "name": "Middle East Military Escalation → Oil Spike", + "description": "Armed conflict or threat in Gulf region triggers Brent/WTI crude spike +10-20% within 2-4 weeks", + "triggers": ["military", "energy", "sanctions"], + "keywords": ["Iran", "Israel", "Saudi", "Gulf", "Strait of Hormuz", "OPEC"], + "historical_instances": [ + {"date": "2019-09-14", "event": "Attack on Saudi Aramco facilities", "brent_move": +14.6, "days": 2}, + {"date": "2020-01-03", "event": "Soleimani assassination", "brent_move": +4.4, "days": 1}, + {"date": "2022-02-24", "event": "Russia invades Ukraine", "brent_move": +28.0, "days": 10}, + ], + "suggested_trades": [ + {"strategy": "Bull Call Spread", "underlying": "USO", "rationale": "Oil ETF call spread, limited risk"}, + {"strategy": "Long Call", "underlying": "CL=F", "rationale": "WTI crude direct exposure"}, + ], + "asset_class": "energy", + "expected_move_pct": 12.0, + "probability": 0.65, + "horizon_days": 30, + }, + { + "id": "P002", + "name": "US Tariff Announcement → Agriculture Selloff", + "description": "Trump/US tariff threats on China cause immediate selloff in soy, corn, wheat (retaliatory risk)", + "triggers": ["trade_war", "political_speech"], + "keywords": ["tariff", "China", "trade", "soybean", "agriculture", "import duty"], + "historical_instances": [ + {"date": "2018-07-06", "event": "US-China trade war tariffs", "zs_move": -10.2, "days": 30}, + {"date": "2019-05-10", "event": "Trump tariff escalation tweet", "zs_move": -5.8, "days": 5}, + {"date": "2025-02-01", "event": "Trump 25% tariff on Canada/Mexico", "zw_move": -3.4, "days": 3}, + ], + "suggested_trades": [ + {"strategy": "Bear Put Spread", "underlying": "SOYB", "rationale": "Downside hedge on soy ETF"}, + {"strategy": "Long Put", "underlying": "ZS=F", "rationale": "Soybean futures put"}, + ], + "asset_class": "agriculture", + "expected_move_pct": -8.0, + "probability": 0.70, + "horizon_days": 21, + }, + { + "id": "P003", + "name": "Geopolitical Risk Flight → Gold Rally", + "description": "Major geopolitical uncertainty drives safe-haven demand for gold +5-15%", + "triggers": ["military", "health_crisis", "financial_crisis", "elections"], + "keywords": ["nuclear", "war", "crisis", "uncertainty", "safe haven", "debt ceiling"], + "historical_instances": [ + {"date": "2022-02-24", "event": "Ukraine invasion", "gc_move": +6.8, "days": 14}, + {"date": "2023-10-07", "event": "Hamas attack on Israel", "gc_move": +9.2, "days": 30}, + {"date": "2020-03-01", "event": "COVID-19 fear peak", "gc_move": +12.1, "days": 45}, + ], + "suggested_trades": [ + {"strategy": "Long Call", "underlying": "GLD", "rationale": "Gold ETF call for safe-haven rally"}, + {"strategy": "Bull Call Spread", "underlying": "GC=F", "rationale": "Gold futures spread, capped risk"}, + ], + "asset_class": "metals", + "expected_move_pct": 7.5, + "probability": 0.72, + "horizon_days": 30, + }, + { + "id": "P004", + "name": "Fed Hawkish Pivot → Dollar Surge / EM Currency Crash", + "description": "Fed signals higher-for-longer rates → USD Index rallies, EUR/USD drops", + "triggers": ["political_speech"], + "keywords": ["Fed", "interest rate", "hike", "hawkish", "inflation", "FOMC", "Powell"], + "historical_instances": [ + {"date": "2022-06-15", "event": "Fed 75bps hike", "dxy_move": +3.2, "days": 5}, + {"date": "2023-03-22", "event": "Fed signals further hikes", "eurusd_move": -1.8, "days": 7}, + ], + "suggested_trades": [ + {"strategy": "Bear Put Spread", "underlying": "FXE", "rationale": "EUR/USD put spread"}, + {"strategy": "Long Call", "underlying": "UUP", "rationale": "Dollar index ETF call"}, + ], + "asset_class": "forex", + "expected_move_pct": 3.0, + "probability": 0.68, + "horizon_days": 14, + }, + { + "id": "P005", + "name": "China Economic Slowdown → Copper/Metals Selloff", + "description": "Weak Chinese PMI or stimulus disappointment drives copper lower (China = 50%+ of global demand)", + "triggers": ["resource_scarcity", "trade_war"], + "keywords": ["China", "PMI", "slowdown", "recession", "property", "Evergrande", "copper demand"], + "historical_instances": [ + {"date": "2015-08-24", "event": "China Black Monday", "hg_move": -8.4, "days": 5}, + {"date": "2022-11-01", "event": "China PMI contraction", "hg_move": -5.2, "days": 10}, + ], + "suggested_trades": [ + {"strategy": "Long Put", "underlying": "COPX", "rationale": "Copper miners ETF put"}, + {"strategy": "Bear Put Spread", "underlying": "HG=F", "rationale": "Copper futures spread"}, + ], + "asset_class": "metals", + "expected_move_pct": -6.5, + "probability": 0.60, + "horizon_days": 21, + }, + { + "id": "P006", + "name": "Ukraine/Russia War Escalation → Wheat Spike + Defense Rally", + "description": "New escalation in Russia-Ukraine conflict → wheat/fertilizer spike, defense stocks rally", + "triggers": ["military", "resource_scarcity"], + "keywords": ["Russia", "Ukraine", "Zelensky", "Kyiv", "grain corridor", "Black Sea", "NATO"], + "historical_instances": [ + {"date": "2022-02-24", "event": "Full-scale invasion", "zw_move": +50.0, "days": 45}, + {"date": "2022-07-22", "event": "Grain deal collapse threat", "zw_move": +6.3, "days": 3}, + {"date": "2023-07-17", "event": "Russia exits grain deal", "zw_move": +8.5, "days": 2}, + ], + "suggested_trades": [ + {"strategy": "Long Call", "underlying": "WEAT", "rationale": "Wheat ETF call on supply shock"}, + {"strategy": "Bull Call Spread", "underlying": "LMT", "rationale": "Lockheed defense stock spread"}, + ], + "asset_class": "agriculture", + "expected_move_pct": 15.0, + "probability": 0.58, + "horizon_days": 45, + }, + { + "id": "P007", + "name": "Natural Gas Supply Disruption → NG Price Spike", + "description": "Pipeline disruption, LNG strike, or extreme weather drives natural gas +20-40%", + "triggers": ["energy", "natural_disaster", "military"], + "keywords": ["pipeline", "LNG", "natural gas", "Nord Stream", "gas supply", "storage"], + "historical_instances": [ + {"date": "2022-09-26", "event": "Nord Stream pipeline explosion", "ng_move": +18.0, "days": 5}, + {"date": "2021-02-10", "event": "Texas winter storm Uri", "ng_move": +40.0, "days": 3}, + ], + "suggested_trades": [ + {"strategy": "Long Call", "underlying": "UNG", "rationale": "Natural gas ETF call"}, + {"strategy": "Bull Call Spread", "underlying": "NG=F", "rationale": "NG futures spread, capped risk"}, + ], + "asset_class": "energy", + "expected_move_pct": 25.0, + "probability": 0.55, + "horizon_days": 14, + }, + { + "id": "P008", + "name": "Pandemic / Health Crisis → VIX Spike + Market Selloff", + "description": "New pandemic scare or major health crisis → VIX spike, equity selloff, gold bid", + "triggers": ["health_crisis"], + "keywords": ["pandemic", "virus", "outbreak", "WHO", "lockdown", "COVID", "mpox", "H5N1"], + "historical_instances": [ + {"date": "2020-02-24", "event": "COVID-19 global spread fear", "spx_move": -34.0, "days": 30}, + {"date": "2022-11-25", "event": "China COVID lockdowns", "spx_move": -3.5, "days": 3}, + ], + "suggested_trades": [ + {"strategy": "Long Put", "underlying": "SPY", "rationale": "S&P 500 put for equity protection"}, + {"strategy": "Long Call", "underlying": "^VIX", "rationale": "VIX call for volatility spike"}, + {"strategy": "Long Call", "underlying": "GLD", "rationale": "Gold safe-haven call"}, + ], + "asset_class": "indices", + "expected_move_pct": -12.0, + "probability": 0.45, + "horizon_days": 30, + }, +] + + +GEOPOLITICAL_RISK_WEIGHTS = { + "military": 0.25, + "energy": 0.20, + "trade_war": 0.15, + "political_speech": 0.15, + "natural_disaster": 0.10, + "health_crisis": 0.10, + "resource_scarcity": 0.05, +} + + +def compute_geo_risk_score(events: List[Dict[str, Any]]) -> Dict[str, Any]: + """Compute a global geopolitical risk score 0-100 from recent events.""" + if not events: + return {"score": 35, "level": "medium", "breakdown": {}} + + category_scores: Dict[str, float] = {} + for event in events[:30]: + cat = event.get("category", "general") + impact = event.get("impact_score", 0.1) + if cat in category_scores: + category_scores[cat] = max(category_scores[cat], impact) + else: + category_scores[cat] = impact + + weighted = sum( + category_scores.get(cat, 0) * weight + for cat, weight in GEOPOLITICAL_RISK_WEIGHTS.items() + ) + score = min(100, round(weighted * 100, 1)) + + if score < 25: + level = "low" + elif score < 50: + level = "medium" + elif score < 75: + level = "high" + else: + level = "extreme" + + return { + "score": score, + "level": level, + "breakdown": {cat: round(v * 100, 1) for cat, v in category_scores.items()}, + "top_risks": sorted(category_scores.items(), key=lambda x: x[1], reverse=True)[:3], + } + + +def match_patterns(events: List[Dict[str, Any]], patterns: Optional[List[Dict[str, Any]]] = None) -> List[Dict[str, Any]]: + """Find which historical geo-patterns best match current event feed.""" + if not events: + return [] + if patterns is None: + patterns = GEO_PATTERNS + + current_categories = set(e.get("category", "") for e in events) + current_tags = set() + for e in events: + current_tags.update(e.get("tags", [])) + current_text = " ".join(e.get("title", "") + " " + e.get("summary", "") for e in events[:20]).lower() + + matches = [] + for pattern in patterns: + trigger_match = len(set(pattern["triggers"]) & current_categories) / len(pattern["triggers"]) + keyword_match = sum(1 for kw in pattern["keywords"] if kw.lower() in current_text) / len(pattern["keywords"]) + similarity = round((trigger_match * 0.5 + keyword_match * 0.5) * 100, 1) + + if similarity > 10: + matches.append({ + "pattern_id": pattern["id"], + "name": pattern["name"], + "description": pattern["description"], + "similarity": similarity, + "suggested_trades": pattern["suggested_trades"], + "asset_class": pattern["asset_class"], + "expected_move_pct": pattern["expected_move_pct"], + "probability": pattern["probability"], + "horizon_days": pattern["horizon_days"], + "historical_instances": pattern["historical_instances"], + }) + + return sorted(matches, key=lambda x: x["similarity"], reverse=True)[:5] + + +def generate_trade_ideas(pattern_matches: List[Dict[str, Any]], geo_score: Dict[str, Any]) -> List[Dict[str, Any]]: + """Convert pattern matches into structured trade ideas with sizing for ~1000€.""" + ideas = [] + for pm in pattern_matches[:5]: + for i, trade in enumerate(pm["suggested_trades"]): # all suggested trades, not just first + move = pm["expected_move_pct"] + confidence = round(pm["probability"] * pm["similarity"] / 100 * 100) + # Use trade-level asset_class if provided, else fall back to pattern-level + asset_class = trade.get("asset_class") or pm["asset_class"] + ideas.append({ + "id": f"IDEA-{pm['pattern_id']}-{i}-{trade['strategy'][:3].upper()}", + "title": f"{trade['strategy']} on {trade['underlying']}", + "rationale": f"[{pm['name']}] {trade['rationale']}. Expected move: {'+' if move > 0 else ''}{move}% in {pm['horizon_days']}d", + "pattern": pm["name"], + "asset_class": asset_class, + "underlying": trade["underlying"], + "strategy": trade["strategy"], + "expected_move_pct": move, + "confidence": min(95, confidence), + "horizon_days": pm["horizon_days"], + "capital_required": 1000, + "risk_level": "high" if abs(move) > 15 else "medium", + "pattern_similarity": pm["similarity"], + }) + return ideas + + +def compute_pattern_relevance( + events: List[Dict[str, Any]], + patterns: Optional[List[Dict[str, Any]]] = None, +) -> List[Dict[str, Any]]: + """Return ALL patterns with news-keyword relevance score + matching news snippets. + Unlike match_patterns(), no similarity threshold — every active pattern is returned. + """ + if patterns is None: + patterns = GEO_PATTERNS + + current_categories = set(e.get("category", "") for e in events) + current_text = " ".join( + e.get("title", "") + " " + e.get("summary", "") for e in events[:30] + ).lower() + + result = [] + for pattern in patterns: + triggers_list = pattern.get("triggers", []) or [] + keywords_list = pattern.get("keywords", []) or [] + + trigger_match = ( + len(set(triggers_list) & current_categories) / len(triggers_list) + if triggers_list else 0 + ) + kw_hits = [kw for kw in keywords_list if kw.lower() in current_text] + keyword_match = len(kw_hits) / len(keywords_list) if keywords_list else 0 + relevance = round((trigger_match * 0.5 + keyword_match * 0.5) * 100, 1) + + # Find matching news with which keywords triggered + matching_news = [] + for e in events[:30]: + text = (e.get("title", "") + " " + e.get("summary", "")).lower() + hits = [kw for kw in keywords_list if kw.lower() in text] + if hits: + matching_news.append({ + "title": e.get("title", ""), + "source": e.get("source", ""), + "date": str(e.get("date", ""))[:16], + "impact": round(e.get("impact_score", 0), 2), + "matched_keywords": hits, + "url": e.get("url", ""), + }) + matching_news.sort(key=lambda x: x["impact"], reverse=True) + + result.append({ + "pattern_id": pattern.get("id", ""), + "name": pattern.get("name", ""), + "description": pattern.get("description", ""), + "asset_class": pattern.get("asset_class", ""), + "relevance": relevance, + "keyword_hits": len(kw_hits), + "keyword_total": len(keywords_list), + "matched_keywords": kw_hits, + "matching_news": matching_news[:5], + "suggested_trades": pattern.get("suggested_trades", []), + "expected_move_pct": pattern.get("expected_move_pct", 0), + "probability": pattern.get("probability", 0), + "horizon_days": pattern.get("horizon_days", 0), + }) + + result.sort(key=lambda x: x["relevance"], reverse=True) + return result + + +def get_all_patterns() -> List[Dict[str, Any]]: + return GEO_PATTERNS diff --git a/backend/services/options_pricer.py b/backend/services/options_pricer.py new file mode 100644 index 0000000..1209674 --- /dev/null +++ b/backend/services/options_pricer.py @@ -0,0 +1,126 @@ +import numpy as np +from scipy.stats import norm +from typing import Dict, Any, List, Optional +from datetime import datetime, timedelta +import math + + +def black_scholes(S: float, K: float, T: float, r: float, sigma: float, option_type: str = "call") -> Dict[str, float]: + """Black-Scholes pricing + Greeks.""" + S = float(S or 100.0) + K = float(K or S) + T = float(T or 0.001) + sigma = float(sigma or 0.25) + if T <= 0 or sigma <= 0: + intrinsic = max(0, S - K) if option_type == "call" else max(0, K - S) + return {"price": intrinsic, "delta": 0, "gamma": 0, "theta": 0, "vega": 0, "rho": 0} + + d1 = (math.log(S / K) + (r + 0.5 * sigma ** 2) * T) / (sigma * math.sqrt(T)) + d2 = d1 - sigma * math.sqrt(T) + + if option_type == "call": + price = S * norm.cdf(d1) - K * math.exp(-r * T) * norm.cdf(d2) + delta = norm.cdf(d1) + rho = K * T * math.exp(-r * T) * norm.cdf(d2) / 100 + else: + price = K * math.exp(-r * T) * norm.cdf(-d2) - S * norm.cdf(-d1) + delta = norm.cdf(d1) - 1 + rho = -K * T * math.exp(-r * T) * norm.cdf(-d2) / 100 + + gamma = norm.pdf(d1) / (S * sigma * math.sqrt(T)) + theta = (-(S * norm.pdf(d1) * sigma) / (2 * math.sqrt(T)) - r * K * math.exp(-r * T) * norm.cdf(d2 if option_type == "call" else -d2)) / 365 + vega = S * norm.pdf(d1) * math.sqrt(T) / 100 + + return { + "price": round(price, 4), + "delta": round(delta, 4), + "gamma": round(gamma, 6), + "theta": round(theta, 4), + "vega": round(vega, 4), + "rho": round(rho, 4), + } + + +def compute_pnl_curve( + S: float, K: float, T: float, r: float, sigma: float, + option_type: str, quantity: int, premium_paid: float +) -> List[Dict[str, float]]: + """P&L at expiry across a range of underlying prices.""" + prices = np.linspace(S * 0.5, S * 1.5, 100) + curve = [] + for price in prices: + if option_type == "call": + intrinsic = max(0, price - K) + else: + intrinsic = max(0, K - price) + pnl = (intrinsic - premium_paid) * quantity * 100 + curve.append({"underlying": round(float(price), 2), "pnl": round(float(pnl), 2)}) + return curve + + +def bull_call_spread(S: float, K_low: float, K_high: float, T: float, r: float, sigma: float) -> Dict[str, Any]: + long_call = black_scholes(S, K_low, T, r, sigma, "call") + short_call = black_scholes(S, K_high, T, r, sigma, "call") + net_debit = long_call["price"] - short_call["price"] + max_gain = (K_high - K_low) - net_debit + return { + "strategy": "Bull Call Spread", + "net_debit": round(net_debit, 4), + "max_loss": round(net_debit * 100, 2), + "max_gain": round(max_gain * 100, 2), + "breakeven": round(K_low + net_debit, 2), + "legs": [ + {"type": "long call", "strike": K_low, "premium": long_call["price"]}, + {"type": "short call", "strike": K_high, "premium": short_call["price"]}, + ], + } + + +def bear_put_spread(S: float, K_high: float, K_low: float, T: float, r: float, sigma: float) -> Dict[str, Any]: + long_put = black_scholes(S, K_high, T, r, sigma, "put") + short_put = black_scholes(S, K_low, T, r, sigma, "put") + net_debit = long_put["price"] - short_put["price"] + max_gain = (K_high - K_low) - net_debit + return { + "strategy": "Bear Put Spread", + "net_debit": round(net_debit, 4), + "max_loss": round(net_debit * 100, 2), + "max_gain": round(max_gain * 100, 2), + "breakeven": round(K_high - net_debit, 2), + "legs": [ + {"type": "long put", "strike": K_high, "premium": long_put["price"]}, + {"type": "short put", "strike": K_low, "premium": short_put["price"]}, + ], + } + + +def long_straddle(S: float, K: float, T: float, r: float, sigma: float) -> Dict[str, Any]: + call = black_scholes(S, K, T, r, sigma, "call") + put = black_scholes(S, K, T, r, sigma, "put") + total_premium = call["price"] + put["price"] + return { + "strategy": "Long Straddle", + "net_debit": round(total_premium, 4), + "max_loss": round(total_premium * 100, 2), + "max_gain": None, + "breakevens": [round(K - total_premium, 2), round(K + total_premium, 2)], + "legs": [ + {"type": "long call", "strike": K, "premium": call["price"]}, + {"type": "long put", "strike": K, "premium": put["price"]}, + ], + } + + +def implied_vol_surface(S: float, strikes_pct: List[float], expiries_days: List[int], r: float, base_sigma: float) -> List[Dict]: + """Generate a simplified IV surface (skew + term structure).""" + surface = [] + for days in expiries_days: + T = days / 365 + for pct in strikes_pct: + K = S * pct + moneyness = math.log(K / S) + skew_adj = -0.3 * moneyness # typical negative skew + term_adj = 0.02 * math.sqrt(30 / max(days, 1)) + iv = max(0.05, base_sigma + skew_adj + term_adj) + surface.append({"expiry_days": days, "strike_pct": pct, "strike": round(K, 2), "iv": round(iv, 4)}) + return surface diff --git a/deploy/.env.example b/deploy/.env.example new file mode 100644 index 0000000..4d1edf7 --- /dev/null +++ b/deploy/.env.example @@ -0,0 +1,8 @@ +# Copier ce fichier en .env et remplir les valeurs +# cp .env.example .env + +# Clé OpenAI pour GPT-4o (obligatoire pour les fonctionnalités IA) +OPENAI_API_KEY=sk-... + +# (Optionnel) Forcer la timezone du backend +# TZ=Europe/Paris diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml new file mode 100644 index 0000000..1ec0a2f --- /dev/null +++ b/deploy/docker-compose.yml @@ -0,0 +1,49 @@ +services: + + backend: + build: + context: ../backend + dockerfile: Dockerfile + environment: + - OPENAI_API_KEY=${OPENAI_API_KEY:-} + volumes: + - db_data:/app/data + restart: unless-stopped + networks: + - internal + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health')"] + interval: 30s + timeout: 10s + retries: 3 + + frontend: + build: + context: ../frontend + dockerfile: Dockerfile + restart: unless-stopped + networks: + - internal + + nginx: + image: nginx:1.27-alpine + ports: + - "80:80" + - "443:443" + volumes: + - ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf:ro + - ./nginx/certs:/etc/letsencrypt:ro + - ./nginx/certbot-webroot:/var/www/certbot:ro + depends_on: + - backend + - frontend + restart: unless-stopped + networks: + - internal + +volumes: + db_data: + +networks: + internal: + driver: bridge diff --git a/deploy/nginx/nginx-http-init.conf b/deploy/nginx/nginx-http-init.conf new file mode 100644 index 0000000..f9eb594 --- /dev/null +++ b/deploy/nginx/nginx-http-init.conf @@ -0,0 +1,25 @@ +# Config temporaire HTTP-only — utilisée uniquement lors du premier setup +# pour permettre à Certbot d'obtenir le certificat SSL. +# Remplacée automatiquement par setup-vps.sh après obtention du cert. + +server { + listen 80; + server_name openfin.open-squared.tech; + + # Challenge ACME pour Let's Encrypt + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + + location /api/ { + proxy_pass http://backend:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_read_timeout 120s; + } + + location / { + proxy_pass http://frontend:80; + proxy_set_header Host $host; + } +} diff --git a/deploy/nginx/nginx-https.conf b/deploy/nginx/nginx-https.conf new file mode 100644 index 0000000..24191e2 --- /dev/null +++ b/deploy/nginx/nginx-https.conf @@ -0,0 +1,61 @@ +# Config HTTPS définitive avec SSL Let's Encrypt +# Copiée vers nginx.conf par setup-vps.sh après obtention du certificat. + +server { + listen 80; + server_name openfin.open-squared.tech; + + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + + location / { + return 301 https://$host$request_uri; + } +} + +server { + listen 443 ssl; + http2 on; + server_name openfin.open-squared.tech; + + ssl_certificate /etc/letsencrypt/live/openfin.open-squared.tech/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/openfin.open-squared.tech/privkey.pem; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers HIGH:!aNULL:!MD5; + ssl_session_cache shared:SSL:10m; + ssl_session_timeout 10m; + + # Sécurité headers + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-Frame-Options SAMEORIGIN always; + add_header X-Content-Type-Options nosniff always; + + # API FastAPI + location /api/ { + proxy_pass http://backend:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 120s; + proxy_buffering off; + } + + # Swagger docs FastAPI + location /docs { + proxy_pass http://backend:8000; + proxy_set_header Host $host; + } + location /openapi.json { + proxy_pass http://backend:8000; + proxy_set_header Host $host; + } + + # Frontend React SPA + location / { + proxy_pass http://frontend:80; + proxy_set_header Host $host; + proxy_read_timeout 30s; + } +} diff --git a/deploy/setup-vps.sh b/deploy/setup-vps.sh new file mode 100644 index 0000000..c583b51 --- /dev/null +++ b/deploy/setup-vps.sh @@ -0,0 +1,93 @@ +#!/usr/bin/env bash +# setup-vps.sh — Premier déploiement complet sur VPS Ubuntu/Debian +# Usage : bash setup-vps.sh +set -euo pipefail + +DOMAIN="openfin.open-squared.tech" +EMAIL="opensquaredgeneva@gmail.com" +REPO="https://gitea.open-squared.tech/admin/OpenFin.git" +DEPLOY_DIR="/opt/openfin" + +GREEN='\033[0;32m'; YELLOW='\033[1;33m'; RED='\033[0;31m'; NC='\033[0m' +info() { echo -e "${GREEN}[INFO]${NC} $*"; } +warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } +abort() { echo -e "${RED}[ERR]${NC} $*"; exit 1; } + +# ── 1. Docker ───────────────────────────────────────────────────────────────── +info "Vérification de Docker..." +if ! command -v docker &>/dev/null; then + info "Installation de Docker..." + curl -fsSL https://get.docker.com | sh + systemctl enable docker + systemctl start docker +fi +if ! docker compose version &>/dev/null; then + info "Installation du plugin docker compose..." + apt-get install -y docker-compose-plugin +fi +info "Docker $(docker --version | cut -d' ' -f3) OK" + +# ── 2. Clone du repo ────────────────────────────────────────────────────────── +if [ -d "$DEPLOY_DIR" ]; then + warn "Le dossier $DEPLOY_DIR existe — pull de la dernière version..." + git -C "$DEPLOY_DIR" pull +else + info "Clone du repo dans $DEPLOY_DIR..." + git clone "$REPO" "$DEPLOY_DIR" +fi +cd "$DEPLOY_DIR/deploy" + +# ── 3. Fichier .env ─────────────────────────────────────────────────────────── +if [ ! -f .env ]; then + cp .env.example .env + warn "Fichier .env créé. Remplis ta clé OpenAI :" + warn " nano $DEPLOY_DIR/deploy/.env" + warn "Puis relance ce script." + exit 0 +fi + +if grep -q "sk-\.\.\." .env; then + abort "La clé OPENAI_API_KEY n'est pas configurée dans .env" +fi + +# ── 4. Créer les dossiers nécessaires ───────────────────────────────────────── +mkdir -p nginx/certs nginx/certbot-webroot + +# ── 5. Premier démarrage HTTP pour obtenir le cert SSL ──────────────────────── +info "Démarrage initial en HTTP pour la vérification Let's Encrypt..." +cp nginx/nginx-http-init.conf nginx/nginx.conf +docker compose up -d --build + +info "Attente que le frontend soit prêt..." +sleep 15 + +# ── 6. Certificat SSL Let's Encrypt ────────────────────────────────────────── +info "Obtention du certificat SSL pour $DOMAIN..." +docker run --rm \ + -v "$DEPLOY_DIR/deploy/nginx/certs:/etc/letsencrypt" \ + -v "$DEPLOY_DIR/deploy/nginx/certbot-webroot:/var/www/certbot" \ + certbot/certbot certonly \ + --webroot \ + --webroot-path=/var/www/certbot \ + -d "$DOMAIN" \ + --email "$EMAIL" \ + --agree-tos \ + --non-interactive \ + --expand + +# ── 7. Passage en HTTPS ─────────────────────────────────────────────────────── +info "Activation de la config HTTPS..." +cp nginx/nginx-https.conf nginx/nginx.conf +docker compose restart nginx + +# ── 8. Renouvellement automatique du cert (cron mensuel) ───────────────────── +CRON_CMD="0 3 1 * * docker run --rm -v $DEPLOY_DIR/deploy/nginx/certs:/etc/letsencrypt -v $DEPLOY_DIR/deploy/nginx/certbot-webroot:/var/www/certbot certbot/certbot renew --quiet && docker compose -f $DEPLOY_DIR/deploy/docker-compose.yml restart nginx" +(crontab -l 2>/dev/null | grep -v certbot; echo "$CRON_CMD") | crontab - +info "Renouvellement SSL automatique configuré (1er de chaque mois à 3h)" + +# ── 9. Résumé ───────────────────────────────────────────────────────────────── +echo "" +info "=== Déploiement terminé ===" +info "Cockpit disponible sur : https://$DOMAIN" +info "Statut des containers :" +docker compose ps diff --git a/deploy/update.sh b/deploy/update.sh new file mode 100644 index 0000000..7573c2b --- /dev/null +++ b/deploy/update.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# update.sh — Mise à jour du cockpit depuis le repo Gitea +# Usage : bash update.sh +set -euo pipefail + +DEPLOY_DIR="/opt/openfin" +cd "$DEPLOY_DIR" + +echo "[UPDATE] Pull de la dernière version..." +git pull + +cd deploy + +echo "[UPDATE] Rebuild et redémarrage des containers..." +docker compose up -d --build + +echo "[UPDATE] Nettoyage des images orphelines..." +docker image prune -f + +echo "[UPDATE] Done. Statut :" +docker compose ps diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..77e107e --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,16 @@ +# ── Étape 1 : build React ───────────────────────────────────────────────────── +FROM node:20-alpine AS build + +WORKDIR /app +COPY package*.json ./ +RUN npm ci --prefer-offline +COPY . . +RUN npm run build + +# ── Étape 2 : servir le build avec nginx ────────────────────────────────────── +FROM nginx:1.27-alpine + +COPY --from=build /app/dist /usr/share/nginx/html +COPY nginx-spa.conf /etc/nginx/conf.d/default.conf + +EXPOSE 80 diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..8039f44 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,18 @@ + + + + + + GeoOptions Intelligence + + + + + +
+ + + diff --git a/frontend/nginx-spa.conf b/frontend/nginx-spa.conf new file mode 100644 index 0000000..47e1143 --- /dev/null +++ b/frontend/nginx-spa.conf @@ -0,0 +1,16 @@ +server { + listen 80; + root /usr/share/nginx/html; + index index.html; + + # SPA routing — toujours renvoyer index.html pour les routes React + location / { + try_files $uri $uri/ /index.html; + } + + # Cache assets statiques (immutables car Vite les hash) + location ~* \.(js|css|png|jpg|jpeg|svg|ico|woff2?)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + } +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..b565f36 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,3476 @@ +{ + "name": "geooptions-cockpit", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "geooptions-cockpit", + "version": "1.0.0", + "dependencies": { + "@tanstack/react-query": "^5.59.0", + "axios": "^1.7.7", + "clsx": "^2.1.1", + "date-fns": "^4.1.0", + "lucide-react": "^0.447.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.26.2", + "recharts": "^2.13.0", + "zustand": "^5.0.0" + }, + "devDependencies": { + "@types/react": "^18.3.11", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.2", + "autoprefixer": "^10.4.20", + "postcss": "^8.4.47", + "tailwindcss": "^3.4.14", + "typescript": "^5.6.3", + "vite": "^5.4.9" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@remix-run/router": { + "version": "1.23.3", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.3.tgz", + "integrity": "sha512-4An71tdz9X8+3sI4Qqqd2LWd9vS39J7sqd9EU4Scw7TJE/qB10Flv/UuqbPVgfQV9XoK8Np6jNquZitnZq5i+Q==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.0.tgz", + "integrity": "sha512-IPIQ55ythEHkfEd9jMEi32OQ7SxURsGA43JI22lj01OLZNt2NUbJX8YUHxkVWyQ6daHPNn0truF5nSj3DQp6YQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.0.tgz", + "integrity": "sha512-M6s9cr10MibETyo8JsOkq+Lo1+lU6hcvb1MApnUql5qte/5hMEgzlN8/ReIKNfRV8rrqX50W1BX9zoUhC192RA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.0.tgz", + "integrity": "sha512-BqCoMoIbn0keKys+dEAdBa70EtOwV1bEsQCUgU9FdiZmmMge/Zk7LlkYGqbrdHR+Frnt0E1FOanly+rlwvvQzw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.0.tgz", + "integrity": "sha512-SIMzST3VFNXDAbeIWDWiFCNM5qncUBDWaEV7NfE7oZbDt2mgfW4MvbKdbYiGOLoM32gbTv608UMd0XktEYSD7w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.0.tgz", + "integrity": "sha512-ezjfSQMP7ArdUsbBwbQIfwAlhE84I2iVnzQNCFSveqV42q+BmKlzVpf7mxv5EchLcoWU4y6/heFzVg1F+hodUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.0.tgz", + "integrity": "sha512-9+qTWGW9AZRhnUgwtTwzNwcPlL87ngkeN0LA+q1bADvmY9aNvWaF2TFW8BZgnQPYxpDI7+rMVLivcd4V737TAQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.0.tgz", + "integrity": "sha512-T1dMEQhXA/jkJ/jyMIw9IovK8bSUq7A8kLIlvZTb/6YIVsp2zLavr4F3oyllHWo7eIVJRyE5n3tUjQJEbE1IuQ==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.0.tgz", + "integrity": "sha512-2as0LgT7qQpyceQq6VUJYnumUMUrgGQCWIiDIN9DE0/tglsk6o66uCB4f3djRawAltvfCNLyZZrsqbPA6inCsA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.0.tgz", + "integrity": "sha512-bVURMg+6eNN9C/yc0aVjooZcwTTtYF4YW3xta5pP0//r3o1V8gXEHXWCndj47w/HhwsFroZrFhR+6uQP5T0n0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.0.tgz", + "integrity": "sha512-Ful8pM/2yYI83PViWdFdpZhdI8HJ5qsXANe5atypbHDf+KIBBDsZsbyy8hbXnULVvW9NsTh5DHwbcBftyLTfiw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.0.tgz", + "integrity": "sha512-9Gp/DgrkzfUBmNPVTyPTvay+4xEP7M/clXpj3efXBcm6uTIVIgDg4rqUpqKXvLEuFRVuEpSAOkhgNeecvaZ4Cg==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.0.tgz", + "integrity": "sha512-m9tsJz54LUXkSYM8+8PG81B9IKK5r+2T0clMq4QrS16xFosufU7firBDAZEsDheDs7wTlP7h3++S7lMsU955HA==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.0.tgz", + "integrity": "sha512-3UvJ5PNVU16aJf6M3tFI24pWzAl2/ynfbyRN3ICyQajK1lSkrnVYNnLz3v04J32qKa0FczJc22zeToc0lr2A3w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.0.tgz", + "integrity": "sha512-vRWUAbYLGHBZS6Q8Msb2sfnf1fvJf+47t8l/TwOerM2qArzy+IeNMTHrYLHXh95h8MoatPHI5hhSZNs+mGXKPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.0.tgz", + "integrity": "sha512-c00T5SYENHAt86cfW47URaP3Us5vLC/4QO7GYud1G5VNRffCwwCuBspwqYrriuJB+5m0WFzClCn9wed0FBjKvg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.0.tgz", + "integrity": "sha512-krrCDilhXOwFkSkO3Wm9I/f9H0L92XHHwy2fwxjukxIbh0dem8gZqOW5Y8BsHrpJv5qwlRBV+Wl4ZFyRWhUpwg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.0.tgz", + "integrity": "sha512-7pfYFSTc4/rUC/FtAI0Qp6QthDBCIi6/AuP1xYqFk5vanI6KnL5dWKP60OM/05LOsbwTmIcvr6eXC4CJuJ75IA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.0.tgz", + "integrity": "sha512-7SDIalKeIpG0Ifogbbdn58HmSotYMlf23K3dCJEmiVd9Fg36Vmni82iPQec27N3wY4Bvbxftkxz6vSx9OcouTg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.0.tgz", + "integrity": "sha512-eRZevouTH2i1HeAVLqJuLnt256krQkGY0TN6WsTmsIhuzbh457HuWDMakKwmi0Cjadux983CoSr8Lim2QhUIFw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.0.tgz", + "integrity": "sha512-3oVS7FLGa4U1qcvao9ylGxrjXZyUQqR8UwxEcnUEyPX53O/C/mKDZegNXTdHCP+h3e6ta/f1EN38Yif1mmZHYg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.0.tgz", + "integrity": "sha512-yTB9TgfWj5wHe5QgktAgXTLLot1gvEjl1NiPPAUiCs4oPrIWFl5V4nC3GrkNdj9LaAU4s94nVrGbGOCqUpyWsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.0.tgz", + "integrity": "sha512-5LOhoaesY3doG1c+ac/2JtgREpKoJr5bUHH8tKY0V8di7+uSV6BwLs2PlR0/yzefGOkR+wE7ZolZphHCsyG5Rw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.0.tgz", + "integrity": "sha512-yYkWHhmbhRTWTnWos5HC4GcPQfjlzzCNbM9e/+GXrLuaBXYA3qSDR9f0Vgufd5S8yX81U8jPKp7ZnAjZFMtRnw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.0.tgz", + "integrity": "sha512-SoTb6lPg25xZlA2ibwQ++ahCCnH+FP0qmEuafMJ4gznZKOlXioKEAeJLgCrqjM98ACziXM9V1amFjICVL4IFoA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.0.tgz", + "integrity": "sha512-5L+T1fMX4RIEBoZzT0+sQ0PhTS36NULFmMXtl1TZo44TMAROIMHbZufSOjVWt/Y622BtxgxtaNOokbTDvfsrZA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tanstack/query-core": { + "version": "5.101.0", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.0.tgz", + "integrity": "sha512-cQetA74EB+seWySv1TTKr828TnP0u39m6LykwDXIo84SNortpDkp30TMEjkqtYCNP9c40uT/iwl6MLiufEt0Ow==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.101.0", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.0.tgz", + "integrity": "sha512-rLlJXSpkqfizLWgkR5+eLeIk0MvTx/meEIR7LRjxic+qxiQP8zVjq7BqQkiCMNLQBlLfuOLqqr6KO5GtrDlmSg==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.101.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.31", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", + "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/autoprefixer": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz", + "integrity": "sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.2", + "caniuse-lite": "^1.0.30001787", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/axios": { + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.17.0.tgz", + "integrity": "sha512-J8SwNxprqqpbfenehxWYXE7CW+wM1BB4w3+N+g+/Wx40xM4rsLrfPmHHxSWIxJLYDgSY/HqlFPIYb2/S3rxafw==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.37", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.37.tgz", + "integrity": "sha512-girxaJ7WZssDOFhzCGZTDKoTa1gk6A1TbflaYTpykLJ4UU9Fz9kx1aREM8JCuoVHbL8X8T/mJg7w2oYSq72Oig==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001799", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz", + "integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/date-fns": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.4.0.tgz", + "integrity": "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.372", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.372.tgz", + "integrity": "sha512-M3yhbAlilnwqC8D21t28UCDGHyitShTmmLRU/H+b74P6Ski16Nb9HONYEaVpMj/pwC7BEo5B95FpjODLCWbtfA==", + "dev": true, + "license": "ISC" + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "license": "MIT" + }, + "node_modules/fast-equals": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz", + "integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.447.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.447.0.tgz", + "integrity": "sha512-SZ//hQmvi+kDKrNepArVkYK7/jfeZ5uFNEnYmd45RKZcbGD78KLnrcNXmgeg6m+xNHFvTG+CblszXCy4n6DN4w==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.47", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.47.tgz", + "integrity": "sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.4.tgz", + "integrity": "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/prop-types/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "6.30.4", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.4.tgz", + "integrity": "sha512-SVUsDe+DybHM/WmYKIVYhZh1o5Dcuf16yM6WjG02Q9XVFMZIJyHYhwrr6bFBXZkVP6z69kNkMyBCujt8FaFLJA==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.4", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.4.tgz", + "integrity": "sha512-q4HvNl+mmDdkS0g+MqiBZNteQJCuimWoOyHMy4T/RQLAn9Z29+E91QXRaxOujeMl2HTzRSS0KFPd7lxX3PjV0Q==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.3", + "react-router": "6.30.4" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/react-smooth": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz", + "integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==", + "license": "MIT", + "dependencies": { + "fast-equals": "^5.0.1", + "prop-types": "^15.8.1", + "react-transition-group": "^4.4.5" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/recharts": { + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz", + "integrity": "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==", + "deprecated": "1.x and 2.x branches are no longer active. Bump to Recharts v3 to receive latest features and bugfixes. See https://github.com/recharts/recharts/wiki/3.0-migration-guide", + "license": "MIT", + "dependencies": { + "clsx": "^2.0.0", + "eventemitter3": "^4.0.1", + "lodash": "^4.17.21", + "react-is": "^18.3.1", + "react-smooth": "^4.0.4", + "recharts-scale": "^0.4.4", + "tiny-invariant": "^1.3.1", + "victory-vendor": "^36.6.8" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/recharts-scale": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz", + "integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==", + "license": "MIT", + "dependencies": { + "decimal.js-light": "^2.4.1" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.0.tgz", + "integrity": "sha512-nc72Wgq62I7rtDV4izT5/aaS0zxy3kttkinf9586ApknY3jZO9NYsmtc24fUckA0X7Q2v+ML4a15pdUlV5V/jA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.0", + "@rollup/rollup-android-arm64": "4.62.0", + "@rollup/rollup-darwin-arm64": "4.62.0", + "@rollup/rollup-darwin-x64": "4.62.0", + "@rollup/rollup-freebsd-arm64": "4.62.0", + "@rollup/rollup-freebsd-x64": "4.62.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.0", + "@rollup/rollup-linux-arm-musleabihf": "4.62.0", + "@rollup/rollup-linux-arm64-gnu": "4.62.0", + "@rollup/rollup-linux-arm64-musl": "4.62.0", + "@rollup/rollup-linux-loong64-gnu": "4.62.0", + "@rollup/rollup-linux-loong64-musl": "4.62.0", + "@rollup/rollup-linux-ppc64-gnu": "4.62.0", + "@rollup/rollup-linux-ppc64-musl": "4.62.0", + "@rollup/rollup-linux-riscv64-gnu": "4.62.0", + "@rollup/rollup-linux-riscv64-musl": "4.62.0", + "@rollup/rollup-linux-s390x-gnu": "4.62.0", + "@rollup/rollup-linux-x64-gnu": "4.62.0", + "@rollup/rollup-linux-x64-musl": "4.62.0", + "@rollup/rollup-openbsd-x64": "4.62.0", + "@rollup/rollup-openharmony-arm64": "4.62.0", + "@rollup/rollup-win32-arm64-msvc": "4.62.0", + "@rollup/rollup-win32-ia32-msvc": "4.62.0", + "@rollup/rollup-win32-x64-gnu": "4.62.0", + "@rollup/rollup-win32-x64-msvc": "4.62.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/victory-vendor": { + "version": "36.9.2", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz", + "integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/zustand": { + "version": "5.0.14", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.14.tgz", + "integrity": "sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..6bfceee --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,33 @@ +{ + "name": "geooptions-cockpit", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.26.2", + "recharts": "^2.13.0", + "@tanstack/react-query": "^5.59.0", + "zustand": "^5.0.0", + "axios": "^1.7.7", + "date-fns": "^4.1.0", + "lucide-react": "^0.447.0", + "clsx": "^2.1.1" + }, + "devDependencies": { + "@types/react": "^18.3.11", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.2", + "autoprefixer": "^10.4.20", + "postcss": "^8.4.47", + "tailwindcss": "^3.4.14", + "typescript": "^5.6.3", + "vite": "^5.4.9" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..01cb28b --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,49 @@ +import { BrowserRouter, Routes, Route } from 'react-router-dom' +import Sidebar from './components/layout/Sidebar' +import Dashboard from './pages/Dashboard' +import GeoRadar from './pages/GeoRadar' +import Markets from './pages/Markets' +import MacroRegime from './pages/MacroRegime' +import OptionsLab from './pages/OptionsLab' +import Backtest from './pages/Backtest' +import CalendarPage from './pages/CalendarPage' +import Portfolio from './pages/Portfolio' +import PatternEditor from './pages/PatternEditor' +import JournalDeBord from './pages/JournalDeBord' +import RapportIA from './pages/RapportIA' +import SuperContexte from './pages/SuperContexte' +import Config from './pages/Config' +import { useCycleWatcher } from './hooks/useApi' + +function GlobalWatcher() { + useCycleWatcher() + return null +} + +export default function App() { + return ( + +
+ + +
+ + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + +
+
+
+ ) +} diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx new file mode 100644 index 0000000..c608c76 --- /dev/null +++ b/frontend/src/components/layout/Sidebar.tsx @@ -0,0 +1,105 @@ +import { NavLink } from 'react-router-dom' +import { + LayoutDashboard, Globe, BarChart2, FlaskConical, + History, Calendar, TrendingUp, Zap, DollarSign, Settings, BrainCircuit, Activity, BookOpen, FileBarChart, Brain +} from 'lucide-react' +import { useGeoRiskScore, useAiStatus, usePortfolioSummary } from '../../hooks/useApi' +import clsx from 'clsx' + +const nav = [ + { to: '/', icon: LayoutDashboard, label: 'Cockpit' }, + { to: '/geo', icon: Globe, label: 'Radar Géopolitique' }, + { to: '/markets', icon: BarChart2, label: 'Marchés & Prix' }, + { to: '/macro', icon: Activity, label: 'Régime Macro' }, + { to: '/options', icon: TrendingUp, label: 'Options Lab' }, + { to: '/patterns', icon: Zap, label: 'Patterns' }, + { to: '/portfolio', icon: DollarSign, label: 'Portefeuille' }, + { to: '/journal', icon: BookOpen, label: 'Journal de Bord' }, + { to: '/rapport', icon: FileBarChart, label: 'Rapport IA' }, + { to: '/super-contexte', icon: Brain, label: 'Super Contexte' }, + { to: '/backtest', icon: History, label: 'Backtest' }, + { to: '/calendar', icon: Calendar, label: 'Calendrier' }, + { to: '/config', icon: Settings, label: 'Configuration' }, +] + +const riskColors: Record = { + low: 'text-emerald-400 bg-emerald-900/30 border-emerald-700/40', + medium: 'text-yellow-400 bg-yellow-900/30 border-yellow-700/40', + high: 'text-orange-400 bg-orange-900/30 border-orange-700/40', + extreme: 'text-red-400 bg-red-900/30 border-red-700/40 animate-pulse', +} + +export default function Sidebar() { + const { data: riskScore } = useGeoRiskScore() + const { data: aiStatus } = useAiStatus() + const { data: summary } = usePortfolioSummary() + + return ( + + ) +} diff --git a/frontend/src/hooks/useApi.ts b/frontend/src/hooks/useApi.ts new file mode 100644 index 0000000..638edb0 --- /dev/null +++ b/frontend/src/hooks/useApi.ts @@ -0,0 +1,545 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { useEffect, useRef } from 'react' +import axios from 'axios' +import type { + Quote, GeoNews, GeoRiskScore, PatternMatch, TradeIdea, + EconomicEvent, HistoricalCandle, BacktestResult +} from '../types' + +export const api = axios.create({ baseURL: '/api' }) + +// ── Market ──────────────────────────────────────────────────────────────────── +export const useAllQuotes = () => + useQuery>({ + queryKey: ['quotes'], + queryFn: () => api.get('/market/quotes').then(r => r.data), + refetchInterval: 60_000, + }) + +export const useHistory = (symbol: string, period = '1y', interval = '1d') => + useQuery({ + queryKey: ['history', symbol, period, interval], + queryFn: () => + api.get(`/market/history/${encodeURIComponent(symbol)}`, { params: { period, interval } }).then(r => r.data), + enabled: !!symbol, + }) + +// ── Geo ─────────────────────────────────────────────────────────────────────── +export const useGeoNews = () => + useQuery({ + queryKey: ['geo-news'], + queryFn: () => api.get('/geo/news').then(r => r.data), + refetchInterval: 3_600_000, + }) + +export const useGeoRiskScore = () => + useQuery({ + queryKey: ['geo-risk-score'], + queryFn: () => api.get('/geo/risk-score').then(r => r.data), + refetchInterval: 3_600_000, + }) + +export const usePatternMatches = () => + useQuery({ + queryKey: ['pattern-matches'], + queryFn: () => api.get('/geo/pattern-matches').then(r => r.data), + }) + +export const usePatternRelevance = (days: number) => + useQuery({ + queryKey: ['pattern-relevance', days], + queryFn: () => api.get('/geo/pattern-relevance', { params: { days } }).then(r => r.data), + staleTime: 5 * 60_000, + }) + +export const useTradeIdeas = () => + useQuery({ + queryKey: ['trade-ideas'], + queryFn: () => api.get('/geo/trade-ideas').then(r => r.data), + }) + +export const useCalendar = () => + useQuery({ + queryKey: ['calendar'], + queryFn: () => api.get('/geo/calendar').then(r => r.data), + }) + +// ── Options ─────────────────────────────────────────────────────────────────── +export const useIvSurface = (symbol: string) => + useQuery({ + queryKey: ['iv-surface', symbol], + queryFn: () => api.get('/options/iv-surface', { params: { symbol } }).then(r => r.data), + enabled: !!symbol, + }) + +export const usePnlCurve = (params: { + symbol: string; strike: number; expiry_days: number + option_type: string; quantity: number; premium_paid: number +}) => + useQuery({ + queryKey: ['pnl-curve', params], + queryFn: () => api.get('/options/pnl-curve', { params }).then(r => r.data), + enabled: !!params.symbol && !!params.strike && !!params.premium_paid, + }) + +// ── Backtest ────────────────────────────────────────────────────────────────── +export const useBacktest = () => + useMutation>({ + mutationFn: (data) => api.post('/backtest/run', data).then(r => r.data), + }) + +// ── AI ──────────────────────────────────────────────────────────────────────── +export const useAiStatus = () => + useQuery({ + queryKey: ['ai-status'], + queryFn: () => api.get('/ai/status').then(r => r.data), + refetchInterval: 30_000, + }) + +export const useAiTopIdeas = () => + useQuery({ + queryKey: ['ai-top-ideas'], + queryFn: () => api.get('/ai/top-ideas').then(r => r.data), + enabled: false, + retry: false, + }) + +export const useAnalyzeSpeech = () => + useMutation({ + mutationFn: (data: { text: string; speaker?: string }) => + api.post('/ai/analyze-speech', data).then(r => r.data), + }) + +export const useEvaluatePattern = () => + useMutation({ + mutationFn: (pattern: Record) => + api.post('/ai/evaluate-pattern', { pattern }).then(r => r.data), + }) + +export const useSuggestPattern = () => + useMutation({ + mutationFn: (context: string) => + api.post('/ai/suggest-pattern', { context }).then(r => r.data), + }) + +export const useAiTopIdeasRefetch = () => + useMutation({ + mutationFn: () => api.get('/ai/top-ideas').then(r => r.data), + }) + +// ── Portfolio ───────────────────────────────────────────────────────────────── +export const usePortfolioPositions = (status = 'open') => + useQuery({ + queryKey: ['portfolio', status], + queryFn: () => api.get('/portfolio/positions', { params: { status } }).then(r => r.data), + refetchInterval: status === 'open' ? 60_000 : false, + }) + +export const usePortfolioSummary = () => + useQuery({ + queryKey: ['portfolio-summary'], + queryFn: () => api.get('/portfolio/summary').then(r => r.data), + refetchInterval: 60_000, + }) + +export const usePnlHistory = () => + useQuery({ + queryKey: ['pnl-history'], + queryFn: () => api.get('/portfolio/pnl-history').then(r => r.data), + }) + +export const useAddPosition = () => { + const qc = useQueryClient() + return useMutation({ + mutationFn: (data: Record) => + api.post('/portfolio/add', data).then(r => r.data), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['portfolio'] }) + qc.invalidateQueries({ queryKey: ['portfolio-summary'] }) + }, + }) +} + +export const useClosePosition = () => { + const qc = useQueryClient() + return useMutation({ + mutationFn: ({ id, close_value }: { id: string; close_value: number }) => + api.post(`/portfolio/close/${id}`, { close_value }).then(r => r.data), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['portfolio'] }) + qc.invalidateQueries({ queryKey: ['portfolio-summary'] }) + qc.invalidateQueries({ queryKey: ['pnl-history'] }) + }, + }) +} + +// ── Config ──────────────────────────────────────────────────────────────────── +export const useConfig = () => + useQuery({ + queryKey: ['config'], + queryFn: () => api.get('/config/').then(r => r.data), + }) + +export const useSources = () => + useQuery({ + queryKey: ['sources'], + queryFn: () => api.get('/config/sources').then(r => r.data), + }) + +export const useUpdateSources = () => { + const qc = useQueryClient() + return useMutation({ + mutationFn: (sources: Record) => + api.put('/config/sources', { sources }).then(r => r.data), + onSuccess: () => qc.invalidateQueries({ queryKey: ['sources'] }), + }) +} + +export const useUpdateApiKeys = () => { + const qc = useQueryClient() + return useMutation({ + mutationFn: (keys: Record) => + api.put('/config/api-keys', keys).then(r => r.data), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['config'] }) + qc.invalidateQueries({ queryKey: ['ai-status'] }) + }, + }) +} + +// ── AI Pattern Scoring ──────────────────────────────────────────────────────── +export const useScorePatterns = () => { + const qc = useQueryClient() + return useMutation({ + mutationFn: (params: { top_n?: number; category_filter?: string; template?: string }) => + api.post('/ai/score-patterns', params).then(r => r.data), + onSuccess: () => qc.invalidateQueries({ queryKey: ['last-scores'] }), + }) +} + +export const useLastScores = () => + useQuery({ + queryKey: ['last-scores'], + queryFn: () => api.get('/ai/last-scores').then(r => r.data), + staleTime: Infinity, + }) + +export const useSuggestNewPatterns = () => + useMutation({ + mutationFn: () => api.post('/ai/suggest-new-patterns').then(r => r.data), + }) + +export const useAnalysisConfig = () => + useQuery({ + queryKey: ['analysis-config'], + queryFn: () => api.get('/config/analysis').then(r => r.data), + }) + +export const useSaveAnalysisConfig = () => { + const qc = useQueryClient() + return useMutation({ + mutationFn: (cfg: { top_n?: number; category_filter?: string; template?: string }) => + api.put('/config/analysis', cfg).then(r => r.data), + onSuccess: () => qc.invalidateQueries({ queryKey: ['analysis-config'] }), + }) +} + +// ── Patterns ────────────────────────────────────────────────────────────────── +export const useAllPatterns = () => + useQuery({ + queryKey: ['all-patterns'], + queryFn: () => api.get('/patterns/all').then(r => r.data), + }) + +export const usePatternSimilarity = () => + useQuery({ + queryKey: ['pattern-similarity'], + queryFn: () => api.get('/ai/pattern-similarity').then(r => r.data), + staleTime: 5 * 60_000, + }) + +export const useMacroRegime = () => { + const qc = useQueryClient() + const query = useQuery({ + queryKey: ['macro-regime'], + queryFn: () => api.get('/market/macro-regime').then(r => r.data), + staleTime: 10 * 60 * 1000, + refetchInterval: 15 * 60 * 1000, + }) + const forceRefetch = async () => { + const fresh = await api.get('/market/macro-regime?force=true').then(r => r.data) + qc.setQueryData(['macro-regime'], fresh) + return fresh + } + return { ...query, forceRefetch } +} + +export const useSavePattern = () => { + const qc = useQueryClient() + return useMutation({ + mutationFn: (pattern: Record) => + api.post('/patterns/custom', pattern).then(r => r.data), + onSuccess: () => qc.invalidateQueries({ queryKey: ['all-patterns'] }), + }) +} + +export const useDeletePattern = () => { + const qc = useQueryClient() + return useMutation({ + mutationFn: (id: string) => + api.delete(`/patterns/custom/${id}`).then(r => r.data), + onSuccess: () => qc.invalidateQueries({ queryKey: ['all-patterns'] }), + }) +} + +export const useTogglePattern = () => { + const qc = useQueryClient() + return useMutation({ + mutationFn: (id: string) => + api.put(`/patterns/toggle/${id}`).then(r => r.data), + onSuccess: () => qc.invalidateQueries({ queryKey: ['all-patterns'] }), + }) +} + +// ── Auto-Cycle ─────────────────────────────────────────────────────────────── + +export const useCycleStatus = () => + useQuery({ + queryKey: ['cycle-status'], + queryFn: () => api.get('/cycle/status').then(r => r.data), + staleTime: 5_000, + // Poll every 5s while a cycle is running, every 30s otherwise + refetchInterval: (query) => ((query.state.data as any)?.running ? 5_000 : 30_000), + }) + +export const useCycleHistory = (limit = 20) => + useQuery({ + queryKey: ['cycle-history', limit], + queryFn: () => api.get(`/cycle/history?limit=${limit}`).then(r => r.data), + staleTime: 60_000, + }) + +export const useUpdateCycleConfig = () => { + const qc = useQueryClient() + return useMutation({ + mutationFn: (cfg: { enabled?: boolean; interval_hours?: number; similarity_threshold?: number; min_ev_threshold?: number; min_score_threshold?: number }) => + api.post('/cycle/config', cfg).then(r => r.data), + onSuccess: () => qc.invalidateQueries({ queryKey: ['cycle-status'] }), + }) +} + +/** Keys to refresh once a cycle finishes — covers cockpit, macro, journal */ +export const CYCLE_REFRESH_KEYS = [ + ['macro-regime'], + ['last-scores'], + ['all-patterns'], + ['cycle-history'], + ['cycle-status'], + ['journal-summary'], + ['journal-mtm'], + ['journal-geo'], + ['journal-macro'], + ['geo-risk-score'], + ['pattern-matches'], +] + +export const useTriggerCycle = () => { + const qc = useQueryClient() + return useMutation({ + mutationFn: () => api.post('/cycle/trigger').then(r => r.data), + onSuccess: () => { + // Immediate status refresh so the spinner shows + qc.invalidateQueries({ queryKey: ['cycle-status'] }) + }, + }) +} + +/** + * Mount this hook once at a high level (e.g. CyclesSection or App). + * It watches cycle-status and, when a running cycle finishes, refreshes + * all cockpit + journal queries so the UI reflects the new scores/regime. + */ +export const useCycleWatcher = () => { + const qc = useQueryClient() + const { data: statusData } = useCycleStatus() + const wasRunning = useRef(false) + + useEffect(() => { + const running = (statusData as any)?.running ?? false + if (running) { + wasRunning.current = true + } else if (wasRunning.current) { + // Transition: was running → now done → refresh everything + wasRunning.current = false + CYCLE_REFRESH_KEYS.forEach(key => qc.invalidateQueries({ queryKey: key })) + } + }, [(statusData as any)?.running]) +} + +// ── Journal de Bord ────────────────────────────────────────────────────────── + +export const useJournalSummary = () => + useQuery({ + queryKey: ['journal-summary'], + queryFn: () => api.get('/journal/summary').then(r => r.data), + staleTime: 2 * 60_000, + }) + +export const useMacroHistory = (days = 15) => + useQuery({ + queryKey: ['journal-macro', days], + queryFn: () => api.get(`/journal/macro-history?days=${days}`).then(r => r.data), + staleTime: 5 * 60_000, + }) + +export const useGeoHistory = (days = 30) => + useQuery({ + queryKey: ['journal-geo', days], + queryFn: () => api.get(`/journal/geo-history?days=${days}`).then(r => r.data), + staleTime: 5 * 60_000, + }) + +export const useTradeMtm = (days = 30) => + useQuery({ + queryKey: ['journal-mtm', days], + queryFn: () => api.get(`/journal/trade-mtm?days=${days}`).then(r => r.data), + staleTime: 0, + refetchInterval: 5 * 60_000, // re-fetch live prices every 5 minutes + refetchIntervalInBackground: false, + }) + +// ── Risk Profiles ───────────────────────────────────────────────────────────── + +export const useRiskProfiles = () => + useQuery({ + queryKey: ['risk-profiles'], + queryFn: () => api.get('/profiles').then(r => r.data), + staleTime: 30_000, + }) + +export const useUpsertProfile = () => { + const qc = useQueryClient() + return useMutation({ + mutationFn: (profile: { + id?: number; name: string; min_score: number; min_gain_pct: number; + color?: string; enabled?: boolean; sort_order?: number + }) => { + if (profile.id) { + return api.put(`/profiles/${profile.id}`, profile).then(r => r.data) + } + return api.post('/profiles', profile).then(r => r.data) + }, + onSuccess: () => qc.invalidateQueries({ queryKey: ['risk-profiles'] }), + }) +} + +export const useDeleteProfile = () => { + const qc = useQueryClient() + return useMutation({ + mutationFn: (id: number) => api.delete(`/profiles/${id}`).then(r => r.data), + onSuccess: () => qc.invalidateQueries({ queryKey: ['risk-profiles'] }), + }) +} + +export const usePreviewTradeScore = (score: number, gainPct: number, enabled = true) => + useQuery({ + queryKey: ['profile-preview', score, gainPct], + queryFn: () => api.get(`/profiles/preview?score=${score}&gain_pct=${gainPct}`).then(r => r.data), + enabled, + staleTime: 0, + }) + +// ── Reasoning Traces / Post-mortem ──────────────────────────────────────────── + +export const useTradePostmortem = (tradeId: number | null) => + useQuery({ + queryKey: ['postmortem', tradeId], + queryFn: () => api.get(`/reasoning/postmortem/${tradeId}`).then(r => r.data), + enabled: tradeId !== null, + staleTime: 60_000, + }) + +export const useAnalyzePostmortem = () => + useMutation({ + mutationFn: (tradeId: number) => + api.post(`/reasoning/postmortem/${tradeId}/analyze`).then(r => r.data), + }) + +export const usePortfolioReportData = (days: number) => + useQuery({ + queryKey: ['portfolio-report-data', days], + queryFn: () => api.get(`/reasoning/portfolio-report?days=${days}`).then(r => r.data), + staleTime: 120_000, + }) + +export const useGeneratePortfolioReport = () => + useMutation({ + mutationFn: (days: number) => + api.post(`/reasoning/portfolio-report/generate?days=${days}`).then(r => r.data), + }) + +export const useAiReportsList = () => + useQuery({ + queryKey: ['ai-reports-list'], + queryFn: () => api.get('/reasoning/reports').then(r => r.data), + staleTime: 30_000, + }) + +export const useAiReport = (reportId: number | null) => + useQuery({ + queryKey: ['ai-report', reportId], + queryFn: () => api.get(`/reasoning/reports/${reportId}`).then(r => r.data), + enabled: reportId !== null, + staleTime: Infinity, + }) + +// ── Super Contexte / Knowledge Base ────────────────────────────────────────── +export const useKnowledgeState = () => + useQuery({ + queryKey: ['knowledge-state'], + queryFn: () => api.get('/knowledge/state').then(r => r.data), + staleTime: 5 * 60_000, + }) + +export const useKnowledgeHistory = () => + useQuery({ + queryKey: ['knowledge-history'], + queryFn: () => api.get('/knowledge/history').then(r => r.data), + staleTime: 5 * 60_000, + }) + +export const useKnowledgeStateVersion = (stateId: number | null) => + useQuery({ + queryKey: ['knowledge-state-version', stateId], + queryFn: () => api.get(`/knowledge/history/${stateId}`).then(r => r.data), + enabled: stateId !== null, + staleTime: Infinity, + }) + +export const useKnowledgeEntries = () => + useQuery({ + queryKey: ['knowledge-entries'], + queryFn: () => api.get('/knowledge/entries').then(r => r.data), + staleTime: 5 * 60_000, + }) + +export const useSynthesizeKnowledge = () => { + const qc = useQueryClient() + return useMutation({ + mutationFn: () => api.post('/knowledge/synthesize').then(r => r.data), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['knowledge-state'] }) + qc.invalidateQueries({ queryKey: ['knowledge-history'] }) + qc.invalidateQueries({ queryKey: ['knowledge-entries'] }) + }, + }) +} + +export const usePatchKbEntryStatus = () => { + const qc = useQueryClient() + return useMutation({ + mutationFn: ({ id, status }: { id: number; status: string }) => + api.patch(`/knowledge/entries/${id}/status`, { status }).then(r => r.data), + onSuccess: () => qc.invalidateQueries({ queryKey: ['knowledge-entries'] }), + }) +} diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..f12be5e --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,52 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + body { + @apply bg-dark-900 text-slate-200 font-mono; + font-family: 'JetBrains Mono', 'Fira Code', Consolas, monospace; + } + + ::-webkit-scrollbar { width: 6px; height: 6px; } + ::-webkit-scrollbar-track { @apply bg-dark-800; } + ::-webkit-scrollbar-thumb { @apply bg-dark-500 rounded; } + ::-webkit-scrollbar-thumb:hover { @apply bg-slate-600; } +} + +@layer components { + .card { + @apply bg-dark-800 border border-slate-700/40 rounded-lg p-4; + } + .card-sm { + @apply bg-dark-700 border border-slate-700/30 rounded p-3; + } + .badge { + @apply inline-flex items-center px-2 py-0.5 rounded text-xs font-medium; + } + .badge-green { @apply badge bg-emerald-900/50 text-emerald-400 border border-emerald-700/30; } + .badge-red { @apply badge bg-red-900/50 text-red-400 border border-red-700/30; } + .badge-yellow { @apply badge bg-yellow-900/50 text-yellow-400 border border-yellow-700/30; } + .badge-blue { @apply badge bg-blue-900/50 text-blue-400 border border-blue-700/30; } + .badge-orange { @apply badge bg-orange-900/50 text-orange-400 border border-orange-700/30; } + .badge-purple { @apply badge bg-purple-900/50 text-purple-400 border border-purple-700/30; } + .stat-value { @apply text-2xl font-bold text-white; } + .stat-label { @apply text-xs text-slate-500 uppercase tracking-wider; } + .nav-link { + @apply flex items-center gap-2 px-3 py-2 rounded text-sm text-slate-400 + hover:bg-dark-600 hover:text-slate-200 transition-colors; + } + .nav-link.active { + @apply bg-dark-600 text-blue-400 border-l-2 border-blue-400; + } + .positive { @apply text-emerald-400; } + .negative { @apply text-red-400; } + .neutral { @apply text-slate-400; } + .section-title { + @apply text-xs font-semibold text-slate-500 uppercase tracking-widest mb-3; + } + .risk-low { @apply text-emerald-400; } + .risk-medium { @apply text-yellow-400; } + .risk-high { @apply text-orange-400; } + .risk-extreme { @apply text-red-400; } +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..eb1ba89 --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,19 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import App from './App' +import './index.css' + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { staleTime: 60_000, retry: 1 }, + }, +}) + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + + + +) diff --git a/frontend/src/pages/Backtest.tsx b/frontend/src/pages/Backtest.tsx new file mode 100644 index 0000000..bd272da --- /dev/null +++ b/frontend/src/pages/Backtest.tsx @@ -0,0 +1,310 @@ +import { useState } from 'react' +import { useBacktest } from '../hooks/useApi' +import clsx from 'clsx' +import { + AreaChart, Area, XAxis, YAxis, Tooltip, ResponsiveContainer, + CartesianGrid, ReferenceLine, +} from 'recharts' +import { History, Play, TrendingUp, TrendingDown, AlertTriangle } from 'lucide-react' +import type { BacktestResult } from '../types' + +const STRATEGIES = [ + { key: 'long_call', label: 'Long Call' }, + { key: 'long_put', label: 'Long Put' }, + { key: 'bull_call_spread', label: 'Bull Call Spread' }, + { key: 'bear_put_spread', label: 'Bear Put Spread' }, +] + +const SYMBOLS = [ + 'GLD', 'USO', 'WEAT', 'UNG', 'SPY', 'QQQ', 'GDX', 'COPX', + 'XLE', 'FXE', 'XOM', 'LMT', 'BA', 'RTX', +] + +function StatCard({ label, value, sub, positive }: { label: string; value: string; sub?: string; positive?: boolean }) { + return ( +
+
{label}
+
+ {value} +
+ {sub &&
{sub}
} +
+ ) +} + +export default function Backtest() { + const { mutate: runBacktest, data: result, isPending } = useBacktest() + + const [form, setForm] = useState({ + symbol: 'GLD', + start_date: '2022-01-01', + end_date: '2024-12-31', + strategy: 'long_call', + strike_offset_pct: 0.05, + expiry_days: 90, + capital: 1000, + }) + + const set = (k: string, v: unknown) => setForm(f => ({ ...f, [k]: v })) + + const run = () => runBacktest(form as Record) + + const typed = result as BacktestResult | undefined + const hasResult = typed && !typed.error + + return ( +
+
+

+ Backtest & Simulation +

+

+ Testez vos stratégies options sur des données historiques réelles +

+
+ +
+ {/* Config panel */} +
+
+
Configuration
+
+
+ +
+ {SYMBOLS.slice(0, 7).map(s => ( + + ))} +
+ set('symbol', e.target.value.toUpperCase())} + className="w-full bg-dark-700 border border-slate-700 rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:border-blue-500" + /> +
+ +
+ + {STRATEGIES.map(s => ( + + ))} +
+ +
+ + set('start_date', e.target.value)} + className="w-full bg-dark-700 border border-slate-700 rounded px-2 py-1.5 text-sm text-white mb-1 focus:outline-none focus:border-blue-500" + /> + set('end_date', e.target.value)} + className="w-full bg-dark-700 border border-slate-700 rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:border-blue-500" + /> +
+ +
+ + set('strike_offset_pct', Number(e.target.value))} + className="w-full accent-blue-500" + /> +
+ +
+ +
+ {[30, 60, 90, 180].map(d => ( + + ))} +
+
+ +
+ + set('capital', Number(e.target.value))} + className="w-full bg-dark-700 border border-slate-700 rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:border-blue-500" + /> +
+ + +
+
+
+ + {/* Results panel */} +
+ {typed?.error && ( +
+
+ {typed.error} +
+
+ )} + + {hasResult && ( + <> + {/* KPIs */} +
+ = 0 ? '+' : ''}${typed.total_return_pct.toFixed(2)}%`} + positive={typed.total_return_pct >= 0} + /> + = 50} + /> + + = 1} + /> +
+ + {/* Equity curve */} +
+
Courbe d'équité — {form.symbol} {STRATEGIES.find(s => s.key === form.strategy)?.label}
+
+ Capital final: {typed.final_capital.toFixed(2)}€ + {' '}(initial: {form.capital}€ · P&L: {typed.total_pnl >= 0 ? '+' : ''}{typed.total_pnl.toFixed(2)}€) +
+ + + + + = 0 ? '#10b981' : '#ef4444'} stopOpacity={0.3} /> + = 0 ? '#10b981' : '#ef4444'} stopOpacity={0} /> + + + + + `${v.toFixed(0)}€`} /> + [`${v.toFixed(2)}€`, 'Capital']} + /> + + = 0 ? '#10b981' : '#ef4444'} + fill="url(#equity-grad)" strokeWidth={2} dot={false} + /> + + +
+ + {/* Last trades */} +
+
Derniers trades exécutés
+
+ + + + + + + + + + + + + + + {typed.trades.map((t, i) => { + const pnl = t.pnl as number + return ( + + + + + + + + + + + ) + })} + +
EntréeSortiePrix entréeStrikePrimePrix sortieP&LCapital
{t.entry_date as string}{t.exit_date as string}${(t.S_entry as number).toFixed(2)}${(t.K as number).toFixed(2)}${(t.premium as number).toFixed(4)}${(t.S_expiry as number).toFixed(2)}= 0 ? 'positive' : 'negative')}> + {pnl >= 0 ? '+' : ''}{pnl.toFixed(2)}€ + {(t.capital as number).toFixed(2)}€
+
+
+ + )} + + {!hasResult && !isPending && !typed?.error && ( +
+
+ +
Configurer et lancer un backtest
+
Données yfinance — historique complet disponible
+
+
+ )} +
+
+
+ ) +} diff --git a/frontend/src/pages/CalendarPage.tsx b/frontend/src/pages/CalendarPage.tsx new file mode 100644 index 0000000..89ffcf0 --- /dev/null +++ b/frontend/src/pages/CalendarPage.tsx @@ -0,0 +1,199 @@ +import { useCalendar, useGeoNews } from '../hooks/useApi' +import clsx from 'clsx' +import { Calendar, Clock, Globe, AlertTriangle } from 'lucide-react' +import type { EconomicEvent, AssetClass } from '../types' +import { format, parseISO, isAfter, isBefore, addDays } from 'date-fns' +import { fr } from 'date-fns/locale' + +const ASSET_EMOJIS: Record = { + energy: '⛽', metals: '🥇', agriculture: '🌾', equities: '📈', + indices: '📊', forex: '💱', rates: '🏦', +} + +const COUNTRY_FLAGS: Record = { + US: '🇺🇸', EU: '🇪🇺', CN: '🇨🇳', JP: '🇯🇵', GB: '🇬🇧', + DE: '🇩🇪', FR: '🇫🇷', Global: '🌍', +} + +const IMPORTANCE_CONFIG: Record = { + high: { color: 'text-red-400 border-red-700/40', label: 'Majeur', dots: 3 }, + medium: { color: 'text-yellow-400 border-yellow-700/40', label: 'Modéré', dots: 2 }, + low: { color: 'text-slate-400 border-slate-700/40', label: 'Mineur', dots: 1 }, +} + +function EventCard({ ev }: { ev: EconomicEvent }) { + const cfg = IMPORTANCE_CONFIG[ev.importance] + const isPast = ev.actual !== undefined && ev.actual !== null + const today = new Date() + const evDate = parseISO(ev.date) + const isToday = format(evDate, 'yyyy-MM-dd') === format(today, 'yyyy-MM-dd') + const isSoon = !isToday && isAfter(evDate, today) && isBefore(evDate, addDays(today, 3)) + + return ( +
+
+
+
+ {COUNTRY_FLAGS[ev.country] ?? '🌍'} + + {'●'.repeat(cfg.dots)} + + {isToday && AUJOURD'HUI} + {isSoon && !isToday && BIENTÔT} +
+
{ev.title}
+
+ {format(evDate, "EEEE d MMM yyyy", { locale: fr })} · {ev.country} +
+
+
+
{cfg.label}
+ {ev.previous &&
Préc: {ev.previous}
} + {ev.forecast &&
Prév: {ev.forecast}
} + {ev.actual &&
Réel: {ev.actual}
} +
+
+ {ev.asset_impact && ev.asset_impact.length > 0 && ( +
+ {ev.asset_impact.map(cls => ( + + {ASSET_EMOJIS[cls] ?? ''} {cls} + + ))} +
+ )} +
+ ) +} + +export default function CalendarPage() { + const { data: calendar, isLoading } = useCalendar() + const { data: news } = useGeoNews() + + const today = new Date() + const upcoming = calendar?.filter(ev => isAfter(parseISO(ev.date), today)) ?? [] + const past = calendar?.filter(ev => isBefore(parseISO(ev.date), today)) ?? [] + + const highImpactNews = news?.filter(n => n.impact_score > 0.4).slice(0, 5) ?? [] + + return ( +
+
+

+ Calendrier Économique & Géopolitique +

+

+ Événements macro, catalyseurs géopolitiques, dates clés +

+
+ + {/* Legend */} +
+
●●● Majeur (forte volatilité attendue)
+
●● Modéré
+
Mineur
+
+ +
+ {/* Economic calendar */} +
+
+ Événements à venir ({upcoming.length}) +
+ {isLoading ? ( + [1,2,3].map(i =>
) + ) : upcoming.length > 0 ? ( + upcoming.map((ev, i) => ) + ) : ( +
+ Démarrer le backend pour charger le calendrier +
+ )} + + {past.length > 0 && ( + <> +
+ Événements passés ({past.length}) +
+ {past.map((ev, i) => )} + + )} +
+ + {/* Right: geo alerts + timeline */} +
+
+
+ Alertes géopolitiques +
+ {highImpactNews.length > 0 ? ( +
+ {highImpactNews.map((n, i) => ( +
+
+
{n.title}
+ + {Math.round(n.impact_score * 100)} + +
+
{n.source}
+
+ ))} +
+ ) : ( +
Charger les actualités géopolitiques
+ )} +
+ + {/* Trade opportunity windows */} +
+
Fenêtres d'opportunité
+
+ {[ + { window: 'Pré-FOMC (-3j)', strategy: 'Straddle sur SPY', rationale: 'IV monte avant décision' }, + { window: 'Pré-NFP (-2j)', strategy: 'Straddle sur indices', rationale: 'Directional uncertainty' }, + { window: 'Post-OPEC', strategy: 'Bull Call Spread USO', rationale: 'Cut → oil spike probable' }, + { window: 'Pré-USDA Crop', strategy: 'Long Call WEAT', rationale: 'Supply news catalyst' }, + { window: 'Élections US approche', strategy: 'Long VIX Call', rationale: 'Vol expansion garantie' }, + ].map((op, i) => ( +
+
{op.window}
+
{op.strategy}
+
{op.rationale}
+
+ ))} +
+
+ + {/* Geo-event impact guide */} +
+
+ Guide d'impact +
+
+ {[ + { event: 'Conflit Moyen-Orient', impact: 'Oil +10-20%', cls: 'energy' }, + { event: 'Sanctions Russie', impact: 'Gaz +15-40%', cls: 'energy' }, + { event: 'Tarifs US-Chine', impact: 'Soja -8%', cls: 'agriculture' }, + { event: 'Crise sanitaire', impact: 'Or +7-12%', cls: 'metals' }, + { event: 'Hausses Fed', impact: 'USD +2-4%', cls: 'forex' }, + { event: 'Guerre Ukraine', impact: 'Blé +15-50%', cls: 'agriculture' }, + ].map((g, i) => ( +
+ {g.event} + + {g.impact} + +
+ ))} +
+
+
+
+
+ ) +} diff --git a/frontend/src/pages/Config.tsx b/frontend/src/pages/Config.tsx new file mode 100644 index 0000000..64dc0cb --- /dev/null +++ b/frontend/src/pages/Config.tsx @@ -0,0 +1,733 @@ +import { useState, useEffect } from 'react' +import { useSources, useUpdateSources, useUpdateApiKeys, useConfig, useAiStatus, useAnalysisConfig, useSaveAnalysisConfig, useCycleStatus, useUpdateCycleConfig, useTriggerCycle, useRiskProfiles, useUpsertProfile, useDeleteProfile } from '../hooks/useApi' +import { Settings, Key, Globe, CheckCircle, XCircle, AlertCircle, Save, Eye, EyeOff, Brain, SlidersHorizontal, RefreshCw, Zap, Plus, Trash2, Pencil, X } from 'lucide-react' +import clsx from 'clsx' + +const SOURCE_CATEGORIES = { + 'Flux RSS actifs': ['reuters_world', 'reuters_business', 'reuters_energy', 'ap_top', 'aljazeera', 'ft', 'bloomberg'], + 'Données économiques': ['newsapi', 'gdelt', 'eia', 'fred', 'usda'], + 'Santé & Catastrophes': ['who', 'emdat'], + 'Réseaux sociaux': ['twitter_trump'], +} + +const SOURCE_DOCS: Record = { + reuters_world: { description: 'Actualités mondiales Reuters', cost: 'Gratuit' }, + reuters_business: { description: 'Business et marchés Reuters', cost: 'Gratuit' }, + reuters_energy: { description: 'Énergie et commodités Reuters', cost: 'Gratuit' }, + ap_top: { description: 'Associated Press — Top Stories', cost: 'Gratuit' }, + aljazeera: { description: 'Al Jazeera — couverture Moyen-Orient/Monde', cost: 'Gratuit' }, + ft: { description: 'Financial Times — finance internationale', cost: 'Gratuit' }, + bloomberg: { description: 'Bloomberg Markets RSS', cost: 'Gratuit' }, + newsapi: { description: '100 req/jour gratuit — actualités mondiales multi-sources', link: 'https://newsapi.org', cost: '100 req/j gratuit' }, + gdelt: { description: 'Base de données géopolitique mondiale — 300K events/jour', link: 'https://gdeltproject.org', cost: 'Gratuit total' }, + eia: { description: 'US Energy Information Administration — données pétrole/gaz hebdo', link: 'https://www.eia.gov/opendata', cost: 'Gratuit avec clé' }, + fred: { description: 'Federal Reserve St. Louis — macro US (PIB, inflation, emploi)', link: 'https://fred.stlouisfed.org/docs/api/fred/', cost: 'Gratuit avec clé' }, + usda: { description: 'USDA — rapports agricoles officiels US', cost: 'Gratuit' }, + who: { description: 'OMS — alertes sanitaires mondiales RSS', cost: 'Gratuit' }, + emdat: { description: 'EM-DAT — base de données catastrophes naturelles', link: 'https://www.emdat.be', cost: 'Inscription gratuite' }, + twitter_trump: { description: 'Flux X/Twitter Trump — discours, annonces tarifs', cost: 'API payante ($100/mois+)' }, +} + +// ── Risk Profiles Component ─────────────────────────────────────────────────── + +const PROFILE_COLORS = [ + { value: '#22c55e', label: 'Vert' }, + { value: '#3b82f6', label: 'Bleu' }, + { value: '#f97316', label: 'Orange' }, + { value: '#ef4444', label: 'Rouge' }, + { value: '#8b5cf6', label: 'Violet' }, + { value: '#eab308', label: 'Jaune' }, +] + +function evNetLabel(evNet: number): { text: string; cls: string } { + if (evNet > 0.05) return { text: `EV nette +${(evNet * 100).toFixed(0)}%`, cls: 'text-emerald-400' } + if (evNet >= -0.01) return { text: 'EV nette ≈ 0', cls: 'text-yellow-400' } + return { text: `EV nette ${(evNet * 100).toFixed(0)}%`, cls: 'text-slate-600' } +} + +function ProfileRow({ + profile, + onSave, + onDelete, +}: { + profile: any + onSave: (p: any) => void + onDelete: (id: number) => void +}) { + const [editing, setEditing] = useState(false) + const [name, setName] = useState(profile.name) + const [score, setScore] = useState(profile.min_score) + const [gain, setGain] = useState(profile.min_gain_pct) + const [color, setColor] = useState(profile.color ?? '#3b82f6') + const [enabled, setEnabled] = useState(profile.enabled !== 0) + + // Live EV computation + const p = score / 100 + const G = gain / 100 + const denom = p * G + (1 - p) + const ev_net = p * G - (1 - p) + const trade_score = denom > 0 ? (p * G / denom * 100) : 0 + const { text: evText, cls: evCls } = evNetLabel(ev_net) + + const save = () => { + onSave({ id: profile.id, name, min_score: score, min_gain_pct: gain, color, enabled, sort_order: profile.sort_order ?? 0 }) + setEditing(false) + } + + if (!editing) { + const { text: fev, cls: fevc } = evNetLabel(profile.ev_net_at_frontier ?? 0) + return ( +
+
+
+
+ {profile.name} + {!enabled && désactivé} + Score ≥ {profile.min_score} + Gain ≥ {profile.min_gain_pct}% + {fev} + Trade Score ≥ {(profile.trade_score_at_frontier ?? 0).toFixed(0)} +
+
+
+ + +
+
+ ) + } + + return ( +
+
+
+ + setName(e.target.value)} + className="w-full bg-dark-800 border border-slate-700 rounded px-2 py-1 text-sm text-white" /> +
+
+ + setScore(parseInt(e.target.value))} + className="w-full accent-blue-500" /> +
+
+ + setGain(parseFloat(e.target.value))} + className="w-full accent-blue-500" /> +
+
+ + {/* Live EV preview */} +
+ À la frontière (score={score}, gain={gain}%) + {evText} + Trade Score = {trade_score.toFixed(1)}/100 + + Score min EV=0 : {Math.ceil(100 / (G + 1))}/100 + +
+ +
+
+ {PROFILE_COLORS.map(c => ( +
+ +
+ + +
+
+
+ ) +} + +function RiskProfilesCard() { + const { data: profileData, isLoading } = useRiskProfiles() + const { mutate: upsert } = useUpsertProfile() + const { mutate: del } = useDeleteProfile() + const [showNew, setShowNew] = useState(false) + const [newName, setNewName] = useState('') + const [newScore, setNewScore] = useState(30) + const [newGain, setNewGain] = useState(200) + const [newColor, setNewColor] = useState('#3b82f6') + + const profiles: any[] = (profileData as any)?.profiles ?? [] + + // Live preview for new profile + const np = newScore / 100 + const nG = newGain / 100 + const nDenom = np * nG + (1 - np) + const nEvNet = np * nG - (1 - np) + const nTradeScore = nDenom > 0 ? (np * nG / nDenom * 100) : 0 + const nMinScore = Math.ceil(100 / (nG + 1)) + + const saveNew = () => { + if (!newName.trim()) return + upsert({ name: newName, min_score: newScore, min_gain_pct: newGain, color: newColor, enabled: true, sort_order: profiles.length }) + setShowNew(false) + setNewName('') + setNewScore(30) + setNewGain(200) + } + + return ( +
+
+
+
Profils de risque
+
+ Un trade est loggé dans le journal s'il passe au moins un profil activé + · Formule : EV nette = (score/100 × gain/100) − (1 − score/100) +
+
+ +
+ + {isLoading ? ( +
+ ) : profiles.length === 0 ? ( +
Aucun profil — tous les trades seront ignorés
+ ) : ( +
+ {profiles.map((p: any) => ( + upsert(data)} + onDelete={id => del(id)} /> + ))} +
+ )} + + {/* New profile form */} + {showNew && ( +
+
+ Nouveau profil + +
+
+
+ + setNewName(e.target.value)} placeholder="ex: Ultra-risqué" + className="w-full bg-dark-800 border border-slate-700 rounded px-2 py-1 text-sm text-white placeholder:text-slate-700" /> +
+
+ + setNewScore(parseInt(e.target.value))} className="w-full accent-blue-500" /> +
+
+ + setNewGain(parseFloat(e.target.value))} className="w-full accent-blue-500" /> +
+
+ + {/* Live math */} +
+ {evNetLabel(nEvNet).text} + Trade Score = {nTradeScore.toFixed(1)}/100 + Score min pour EV=0 avec gain {newGain}% : {nMinScore}/100 +
+ +
+
+ {PROFILE_COLORS.map(c => ( +
+
+ + +
+
+
+ )} + + {/* Frontier visualization */} + {profiles.length > 0 && ( +
+
Frontière d'acceptation — chaque point représente le minimum requis par profil
+
+ {profiles.filter((p: any) => p.enabled).map((p: any) => { + const { text: ev, cls } = evNetLabel(p.ev_net_at_frontier ?? 0) + return ( +
+
+ {p.name} + {p.min_score}pts + × + {p.min_gain_pct}% + → {ev} +
+ ) + })} +
+
+ )} +
+ ) +} + +export default function Config() { + const { data: sources, isLoading } = useSources() + const { data: config } = useConfig() + const { data: aiStatus } = useAiStatus() + const { data: analysisCfg } = useAnalysisConfig() + const { mutate: updateSources, isPending: savingSources } = useUpdateSources() + const { mutate: updateApiKeys, isPending: savingKeys } = useUpdateApiKeys() + const { mutate: saveAnalysis, isPending: savingAnalysis } = useSaveAnalysisConfig() + const { data: cycleStatus, refetch: refetchCycle } = useCycleStatus() + const { mutate: updateCycleConfig, isPending: savingCycle } = useUpdateCycleConfig() + const { mutate: triggerCycle, isPending: triggeringCycle } = useTriggerCycle() + + const [localSources, setLocalSources] = useState | null>(null) + const [openaiKey, setOpenaiKey] = useState('') + const [newsapiKey, setNewsapiKey] = useState('') + const [eiaKey, setEiaKey] = useState('') + const [fredKey, setFredKey] = useState('') + const [showKeys, setShowKeys] = useState(false) + const [savedMsg, setSavedMsg] = useState('') + + // Analysis config local state + const [analysisTopN, setAnalysisTopN] = useState(10) + const [analysisCategoryDefault, setAnalysisCategoryDefault] = useState('all') + const [analysisTemplate, setAnalysisTemplate] = useState('') + + // Auto-cycle local state + const cs = cycleStatus as any + const [cycleEnabled, setCycleEnabled] = useState(false) + const [cycleHours, setCycleHours] = useState(3) + const [cycleSimilarity, setCycleSimilarity] = useState(0.30) + const [minEv, setMinEv] = useState(0.0) + const [minScore, setMinScore] = useState(0) + useEffect(() => { + if (cs) { + setCycleEnabled(cs.enabled ?? false) + setCycleHours(cs.interval_hours ?? 3) + setCycleSimilarity(cs.similarity_threshold ?? 0.30) + setMinEv(cs.min_ev_threshold ?? 0.0) + setMinScore(cs.min_score_threshold ?? 0) + } + }, [cs]) + + useEffect(() => { + if (analysisCfg) { + setAnalysisTopN(analysisCfg.top_n ?? 10) + setAnalysisCategoryDefault(analysisCfg.category_filter ?? 'all') + setAnalysisTemplate(analysisCfg.template ?? '') + } + }, [analysisCfg]) + + const displaySources = localSources ?? sources ?? {} + + const toggleSource = (key: string) => { + setLocalSources(prev => { + const base = prev ?? sources ?? {} + return { ...base, [key]: { ...base[key], enabled: !base[key]?.enabled } } + }) + } + + const saveSources = () => { + updateSources(displaySources, { + onSuccess: () => { setSavedMsg('Sources sauvegardées'); setTimeout(() => setSavedMsg(''), 2000) } + }) + } + + const saveKeys = () => { + const keys: Record = {} + if (openaiKey) keys.openai_api_key = openaiKey + if (newsapiKey) keys.newsapi_key = newsapiKey + if (eiaKey) keys.eia_api_key = eiaKey + if (fredKey) keys.fred_api_key = fredKey + updateApiKeys(keys, { + onSuccess: () => { + setSavedMsg('Clés API sauvegardées') + setTimeout(() => setSavedMsg(''), 2000) + setOpenaiKey(''); setNewsapiKey(''); setEiaKey(''); setFredKey('') + } + }) + } + + return ( +
+
+

+ Configuration +

+

Clés API, sources d'information, paramètres IA

+
+ + {savedMsg && ( +
+ {savedMsg} +
+ )} + +
+ {/* Left col: API Keys + AI status */} +
+ {/* AI Status */} +
+
Statut IA
+
+ {aiStatus?.enabled ? ( + <> +
OpenAI Connecté
+
GPT-4o + GPT-4o-mini
+ ) : ( + <> +
OpenAI Non configuré
+
Entrer la clé ci-dessous
+ )} +
+
+
• Analyse de discours (Trump, Powell...)
+
• Classification IA des actualités
+
• Évaluation de patterns
+
• Top 10 idées contextualisées
+
+
+ + {/* API Keys */} +
+
+
Clés API
+ +
+
+ {[ + { label: 'OpenAI API Key', value: openaiKey, setter: setOpenaiKey, placeholder: 'sk-proj-...', status: aiStatus?.enabled }, + { label: 'NewsAPI Key', value: newsapiKey, setter: setNewsapiKey, placeholder: 'Obtenir sur newsapi.org', status: false }, + { label: 'EIA API Key', value: eiaKey, setter: setEiaKey, placeholder: 'Obtenir sur eia.gov', status: false }, + { label: 'FRED API Key', value: fredKey, setter: setFredKey, placeholder: 'Obtenir sur fred.stlouisfed.org', status: false }, + ].map(({ label, value, setter, placeholder, status }) => ( +
+
+ + {status !== undefined && ( + + {status ? '✓ Actif' : '○ Inactif'} + + )} +
+ setter(e.target.value)} + placeholder={placeholder} + className="w-full bg-dark-700 border border-slate-700 rounded px-2 py-1.5 text-xs text-white focus:outline-none focus:border-blue-500 font-mono" + /> +
+ ))} + +
+
+
+ + {/* Right col: Sources */} +
+ {isLoading ? ( +
+ ) : ( + <> + {Object.entries(SOURCE_CATEGORIES).map(([catName, keys]) => ( +
+
+ {catName} +
+
+ {keys.map(key => { + const src = displaySources[key] ?? {} + const enabled = src.enabled ?? false + const requiresKey = src.requires_key + const doc = SOURCE_DOCS[key] + const keySet = requiresKey ? !!config?.[requiresKey + '_set'] : true + + return ( +
+
+
+ {src.name || key} + + {doc?.cost} + + {requiresKey && !keySet && ( + Clé requise + )} +
+
{doc?.description}
+
+ +
+ ) + })} +
+
+ ))} + +
+ +
+ + )} +
+
+ + {/* ── Paramètres d'analyse IA ── */} +
+

+ Paramètres d'analyse IA +

+ +
+
+ +
+ {[5, 10, 15, 20].map(n => ( + + ))} +
+
+
+ + +
+
+ +
+
+ + +
+