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

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