Files
OpenFin/backend/services/portfolio_context.py
OpenSquared 4ad3a9a782 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>
2026-06-21 10:36:05 +02:00

129 lines
5.2 KiB
Python

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