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:
OpenSquared
2026-06-19 15:49:46 +02:00
parent d34b4043fb
commit 58c3767a9d
8 changed files with 749 additions and 38 deletions

View File

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

View File

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

View File

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

View 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)