feat: simulation portfolio surveillance + patterns grid/filter UI
Portfolio Monitor (v4.4): - New portfolio_risk.py service: concentration by asset_class, directional conflict detection (same underlying, opposite directions), overweight alerts - AI agent (Step 7b) runs GPT-4o-mini after each cycle log: assessment + prioritized actions + rebalance suggestion, persisted in system_logs - GET /api/journal/portfolio-risk — full risk breakdown + latest AI monitor reco - POST /api/journal/trade-check — pre-entry conflict & concentration check - asset_class column added to trade_entry_prices (auto-migration + populated at INSERT) - Journal: new "Risque Sim." tab with concentration bars, conflict alerts, AI recommendations; red badge on tab when danger alerts exist PatternEditor: - Grid view default (2-3 cols responsive), list toggle - Asset class filter chips (energy/metals/agri/equities/indices/forex/rates) - Sort: Date (default) / Score IA / Prob. - Period filter: Tout / 7j / 30j - Result count badge when filters active Doc: v4.3 → v4.4, updated Journal/PatternEditor/cycle steps/schema/glossary Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -218,6 +218,41 @@ def close_trade_endpoint(trade_id: int, body: CloseTradeRequest):
|
||||
return {"closed": True, "trade_id": trade_id, "pnl_realized": pnl}
|
||||
|
||||
|
||||
@router.get("/portfolio-risk")
|
||||
def portfolio_risk():
|
||||
"""Risk analysis of the open simulation portfolio (asset class concentration, conflicts)."""
|
||||
import json as _json
|
||||
from services.portfolio_risk import analyze_simulation_portfolio
|
||||
from services.database import get_system_logs
|
||||
result = analyze_simulation_portfolio()
|
||||
|
||||
# Attach latest AI monitor recommendation if available
|
||||
logs = get_system_logs(source="portfolio_monitor", limit=1)
|
||||
if logs:
|
||||
raw = logs[0].get("details")
|
||||
if raw:
|
||||
try:
|
||||
result["ai_monitor"] = _json.loads(raw)
|
||||
result["ai_monitor_ts"] = logs[0].get("ts")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return _sanitize(result)
|
||||
|
||||
|
||||
class TradeCheckRequest(BaseModel):
|
||||
underlying: str
|
||||
strategy: str
|
||||
asset_class: str = ""
|
||||
|
||||
|
||||
@router.post("/trade-check")
|
||||
def trade_check(body: TradeCheckRequest):
|
||||
"""Pre-entry check: would adding this trade create conflicts or concentration issues?"""
|
||||
from services.portfolio_risk import check_new_trade
|
||||
return check_new_trade(body.underlying, body.strategy, body.asset_class)
|
||||
|
||||
|
||||
@router.delete("/reset")
|
||||
def reset_journal():
|
||||
"""Truncate all journal history (trades, macro, geo, cycles). Irreversible."""
|
||||
|
||||
@@ -87,6 +87,63 @@ def _is_duplicate_pattern(
|
||||
return sim >= jaccard_threshold
|
||||
|
||||
|
||||
# ── Portfolio monitor agent ───────────────────────────────────────────────────
|
||||
|
||||
def _run_portfolio_monitor(risk: dict, cycle_id: str) -> dict:
|
||||
"""
|
||||
AI agent that analyses the open simulation portfolio and generates recommendations.
|
||||
Runs only when there are alerts (conflicts, concentration). Results saved to system_logs.
|
||||
"""
|
||||
from services.portfolio_risk import build_monitor_context
|
||||
from services.database import log_system_event
|
||||
from services.ai_analyzer import _chat
|
||||
import json as _json
|
||||
|
||||
context = build_monitor_context(risk)
|
||||
n_conflicts = len(risk.get("conflicts", []))
|
||||
n_alerts = len(risk.get("alerts", []))
|
||||
|
||||
system_prompt = (
|
||||
"Tu es un risk manager spécialisé dans les portefeuilles d'options géopolitiques. "
|
||||
"Analyse l'état du portefeuille simulé ci-dessous et génère des recommandations concrètes. "
|
||||
"Réponds en JSON avec ce schéma exact: "
|
||||
'{"assessment": "<2 phrases max sur l\'état global>", '
|
||||
'"actions": [{"priority": "high|medium", "type": "close_trade|rebalance|monitor", '
|
||||
'"trade_id": <int ou null>, "underlying": "<ticker>", "reason": "<raison courte>"}], '
|
||||
'"rebalance_suggestion": "<suggestion concrète de rééquilibrage en 1 phrase>"}'
|
||||
)
|
||||
user_prompt = context
|
||||
|
||||
try:
|
||||
result = _chat(system_prompt, user_prompt, model="gpt-4o-mini", json_mode=True, max_tokens=600)
|
||||
if isinstance(result, dict) and "assessment" in result:
|
||||
details_str = _json.dumps(result, ensure_ascii=False)
|
||||
msg = f"[PortfolioMonitor] {n_conflicts} conflits, {n_alerts} alertes — {result.get('assessment', '')[:120]}"
|
||||
log_system_event("WARN" if n_conflicts else "INFO", "portfolio_monitor",
|
||||
msg, cycle_id=cycle_id, details=details_str)
|
||||
logger.info(f"[PortfolioMonitor] AI assessment saved — {len(result.get('actions', []))} actions")
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.warning(f"[PortfolioMonitor] AI call failed: {e}")
|
||||
|
||||
# Fallback: log raw alerts without AI
|
||||
fallback = {
|
||||
"assessment": f"{n_conflicts} conflits directionnels et {n_alerts} alertes détectées dans le portefeuille simulé.",
|
||||
"actions": [
|
||||
{"priority": "high" if a["level"] == "danger" else "medium",
|
||||
"type": "close_trade" if a["type"] == "conflict" else "monitor",
|
||||
"trade_id": None, "underlying": a.get("underlying", ""),
|
||||
"reason": a["message"]}
|
||||
for a in risk.get("alerts", [])[:5]
|
||||
],
|
||||
"rebalance_suggestion": "Revérifier manuellement les positions contradictoires.",
|
||||
}
|
||||
log_system_event("WARN", "portfolio_monitor",
|
||||
f"[PortfolioMonitor] {n_alerts} alertes (analyse IA indisponible)",
|
||||
cycle_id=cycle_id, details=_json.dumps(fallback, ensure_ascii=False))
|
||||
return fallback
|
||||
|
||||
|
||||
# ── Core cycle logic ──────────────────────────────────────────────────────────
|
||||
|
||||
def run_cycle_once(trigger: str = "auto") -> Dict[str, Any]:
|
||||
@@ -466,6 +523,25 @@ def run_cycle_once(trigger: str = "auto") -> Dict[str, Any]:
|
||||
|
||||
log_trade_entries(run_id=scoring_run_id, scored_patterns=scored, quotes=quotes)
|
||||
|
||||
# ── Step 5.1: Portfolio monitor — conflict & concentration check ──────
|
||||
try:
|
||||
from services.portfolio_risk import analyze_simulation_portfolio
|
||||
_risk = analyze_simulation_portfolio()
|
||||
if _risk.get("alerts"):
|
||||
logger.info(f"[PortfolioMonitor] {len(_risk['alerts'])} alerts ({len(_risk['conflicts'])} conflicts) — running AI monitor")
|
||||
_pm = _run_portfolio_monitor(_risk, scoring_run_id)
|
||||
if _pm:
|
||||
summary["portfolio_monitor"] = {
|
||||
"alerts": len(_risk["alerts"]),
|
||||
"conflicts": len(_risk["conflicts"]),
|
||||
"assessment": _pm.get("assessment", ""),
|
||||
"actions_count": len(_pm.get("actions", [])),
|
||||
}
|
||||
else:
|
||||
logger.info(f"[PortfolioMonitor] OK — {_risk['open_count']} positions, no alerts")
|
||||
except Exception as _pme:
|
||||
logger.warning(f"[PortfolioMonitor] Failed (non-blocking): {_pme}")
|
||||
|
||||
# Auto-add any new underlying tickers to the IV watchlist
|
||||
try:
|
||||
from services.database import _normalize_ticker, add_watchlist_ticker, get_watchlist_tickers
|
||||
|
||||
@@ -218,6 +218,7 @@ def init_db():
|
||||
("target_pct", "REAL"),
|
||||
("stop_loss_pct", "REAL"),
|
||||
("signal_threshold", "REAL"),
|
||||
("asset_class", "TEXT"),
|
||||
]:
|
||||
try:
|
||||
c.execute(f"ALTER TABLE trade_entry_prices ADD COLUMN {col} {definition}")
|
||||
@@ -1049,18 +1050,24 @@ def log_trade_entries(run_id: str, scored_patterns: List[Dict[str, Any]], quotes
|
||||
)
|
||||
updated_count += 1
|
||||
else:
|
||||
_trade_asset_class = (
|
||||
trade.get("asset_class") or
|
||||
sp.get("asset_class") or
|
||||
_orig.get("asset_class") or
|
||||
""
|
||||
)
|
||||
conn.execute("""INSERT INTO trade_entry_prices
|
||||
(run_id, pattern_id, pattern_name, underlying, strategy,
|
||||
entry_price, entry_date, score_at_entry, latest_score,
|
||||
expected_move_pct, horizon_days, ev_at_entry, ev_net,
|
||||
trade_score, matched_profile, last_seen_at,
|
||||
strike_guidance, expiry_days_at_entry)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", (
|
||||
strike_guidance, expiry_days_at_entry, asset_class)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", (
|
||||
run_id, pid, pattern_name, ticker_key, strategy,
|
||||
entry_price, today, eff_score, eff_score,
|
||||
exp_move, horizon, ev_gross, ev_net,
|
||||
trade_score, matched, now_ts,
|
||||
strike_guidance, expiry_days_entry,
|
||||
strike_guidance, expiry_days_entry, _trade_asset_class,
|
||||
))
|
||||
inserted_count += 1
|
||||
_log.info(f"[TradeLog] NEW trade: pattern='{pattern_name}' {underlying} {strategy} score={eff_score} gain={exp_move:.0f}% profile='{matched}' price={entry_price}")
|
||||
|
||||
211
backend/services/portfolio_risk.py
Normal file
211
backend/services/portfolio_risk.py
Normal file
@@ -0,0 +1,211 @@
|
||||
"""Simulation portfolio risk analysis — mirrors IBKR risk module for logged trades."""
|
||||
from __future__ import annotations
|
||||
from typing import Any, Dict, List, Optional
|
||||
from services.database import get_conn
|
||||
|
||||
_BEARISH_KEYWORDS = {"bear", "put", "short", "sell", "vente", "baissier"}
|
||||
|
||||
|
||||
def _direction(strategy: str) -> str:
|
||||
s = (strategy or "").lower()
|
||||
return "bearish" if any(kw in s for kw in _BEARISH_KEYWORDS) else "bullish"
|
||||
|
||||
|
||||
def get_open_simulation_trades() -> List[Dict[str, Any]]:
|
||||
"""Open trades enriched with asset_class from joined pattern table."""
|
||||
conn = get_conn()
|
||||
rows = conn.execute("""
|
||||
SELECT tep.*,
|
||||
COALESCE(tep.asset_class, cp.asset_class) AS asset_class
|
||||
FROM trade_entry_prices tep
|
||||
LEFT JOIN custom_patterns cp ON cp.id = tep.pattern_id
|
||||
WHERE (tep.status IS NULL OR tep.status = 'open')
|
||||
ORDER BY tep.entry_date DESC
|
||||
""").fetchall()
|
||||
conn.close()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def analyze_simulation_portfolio() -> Dict[str, Any]:
|
||||
"""Full risk breakdown of the open simulation portfolio."""
|
||||
trades = get_open_simulation_trades()
|
||||
if not trades:
|
||||
return {
|
||||
"open_count": 0, "conflicts": [], "concentration": {},
|
||||
"by_underlying": {}, "alerts": [], "direction_exposure": {},
|
||||
}
|
||||
|
||||
total = len(trades)
|
||||
|
||||
# Group by asset_class
|
||||
by_class: Dict[str, List] = {}
|
||||
for t in trades:
|
||||
ac = (t.get("asset_class") or "unknown").lower()
|
||||
by_class.setdefault(ac, []).append(t)
|
||||
|
||||
concentration = {
|
||||
ac: {
|
||||
"count": len(items),
|
||||
"pct": round(len(items) / total * 100, 1),
|
||||
"tickers": sorted({t["underlying"] for t in items}),
|
||||
"bullish": sum(1 for t in items if _direction(t.get("strategy", "")) == "bullish"),
|
||||
"bearish": sum(1 for t in items if _direction(t.get("strategy", "")) == "bearish"),
|
||||
}
|
||||
for ac, items in by_class.items()
|
||||
}
|
||||
|
||||
# Group by underlying
|
||||
by_underlying: Dict[str, List] = {}
|
||||
for t in trades:
|
||||
u = (t.get("underlying") or "").upper()
|
||||
if u:
|
||||
by_underlying.setdefault(u, []).append(t)
|
||||
|
||||
# Detect directional conflicts
|
||||
conflicts = []
|
||||
for underlying, group in by_underlying.items():
|
||||
dirs = [_direction(t.get("strategy", "")) for t in group]
|
||||
if "bullish" in dirs and "bearish" in dirs:
|
||||
conflicts.append({
|
||||
"underlying": underlying,
|
||||
"trades": [
|
||||
{
|
||||
"id": t["id"],
|
||||
"strategy": t.get("strategy", ""),
|
||||
"direction": _direction(t.get("strategy", "")),
|
||||
"entry_date": t.get("entry_date", ""),
|
||||
"pattern_name": t.get("pattern_name", ""),
|
||||
"score_at_entry": t.get("score_at_entry", 0),
|
||||
"asset_class": (t.get("asset_class") or "").lower(),
|
||||
}
|
||||
for t in group
|
||||
],
|
||||
})
|
||||
|
||||
# Direction exposure summary per class
|
||||
direction_exposure = {
|
||||
ac: {
|
||||
"bullish": data["bullish"],
|
||||
"bearish": data["bearish"],
|
||||
"net": data["bullish"] - data["bearish"],
|
||||
"bias": "bullish" if data["bullish"] > data["bearish"]
|
||||
else "bearish" if data["bearish"] > data["bullish"]
|
||||
else "neutral",
|
||||
}
|
||||
for ac, data in concentration.items()
|
||||
}
|
||||
|
||||
# Build alerts — sorted by severity
|
||||
alerts: List[Dict] = []
|
||||
for c in conflicts:
|
||||
alerts.append({
|
||||
"type": "conflict", "level": "danger",
|
||||
"underlying": c["underlying"],
|
||||
"message": f"Positions contradictoires sur {c['underlying']} — {len(c['trades'])} trades opposés",
|
||||
})
|
||||
for ac, data in concentration.items():
|
||||
if data["pct"] >= 35:
|
||||
alerts.append({
|
||||
"type": "concentration", "level": "warning",
|
||||
"asset_class": ac, "pct": data["pct"],
|
||||
"message": f"{ac} = {data['pct']}% du portefeuille simulé ({data['count']} trades)",
|
||||
})
|
||||
for underlying, group in by_underlying.items():
|
||||
if len(group) >= 3:
|
||||
alerts.append({
|
||||
"type": "overweight", "level": "warning",
|
||||
"underlying": underlying, "count": len(group),
|
||||
"message": f"{len(group)} trades sur {underlying} — sur-exposition",
|
||||
})
|
||||
alerts.sort(key=lambda a: {"danger": 0, "warning": 1}.get(a["level"], 2))
|
||||
|
||||
return {
|
||||
"open_count": total,
|
||||
"conflicts": conflicts,
|
||||
"concentration": concentration,
|
||||
"by_underlying": {u: len(g) for u, g in by_underlying.items()},
|
||||
"direction_exposure": direction_exposure,
|
||||
"alerts": alerts,
|
||||
}
|
||||
|
||||
|
||||
def check_new_trade(underlying: str, strategy: str, asset_class: str) -> Dict[str, Any]:
|
||||
"""Pre-entry check: would this new trade create conflicts or concentration issues?"""
|
||||
open_trades = get_open_simulation_trades()
|
||||
total = len(open_trades)
|
||||
|
||||
new_dir = _direction(strategy)
|
||||
underlying_upper = underlying.upper()
|
||||
warnings: List[Dict] = []
|
||||
|
||||
same_underlying = [t for t in open_trades if (t.get("underlying") or "").upper() == underlying_upper]
|
||||
for t in same_underlying:
|
||||
existing_dir = _direction(t.get("strategy", ""))
|
||||
if existing_dir != new_dir:
|
||||
warnings.append({
|
||||
"type": "conflict", "level": "danger",
|
||||
"trade_id": t["id"], "existing_direction": existing_dir,
|
||||
"message": f"Conflit : trade #{t['id']} est {existing_dir} sur {underlying} ({t.get('strategy', '')})",
|
||||
})
|
||||
else:
|
||||
warnings.append({
|
||||
"type": "accumulation", "level": "info",
|
||||
"trade_id": t["id"],
|
||||
"message": f"Accumulation {new_dir} sur {underlying} (trade #{t['id']} déjà en position)",
|
||||
})
|
||||
|
||||
# Forecast concentration after adding this trade
|
||||
total_after = total + 1
|
||||
ac_key = (asset_class or "").lower()
|
||||
current_ac = sum(
|
||||
1 for t in open_trades
|
||||
if (t.get("asset_class") or "").lower() == ac_key
|
||||
)
|
||||
future_pct = round((current_ac + 1) / total_after * 100, 1) if total_after > 0 else 100
|
||||
if future_pct >= 35 and ac_key:
|
||||
warnings.append({
|
||||
"type": "concentration", "level": "warning",
|
||||
"asset_class": ac_key, "future_pct": future_pct,
|
||||
"message": f"Ce trade porterait {ac_key} à {future_pct}% du portefeuille simulé",
|
||||
})
|
||||
|
||||
return {
|
||||
"warnings": warnings,
|
||||
"ok": not any(w["level"] == "danger" for w in warnings),
|
||||
"underlying": underlying,
|
||||
"strategy": strategy,
|
||||
"direction": new_dir,
|
||||
"total_open_after": total_after,
|
||||
}
|
||||
|
||||
|
||||
def build_monitor_context(risk: Dict[str, Any]) -> str:
|
||||
"""Build a compact text summary of portfolio risk for GPT-4o."""
|
||||
lines = [
|
||||
f"PORTEFEUILLE SIMULÉ — {risk['open_count']} positions ouvertes",
|
||||
"",
|
||||
"RÉPARTITION PAR CLASSE D'ACTIF:",
|
||||
]
|
||||
for ac, data in sorted(risk["concentration"].items(), key=lambda x: -x[1]["pct"]):
|
||||
exp = risk["direction_exposure"].get(ac, {})
|
||||
lines.append(
|
||||
f" {ac}: {data['pct']}% ({data['count']} trades) — "
|
||||
f"bull={exp.get('bullish', 0)} bear={exp.get('bearish', 0)} — "
|
||||
f"tickers: {', '.join(data['tickers'][:5])}"
|
||||
)
|
||||
|
||||
if risk["conflicts"]:
|
||||
lines += ["", "CONFLITS DIRECTIONNELS (DANGER):"]
|
||||
for c in risk["conflicts"]:
|
||||
trades_desc = "; ".join(
|
||||
f"#{t['id']} {t['direction']} ({t['strategy']}) depuis {t['entry_date']}"
|
||||
for t in c["trades"]
|
||||
)
|
||||
lines.append(f" {c['underlying']}: {trades_desc}")
|
||||
|
||||
if risk["alerts"]:
|
||||
lines += ["", "ALERTES:"]
|
||||
for a in risk["alerts"]:
|
||||
lines.append(f" [{a['level'].upper()}] {a['message']}")
|
||||
|
||||
return "\n".join(lines)
|
||||
Reference in New Issue
Block a user