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 <noreply@anthropic.com>
298 lines
10 KiB
Python
298 lines
10 KiB
Python
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": "<ton texte 5-7 phrases>"}}"""
|
||
|
||
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", "")}
|