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

@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>GeoOptions Intelligence — Manuel Complet v4.3</title>
<title>GeoOptions Intelligence — Manuel Complet v4.4</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: 'Georgia', 'Times New Roman', serif; font-size: 10.5pt; line-height: 1.65; color: #1a1a2e; background: #fff; max-width: 960px; margin: 0 auto; padding: 40px 48px; }
@@ -239,7 +239,7 @@ li { margin-bottom: 4px; font-size: 9.5pt; }
<tr><td>5</td><td><strong>Options Lab</strong></td><td>IV Rank, IV Percentile, Term Structure, Skew, Open Interest pour tous tickers</td><td>Avant chaque trade</td></tr>
<tr><td>6</td><td><strong>Patterns IA</strong></td><td>Suggestions du cycle en cours avec scores, tailles Kelly, contre-thèses</td><td>À chaque cycle (6h)</td></tr>
<tr><td>7</td><td><strong>Portefeuille</strong></td><td>Positions ouvertes, P&L, concentration, alertes risque cluster</td><td>Quotidien</td></tr>
<tr><td>8</td><td><strong>Journal de Bord</strong></td><td>Trades ouverts (MtM + alertes cible/stop) · Trades fermés (P&L réalisé, win rate) · Fermeture 1-clic avec motif</td><td>Quotidien</td></tr>
<tr><td>8</td><td><strong>Journal de Bord</strong></td><td>Trades ouverts (MtM + alertes cible/stop) · Trades fermés (P&L réalisé, win rate) · <strong>Risque Sim.</strong> (conflits directionnels, concentration par classe d'actif, recommandations IA) · Fermeture 1-clic avec motif</td><td>Quotidien</td></tr>
<tr><td>9</td><td><strong>Rapport IA</strong></td><td>Texte complet de la dernière analyse GPT-4o, raisonnement visible</td><td>À chaque cycle</td></tr>
<tr><td>10</td><td><strong>Super Contexte</strong></td><td>Texte narratif enrichi injecté dans le méga-prompt : patterns matures, historique récent</td><td>Lecture mensuelle</td></tr>
<tr><td>11</td><td><strong>Analytics</strong></td><td>Win rate par pattern, PnL cumulé, distribution des scores, calibration Brier</td><td>Mensuel</td></tr>
@@ -456,6 +456,14 @@ final_relevance = clamp(base_relevance + ai_alignment, 0, 100)
<p><strong>Output :</strong> Tables <code>pattern_performance</code> et <code>bayesian_estimates</code> mises à jour</p>
</div>
<div class="step-block">
<div class="step-num">Étape 7b — Moniteur de Portefeuille Simulé <span class="badge badge-ai">GPT-4o-mini</span></div>
<p><strong>Input :</strong> Ensemble des trades ouverts dans <code>trade_entry_prices</code> après le log du cycle courant</p>
<p><strong>Traitement :</strong> Le service <code>portfolio_risk.py</code> analyse la répartition par classe d'actif, détecte les conflits directionnels (même sous-jacent, directions opposées) et les sur-concentrations (&gt; 35% en une seule classe). Si des alertes sont générées, GPT-4o-mini reçoit un résumé compact et produit : un assessment global en 2 phrases, une liste d'actions priorisées (<code>close_trade / rebalance / monitor</code>), et une suggestion de rééquilibrage.</p>
<p><strong>Output :</strong> Résultat persisté dans <code>system_logs</code> (source = <code>portfolio_monitor</code>). Affiché dans l'onglet <strong>Risque Sim.</strong> du Journal de Bord avec badge rouge sur l'onglet en cas de conflit. Étape non-bloquante : une erreur n'interrompt pas le cycle.</p>
<p><strong>Colonne ajoutée :</strong> <code>asset_class</code> maintenant stockée dans <code>trade_entry_prices</code> (migration automatique) — renseignée à chaque INSERT depuis le pattern d'origine.</p>
</div>
<div class="step-block">
<div class="step-num">Étape 8 — Notification &amp; Affichage</div>
<p><strong>Input :</strong> Patterns validés, alertes risque, signaux d'invalidation détectés</p>
@@ -1322,8 +1330,9 @@ Seuil : confidence &gt; 0.75 → ALERTE INVALIDATION générée dans l'UI
<table>
<thead><tr><th>Table</th><th>Colonnes Clés</th><th>Rôle</th></tr></thead>
<tbody>
<tr><td><code>portfolio</code></td><td>id, ticker, strategie, direction, score_entree, taille_eur, date_entree, date_expiration, pnl_actuel_pct, facteurs_risque_json, triggers_invalidation_json, statut</td><td>Positions options ouvertes avec métadonnées complètes</td></tr>
<tr><td><code>portfolio</code></td><td>id, ticker, strategie, direction, score_entree, taille_eur, date_entree, date_expiration, pnl_actuel_pct, facteurs_risque_json, triggers_invalidation_json, statut</td><td>Positions options ouvertes (portefeuille réel IBKR) avec métadonnées complètes</td></tr>
<tr><td><code>trades</code></td><td>id, portfolio_id, date_fermeture, pnl_final_pct, raison_fermeture, notes_trader</td><td>Historique complet des trades fermés</td></tr>
<tr><td><code>trade_entry_prices</code></td><td>id, run_id, pattern_id, pattern_name, underlying, strategy, <strong>asset_class</strong>, entry_price, entry_date, score_at_entry, horizon_days, status, pnl_realized, close_price, close_reason, target_pct, stop_loss_pct</td><td>Portefeuille simulé — trades logués par le cycle IA avec suivi MtM. <code>asset_class</code> permet la surveillance de la concentration par classe. Distinct du portefeuille réel IBKR.</td></tr>
<tr><td><code>invalidation_alerts</code></td><td>id, portfolio_id, trigger_text, confidence, news_citation, timestamp, acquittee</td><td>Alertes d'invalidation détectées et leur statut</td></tr>
</tbody>
</table>
@@ -1380,19 +1389,40 @@ Seuil : confidence &gt; 0.75 → ALERTE INVALIDATION générée dans l'UI
Lire TOUJOURS la contre-thèse avant de valider. Si elle vous semble plus probable que la thèse, rejeter le pattern même si le score est élevé. L'IA ne connaît pas votre contexte informationnel personnel.
</div>
<h3>Page — Éditeur de Patterns Géopolitiques</h3>
<p>L'éditeur de patterns (<code>PatternEditor</code>) est la bibliothèque de tous les patterns actifs — intégrés et personnalisés. Il offre :</p>
<ul>
<li><strong>Vue grille par défaut</strong> — affichage 23 colonnes adaptatif. Basculer en vue liste via le toggle <code>⊞ / ≡</code> en haut à droite.</li>
<li><strong>Filtres par classe d'actif</strong> — chips cliquables : ⚡ Énergie · 🥇 Métaux · 🌾 Agri · 📈 Actions · 📊 Indices · 💱 Forex · 📉 Taux. Click sur le chip actif = réinitialisation.</li>
<li><strong>Tri</strong> — trois modes : <em>Date</em> (plus récent en tête, défaut), <em>Score IA</em> (meilleur score du dernier cycle en tête), <em>Prob.</em> (probabilité déclarée décroissante).</li>
<li><strong>Filtre temporel</strong> — Tout / 7j / 30j (patterns ajoutés dans la période).</li>
<li>Badge de résultat ("<em>N résultats</em>") affiché dès qu'un filtre autre que "Tous types" est actif.</li>
</ul>
<p><strong>Quand agir :</strong> Après chaque cycle IA, filtrer par classe d'actif puis trier par Score IA pour identifier les patterns les plus alignés avec le régime actuel. Utiliser le filtre "7j" pour revoir uniquement les ajouts récents de l'IA.</p>
<h3>Page 7 — Portefeuille</h3>
<p><strong>Regarder en premier :</strong> Alertes de cluster (facteur saturé) en rouge. Puis P&L des positions matures (&gt;60% horizon).</p>
<p><strong>Quand agir :</strong> Position mature en profit +15% → prise de bénéfices partielle. Perte &gt;20% avec trigger d'invalidation détecté → fermer immédiatement.</p>
<h3>Page 8 — Journal de Bord</h3>
<p>Le Journal comporte 5 onglets : <strong>Cycles IA</strong> · <strong>Régimes Macro</strong> · <strong>Ouverts</strong> · <strong>Fermés</strong> · <strong>Alertes Géo</strong>.</p>
<p>Le Journal comporte <strong>6 onglets</strong> : <strong>Cycles IA</strong> · <strong>Régimes Macro</strong> · <strong>Risque Sim.</strong> · <strong>Ouverts</strong> · <strong>Fermés</strong> · <strong>Alertes Géo</strong>.</p>
<p><strong>Onglet Risque Sim. — Surveillance du Portefeuille Simulé :</strong></p>
<ul>
<li><strong>Répartition par classe d'actif</strong> — barres horizontales colorées (⚡ Énergie, 🥇 Métaux, 🌾 Agri, 📈 Actions, 📊 Indices, 💱 Forex, 📉 Taux) avec compteurs bull/bear par classe et pourcentage du portefeuille total.</li>
<li><strong>Alertes rouge — Conflits directionnels</strong> : le système détecte automatiquement deux trades de sens opposés sur le même sous-jacent (ex. long call EUR/USD + short put EUR/USD). Chaque conflit affiche les IDs des trades impliqués, leur direction, leur date d'entrée et le pattern associé.</li>
<li><strong>Alertes jaune — Concentration excessive</strong> : une classe d'actif représentant &gt; 35% du portefeuille simulé, ou 3+ trades sur le même sous-jacent, déclenchent une alerte de sur-exposition.</li>
<li><strong>Recommandations IA</strong> (panel bleu) : à chaque cycle, un agent GPT-4o-mini analyse les alertes détectées et génère un assessment en 2 phrases + une liste d'actions prioritaires (close_trade / rebalance / monitor) avec le sous-jacent et la raison. Mis à jour automatiquement après chaque cycle.</li>
<li>Badge rouge sur l'onglet dès qu'un conflit directionnel existe — visible sans entrer dans l'onglet.</li>
</ul>
<p><strong>Onglet Ouverts (Mark-to-Market) :</strong></p>
<ul>
<li>Chaque trade logué par le cycle affiche le <strong>P&L théorique</strong> en temps réel (prix actuel vs prix d'entrée), avec une <strong>barre Cible/Stop</strong> qui visualise la distance à l'objectif et au stop-loss.</li>
<li>Badge <code>🎯</code> = objectif de gain atteint (ex. +30%). Badge <code>🛑</code> = stop-loss déclenché (ex. -50%). Ces seuils sont configurés dans Config → "Paramètres de sortie" et peuvent être surchargés trade par trade.</li>
<li>Bouton 🔒 (colonne de droite) → ouvre le modal de fermeture : saisir le prix de clôture, choisir le motif (Objectif / Stop-loss / Signal IA / Manuel), ajouter une note. Le P&L réalisé est calculé automatiquement.</li>
<li>Badge <code>⚠ NO DATA / NO ENTRY / NO LIVE</code> en ambre sur l'underlying = données de prix manquantes pour ce trade, monitoring impossible.</li>
<li>Bouton 🔒 (colonne de droite, scroll horizontal) → ouvre le modal de fermeture : saisir le prix de clôture, choisir le motif (Objectif / Stop-loss / Signal IA / Manuel), ajouter une note. Le P&L réalisé est calculé automatiquement.</li>
</ul>
<p><strong>Onglet Fermés :</strong></p>
@@ -1401,8 +1431,13 @@ Seuil : confidence &gt; 0.75 → ALERTE INVALIDATION générée dans l'UI
<li>Table complète avec dates d'entrée/sortie, durée de détention, P&L réalisé coloré, motif de fermeture, note libre.</li>
</ul>
<p><strong>Regarder en premier :</strong> Onglet Fermés → win rate sur les 30/60/90 derniers jours. Si win rate en "Signal IA retourné" &lt; 45% → les signaux de retournement sont trop tardifs, abaisser le seuil dans Config.</p>
<p><strong>Quand agir :</strong> Badge 🎯 ou 🛑 visible dans l'onglet Ouverts → décider manuellement de fermer ou de laisser courir. Le système n'agit jamais automatiquement — la confirmation humaine est toujours requise.</p>
<p><strong>Regarder en premier :</strong> Onglet <strong>Risque Sim.</strong> en premier — badge rouge sur l'onglet = conflit directionnel actif à résoudre. Puis Onglet Fermés → win rate sur les 30/60/90 derniers jours.</p>
<p><strong>Quand agir :</strong> Conflit détecté → lire la recommandation IA et décider quel trade fermer. Badge 🎯 ou 🛑 dans l'onglet Ouverts → décider de fermer ou laisser courir. Le système n'agit jamais automatiquement — la confirmation humaine est toujours requise.</p>
<div class="callout amber">
<div class="label">Philosophie Portefeuille Simulé</div>
Le portefeuille simulé (trades logués) est traité avec les mêmes outils de gestion du risque que le portefeuille réel Interactive Brokers. La surveillance de la concentration par classe d'actif et la détection des conflits directionnels sont identiques — seule la source des trades diffère (cycle IA vs exécution manuelle IBKR).
</div>
<h3>Page 9 — Rapport IA</h3>
<p><strong>Regarder en premier :</strong> Section "Risques à Surveiller" — liste les catalyseurs à calendrier connu (réunions Fed, publications PMI, élections).</p>
@@ -1499,13 +1534,18 @@ Seuil : confidence &gt; 0.75 → ALERTE INVALIDATION générée dans l'UI
<tr><td><strong>Géo Score</strong></td><td>Score agrégé de tension géopolitique mondiale, 0100. Calculé par moyenne pondérée des scores régionaux. &gt;60 = élevé, &gt;75 = critique. Impacte directement les suggestions IA.</td></tr>
<tr><td><strong>Trigger d'Invalidation</strong></td><td>Événement spécifique dont l'occurrence remet en cause la thèse d'un trade. Surveillance automatique via RSS + GPT-4o-mini à chaque cycle. Alerte si confidence &gt; 0.75.</td></tr>
<tr><td><strong>Factor Clustering</strong></td><td>Classification des positions par facteur de risque sous-jacent (géopolitique, inflation, dollar, liquidité) pour détecter les corrélations cachées entre positions de secteurs différents.</td></tr>
<tr><td><strong>Portefeuille Simulé</strong></td><td>Ensemble des trades logués automatiquement par le cycle IA dans la table <code>trade_entry_prices</code>. Distinct du portefeuille réel Interactive Brokers. Soumis aux mêmes outils d'analyse du risque (concentration, conflits, P&L MtM). Ne déclenche aucune exécution — simulation et apprentissage uniquement.</td></tr>
<tr><td><strong>Conflit Directionnel</strong></td><td>Situation où deux trades ouverts dans le portefeuille simulé ont des directions opposées sur le même sous-jacent (ex. long call XLE + short put XLE). Détecté automatiquement par <code>portfolio_risk.py</code>. Déclenche une alerte rouge dans l'onglet Risque Sim. et une recommandation IA de fermeture.</td></tr>
<tr><td><strong>Moniteur de Portefeuille IA</strong></td><td>Agent GPT-4o-mini (Step 7b du cycle) qui analyse l'état du portefeuille simulé après chaque log de trades. Produit un assessment global + liste d'actions (close / rebalance / monitor) + suggestion de rééquilibrage. Non-bloquant et persisté dans system_logs pour affichage dans le Journal.</td></tr>
<tr><td><strong>Concentration de Classe</strong></td><td>Pourcentage d'une classe d'actif (energy, metals, agriculture, equities, indices, forex, rates) dans le portefeuille simulé total. Seuil d'alerte : &gt; 35%. Affiché sous forme de barres horizontales colorées dans l'onglet Risque Sim.</td></tr>
<tr><td><strong>Price Warning</strong></td><td>Badge ambre sur un trade dans l'onglet Ouverts : <code>NO DATA</code> (ni prix d'entrée ni prix actuel disponible), <code>NO ENTRY</code> (prix d'entrée absent), <code>NO LIVE</code> (prix actuel non récupéré). Signale que le monitoring MtM est impossible pour ce trade.</td></tr>
</tbody>
</table>
<hr>
<div class="footer-doc">
<p>Document généré le 19 juin 2026 · GeoOptions Intelligence v4.3</p>
<p>Document généré le 19 juin 2026 · GeoOptions Intelligence v4.4</p>
<p>FastAPI + React + SQLite + GPT-4o · Usage interne trader · Confidentiel</p>
<p>Sections : Architecture · Cycle complet simulé · Phase 1 IV/Structure · Phase 2 Fiabilité/Calibration · Phase 3 Risk Engine · Phase 4 IA Probabiliste · 6 Prompts IA · 14+ Tables DB · Guide 15 pages · 10 Décisions conception · Glossaire 35+ termes</p>
</div>

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)

View File

@@ -341,6 +341,7 @@ export const CYCLE_REFRESH_KEYS = [
['journal-macro'],
['geo-risk-score'],
['pattern-matches'],
['journal-portfolio-risk'],
]
export const useTriggerCycle = () => {
@@ -458,6 +459,19 @@ export const useSaveExitDefaults = () => {
})
}
export const useSimPortfolioRisk = () =>
useQuery({
queryKey: ['journal-portfolio-risk'],
queryFn: () => api.get('/journal/portfolio-risk').then(r => r.data),
staleTime: 30_000,
})
export const useTradeCheck = () =>
useMutation({
mutationFn: (body: { underlying: string; strategy: string; asset_class: string }) =>
api.post('/journal/trade-check', body).then(r => r.data),
})
// ── Risk Profiles ─────────────────────────────────────────────────────────────
export const useRiskProfiles = () =>

