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:
@@ -1,7 +1,7 @@
|
|||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from typing import Optional
|
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
|
from services.auto_cycle import get_status, trigger_manual, restart_scheduler
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/cycle", tags=["cycle"])
|
router = APIRouter(prefix="/api/cycle", tags=["cycle"])
|
||||||
@@ -100,6 +100,13 @@ def get_context_snapshot(run_id: str):
|
|||||||
return snap
|
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):
|
class ReplayRequest(BaseModel):
|
||||||
override_notes: Optional[str] = None # optional annotation added to the replay
|
override_notes: Optional[str] = None # optional annotation added to the replay
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,14 @@ def get_client() -> Optional[OpenAI]:
|
|||||||
return _client
|
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
|
import time as _time
|
||||||
client = get_client()
|
client = get_client()
|
||||||
if not 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:
|
if json_mode:
|
||||||
kwargs["response_format"] = {"type": "json_object"}
|
kwargs["response_format"] = {"type": "json_object"}
|
||||||
|
|
||||||
last_exc = None
|
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:
|
try:
|
||||||
resp = client.chat.completions.create(**kwargs)
|
resp = client.chat.completions.create(**kwargs)
|
||||||
content = resp.choices[0].message.content
|
content = resp.choices[0].message.content
|
||||||
if json_mode:
|
usage = resp.usage
|
||||||
return json.loads(content)
|
result = json.loads(content) if json_mode else {"text": content}
|
||||||
return {"text": content}
|
break # success
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
last_exc = e
|
last_exc = e
|
||||||
err_str = str(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:
|
if "429" in err_str or "rate_limit" in err_str:
|
||||||
import re as _re
|
import re as _re
|
||||||
m = _re.search(r"try again in ([\d.]+)s", err_str)
|
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))
|
_time.sleep(min(wait, 60.0))
|
||||||
continue
|
continue
|
||||||
raise # non-429 errors propagate immediately
|
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 ───────────────────────────────────────────────────
|
# ── News / Article Analysis ───────────────────────────────────────────────────
|
||||||
@@ -356,6 +392,8 @@ def score_patterns_with_context(
|
|||||||
tech_indicators_block: str = "",
|
tech_indicators_block: str = "",
|
||||||
fred_block: str = "",
|
fred_block: str = "",
|
||||||
price_discovery_block: str = "",
|
price_discovery_block: str = "",
|
||||||
|
portfolio_context_block: str = "",
|
||||||
|
run_id: str = "",
|
||||||
) -> List[Dict[str, Any]]:
|
) -> List[Dict[str, Any]]:
|
||||||
"""Score all patterns with rich context (news, prices, IV, risk clusters) using GPT-4o."""
|
"""Score all patterns with rich context (news, prices, IV, risk clusters) using GPT-4o."""
|
||||||
if not get_client():
|
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 ""
|
_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 ""
|
_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 ""
|
_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:
|
user = f"""CONTEXTE GLOBAL:
|
||||||
- Score risque géopolitique: {geo_score.get('score', 50)}/100 ({geo_score.get('level', 'medium')})
|
- 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}
|
{_fred_sc_section}
|
||||||
{_pd_sc_section}
|
{_pd_sc_section}
|
||||||
{_tech_sc_section}
|
{_tech_sc_section}
|
||||||
|
{_portfolio_sc_section}
|
||||||
TEMPLATE DE NOTATION:
|
TEMPLATE DE NOTATION:
|
||||||
{scoring_template}
|
{scoring_template}
|
||||||
|
|
||||||
@@ -765,7 +805,10 @@ TEMPLATE DE NOTATION:
|
|||||||
+ _return_schema
|
+ _return_schema
|
||||||
)
|
)
|
||||||
try:
|
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:
|
except Exception as e:
|
||||||
_scorer_log.error(f"[Scorer] GPT-4o call failed for batch {ids}: {e}")
|
_scorer_log.error(f"[Scorer] GPT-4o call failed for batch {ids}: {e}")
|
||||||
res = None
|
res = None
|
||||||
@@ -977,6 +1020,8 @@ def suggest_patterns_from_market_context(
|
|||||||
tech_indicators_block: str = "",
|
tech_indicators_block: str = "",
|
||||||
fred_block: str = "",
|
fred_block: str = "",
|
||||||
price_discovery_block: str = "",
|
price_discovery_block: str = "",
|
||||||
|
portfolio_context_block: str = "",
|
||||||
|
run_id: str = "",
|
||||||
) -> List[Dict]:
|
) -> List[Dict]:
|
||||||
"""Ask GPT-4o to propose new patterns based on current geo/market + macro regime context."""
|
"""Ask GPT-4o to propose new patterns based on current geo/market + macro regime context."""
|
||||||
_cycle_meta = cycle_meta or {}
|
_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 ""
|
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 ""
|
fred_section = f"\n{fred_block}\n" if fred_block else ""
|
||||||
pd_section = f"\n{price_discovery_block}\n" if price_discovery_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.
|
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}
|
{macro_block}{geo_block}{lessons_block}{reliability_block}{iv_block}
|
||||||
@@ -1134,6 +1180,7 @@ Règles supplémentaires:
|
|||||||
{fred_section}
|
{fred_section}
|
||||||
{pd_section}
|
{pd_section}
|
||||||
{tech_block_section}
|
{tech_block_section}
|
||||||
|
{portfolio_section}
|
||||||
## Calendrier économique à venir
|
## Calendrier économique à venir
|
||||||
{cal_block}
|
{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:
|
if not result:
|
||||||
return []
|
return []
|
||||||
return result.get("patterns", [])
|
return result.get("patterns", [])
|
||||||
|
|||||||
@@ -183,6 +183,9 @@ def run_cycle_once(trigger: str = "auto") -> Dict[str, Any]:
|
|||||||
suggest_patterns_from_market_context, score_patterns_with_context,
|
suggest_patterns_from_market_context, score_patterns_with_context,
|
||||||
ai_score_news_batch, _chat, DEFAULT_ANALYSIS_TEMPLATE,
|
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)
|
# KB confidence decay (non-blocking)
|
||||||
try:
|
try:
|
||||||
@@ -447,6 +450,18 @@ def run_cycle_once(trigger: str = "auto") -> Dict[str, Any]:
|
|||||||
except Exception as _te:
|
except Exception as _te:
|
||||||
logger.warning(f"[Cycle] Tech indicators failed (non-blocking): {_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 ─────────────────────────────────
|
# ── Save full context snapshot ─────────────────────────────────
|
||||||
try:
|
try:
|
||||||
from services.ai_analyzer import apply_news_decay as _apply_decay2, partition_news_by_age as _part
|
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 "",
|
"iv_context_preview": iv_context[:500] if iv_context else "",
|
||||||
"calendar": calendar[:8] if calendar 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()},
|
"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)
|
save_cycle_context_snapshot(run_id, _context_snapshot)
|
||||||
logger.info(f"[Cycle {run_id[:16]}] Context snapshot saved")
|
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,
|
tech_indicators_block=_tech_block,
|
||||||
fred_block=_fred_block,
|
fred_block=_fred_block,
|
||||||
price_discovery_block=_price_discovery_block,
|
price_discovery_block=_price_discovery_block,
|
||||||
|
portfolio_context_block=_portfolio_block,
|
||||||
|
run_id=run_id,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"[Cycle] Suggestion step failed: {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,
|
tech_indicators_block=_tech_block,
|
||||||
fred_block=_fred_block,
|
fred_block=_fred_block,
|
||||||
price_discovery_block=_price_discovery_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_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")]
|
scored_without_id = [s for s in scored if not s.get("pattern_id")]
|
||||||
|
|||||||
@@ -407,6 +407,26 @@ def init_db():
|
|||||||
context_json TEXT NOT NULL
|
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 (
|
c.execute("""CREATE TABLE IF NOT EXISTS news_price_snapshots (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
article_hash TEXT NOT NULL,
|
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]
|
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) ─────────────────────────
|
# ── News Price Snapshots (Phase 4 — Price Discovery) ─────────────────────────
|
||||||
|
|
||||||
def save_news_price_snapshot(
|
def save_news_price_snapshot(
|
||||||
|
|||||||
128
backend/services/portfolio_context.py
Normal file
128
backend/services/portfolio_context.py
Normal 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
|
||||||
@@ -888,6 +888,14 @@ export const useReplayCycle = () =>
|
|||||||
api.post(`/cycle/contexts/${runId}/replay`, { override_notes: notes ?? null }).then(r => r.data),
|
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 ───────────────────────────────────────────────────
|
// ── IV Watchlist Management ───────────────────────────────────────────────────
|
||||||
|
|
||||||
export const useWatchlistTickers = () =>
|
export const useWatchlistTickers = () =>
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { useState, useMemo } from 'react'
|
import { useState, useMemo } from 'react'
|
||||||
import { format } from 'date-fns'
|
import { format } from 'date-fns'
|
||||||
import { fr } from 'date-fns/locale'
|
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 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'
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
|
|
||||||
const LEVELS = ['', 'INFO', 'WARNING', 'ERROR', 'CRITICAL']
|
const LEVELS = ['', 'INFO', 'WARNING', 'ERROR', 'CRITICAL']
|
||||||
@@ -76,9 +76,127 @@ function LogRow({ log }: { log: any }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── AI call type config ──────────────────────────────────────────────────────
|
||||||
|
const AI_CALL_CONFIG: Record<string, { label: string; color: string; icon: React.ReactNode }> = {
|
||||||
|
suggest: { label: 'Suggestion patterns', color: 'text-purple-300 border-purple-700/40 bg-purple-900/20', icon: <Brain className="w-3 h-3" /> },
|
||||||
|
score_batch: { label: 'Scoring batch', color: 'text-cyan-300 border-cyan-700/40 bg-cyan-900/20', icon: <BarChart2 className="w-3 h-3" /> },
|
||||||
|
unknown: { label: 'Appel IA', color: 'text-slate-300 border-slate-700/40 bg-slate-800', icon: <Zap className="w-3 h-3" /> },
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="border border-slate-800 rounded overflow-hidden">
|
||||||
|
<button
|
||||||
|
onClick={() => setExpanded(e => !e)}
|
||||||
|
className="w-full flex items-center gap-3 px-3 py-2.5 hover:bg-slate-800/50 transition-colors text-left"
|
||||||
|
>
|
||||||
|
<span className={clsx('inline-flex items-center gap-1.5 px-2 py-0.5 rounded border text-[10px] font-semibold shrink-0', cfg.color)}>
|
||||||
|
{cfg.icon} {cfg.label}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-slate-500 font-mono shrink-0">{ts}</span>
|
||||||
|
{call.pattern_name && (
|
||||||
|
<span className="text-[10px] text-slate-400 truncate">— {call.pattern_name}</span>
|
||||||
|
)}
|
||||||
|
<div className="ml-auto flex items-center gap-3 shrink-0">
|
||||||
|
{totalTokens > 0 && (
|
||||||
|
<span className="text-[10px] text-slate-600 font-mono">{totalTokens.toLocaleString()} tokens</span>
|
||||||
|
)}
|
||||||
|
{call.duration_ms > 0 && (
|
||||||
|
<span className="text-[10px] text-slate-600 font-mono flex items-center gap-0.5">
|
||||||
|
<Clock className="w-2.5 h-2.5" />{(call.duration_ms / 1000).toFixed(1)}s
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{expanded ? <ChevronDown className="w-3.5 h-3.5 text-slate-500" /> : <ChevronRight className="w-3.5 h-3.5 text-slate-500" />}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{expanded && (
|
||||||
|
<div className="border-t border-slate-800">
|
||||||
|
{/* Token breakdown */}
|
||||||
|
{totalTokens > 0 && (
|
||||||
|
<div className="flex gap-4 px-3 py-2 bg-slate-900/50 text-[10px] font-mono text-slate-500 border-b border-slate-800">
|
||||||
|
<span>Modèle: <span className="text-slate-300">{call.model || 'gpt-4o'}</span></span>
|
||||||
|
<span>Prompt: <span className="text-amber-300">{(call.tokens_prompt ?? 0).toLocaleString()} tok</span></span>
|
||||||
|
<span>Completion: <span className="text-green-300">{(call.tokens_completion ?? 0).toLocaleString()} tok</span></span>
|
||||||
|
<span>Total: <span className="text-white">{totalTokens.toLocaleString()} tok</span></span>
|
||||||
|
<span>Durée: <span className="text-purple-300">{(call.duration_ms / 1000).toFixed(2)}s</span></span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Pane selector */}
|
||||||
|
<div className="flex gap-0 border-b border-slate-800">
|
||||||
|
{([
|
||||||
|
{ key: 'user' as const, label: 'Prompt utilisateur', icon: <MessageSquare className="w-3 h-3" /> },
|
||||||
|
{ key: 'system' as const, label: 'Prompt système', icon: <Brain className="w-3 h-3" /> },
|
||||||
|
{ key: 'response' as const, label: 'Réponse IA', icon: <Zap className="w-3 h-3" /> },
|
||||||
|
]).map(p => (
|
||||||
|
<button
|
||||||
|
key={p.key}
|
||||||
|
onClick={() => setActivePane(p.key)}
|
||||||
|
className={clsx(
|
||||||
|
'flex items-center gap-1 px-3 py-1.5 text-[10px] font-medium border-b-2 -mb-px transition-colors',
|
||||||
|
activePane === p.key
|
||||||
|
? 'border-purple-500 text-purple-300'
|
||||||
|
: 'border-transparent text-slate-500 hover:text-slate-300'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{p.icon} {p.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{/* Content */}
|
||||||
|
<div className="p-3 bg-black/40 max-h-[500px] overflow-y-auto">
|
||||||
|
{activePane === 'user' && (
|
||||||
|
<pre className="text-[10px] font-mono text-slate-300 whitespace-pre-wrap leading-relaxed">{call.user_prompt || '(vide)'}</pre>
|
||||||
|
)}
|
||||||
|
{activePane === 'system' && (
|
||||||
|
<pre className="text-[10px] font-mono text-slate-400 whitespace-pre-wrap leading-relaxed">{call.system_prompt || '(vide)'}</pre>
|
||||||
|
)}
|
||||||
|
{activePane === 'response' && (
|
||||||
|
<pre className="text-[10px] font-mono text-green-300 whitespace-pre-wrap leading-relaxed">{responseStr || '(pas de réponse)'}</pre>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AiCallsSection({ runId }: { runId: string }) {
|
||||||
|
const { data, isLoading } = useAiCallLogs(runId)
|
||||||
|
const calls: any[] = data?.calls ?? []
|
||||||
|
|
||||||
|
if (isLoading) return <div className="p-4 text-xs text-slate-500">Chargement des appels IA…</div>
|
||||||
|
if (calls.length === 0) return (
|
||||||
|
<div className="p-4 text-xs text-slate-600 text-center">
|
||||||
|
Aucun appel IA enregistré pour ce cycle — les logs seront disponibles dès le prochain cycle.
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-[10px] text-slate-500 px-1 font-mono">
|
||||||
|
{calls.length} appel(s) IA — cliquer pour voir prompts + réponse complète
|
||||||
|
</div>
|
||||||
|
{calls.map(c => <AiCallRow key={c.id} call={c} />)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function ContextTab() {
|
function ContextTab() {
|
||||||
const [selectedRunId, setSelectedRunId] = useState<string | null>(null)
|
const [selectedRunId, setSelectedRunId] = useState<string | null>(null)
|
||||||
const [replayNotes, setReplayNotes] = useState('')
|
const [replayNotes, setReplayNotes] = useState('')
|
||||||
|
const [rightPane, setRightPane] = useState<'context' | 'calls'>('context')
|
||||||
const [replayResult, setReplayResult] = useState<any>(null)
|
const [replayResult, setReplayResult] = useState<any>(null)
|
||||||
const { data: snapshotsData, isLoading: loadingList } = useCycleContextSnapshots(30)
|
const { data: snapshotsData, isLoading: loadingList } = useCycleContextSnapshots(30)
|
||||||
const { data: snapData, isLoading: loadingSnap } = useCycleContextSnapshot(selectedRunId)
|
const { data: snapData, isLoading: loadingSnap } = useCycleContextSnapshot(selectedRunId)
|
||||||
@@ -113,7 +231,7 @@ function ContextTab() {
|
|||||||
{snapshots.map(s => (
|
{snapshots.map(s => (
|
||||||
<button
|
<button
|
||||||
key={s.run_id}
|
key={s.run_id}
|
||||||
onClick={() => { setSelectedRunId(s.run_id); setReplayResult(null) }}
|
onClick={() => { setSelectedRunId(s.run_id); setReplayResult(null); setRightPane('context') }}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'w-full text-left px-3 py-2.5 hover:bg-slate-800/60 transition-colors',
|
'w-full text-left px-3 py-2.5 hover:bg-slate-800/60 transition-colors',
|
||||||
selectedRunId === s.run_id && 'bg-purple-900/30 border-l-2 border-purple-500'
|
selectedRunId === s.run_id && 'bg-purple-900/30 border-l-2 border-purple-500'
|
||||||
@@ -191,12 +309,40 @@ function ContextTab() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Context sections */}
|
{/* Sub-tabs: Contexte / Appels IA */}
|
||||||
<div className="overflow-y-auto flex-1 p-3 space-y-2">
|
<div className="flex gap-0 border-b border-slate-800 bg-slate-900/40 shrink-0">
|
||||||
{snapData.context && Object.entries(snapData.context).map(([key, val]) => (
|
{([
|
||||||
<ContextSection key={key} label={key} value={val} />
|
{ key: 'context' as const, label: 'Contexte IA', icon: <Brain className="w-3 h-3" /> },
|
||||||
|
{ key: 'calls' as const, label: 'Appels IA', icon: <Zap className="w-3 h-3" /> },
|
||||||
|
]).map(t => (
|
||||||
|
<button
|
||||||
|
key={t.key}
|
||||||
|
onClick={() => setRightPane(t.key)}
|
||||||
|
className={clsx(
|
||||||
|
'flex items-center gap-1.5 px-4 py-2 text-[11px] font-medium border-b-2 -mb-px transition-colors',
|
||||||
|
rightPane === t.key
|
||||||
|
? 'border-purple-500 text-purple-300'
|
||||||
|
: 'border-transparent text-slate-500 hover:text-slate-300'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{t.icon} {t.label}
|
||||||
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{rightPane === 'context' && (
|
||||||
|
<div className="overflow-y-auto flex-1 p-3 space-y-2">
|
||||||
|
{snapData.context && Object.entries(snapData.context).map(([key, val]) => (
|
||||||
|
<ContextSection key={key} label={key} value={val} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{rightPane === 'calls' && (
|
||||||
|
<div className="overflow-y-auto flex-1 p-3">
|
||||||
|
<AiCallsSection runId={selectedRunId} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user