diff --git a/backend/routers/cycle.py b/backend/routers/cycle.py index 2770f4b..cadab42 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, list_cycle_context_snapshots, get_cycle_context_snapshot +from services.database import get_cycle_runs, get_cycle_run, set_config, get_config, list_cycle_context_snapshots, get_cycle_context_snapshot, get_ai_call_logs from services.auto_cycle import get_status, trigger_manual, restart_scheduler router = APIRouter(prefix="/api/cycle", tags=["cycle"]) @@ -100,6 +100,13 @@ def get_context_snapshot(run_id: str): return snap +@router.get("/ai-calls/{run_id}") +def get_cycle_ai_calls(run_id: str): + """Return all AI calls logged for a given cycle run_id.""" + calls = get_ai_call_logs(run_id) + return {"run_id": run_id, "calls": calls, "count": len(calls)} + + class ReplayRequest(BaseModel): override_notes: Optional[str] = None # optional annotation added to the replay diff --git a/backend/services/ai_analyzer.py b/backend/services/ai_analyzer.py index 6093ec4..31c7eef 100644 --- a/backend/services/ai_analyzer.py +++ b/backend/services/ai_analyzer.py @@ -22,7 +22,14 @@ def get_client() -> Optional[OpenAI]: return _client -def _chat(system: str, user: str, model: str = "gpt-4o-mini", json_mode: bool = True, max_tokens: int = 1500) -> Optional[Dict]: +def _chat( + system: str, + user: str, + model: str = "gpt-4o-mini", + json_mode: bool = True, + max_tokens: int = 1500, + log_meta: Optional[Dict] = None, +) -> Optional[Dict]: import time as _time client = get_client() if not client: @@ -35,18 +42,22 @@ def _chat(system: str, user: str, model: str = "gpt-4o-mini", json_mode: bool = } if json_mode: kwargs["response_format"] = {"type": "json_object"} + last_exc = None - for attempt in range(4): # up to 3 retries + result: Optional[Dict] = None + usage = None + t0 = _time.time() + + for attempt in range(4): try: resp = client.chat.completions.create(**kwargs) content = resp.choices[0].message.content - if json_mode: - return json.loads(content) - return {"text": content} + usage = resp.usage + result = json.loads(content) if json_mode else {"text": content} + break # success except Exception as e: last_exc = e err_str = str(e) - # 429 rate-limit: respect the retry-after hint, then back off if "429" in err_str or "rate_limit" in err_str: import re as _re m = _re.search(r"try again in ([\d.]+)s", err_str) @@ -54,7 +65,32 @@ def _chat(system: str, user: str, model: str = "gpt-4o-mini", json_mode: bool = _time.sleep(min(wait, 60.0)) continue raise # non-429 errors propagate immediately - raise last_exc + + if result is None: + raise last_exc + + # Persist AI call log when requested (non-blocking) + if log_meta and log_meta.get("run_id"): + try: + from services.database import save_ai_call_log + duration_ms = int((_time.time() - t0) * 1000) + save_ai_call_log( + run_id=log_meta["run_id"], + call_type=log_meta.get("call_type", "unknown"), + system_prompt=system, + user_prompt=user, + response_json=json.dumps(result, ensure_ascii=False), + model=model, + tokens_prompt=usage.prompt_tokens if usage else 0, + tokens_completion=usage.completion_tokens if usage else 0, + duration_ms=duration_ms, + pattern_id=log_meta.get("pattern_id"), + pattern_name=log_meta.get("pattern_name"), + ) + except Exception: + pass + + return result # ── News / Article Analysis ─────────────────────────────────────────────────── @@ -356,6 +392,8 @@ def score_patterns_with_context( tech_indicators_block: str = "", fred_block: str = "", price_discovery_block: str = "", + portfolio_context_block: str = "", + run_id: str = "", ) -> List[Dict[str, Any]]: """Score all patterns with rich context (news, prices, IV, risk clusters) using GPT-4o.""" if not get_client(): @@ -577,6 +615,7 @@ 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 "" _pd_sc_section = f"\n{price_discovery_block}\n" if price_discovery_block else "" + _portfolio_sc_section = f"\n{portfolio_context_block}\n" if portfolio_context_block else "" user = f"""CONTEXTE GLOBAL: - Score risque géopolitique: {geo_score.get('score', 50)}/100 ({geo_score.get('level', 'medium')}) @@ -586,6 +625,7 @@ Instructions de notation: {_fred_sc_section} {_pd_sc_section} {_tech_sc_section} +{_portfolio_sc_section} TEMPLATE DE NOTATION: {scoring_template} @@ -765,7 +805,10 @@ TEMPLATE DE NOTATION: + _return_schema ) try: - res = _chat(SYSTEM_SCORER, batch_user, model="gpt-4o", json_mode=True, max_tokens=12000) + res = _chat( + SYSTEM_SCORER, batch_user, model="gpt-4o", json_mode=True, max_tokens=12000, + log_meta={"run_id": run_id, "call_type": "score_batch", "pattern_name": f"batch:{','.join(ids[:3])}"} if run_id else None, + ) except Exception as e: _scorer_log.error(f"[Scorer] GPT-4o call failed for batch {ids}: {e}") res = None @@ -977,6 +1020,8 @@ def suggest_patterns_from_market_context( tech_indicators_block: str = "", fred_block: str = "", price_discovery_block: str = "", + portfolio_context_block: str = "", + run_id: str = "", ) -> List[Dict]: """Ask GPT-4o to propose new patterns based on current geo/market + macro regime context.""" _cycle_meta = cycle_meta or {} @@ -1125,6 +1170,7 @@ 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 "" pd_section = f"\n{price_discovery_block}\n" if price_discovery_block else "" + portfolio_section = f"\n{portfolio_context_block}\n" if portfolio_context_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} @@ -1134,6 +1180,7 @@ Règles supplémentaires: {fred_section} {pd_section} {tech_block_section} +{portfolio_section} ## Calendrier économique à venir {cal_block} @@ -1188,7 +1235,10 @@ Retourne UNIQUEMENT ce JSON: ] }}""" - result = _chat(SYSTEM_SCORER, user, model="gpt-4o", json_mode=True, max_tokens=4000) + result = _chat( + SYSTEM_SCORER, user, model="gpt-4o", json_mode=True, max_tokens=4000, + log_meta={"run_id": run_id, "call_type": "suggest"} if run_id else None, + ) if not result: return [] return result.get("patterns", []) diff --git a/backend/services/auto_cycle.py b/backend/services/auto_cycle.py index fa92d08..4600747 100644 --- a/backend/services/auto_cycle.py +++ b/backend/services/auto_cycle.py @@ -183,6 +183,9 @@ def run_cycle_once(trigger: str = "auto") -> Dict[str, Any]: suggest_patterns_from_market_context, score_patterns_with_context, ai_score_news_batch, _chat, DEFAULT_ANALYSIS_TEMPLATE, ) + from services.portfolio_context import ( + get_open_trades_with_moves, get_portfolio_concentration, build_portfolio_context_block, + ) # KB confidence decay (non-blocking) try: @@ -447,6 +450,18 @@ def run_cycle_once(trigger: str = "auto") -> Dict[str, Any]: except Exception as _te: logger.warning(f"[Cycle] Tech indicators failed (non-blocking): {_te}") + # ── Portfolio context (open positions + recent moves) ────────── + _portfolio_block = "" + _open_trades_snap = [] + _portfolio_conc = {} + try: + _open_trades_snap = get_open_trades_with_moves() + _portfolio_conc = get_portfolio_concentration(_open_trades_snap) + _portfolio_block = build_portfolio_context_block(_open_trades_snap, _portfolio_conc) + logger.info(f"[Cycle {run_id[:16]}] Portfolio context: {len(_open_trades_snap)} open trades") + except Exception as _pe: + logger.warning(f"[Cycle] Portfolio context failed (non-blocking): {_pe}") + # ── Save full context snapshot ───────────────────────────────── try: from services.ai_analyzer import apply_news_decay as _apply_decay2, partition_news_by_age as _part @@ -473,6 +488,14 @@ def run_cycle_once(trigger: str = "auto") -> Dict[str, Any]: "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()}, + "portfolio_open_positions": { + "count": len(_open_trades_snap), + "concentration": _portfolio_conc, + "trades": [ + {k: v for k, v in t.items() if k != "id"} + for t in _open_trades_snap + ], + }, } save_cycle_context_snapshot(run_id, _context_snapshot) logger.info(f"[Cycle {run_id[:16]}] Context snapshot saved") @@ -488,6 +511,8 @@ def run_cycle_once(trigger: str = "auto") -> Dict[str, Any]: tech_indicators_block=_tech_block, fred_block=_fred_block, price_discovery_block=_price_discovery_block, + portfolio_context_block=_portfolio_block, + run_id=run_id, ) except Exception as e: logger.warning(f"[Cycle] Suggestion step failed: {e}") @@ -614,6 +639,8 @@ def run_cycle_once(trigger: str = "auto") -> Dict[str, Any]: tech_indicators_block=_tech_block, fred_block=_fred_block, price_discovery_block=_price_discovery_block, + portfolio_context_block=_portfolio_block, + run_id=run_id, ) 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 5ab3a0b..c5f869c 100644 --- a/backend/services/database.py +++ b/backend/services/database.py @@ -407,6 +407,26 @@ def init_db(): context_json TEXT NOT NULL )""") + c.execute("""CREATE TABLE IF NOT EXISTS ai_call_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + run_id TEXT NOT NULL, + call_type TEXT NOT NULL, + pattern_id TEXT, + pattern_name TEXT, + system_prompt TEXT, + user_prompt TEXT, + response_json TEXT, + model TEXT DEFAULT 'gpt-4o', + tokens_prompt INTEGER DEFAULT 0, + tokens_completion INTEGER DEFAULT 0, + duration_ms INTEGER DEFAULT 0, + called_at TEXT NOT NULL DEFAULT (datetime('now')) + )""") + try: + c.execute("CREATE INDEX IF NOT EXISTS idx_acl_run ON ai_call_logs(run_id, called_at DESC)") + except Exception: + pass + c.execute("""CREATE TABLE IF NOT EXISTS news_price_snapshots ( id INTEGER PRIMARY KEY AUTOINCREMENT, article_hash TEXT NOT NULL, @@ -2239,6 +2259,60 @@ def list_cycle_context_snapshots(limit: int = 30) -> list: return [{"run_id": r["run_id"], "ts": r["ts"]} for r in rows] +# ── AI Call Logs ────────────────────────────────────────────────────────────── + +def save_ai_call_log( + run_id: str, + call_type: str, + system_prompt: str, + user_prompt: str, + response_json: str, + model: str = "gpt-4o", + tokens_prompt: int = 0, + tokens_completion: int = 0, + duration_ms: int = 0, + pattern_id: str = None, + pattern_name: str = None, +) -> None: + import json as _json + conn = get_conn() + conn.execute( + """INSERT INTO ai_call_logs + (run_id, call_type, pattern_id, pattern_name, system_prompt, user_prompt, + response_json, model, tokens_prompt, tokens_completion, duration_ms) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + (run_id, call_type, pattern_id, pattern_name, + system_prompt[:8000], # cap system prompt at 8KB + user_prompt[:80000], # cap user prompt at 80KB + response_json[:50000], # cap response at 50KB + model, tokens_prompt, tokens_completion, duration_ms) + ) + conn.commit() + conn.close() + + +def get_ai_call_logs(run_id: str) -> list: + import json as _json + conn = get_conn() + rows = conn.execute( + """SELECT id, call_type, pattern_id, pattern_name, system_prompt, user_prompt, + response_json, model, tokens_prompt, tokens_completion, duration_ms, called_at + FROM ai_call_logs WHERE run_id = ? ORDER BY called_at ASC""", + (run_id,) + ).fetchall() + conn.close() + result = [] + for r in rows: + d = dict(r) + # Try to parse response_json for display + try: + d["response"] = _json.loads(d["response_json"]) if d["response_json"] else None + except Exception: + d["response"] = d["response_json"] + result.append(d) + return result + + # ── News Price Snapshots (Phase 4 — Price Discovery) ───────────────────────── def save_news_price_snapshot( diff --git a/backend/services/portfolio_context.py b/backend/services/portfolio_context.py new file mode 100644 index 0000000..3c40367 --- /dev/null +++ b/backend/services/portfolio_context.py @@ -0,0 +1,128 @@ +""" +Portfolio context builder — injects open positions into AI prompts. + +Provides: + - get_open_trades_with_moves(): open trades + 1d/5d price moves via yfinance + - get_portfolio_concentration(): count per asset_class + - build_portfolio_context_block(): formatted prompt block for AI injection +""" +import logging +from typing import List, Dict, Optional +from datetime import date, datetime + +_log = logging.getLogger("portfolio_context") + + +def get_open_trades_with_moves() -> List[Dict]: + """Fetch all open trades and compute recent underlying price moves.""" + from services.database import get_trade_entry_prices, _normalize_asset_class, _asset_class_from_ticker + import yfinance as yf + + trades = get_trade_entry_prices(days=365) # all open trades regardless of age + + result = [] + for t in trades: + sym = t.get("underlying", "") + entry_price = t.get("entry_price") + move_1d = move_5d = current_price = None + + try: + hist = yf.Ticker(sym).history(period="5d", auto_adjust=True) + if not hist.empty: + current_price = float(hist["Close"].iloc[-1]) + if len(hist) >= 2: + move_1d = (hist["Close"].iloc[-1] / hist["Close"].iloc[-2] - 1) * 100 + if len(hist) >= 5: + move_5d = (hist["Close"].iloc[-1] / hist["Close"].iloc[0] - 1) * 100 + except Exception as e: + _log.debug(f"[PortfolioCtx] yfinance failed for {sym}: {e}") + + # Days held / remaining + entry_str = t.get("entry_date", "") + horizon = t.get("horizon_days") or 30 + days_held = 0 + days_remaining = horizon + try: + entry_dt = datetime.strptime(entry_str, "%Y-%m-%d").date() + days_held = (date.today() - entry_dt).days + days_remaining = max(0, horizon - days_held) + except Exception: + pass + + # Canonical asset class + cls = _normalize_asset_class(t.get("asset_class") or "", sym) + + result.append({ + "id": t.get("id"), + "underlying": sym, + "strategy": t.get("strategy") or "?", + "asset_class": cls, + "pattern_name": (t.get("pattern_name") or "")[:50], + "entry_price": entry_price, + "current_price": round(current_price, 4) if current_price else None, + "move_1d_pct": round(move_1d, 2) if move_1d is not None else None, + "move_5d_pct": round(move_5d, 2) if move_5d is not None else None, + "score_at_entry": t.get("score_at_entry"), + "days_held": days_held, + "days_remaining": days_remaining, + "horizon_days": horizon, + }) + + return result + + +def get_portfolio_concentration(open_trades: List[Dict]) -> Dict[str, int]: + """Count open trades by canonical asset_class.""" + conc: Dict[str, int] = {} + for t in open_trades: + cls = t.get("asset_class") or "unknown" + conc[cls] = conc.get(cls, 0) + 1 + return conc + + +def build_portfolio_context_block(open_trades: List[Dict], concentration: Dict[str, int]) -> str: + """Build a prompt section describing current portfolio for injection into AI prompts.""" + if not open_trades: + return "\n## PORTEFEUILLE ACTUEL\nAucun trade en cours — portefeuille vide.\n" + + total = len(open_trades) + conc_sorted = sorted(concentration.items(), key=lambda x: -x[1]) + conc_str = " | ".join(f"{cls.upper()}: {n}" for cls, n in conc_sorted) + + lines: List[str] = [f"Total: {total} trade(s) ouvert(s) | Concentration: {conc_str}", ""] + + for t in open_trades: + sym = t["underlying"] + strat = t["strategy"] + cls = t.get("asset_class") or "?" + held = t.get("days_held", "?") + rem = t.get("days_remaining", "?") + m1d_str = f"{t['move_1d_pct']:+.1f}%" if t.get("move_1d_pct") is not None else "N/A" + m5d_str = f"{t['move_5d_pct']:+.1f}%" if t.get("move_5d_pct") is not None else "N/A" + pat = t.get("pattern_name", "") + entry = t.get("entry_price") + cur = t.get("current_price") + ep_str = f"entrée {entry:.2f} → actuel {cur:.2f}" if entry and cur else "" + + line = f" • {sym} | {strat} [{cls}] | {held}j tenu / {rem}j restants | J-1: {m1d_str} | J-5: {m5d_str}" + if ep_str: + line += f" | {ep_str}" + if pat: + line += f" | thèse: «{pat}»" + lines.append(line) + + # Identify overweight classes + overweight = [cls for cls, n in conc_sorted if n >= 3] + ow_str = ", ".join(overweight) if overweight else "aucune" + + block = ( + "\n## PORTEFEUILLE ACTUEL — POSITIONS OUVERTES\n" + + "\n".join(lines) + + f"\n\nClasses surpondérées (≥3 trades): {ow_str}\n" + + "\n⚠️ CONSIGNES IMPÉRATIVES (non négociables):\n" + + "1. NE PAS suggérer un nouveau trade sur un sous-jacent déjà en portefeuille — doublement interdit.\n" + + "2. Signaler explicitement dans 'rationale' si une suggestion CONTREDIT une position ouverte (signal de clôture potentiel).\n" + + "3. Éviter d'alourdir une classe surpondérée (≥3 trades) sauf catalyseur exceptionnel justifié.\n" + + "4. Un signal opposé à une position ouverte = opportunité de SORTIE à documenter, pas d'entrée inversée.\n" + ) + return block diff --git a/frontend/src/hooks/useApi.ts b/frontend/src/hooks/useApi.ts index 8a6e838..f2ab4a6 100644 --- a/frontend/src/hooks/useApi.ts +++ b/frontend/src/hooks/useApi.ts @@ -888,6 +888,14 @@ export const useReplayCycle = () => api.post(`/cycle/contexts/${runId}/replay`, { override_notes: notes ?? null }).then(r => r.data), }) +export const useAiCallLogs = (runId: string | null) => + useQuery({ + queryKey: ['ai-call-logs', runId], + queryFn: () => api.get(`/cycle/ai-calls/${runId}`).then(r => r.data), + enabled: !!runId, + staleTime: 60_000, + }) + // ── IV Watchlist Management ─────────────────────────────────────────────────── export const useWatchlistTickers = () => diff --git a/frontend/src/pages/SystemLogs.tsx b/frontend/src/pages/SystemLogs.tsx index ef55506..5f0b1f9 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, Brain, Play, Loader2 } from 'lucide-react' +import { AlertTriangle, XCircle, Info, RefreshCw, Trash2, ChevronDown, ChevronRight, Brain, Play, Loader2, Zap, MessageSquare, BarChart2, Clock } from 'lucide-react' import clsx from 'clsx' -import { useSystemLogs, useLogSources, useLogCycles, useClearLogs, useCycleContextSnapshots, useCycleContextSnapshot, useReplayCycle, type LogFilters } from '../hooks/useApi' +import { useSystemLogs, useLogSources, useLogCycles, useClearLogs, useCycleContextSnapshots, useCycleContextSnapshot, useReplayCycle, useAiCallLogs, type LogFilters } from '../hooks/useApi' import { useQueryClient } from '@tanstack/react-query' const LEVELS = ['', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'] @@ -76,9 +76,127 @@ function LogRow({ log }: { log: any }) { ) } +// ── AI call type config ────────────────────────────────────────────────────── +const AI_CALL_CONFIG: Record = { + suggest: { label: 'Suggestion patterns', color: 'text-purple-300 border-purple-700/40 bg-purple-900/20', icon: }, + score_batch: { label: 'Scoring batch', color: 'text-cyan-300 border-cyan-700/40 bg-cyan-900/20', icon: }, + unknown: { label: 'Appel IA', color: 'text-slate-300 border-slate-700/40 bg-slate-800', icon: }, +} + +function AiCallRow({ call }: { call: any }) { + const [expanded, setExpanded] = useState(false) + const [activePane, setActivePane] = useState<'user' | 'system' | 'response'>('user') + const cfg = AI_CALL_CONFIG[call.call_type] ?? AI_CALL_CONFIG.unknown + const ts = (() => { try { return format(new Date(call.called_at), 'HH:mm:ss', { locale: fr }) } catch { return call.called_at } })() + const totalTokens = (call.tokens_prompt ?? 0) + (call.tokens_completion ?? 0) + + const responseStr = useMemo(() => { + if (!call.response) return '' + try { return JSON.stringify(call.response, null, 2) } catch { return String(call.response) } + }, [call.response]) + + return ( +
+ + + {expanded && ( +
+ {/* Token breakdown */} + {totalTokens > 0 && ( +
+ Modèle: {call.model || 'gpt-4o'} + Prompt: {(call.tokens_prompt ?? 0).toLocaleString()} tok + Completion: {(call.tokens_completion ?? 0).toLocaleString()} tok + Total: {totalTokens.toLocaleString()} tok + Durée: {(call.duration_ms / 1000).toFixed(2)}s +
+ )} + {/* Pane selector */} +
+ {([ + { key: 'user' as const, label: 'Prompt utilisateur', icon: }, + { key: 'system' as const, label: 'Prompt système', icon: }, + { key: 'response' as const, label: 'Réponse IA', icon: }, + ]).map(p => ( + + ))} +
+ {/* Content */} +
+ {activePane === 'user' && ( +
{call.user_prompt || '(vide)'}
+ )} + {activePane === 'system' && ( +
{call.system_prompt || '(vide)'}
+ )} + {activePane === 'response' && ( +
{responseStr || '(pas de réponse)'}
+ )} +
+
+ )} +
+ ) +} + +function AiCallsSection({ runId }: { runId: string }) { + const { data, isLoading } = useAiCallLogs(runId) + const calls: any[] = data?.calls ?? [] + + if (isLoading) return
Chargement des appels IA…
+ if (calls.length === 0) return ( +
+ Aucun appel IA enregistré pour ce cycle — les logs seront disponibles dès le prochain cycle. +
+ ) + + return ( +
+
+ {calls.length} appel(s) IA — cliquer pour voir prompts + réponse complète +
+ {calls.map(c => )} +
+ ) +} + function ContextTab() { const [selectedRunId, setSelectedRunId] = useState(null) const [replayNotes, setReplayNotes] = useState('') + const [rightPane, setRightPane] = useState<'context' | 'calls'>('context') const [replayResult, setReplayResult] = useState(null) const { data: snapshotsData, isLoading: loadingList } = useCycleContextSnapshots(30) const { data: snapData, isLoading: loadingSnap } = useCycleContextSnapshot(selectedRunId) @@ -113,7 +231,7 @@ function ContextTab() { {snapshots.map(s => ( ))} + + {rightPane === 'context' && ( +
+ {snapData.context && Object.entries(snapData.context).map(([key, val]) => ( + + ))} +
+ )} + + {rightPane === 'calls' && ( +
+ +
+ )} )}