View File

@@ -1,7 +1,7 @@
import { useState, useEffect, useRef, Fragment } from 'react'
import { BookOpen, TrendingUp, TrendingDown, Activity, AlertTriangle, RefreshCw, Zap, CheckCircle, XCircle, Brain, Trash2, Search, X, ChevronDown, ChevronUp, Terminal, Lock } from 'lucide-react'
import { BookOpen, TrendingUp, TrendingDown, Activity, AlertTriangle, RefreshCw, Zap, CheckCircle, XCircle, Brain, Trash2, Search, X, ChevronDown, ChevronUp, Terminal, Lock, ShieldAlert, PieChart } from 'lucide-react'
import clsx from 'clsx'
import { useJournalSummary, useMacroHistory, useGeoHistory, useTradeMtm, useClosedTrades, useCloseTrade, useUpdateExitParams, useExitDefaults, useCycleHistory, useCycleStatus, useTriggerCycle, useTradePostmortem, useAnalyzePostmortem, useIvForTrade, useKellySizing, api } from '../hooks/useApi'
import { useJournalSummary, useMacroHistory, useGeoHistory, useTradeMtm, useClosedTrades, useCloseTrade, useUpdateExitParams, useExitDefaults, useCycleHistory, useCycleStatus, useTriggerCycle, useTradePostmortem, useAnalyzePostmortem, useIvForTrade, useKellySizing, useSimPortfolioRisk, api } from '../hooks/useApi'
import { useQueryClient } from '@tanstack/react-query'
import { format } from 'date-fns'
import { fr } from 'date-fns/locale'
@@ -1234,21 +1234,228 @@ function CyclesSection() {
// ── Page principale ────────────────────────────────────────────────────────────
const ASSET_CLASS_COLORS: Record<string, string> = {
energy: '#f97316', metals: '#eab308', agriculture: '#84cc16',
equities: '#22c55e', indices: '#3b82f6', forex: '#8b5cf6', rates: '#06b6d4',
unknown: '#64748b',
}
function SimPortfolioRiskPanel() {
const { data, isLoading, refetch } = useSimPortfolioRisk()
const risk = data as any
if (isLoading) {
return (
<div className="card animate-pulse">
<div className="h-5 bg-dark-700 rounded w-48 mb-4" />
<div className="space-y-2">{[1, 2, 3].map(i => <div key={i} className="h-8 bg-dark-700 rounded" />)}</div>
</div>
)
}
if (!risk || risk.open_count === 0) {
return (
<div className="card text-center py-8 text-slate-500">
<PieChart className="w-8 h-8 mx-auto mb-2 opacity-20" />
<div className="text-sm">Aucune position ouverte dans le portefeuille simulé</div>
<div className="text-xs mt-1">Les trades logués apparaîtront ici après le prochain cycle</div>
</div>
)
}
const concentration: Record<string, any> = risk.concentration ?? {}
const alerts: any[] = risk.alerts ?? []
const conflicts: any[] = risk.conflicts ?? []
const aiMonitor: any = risk.ai_monitor
const aiTs: string = risk.ai_monitor_ts
const dangers = alerts.filter((a: any) => a.level === 'danger')
const warnings = alerts.filter((a: any) => a.level === 'warning')
return (
<div className="space-y-3">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<PieChart className="w-4 h-4 text-blue-400" />
<span className="text-sm font-semibold text-white">Portefeuille Simulé — Analyse de Risque</span>
<span className="text-xs text-slate-500">{risk.open_count} positions ouvertes</span>
{dangers.length > 0 && (
<span className="inline-flex items-center gap-1 text-[10px] font-bold bg-red-900/30 border border-red-600/40 text-red-400 rounded px-1.5 py-0.5">
<ShieldAlert className="w-3 h-3" /> {dangers.length} CONFLIT{dangers.length > 1 ? 'S' : ''}
</span>
)}
</div>
<button onClick={() => refetch()} className="text-slate-600 hover:text-slate-400 transition-colors">
<RefreshCw className="w-3.5 h-3.5" />
</button>
</div>
{/* Asset class concentration bars */}
<div className="card">
<div className="text-xs text-slate-500 mb-3 font-semibold uppercase tracking-wide">Répartition par classe d'actif</div>
<div className="space-y-2">
{Object.entries(concentration)
.sort(([, a]: any, [, b]: any) => b.pct - a.pct)
.map(([ac, data]: [string, any]) => {
const color = ASSET_CLASS_COLORS[ac] ?? ASSET_CLASS_COLORS.unknown
const exposure = risk.direction_exposure?.[ac] ?? {}
return (
<div key={ac}>
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-2">
<span className="text-xs font-semibold w-20 capitalize" style={{ color }}>{ac}</span>
<span className="text-[10px] text-slate-500">{data.tickers?.slice(0, 3).join(', ')}{data.tickers?.length > 3 ? '…' : ''}</span>
</div>
<div className="flex items-center gap-2 text-[10px]">
{exposure.bullish > 0 && (
<span className="text-emerald-400 flex items-center gap-0.5">
<TrendingUp className="w-2.5 h-2.5" /> {exposure.bullish}
</span>
)}
{exposure.bearish > 0 && (
<span className="text-red-400 flex items-center gap-0.5">
<TrendingDown className="w-2.5 h-2.5" /> {exposure.bearish}
</span>
)}
<span className="text-slate-400 font-mono font-bold">{data.pct}%</span>
<span className="text-slate-600">({data.count})</span>
</div>
</div>
<div className="h-1.5 bg-dark-700 rounded-full overflow-hidden">
<div
className="h-full rounded-full transition-all"
style={{ width: `${data.pct}%`, background: `${color}cc` }}
/>
</div>
</div>
)
})}
</div>
</div>
{/* Alerts */}
{alerts.length > 0 && (
<div className="space-y-2">
{/* Conflicts */}
{conflicts.map((c: any, i: number) => (
<div key={i} className="card border-red-700/40 bg-red-900/10">
<div className="flex items-start gap-2">
<ShieldAlert className="w-4 h-4 text-red-400 shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
<div className="text-xs font-semibold text-red-300 mb-1">
Conflit directionnel {c.underlying}
</div>
<div className="space-y-0.5">
{c.trades.map((t: any) => (
<div key={t.id} className="flex items-center gap-2 text-[11px]">
<span className={clsx('font-bold font-mono',
t.direction === 'bullish' ? 'text-emerald-400' : 'text-red-400')}>
{t.direction === 'bullish' ? '▲' : '▼'} #{t.id}
</span>
<span className="text-slate-400">{t.strategy}</span>
<span className="text-slate-600">{t.entry_date}</span>
{t.pattern_name && (
<span className="text-slate-600 truncate max-w-[180px]">{t.pattern_name}</span>
)}
</div>
))}
</div>
</div>
</div>
</div>
))}
{/* Concentration & overweight warnings */}
{warnings.length > 0 && (
<div className="card border-amber-700/30 bg-amber-900/10">
<div className="text-xs font-semibold text-amber-300 mb-1.5 flex items-center gap-1">
<AlertTriangle className="w-3.5 h-3.5" /> Alertes de concentration
</div>
<div className="space-y-1">
{warnings.map((a: any, i: number) => (
<div key={i} className="text-[11px] text-amber-200/80 flex items-start gap-1.5">
<span className="text-amber-500 shrink-0"></span>
{a.message}
</div>
))}
</div>
</div>
)}
</div>
)}
{/* AI Monitor recommendations */}
{aiMonitor && (
<div className="card border-blue-700/30 bg-blue-900/10">
<div className="flex items-center justify-between mb-2">
<div className="text-xs font-semibold text-blue-300 flex items-center gap-1.5">
<Brain className="w-3.5 h-3.5" /> Recommandations IA Moniteur de portefeuille
</div>
{aiTs && (
<span className="text-[10px] text-slate-600">{aiTs.slice(0, 16).replace('T', ' ')}</span>
)}
</div>
{aiMonitor.assessment && (
<p className="text-xs text-slate-300 mb-2">{aiMonitor.assessment}</p>
)}
{aiMonitor.actions?.length > 0 && (
<div className="space-y-1 mb-2">
{aiMonitor.actions.map((action: any, i: number) => (
<div key={i} className={clsx(
'flex items-start gap-2 text-[11px] rounded px-2 py-1',
action.priority === 'high'
? 'bg-red-900/20 border border-red-700/20 text-red-300'
: 'bg-slate-800/40 border border-slate-700/20 text-slate-300'
)}>
<span className="shrink-0 font-bold">
{action.type === 'close_trade' ? '🔒' : action.type === 'rebalance' ? '⚖️' : '👁'}
</span>
<div>
{action.underlying && <span className="font-mono font-bold mr-1">{action.underlying}</span>}
{action.trade_id && <span className="text-slate-500 mr-1">#{action.trade_id}</span>}
{action.reason}
</div>
</div>
))}
</div>
)}
{aiMonitor.rebalance_suggestion && (
<div className="text-[11px] text-blue-300/70 border-t border-blue-700/20 pt-2 mt-1">
{aiMonitor.rebalance_suggestion}
</div>
)}
</div>
)}
{alerts.length === 0 && (
<div className="card border-emerald-700/30 bg-emerald-900/10 text-center py-3">
<CheckCircle className="w-5 h-5 text-emerald-400 mx-auto mb-1" />
<div className="text-xs text-emerald-300">Portefeuille équilibré aucun conflit ni sur-concentration détectés</div>
</div>
)}
</div>
)
}
const TABS = [
{ key: 'cycles', label: 'Cycles IA', icon: Zap },
{ key: 'macro', label: 'Régimes Macro', icon: Activity },
{ key: 'risk', label: 'Risque Sim.', icon: ShieldAlert },
{ key: 'mtm', label: 'Ouverts', icon: TrendingUp },
{ key: 'closed', label: 'Fermés', icon: Lock },
{ key: 'geo', label: 'Alertes Géo', icon: AlertTriangle },
] as const
export default function JournalDeBord() {
const [tab, setTab] = useState<'cycles' | 'macro' | 'mtm' | 'closed' | 'geo'>('cycles')
const [tab, setTab] = useState<'cycles' | 'macro' | 'risk' | 'mtm' | 'closed' | 'geo'>('cycles')
const [days, setDays] = useState(15)
const [confirmReset, setConfirmReset] = useState(false)
const [resetting, setResetting] = useState(false)
const [resetMsg, setResetMsg] = useState('')
const { data: summary, refetch: refetchSummary } = useJournalSummary()
const { data: riskData } = useSimPortfolioRisk()
const riskAlertCount = (riskData as any)?.alerts?.filter((a: any) => a.level === 'danger').length ?? 0
const qc = useQueryClient()
const s = summary as any
@@ -1371,13 +1578,18 @@ export default function JournalDeBord() {
{/* Tabs */}
<div className="flex gap-1 bg-dark-700 p-1 rounded w-fit">
{TABS.map(({ key, label, icon: Icon }) => (
<button key={key} onClick={() => setTab(key)}
<button key={key} onClick={() => setTab(key as any)}
className={clsx('flex items-center gap-1.5 px-3 py-1.5 rounded text-sm transition-colors', {
'bg-blue-600 text-white': tab === key,
'text-slate-400 hover:text-slate-200': tab !== key,
})}>
<Icon className="w-3.5 h-3.5" />
{label}
{key === 'risk' && riskAlertCount > 0 && (
<span className="ml-0.5 text-[10px] font-bold bg-red-500 text-white rounded-full w-4 h-4 flex items-center justify-center shrink-0">
{riskAlertCount}
</span>
)}
</button>
))}
</div>
@@ -1385,6 +1597,7 @@ export default function JournalDeBord() {
{/* Content */}
{tab === 'cycles' && <CyclesSection />}
{tab === 'macro' && <MacroHistorySection days={days} />}
{tab === 'risk' && <SimPortfolioRiskPanel />}
{tab === 'mtm' && <TradeMtmSection days={days} />}
{tab === 'closed' && <ClosedTradesSection days={days} />}
{tab === 'geo' && <GeoHistorySection days={days} />}

View File

@@ -1,7 +1,7 @@
import { useState, useMemo } from 'react'
import { useAllPatterns, useSavePattern, useDeletePattern, useEvaluatePattern, useSuggestPattern, useAiStatus, useSuggestNewPatterns, useTogglePattern, usePatternSimilarity, useLastScores, usePatternReliability } from '../hooks/useApi'
import clsx from 'clsx'
import { Zap, Plus, Trash2, Edit3, Brain, Save, RotateCcw, Sparkles, X, Check, Eye, EyeOff, ShieldAlert } from 'lucide-react'
import { Zap, Plus, Trash2, Edit3, Brain, Save, RotateCcw, Sparkles, X, Check, Eye, EyeOff, ShieldAlert, LayoutGrid, List, SlidersHorizontal } from 'lucide-react'
function jaccard(a: string[], b: string[]): number {
if (!a.length && !b.length) return 0
@@ -15,6 +15,10 @@ function jaccard(a: string[], b: string[]): number {
const TRIGGERS = ['military', 'sanctions', 'elections', 'natural_disaster', 'health_crisis', 'resource_scarcity', 'trade_war', 'energy', 'political_speech', 'financial_crisis']
const ASSET_CLASSES = ['energy', 'metals', 'agriculture', 'equities', 'indices', 'forex', 'rates']
const ASSET_CLASS_LABELS: Record<string, string> = {
energy: '⚡ Énergie', metals: '🥇 Métaux', agriculture: '🌾 Agri',
equities: '📈 Actions', indices: '📊 Indices', forex: '💱 Forex', rates: '📉 Taux',
}
const TRIGGER_LABELS: Record<string, string> = {
military: '⚔️ Militaire', sanctions: '🚫 Sanctions', elections: '🗳️ Élections',
natural_disaster: '🌪️ Catastrophe', health_crisis: '🏥 Santé',
@@ -635,6 +639,10 @@ export default function PatternEditor() {
const [creating, setCreating] = useState(false)
const [showAiSuggest, setShowAiSuggest] = useState(false)
const [tab, setTab] = useState<'all' | 'custom' | 'builtin'>('all')
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid')
const [sortBy, setSortBy] = useState<'date' | 'score' | 'prob'>('date')
const [filterAsset, setFilterAsset] = useState<string>('all')
const [filterDays, setFilterDays] = useState<number | null>(null)
// Build AI score map: patternId → best effective score (same logic as cockpit panel)
const aiScoreMap = useMemo(() => {
@@ -672,18 +680,40 @@ export default function PatternEditor() {
return map
}, [simData])
const displayPatterns = (patterns ?? []).filter((p: any) => {
if (tab === 'custom') return p.source === 'custom'
if (tab === 'builtin') return p.source === 'builtin'
return true
})
const displayPatterns = useMemo(() => {
let result = (patterns ?? []).filter((p: any) => {
if (tab === 'custom') return p.source === 'custom'
if (tab === 'builtin') return p.source === 'builtin'
return true
})
if (filterAsset !== 'all') {
result = result.filter((p: any) => p.asset_class === filterAsset)
}
if (filterDays !== null) {
const cutoff = new Date()
cutoff.setDate(cutoff.getDate() - filterDays)
result = result.filter((p: any) => p.created_at && new Date(p.created_at) >= cutoff)
}
switch (sortBy) {
case 'score':
return [...result].sort((a, b) => (aiScoreMap[b.id] ?? 0) - (aiScoreMap[a.id] ?? 0))
case 'prob':
return [...result].sort((a, b) => (b.probability ?? 0) - (a.probability ?? 0))
default:
return [...result].sort((a, b) => (b.created_at ?? '').localeCompare(a.created_at ?? ''))
}
}, [patterns, tab, filterAsset, filterDays, sortBy, aiScoreMap])
const handleSave = (form: any) => {
savePattern(form, { onSuccess: () => { setEditing(null); setCreating(false) } })
}
const gridClass = viewMode === 'grid'
? 'grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3'
: 'space-y-3'
return (
<div className="p-6 space-y-5">
<div className="p-6 space-y-4">
{showAiSuggest && (
<AiSuggestModal
onClose={() => setShowAiSuggest(false)}
@@ -698,7 +728,7 @@ export default function PatternEditor() {
<Zap className="w-5 h-5 text-blue-400" /> Patterns Géopolitiques
</h1>
<p className="text-xs text-slate-500 mt-0.5">
{patterns?.length ?? 0} patterns actifs ·{' '}
{patterns?.length ?? 0} patterns ·{' '}
{patterns?.filter((p: any) => p.source === 'custom').length ?? 0} personnalisés
</p>
</div>
@@ -729,26 +759,105 @@ export default function PatternEditor() {
</div>
)}
<div className="flex gap-1 bg-dark-700 p-1 rounded w-fit">
{[
{ key: 'all', label: `Tous (${patterns?.length ?? 0})` },
{ key: 'builtin', label: `Intégrés (${patterns?.filter((p: any) => p.source === 'builtin').length ?? 0})` },
{ key: 'custom', label: `Mes patterns (${patterns?.filter((p: any) => p.source === 'custom').length ?? 0})` },
].map(({ key, label }) => (
<button key={key} onClick={() => setTab(key as any)}
className={clsx('px-3 py-1.5 rounded text-sm', {
'bg-blue-600 text-white': tab === key,
'text-slate-400 hover:text-slate-200': tab !== key,
{/* Source tabs + controls */}
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex gap-1 bg-dark-700 p-1 rounded">
{[
{ key: 'all', label: `Tous (${patterns?.length ?? 0})` },
{ key: 'builtin', label: `Intégrés (${patterns?.filter((p: any) => p.source === 'builtin').length ?? 0})` },
{ key: 'custom', label: `Mes patterns (${patterns?.filter((p: any) => p.source === 'custom').length ?? 0})` },
].map(({ key, label }) => (
<button key={key} onClick={() => setTab(key as any)}
className={clsx('px-3 py-1.5 rounded text-sm', {
'bg-blue-600 text-white': tab === key,
'text-slate-400 hover:text-slate-200': tab !== key,
})}>
{label}
</button>
))}
</div>
<div className="flex items-center gap-2 flex-wrap">
{/* Sort */}
<div className="flex gap-1 bg-dark-700 p-1 rounded items-center">
<SlidersHorizontal className="w-3.5 h-3.5 text-slate-500 ml-1 mr-0.5" />
{([
{ key: 'date', label: 'Date' },
{ key: 'score', label: 'Score IA' },
{ key: 'prob', label: 'Prob.' },
] as const).map(({ key, label }) => (
<button key={key} onClick={() => setSortBy(key)}
className={clsx('px-2 py-1 rounded text-xs transition-colors', {
'bg-slate-600 text-white': sortBy === key,
'text-slate-400 hover:text-slate-200': sortBy !== key,
})}>
{label}
</button>
))}
</div>
{/* Period filter */}
<div className="flex gap-1 bg-dark-700 p-1 rounded">
{([
{ key: null, label: 'Tout' },
{ key: 7, label: '7j' },
{ key: 30, label: '30j' },
] as const).map(({ key, label }) => (
<button key={String(key)} onClick={() => setFilterDays(key)}
className={clsx('px-2 py-1 rounded text-xs transition-colors', {
'bg-slate-600 text-white': filterDays === key,
'text-slate-400 hover:text-slate-200': filterDays !== key,
})}>
{label}
</button>
))}
</div>
{/* View mode */}
<div className="flex gap-1 bg-dark-700 p-1 rounded">
<button onClick={() => setViewMode('grid')}
className={clsx('p-1.5 rounded transition-colors', viewMode === 'grid' ? 'bg-slate-600 text-white' : 'text-slate-400 hover:text-slate-200')}>
<LayoutGrid className="w-3.5 h-3.5" />
</button>
<button onClick={() => setViewMode('list')}
className={clsx('p-1.5 rounded transition-colors', viewMode === 'list' ? 'bg-slate-600 text-white' : 'text-slate-400 hover:text-slate-200')}>
<List className="w-3.5 h-3.5" />
</button>
</div>
</div>
</div>
{/* Asset class filter chips */}
<div className="flex flex-wrap gap-1.5 items-center">
<button onClick={() => setFilterAsset('all')}
className={clsx('px-2.5 py-1 rounded border text-xs transition-all', {
'bg-slate-600/40 border-slate-500/60 text-white': filterAsset === 'all',
'border-slate-700/40 text-slate-500 hover:border-slate-500 hover:text-slate-300': filterAsset !== 'all',
})}>
Tous types
</button>
{ASSET_CLASSES.map(ac => (
<button key={ac} onClick={() => setFilterAsset(filterAsset === ac ? 'all' : ac)}
className={clsx('px-2.5 py-1 rounded border text-xs transition-all', {
'bg-blue-600/30 border-blue-500/60 text-blue-300': filterAsset === ac,
'border-slate-700/40 text-slate-500 hover:border-slate-500 hover:text-slate-300': filterAsset !== ac,
})}>
{label}
{ASSET_CLASS_LABELS[ac]}
</button>
))}
{(filterAsset !== 'all' || filterDays !== null) && (
<span className="text-xs text-slate-600 ml-1">
{displayPatterns.length} résultat{displayPatterns.length !== 1 ? 's' : ''}
</span>
)}
</div>
{isLoading ? (
<div className="space-y-3">{[1,2,3].map(i => <div key={i} className="card animate-pulse h-20 bg-dark-700" />)}</div>
<div className={gridClass}>
{[1,2,3,4,5,6].map(i => <div key={i} className="card animate-pulse h-24 bg-dark-700" />)}
</div>
) : (
<div className="space-y-3">
<div className={gridClass}>
{displayPatterns.map((p: any) => (
<PatternCard key={p.id}
p={p}
@@ -761,9 +870,15 @@ export default function PatternEditor() {
/>
))}
{displayPatterns.length === 0 && (
<div className="card text-center py-10 text-slate-500">
<div className="card text-center py-10 text-slate-500 col-span-full">
<Zap className="w-8 h-8 mx-auto mb-2 opacity-20" />
<div>Aucun pattern personnalisé</div>
<div>
{filterAsset !== 'all'
? `Aucun pattern dans "${ASSET_CLASS_LABELS[filterAsset]}"`
: filterDays !== null
? `Aucun pattern dans les ${filterDays} derniers jours`
: 'Aucun pattern personnalisé'}
</div>
<div className="text-xs mt-1">Créer un pattern avec l'aide de l'IA</div>
</div>
)}