feat: portfolio context injection + AI call log viewer

Portfolio context (portfolio_context.py):
- get_open_trades_with_moves(): fetches open trades + 1d/5d yfinance price moves
- get_portfolio_concentration(): counts by asset_class
- build_portfolio_context_block(): formatted prompt block with strict AI instructions
  (no double positions, flag contradictions, avoid overweight classes)

AI call logging:
- ai_call_logs table in DB (run_id, call_type, system/user prompt, response, tokens, ms)
- _chat() now accepts log_meta dict → saves call to DB non-blocking after each call
- suggest and score_batch calls pass run_id + call_type for full traceability

auto_cycle.py:
- Builds portfolio context before snapshot and both AI calls
- Context snapshot now includes portfolio_open_positions key

SystemLogs.tsx:
- "Contexte IA" tab gains sub-tabs: Contexte / Appels IA
- AiCallRow: expandable with 3 panes (user prompt / system prompt / response)
  shows model, tokens breakdown, duration, call type badge

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
OpenSquared
2026-06-21 10:36:05 +02:00
parent 5d3ff19393
commit 4ad3a9a782
7 changed files with 457 additions and 17 deletions

View File

@@ -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

View File

@@ -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", [])

View File

@@ -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")]

View File

@@ -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(

View File

@@ -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