diff --git a/backend/routers/cycle.py b/backend/routers/cycle.py index 2aefce2..b0e17e7 100644 --- a/backend/routers/cycle.py +++ b/backend/routers/cycle.py @@ -1,7 +1,7 @@ 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.database import get_cycle_runs, get_cycle_run, set_config, get_config, list_cycle_context_snapshots, get_cycle_context_snapshot from services.auto_cycle import get_status, trigger_manual, restart_scheduler router = APIRouter(prefix="/api/cycle", tags=["cycle"]) @@ -82,3 +82,19 @@ def update_cycle_config(req: CycleConfigRequest): restart_scheduler() return get_status() + + + +@router.get("/contexts") +def list_context_snapshots(limit: int = 30): + """List cycle context snapshots (most recent first).""" + return {"snapshots": list_cycle_context_snapshots(limit=limit)} + + +@router.get("/contexts/{run_id}") +def get_context_snapshot(run_id: str): + """Return the full context snapshot for a given cycle run_id.""" + snap = get_cycle_context_snapshot(run_id) + if not snap: + raise HTTPException(404, "Snapshot non trouvé pour ce cycle") + return snap diff --git a/backend/services/ai_analyzer.py b/backend/services/ai_analyzer.py index 768d3a0..e2909c3 100644 --- a/backend/services/ai_analyzer.py +++ b/backend/services/ai_analyzer.py @@ -354,6 +354,7 @@ def score_patterns_with_context( risk_context: str = "", cycle_meta: Optional[Dict] = None, tech_indicators_block: str = "", + fred_block: str = "", ) -> List[Dict[str, Any]]: """Score all patterns with rich context (news, prices, IV, risk clusters) using GPT-4o.""" if not get_client(): @@ -573,12 +574,14 @@ Instructions de notation: ) _tech_sc_section = f"\n{tech_indicators_block}\n" if tech_indicators_block else "" + _fred_sc_section = f"\n{fred_block}\n" if fred_block else "" 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', [])} {temporal_section_sc} {macro_section} +{_fred_sc_section} {_tech_sc_section} TEMPLATE DE NOTATION: {scoring_template} @@ -969,6 +972,7 @@ def suggest_patterns_from_market_context( iv_context: str = "", cycle_meta: Optional[Dict] = None, tech_indicators_block: str = "", + fred_block: str = "", ) -> List[Dict]: """Ask GPT-4o to propose new patterns based on current geo/market + macro regime context.""" _cycle_meta = cycle_meta or {} @@ -1115,12 +1119,14 @@ Règles supplémentaires: ) tech_block_section = f"\n{tech_indicators_block}\n" if tech_indicators_block else "" + fred_section = f"\n{fred_block}\n" if fred_block else "" user = f"""Tu es un stratège géopolitique et financier senior, expert en options. {macro_block}{geo_block}{lessons_block}{reliability_block}{iv_block} {temporal_news_block} ## Prix des marchés (variation J-1) {market_block} +{fred_section} {tech_block_section} ## Calendrier économique à venir {cal_block} diff --git a/backend/services/auto_cycle.py b/backend/services/auto_cycle.py index 1766eaf..21b6f5d 100644 --- a/backend/services/auto_cycle.py +++ b/backend/services/auto_cycle.py @@ -174,7 +174,7 @@ def run_cycle_once(trigger: str = "auto") -> Dict[str, Any]: 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, log_system_event, - get_last_completed_cycle_ts, + get_last_completed_cycle_ts, save_cycle_context_snapshot, ) 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 @@ -370,6 +370,18 @@ def run_cycle_once(trigger: str = "auto") -> Dict[str, Any]: logger.info(f"[Cycle {run_id[:16]}] Reliability map: {len(_reliability_map)} patterns") except Exception as _re: logger.warning(f"[Cycle] Reliability map failed (non-blocking): {_re}") + # ── Phase 2: FRED recent macro releases ────────────────────────────── + _fred_releases = [] + _fred_block = "" + try: + from services.fred_fetcher import get_fred_recent_releases, build_fred_context_block + _fred_releases = get_fred_recent_releases() + _fred_block = build_fred_context_block(_fred_releases) + if _fred_releases: + logger.info(f"[Cycle {run_id[:16]}] FRED: {len(_fred_releases)} releases fetched") + except Exception as _fe: + logger.warning(f"[Cycle] FRED fetch failed (non-blocking): {_fe}") + try: from services.data_fetcher import get_economic_calendar calendar = get_economic_calendar() @@ -406,6 +418,34 @@ def run_cycle_once(trigger: str = "auto") -> Dict[str, Any]: except Exception as _te: logger.warning(f"[Cycle] Tech indicators failed (non-blocking): {_te}") + # ── Save full context snapshot ───────────────────────────────── + try: + from services.ai_analyzer import apply_news_decay as _apply_decay2, partition_news_by_age as _part + _news_snap = _part(_apply_decay2(news), cycle_meta.get("delta_minutes", 180)) + _context_snapshot = { + "cycle_meta": cycle_meta, + "macro_regime": { + "dominant": (macro_regime or {}).get("scenarios", {}).get("dominant"), + "scores": (macro_regime or {}).get("scenarios", {}).get("scores"), + "gauges_summary": {k: v.get("value") for k, v in ((macro_regime or {}).get("gauges", {})).items()}, + }, + "geo_score": geo_score_obj, + "news_partitioned": { + "inter_cycle": [{"title": n.get("title"), "source": n.get("source"), "decayed_score": n.get("decayed_score"), "age_hours": n.get("age_hours")} for n in _news_snap["inter_cycle"][:10]], + "recent_24h": [{"title": n.get("title"), "source": n.get("source"), "decayed_score": n.get("decayed_score")} for n in _news_snap["recent_24h"][:10]], + "older": [{"title": n.get("title"), "source": n.get("source")} for n in _news_snap["older"][:5]], + }, + "fred_releases": _fred_releases, + "tech_indicators_block": _tech_block, + "iv_context_preview": iv_context[:500] if iv_context else "", + "calendar": calendar[:8] if calendar else [], + "quotes_summary": {cls: [{"symbol": q.get("symbol"), "price": q.get("price"), "change_pct": q.get("change_pct")} for q in qs[:3]] for cls, qs in quotes.items()}, + } + save_cycle_context_snapshot(run_id, _context_snapshot) + logger.info(f"[Cycle {run_id[:16]}] Context snapshot saved") + except Exception as _snap_e: + logger.warning(f"[Cycle] Context snapshot save failed (non-blocking): {_snap_e}") + suggestions = suggest_patterns_from_market_context( news, quotes, calendar, macro_regime=macro_regime, geo_score=geo_score_obj, portfolio_lessons=portfolio_lessons, @@ -413,6 +453,7 @@ def run_cycle_once(trigger: str = "auto") -> Dict[str, Any]: iv_context=iv_context, cycle_meta=cycle_meta, tech_indicators_block=_tech_block, + fred_block=_fred_block, ) except Exception as e: logger.warning(f"[Cycle] Suggestion step failed: {e}") @@ -537,6 +578,7 @@ def run_cycle_once(trigger: str = "auto") -> Dict[str, Any]: risk_context=risk_cluster_context, cycle_meta=cycle_meta, tech_indicators_block=_tech_block, + fred_block=_fred_block, ) 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")] diff --git a/backend/services/database.py b/backend/services/database.py index bffcdea..92326b0 100644 --- a/backend/services/database.py +++ b/backend/services/database.py @@ -379,6 +379,12 @@ def init_db(): details TEXT )""") + c.execute("""CREATE TABLE IF NOT EXISTS cycle_context_snapshots ( + run_id TEXT PRIMARY KEY, + ts TEXT NOT NULL DEFAULT (datetime('now')), + context_json TEXT NOT NULL + )""") + c.execute("""CREATE TABLE IF NOT EXISTS skipped_trades ( id INTEGER PRIMARY KEY AUTOINCREMENT, run_id TEXT, @@ -2090,6 +2096,40 @@ def clear_system_logs(older_than_days: int = 30) -> int: return deleted +# ── Cycle Context Snapshots ─────────────────────────────────────────────────── + +def save_cycle_context_snapshot(run_id: str, context: dict) -> None: + import json as _json + conn = get_conn() + conn.execute( + "INSERT OR REPLACE INTO cycle_context_snapshots (run_id, context_json) VALUES (?, ?)", + (run_id, _json.dumps(context, ensure_ascii=False, default=str)), + ) + conn.commit() + conn.close() + + +def get_cycle_context_snapshot(run_id: str) -> Optional[dict]: + import json as _json + conn = get_conn() + row = conn.execute( + "SELECT run_id, ts, context_json FROM cycle_context_snapshots WHERE run_id = ?", (run_id,) + ).fetchone() + conn.close() + if not row: + return None + return {"run_id": row["run_id"], "ts": row["ts"], "context": _json.loads(row["context_json"])} + + +def list_cycle_context_snapshots(limit: int = 30) -> list: + conn = get_conn() + rows = conn.execute( + "SELECT run_id, ts FROM cycle_context_snapshots ORDER BY ts DESC LIMIT ?", (limit,) + ).fetchall() + conn.close() + return [{"run_id": r["run_id"], "ts": r["ts"]} for r in rows] + + # ── Knowledge Base Decay ────────────────────────────────────────────────────── def decay_kb_confidence() -> int: diff --git a/backend/services/fred_fetcher.py b/backend/services/fred_fetcher.py new file mode 100644 index 0000000..6936e8f --- /dev/null +++ b/backend/services/fred_fetcher.py @@ -0,0 +1,176 @@ +""" +FRED API fetcher — récupère les dernières releases macro US. + +Séries suivies : + CPIAUCSL → CPI mensuel (YoY calculé) + PAYEMS → Non-Farm Payrolls (variation mensuelle k emplois) + UNRATE → Taux de chômage + FEDFUNDS → Fed Funds Rate + GDP → PIB US trimestriel (croissance %) + ICSA → Initial Jobless Claims (hebdo) + T10Y2Y → Spread 10Y-2Y (courbe des taux) + DEXUSEU → EUR/USD (proxy macro) +""" +from __future__ import annotations + +import logging +from datetime import datetime, timedelta +from typing import Any, Dict, List, Optional + +logger = logging.getLogger("fred_fetcher") + +# Series config : (id, label, unit, asset_impact, direction_interpretation) +FRED_SERIES = [ + ("CPIAUCSL", "US CPI (inflation)", "index", ["metals", "rates", "forex"], "higher_bearish"), + ("PAYEMS", "US Non-Farm Payrolls", "k jobs", ["indices", "forex", "rates"], "higher_bullish"), + ("UNRATE", "US Unemployment Rate", "%", ["indices", "forex"], "higher_bearish"), + ("FEDFUNDS", "Fed Funds Rate", "%", ["bonds", "forex", "indices"], "higher_bearish_growth"), + ("GDP", "US GDP Growth", "bn$", ["indices", "forex"], "higher_bullish"), + ("ICSA", "US Initial Jobless Claims","k claims",["indices", "forex"], "higher_bearish"), + ("T10Y2Y", "10Y-2Y Spread (courbe)", "%", ["bonds", "indices"], "positive_bullish"), +] + + +def _get_fred_key() -> Optional[str]: + try: + from services.database import get_config + return get_config("fred_api_key") or None + except Exception: + return None + + +def _fetch_series_observations(series_id: str, api_key: str, count: int = 3) -> List[Dict]: + """Fetch last N observations from FRED for a given series.""" + import urllib.request + import urllib.parse + import json + + params = urllib.parse.urlencode({ + "series_id": series_id, + "api_key": api_key, + "file_type": "json", + "sort_order": "desc", + "limit": count, + "observation_start": (datetime.utcnow() - timedelta(days=730)).strftime("%Y-%m-%d"), + }) + url = f"https://api.stlouisfed.org/fred/series/observations?{params}" + try: + with urllib.request.urlopen(url, timeout=8) as resp: + data = json.loads(resp.read()) + obs = data.get("observations", []) + return [{"date": o["date"], "value": o["value"]} for o in obs if o.get("value") not in (".", None)] + except Exception as e: + logger.warning(f"[FRED] {series_id} fetch failed: {e}") + return [] + + +def _parse_value(v: str) -> Optional[float]: + try: + return float(v) + except (ValueError, TypeError): + return None + + +def _surprise_direction(series_id: str, current: float, previous: float, direction_hint: str) -> str: + """Returns 'bullish', 'bearish', or 'neutral' based on change direction.""" + delta = current - previous + if abs(delta) < 0.01: + return "neutral" + if direction_hint == "higher_bullish": + return "bullish" if delta > 0 else "bearish" + elif direction_hint in ("higher_bearish", "higher_bearish_growth"): + return "bearish" if delta > 0 else "bullish" + elif direction_hint == "positive_bullish": + return "bullish" if current > 0 else "bearish" + return "neutral" + + +def _yoy_change(obs: List[Dict]) -> Optional[float]: + """For monthly series, compute rough YoY % if we have ≥12 month history.""" + if len(obs) < 2: + return None + v_latest = _parse_value(obs[0]["value"]) + v_prev = _parse_value(obs[-1]["value"]) + if v_latest is None or v_prev is None or v_prev == 0: + return None + return round((v_latest - v_prev) / abs(v_prev) * 100, 2) + + +def get_fred_recent_releases() -> List[Dict[str, Any]]: + """ + Fetch latest FRED data for key macro series. + + Returns a list of dicts, one per series, with: + series_id, label, unit, latest_date, latest_value, + previous_value, change, change_pct, direction, asset_impact + """ + api_key = _get_fred_key() + if not api_key: + return [] + + results = [] + for series_id, label, unit, asset_impact, direction_hint in FRED_SERIES: + obs = _fetch_series_observations(series_id, api_key, count=13) # 13 for YoY + if not obs: + continue + latest = obs[0] + previous = obs[1] if len(obs) > 1 else None + + v_latest = _parse_value(latest["value"]) + v_prev = _parse_value(previous["value"]) if previous else None + + if v_latest is None: + continue + + change = round(v_latest - v_prev, 4) if v_prev is not None else None + change_pct = round((v_latest - v_prev) / abs(v_prev) * 100, 2) if v_prev and v_prev != 0 else None + direction = _surprise_direction(series_id, v_latest, v_prev, direction_hint) if v_prev is not None else "neutral" + + # For CPI, compute YoY + display_value = v_latest + display_unit = unit + if series_id == "CPIAUCSL" and len(obs) >= 13: + yoy = _yoy_change(obs[:13]) + if yoy is not None: + display_value = yoy + display_unit = "% YoY" + # Re-compute change vs previous month's YoY + if len(obs) >= 14: + yoy_prev = _yoy_change(obs[1:14]) + if yoy_prev is not None: + change = round(yoy - yoy_prev, 3) + direction = "bearish" if change > 0 else "bullish" # higher CPI = bearish markets + + entry: Dict[str, Any] = { + "series_id": series_id, + "label": label, + "unit": display_unit, + "latest_date": latest["date"], + "latest_value": display_value, + "previous_value": v_prev, + "change": change, + "direction": direction, + "asset_impact": asset_impact, + } + results.append(entry) + + return results + + +def build_fred_context_block(releases: List[Dict]) -> str: + """Format FRED releases as a prompt-ready string.""" + if not releases: + return "" + lines = ["## 📈 DONNÉES MACRO RÉCENTES (FRED — dernières releases)"] + for r in releases: + val_str = f"{r['latest_value']:.2f}{r['unit']}" if isinstance(r.get("latest_value"), float) else str(r.get("latest_value", "N/A")) + chg_str = "" + if r.get("change") is not None: + arrow = "▲" if r["change"] > 0 else "▼" + chg_str = f" ({arrow}{abs(r['change']):.3f} vs release précédente)" + dir_emoji = {"bullish": "🟢", "bearish": "🔴", "neutral": "⚪"}.get(r.get("direction", "neutral"), "⚪") + lines.append( + f" {dir_emoji} {r['label']} [{r['latest_date']}] : {val_str}{chg_str} → {r['direction'].upper()}" + ) + lines.append("⚠️ Compare ces chiffres au consensus attendu pour évaluer si le marché a déjà intégré la surprise.") + return "\n".join(lines) diff --git a/frontend/src/hooks/useApi.ts b/frontend/src/hooks/useApi.ts index 4944882..81b0f55 100644 --- a/frontend/src/hooks/useApi.ts +++ b/frontend/src/hooks/useApi.ts @@ -867,6 +867,21 @@ export const useClearLogs = () => mutationFn: (days: number) => api.delete('/logs/clear', { params: { older_than_days: days } }).then(r => r.data), }) +export const useCycleContextSnapshots = (limit = 30) => + useQuery({ + queryKey: ['cycle-context-snapshots'], + queryFn: () => api.get('/cycle/contexts', { params: { limit } }).then(r => r.data), + staleTime: 30_000, + }) + +export const useCycleContextSnapshot = (runId: string | null) => + useQuery({ + queryKey: ['cycle-context-snapshot', runId], + queryFn: () => api.get(`/cycle/contexts/${runId}`).then(r => r.data), + enabled: !!runId, + staleTime: 300_000, + }) + // ── IV Watchlist Management ─────────────────────────────────────────────────── export const useWatchlistTickers = () => diff --git a/frontend/src/pages/SystemLogs.tsx b/frontend/src/pages/SystemLogs.tsx index 51b9588..e084c87 100644 --- a/frontend/src/pages/SystemLogs.tsx +++ b/frontend/src/pages/SystemLogs.tsx @@ -1,9 +1,9 @@ import { useState, useMemo } from 'react' import { format } from 'date-fns' import { fr } from 'date-fns/locale' -import { AlertTriangle, XCircle, Info, RefreshCw, Trash2, ChevronDown, ChevronRight } from 'lucide-react' +import { AlertTriangle, XCircle, Info, RefreshCw, Trash2, ChevronDown, ChevronRight, Brain } from 'lucide-react' import clsx from 'clsx' -import { useSystemLogs, useLogSources, useLogCycles, useClearLogs, type LogFilters } from '../hooks/useApi' +import { useSystemLogs, useLogSources, useLogCycles, useClearLogs, useCycleContextSnapshots, useCycleContextSnapshot, type LogFilters } from '../hooks/useApi' import { useQueryClient } from '@tanstack/react-query' const LEVELS = ['', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'] @@ -76,8 +76,124 @@ function LogRow({ log }: { log: any }) { ) } +function ContextTab() { + const [selectedRunId, setSelectedRunId] = useState(null) + const { data: snapshotsData, isLoading: loadingList } = useCycleContextSnapshots(30) + const { data: snapData, isLoading: loadingSnap } = useCycleContextSnapshot(selectedRunId) + const snapshots: any[] = snapshotsData?.snapshots ?? [] + + const fmtTs = (ts: string) => { + try { return format(new Date(ts), 'dd/MM HH:mm:ss', { locale: fr }) } catch { return ts } + } + + return ( +
+ {/* Left: list of snapshots */} +
+ {loadingList ? ( +
Chargement…
+ ) : snapshots.length === 0 ? ( +
+ Aucun snapshot — les contextes seront sauvegardés lors du prochain cycle. +
+ ) : ( +
+ {snapshots.map(s => ( + + ))} +
+ )} +
+ + {/* Right: context JSON viewer */} +
+ {!selectedRunId ? ( +
+ + Sélectionne un cycle à gauche pour voir le contexte complet envoyé à l'IA. +
+ ) : loadingSnap ? ( +
Chargement du contexte…
+ ) : !snapData ? ( +
Erreur lors du chargement.
+ ) : ( +
+ {/* Header strip with meta */} +
+ {snapData.run_id} + {fmtTs(snapData.ts)} + {snapData.context?.cycle_meta && ( + <> + + Δ {snapData.context.cycle_meta.delta_minutes?.toFixed(0)}min + + {snapData.context.cycle_meta.calibration_label} + + )} +
+ + {/* Sections accordion */} +
+ {snapData.context && Object.entries(snapData.context).map(([key, val]) => ( + + ))} +
+
+ )} +
+
+ ) +} + +function ContextSection({ label, value }: { label: string; value: any }) { + const [open, setOpen] = useState(['cycle_meta', 'news_partitioned', 'fred_releases'].includes(label)) + const json = JSON.stringify(value, null, 2) + const lineCount = json.split('\n').length + + const labelColor: Record = { + cycle_meta: 'text-purple-400', + macro_regime: 'text-blue-400', + geo_score: 'text-orange-400', + news_partitioned: 'text-yellow-400', + fred_releases: 'text-green-400', + tech_indicators_block: 'text-cyan-400', + iv_context_preview: 'text-pink-400', + calendar: 'text-slate-300', + quotes_summary: 'text-slate-300', + } + + return ( +
+ + {open && ( +
+          {json}
+        
+ )} +
+ ) +} + export default function SystemLogs() { const qc = useQueryClient() + const [activeTab, setActiveTab] = useState<'logs' | 'context'>('logs') const [filters, setFilters] = useState({ limit: 300 }) const [tickerInput, setTickerInput] = useState('') const [cycleInput, setCycleInput] = useState('') @@ -114,7 +230,7 @@ export default function SystemLogs() {

Logs Système

-

Erreurs, avertissements et événements des cycles IA

+

Erreurs, avertissements et contextes complets des cycles IA

- + {activeTab === 'logs' && ( + + )}
+ {/* Tabs */} +
+ {([ + { key: 'logs', label: 'Logs système', icon: }, + { key: 'context', label: 'Contexte IA', icon: }, + ] as const).map(tab => ( + + ))} +
+ + {activeTab === 'context' && } + + {activeTab === 'logs' && <> {/* Summary chips */}
{[ @@ -251,6 +393,7 @@ export default function SystemLogs() {
)} + } ) }