Files
OpenFin/backend/services/auto_cycle.py
OpenSquared 16ccc7c2c7 feat: cycle
2026-07-15 14:59:11 +02:00

2308 lines
113 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Auto-cycle orchestration — runs every N hours (configurable).
Cycle steps:
1. Fetch current context (news, quotes, macro, geo)
2. Ask GPT-4o to suggest new patterns
3. Filter: keep only those with Jaccard keyword similarity < threshold vs existing
4. Save filtered patterns to DB
5. Score ALL patterns (existing + new)
6. Log: pattern scores, trade entry prices, geo alert, macro snapshot
7. Generate GPT-4o commentary: why are top/bottom trades performing this way?
8. Update cycle_runs with results + commentary
"""
import logging
import threading
import uuid
from datetime import datetime, timedelta
from typing import Any, Dict, List, Optional
logger = logging.getLogger(__name__)
# ── Global scheduler state ────────────────────────────────────────────────────
_stop_event = threading.Event()
_cycle_thread: Optional[threading.Thread] = None
_cycle_lock = threading.Lock() # prevents concurrent cycles
_current_status: Dict[str, Any] = {
"running": False,
"last_run_id": None,
"last_run_at": None,
"next_run_at": None,
"enabled": False,
"interval_hours": 3,
}
# ── Helpers ───────────────────────────────────────────────────────────────────
def _jaccard(a: List[str], b: List[str]) -> float:
sa = {x.lower() for x in (a or [])}
sb = {x.lower() for x in (b or [])}
if not sa and not sb:
return 0.0
union = sa | sb
return len(sa & sb) / len(union) if union else 0.0
def _max_similarity_vs_existing(candidate_kws: List[str], existing: List[Dict]) -> float:
return max((_jaccard(candidate_kws, p.get("keywords") or []) for p in existing), default=0.0)
_EMBED_SIM_THRESHOLD = 0.75 # cosine threshold to consider two patterns as duplicates — fallback default, see CYCLE_STEP_CATALOG["duplicate_detection"]
def _is_duplicate_pattern(
candidate: Dict,
existing: List[Dict],
api_key: str,
jaccard_threshold: float = 0.30,
embed_threshold: float = _EMBED_SIM_THRESHOLD,
) -> bool:
"""
Returns True if the candidate is too similar to an existing pattern.
Tries cosine embeddings (Sprint 4.3) with Jaccard fallback.
"""
if not existing:
return False
# Tentative embedding cosinus
if api_key:
text = ((candidate.get("name") or "") + " " + (candidate.get("description") or "")).strip()
if text:
try:
from services.database import max_cosine_similarity_vs_existing
sim = max_cosine_similarity_vs_existing(
candidate_text=text,
existing_patterns=existing,
api_key=api_key,
candidate_id=candidate.get("id"),
)
if sim > 0: # embedding worked
return sim >= embed_threshold
except Exception as _emb_err:
logger.debug(f"[Cycle] Embedding failed, fallback Jaccard: {_emb_err}")
# Fallback Jaccard
sim = _max_similarity_vs_existing(candidate.get("keywords") or [], existing)
return sim >= jaccard_threshold
# ── Cycle step configuration — self-describing catalog + DB-backed overrides ──
# Mirrors the SIGNAL_CATALOG pattern already used for AI Desks (routers/ai_desks.py):
# one declarative entry per knob so Config.tsx can render a generic form instead
# of hand-coded sliders. Stored as one JSON blob under config key
# "cycle_step_config" (same convention as exit_defaults/analysis_config), merged
# with these defaults at read time — adding a new knob here never requires a
# DB migration.
CYCLE_STEP_CATALOG: List[Dict[str, Any]] = [
{"id": "portfolio_monitor", "label": "Portfolio Monitor", "group": "portfolio",
"description": "Alerte IA quand le risque du portefeuille simulé dépasse un seuil (conflits, concentration).",
"params": {"enabled": {"type": "bool", "default": True}}},
{"id": "watchlist_auto_add", "label": "Ajout auto à la watchlist", "group": "portfolio",
"description": "Ajoute automatiquement les nouveaux tickers détectés par le cycle à la watchlist.",
"params": {"enabled": {"type": "bool", "default": True}}},
{"id": "regime_clustering", "label": "Clustering de régime macro", "group": "ai",
"description": "Reclassifie le régime macro courant par clustering.",
"params": {
"enabled": {"type": "bool", "default": True},
"n_clusters": {"type": "int", "label": "Nb clusters", "default": 4, "min": 2, "max": 8},
"lookback_days": {"type": "int", "label": "Lookback (j)", "default": 180, "min": 30, "max": 365},
}},
{"id": "knowledge_synthesis", "label": "Synthèse de connaissances", "group": "ai",
"description": "Résume les derniers rapports IA en enseignements exploitables.",
"params": {
"enabled": {"type": "bool", "default": True},
"min_hours_between": {"type": "int", "label": "Délai min entre synthèses (h)", "default": 6, "min": 1, "max": 48},
}},
{"id": "institutional_absorption", "label": "Absorption rapports institutionnels", "group": "data",
"description": "Fenêtre de suivi de l'absorption marché d'un rapport institutionnel.",
"params": {"lookback_days": {"type": "int", "label": "Lookback (j)", "default": 14, "min": 3, "max": 60}}},
{"id": "var_snapshot", "label": "Snapshot VaR", "group": "portfolio",
"description": "Paramètres du calcul de Value-at-Risk dans le rapport de cycle.",
"params": {
"confidence": {"type": "float", "label": "Confiance", "default": 0.95, "min": 0.80, "max": 0.99},
"horizon_days": {"type": "int", "label": "Horizon (j)", "default": 1, "min": 1, "max": 30},
"lookback_days": {"type": "int", "label": "Lookback (j)", "default": 252, "min": 60, "max": 504},
"default_iv": {"type": "float", "label": "IV par défaut", "default": 0.20, "min": 0.05, "max": 1.0},
}},
{"id": "duplicate_detection", "label": "Détection de doublons de patterns", "group": "ai",
"description": "Seuil de similarité cosinus (embeddings) pour éviter de recréer un pattern existant.",
"params": {"embedding_threshold": {"type": "float", "label": "Seuil embedding", "default": 0.75, "min": 0.5, "max": 0.95}}},
{"id": "price_snapshot_retention", "label": "Rétention snapshots de prix", "group": "data",
"description": "Durée de conservation des snapshots de prix intra-cycle.",
"params": {"days": {"type": "int", "label": "Jours", "default": 14, "min": 1, "max": 90}}},
{"id": "iv_context", "label": "Contexte IV / mouvements récents", "group": "data",
"description": "Fenêtre de trades utilisée pour le contexte IV (utilisée à 3 endroits du cycle).",
"params": {"lookback_days": {"type": "int", "label": "Jours", "default": 90, "min": 7, "max": 365}}},
{"id": "absorption_detection", "label": "Détection d'absorption de prix", "group": "data",
"description": "Fenêtre d'âge pour la détection d'absorption de prix (Phase 4B).",
"params": {
"min_age_minutes": {"type": "float", "label": "Âge min (min)", "default": 30.0, "min": 5, "max": 180},
"max_age_days": {"type": "int", "label": "Âge max (j)", "default": 7, "min": 1, "max": 30},
}},
{"id": "economic_surprises", "label": "Surprises économiques (FRED)", "group": "data",
"description": "Fenêtre de lookback pour les surprises économiques récentes.",
"params": {"lookback_days": {"type": "int", "label": "Jours", "default": 60, "min": 7, "max": 180}}},
{"id": "institutional_block", "label": "Bloc institutionnel (prompt)", "group": "data",
"description": "Fenêtre des rapports institutionnels injectés dans le prompt de suggestion.",
"params": {"lookback_days": {"type": "int", "label": "Jours", "default": 7, "min": 1, "max": 30}}},
{"id": "cycle_commentary", "label": "Commentaire de fin de cycle", "group": "ai",
"description": "Fenêtre de trades récents utilisée pour le commentaire GPT-4o de fin de cycle.",
"params": {"lookback_days": {"type": "int", "label": "Jours", "default": 7, "min": 1, "max": 30}}},
]
def _default_cycle_step_config() -> Dict[str, Dict[str, Any]]:
return {step["id"]: {k: p["default"] for k, p in step["params"].items()} for step in CYCLE_STEP_CATALOG}
def get_cycle_step_config() -> Dict[str, Dict[str, Any]]:
"""Merge saved overrides (config key "cycle_step_config") over the catalog
defaults — missing keys/steps fall back to defaults, so adding a new knob
to CYCLE_STEP_CATALOG is never a breaking change for existing saved config."""
import json
from services.database import get_config
defaults = _default_cycle_step_config()
try:
saved = json.loads(get_config("cycle_step_config") or "{}")
except Exception:
saved = {}
return {step_id: {**step_defaults, **(saved.get(step_id) or {})} for step_id, step_defaults in defaults.items()}
# ── 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 = (
"You are a risk manager specializing in geopolitical options portfolios. "
"Analyze the state of the simulated portfolio below and generate concrete recommendations. "
"Respond in JSON with this exact schema: "
'{"assessment": "<max 2 sentences on the overall state>", '
'"actions": [{"priority": "high|medium", "type": "close_trade|rebalance|monitor", '
'"trade_id": <int or null>, "underlying": "<ticker>", "reason": "<short reason>"}], '
'"rebalance_suggestion": "<concrete rebalancing suggestion in 1 sentence>"}'
)
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]:
"""
Execute one full auto-cycle. Thread-safe (skips if already running).
Returns a summary dict.
"""
if not _cycle_lock.acquire(blocking=False):
logger.info("Auto-cycle skipped — another cycle is already running")
return {"skipped": True, "reason": "already_running"}
run_id = datetime.utcnow().isoformat()
summary: Dict[str, Any] = {
"run_id": run_id,
"trigger": trigger,
"patterns_suggested": 0,
"patterns_added": 0,
"patterns_scored": 0,
"geo_score": None,
"dominant_regime": None,
"commentary": None,
"status": "error",
}
try:
from services.database import (
get_config, get_custom_patterns, save_custom_pattern,
save_pattern_scores, log_macro_regime, log_geo_alert, log_trade_entries,
add_cycle_run, update_cycle_run, save_reasoning_trace,
get_latest_portfolio_lessons, log_system_event,
get_last_completed_cycle_ts, save_cycle_context_snapshot,
purge_old_price_snapshots,
)
from services.data_fetcher import fetch_geo_news, get_all_quotes, get_macro_gauges, score_macro_scenarios
from services.geo_analyzer import compute_geo_risk_score
from services.ai_analyzer import (
suggest_patterns_from_market_context, score_patterns_with_context,
ai_score_news_batch, ai_score_geo_risk, _chat, DEFAULT_ANALYSIS_TEMPLATE,
)
from services.portfolio_context import (
get_open_trades_with_moves, get_portfolio_concentration, build_portfolio_context_block,
)
# KB confidence decay (non-blocking)
try:
from services.database import decay_kb_confidence
_decayed = decay_kb_confidence()
if _decayed:
logger.info(f"[Cycle {run_id[:16]}] KB decay: {_decayed} entrée(s) mise(s) à jour")
except Exception as _e:
logger.warning(f"[Cycle] KB decay failed (non-blocking): {_e}")
# Check AI key
ai_key = get_config("openai_api_key") or ""
if not ai_key:
logger.warning("Auto-cycle: no OpenAI key configured, skipping AI steps")
return {**summary, "status": "no_ai_key"}
import os
os.environ["OPENAI_API_KEY"] = ai_key
sim_threshold = float(get_config("auto_cycle_similarity_threshold") or "0.30")
step_cfg = get_cycle_step_config()
add_cycle_run(run_id, trigger=trigger)
_current_status["running"] = True
_current_status["last_run_id"] = run_id
# ── Cycle meta — timing context ───────────────────────────────────────
_now = datetime.utcnow()
_last_cycle_ts_str = get_last_completed_cycle_ts()
_delta_minutes: float = 180.0 # default 3h if no prior cycle
if _last_cycle_ts_str:
try:
_last_dt = datetime.fromisoformat(_last_cycle_ts_str.replace("Z", ""))
_delta_minutes = max(1.0, (_now - _last_dt).total_seconds() / 60)
except Exception:
pass
_interval_hours = float(get_config("auto_cycle_hours") or "3")
if _delta_minutes < 90:
_calib_label = "Court terme (<2h) — signaux très récents"
elif _delta_minutes < 720:
_calib_label = f"Moyen terme ({_delta_minutes/60:.0f}h) — signaux potentiellement pas encore pricés"
else:
_calib_label = f"Long terme ({_delta_minutes/60:.0f}h) — marchés ont eu le temps d'intégrer"
# ── Market calendar context ────────────────────────────────────────
from datetime import timezone as _tz
_now_utc = _now.replace(tzinfo=_tz.utc) if _now.tzinfo is None else _now.astimezone(_tz.utc)
_weekday = _now_utc.weekday() # 0=Mon … 6=Sun
_day_names = ["Lundi", "Mardi", "Mercredi", "Jeudi", "Vendredi", "Samedi", "Dimanche"]
_is_weekend = _weekday >= 5
# CME equity/energy/metals futures: closed Fri 17:00 ET → Sun 18:00 ET
# Rough ET offset (UTC-5 winter / UTC-4 summer) — good enough for intent
_et_hour = (_now_utc.hour - 5) % 24
if _is_weekend:
_market_session = "closed"
_market_note = f"{_day_names[_weekday]} — marchés US et CME fermés. Derniers prix disponibles: vendredi clôture."
elif _et_hour < 4:
_market_session = "overnight"
_market_note = "Nuit US (overnight) — liquidité réduite, spreads larges."
elif _et_hour < 9:
_market_session = "pre_market"
_market_note = "Pré-marché US — prix indicatifs, faible liquidité."
elif _et_hour < 16:
_market_session = "open"
_market_note = "Marché US ouvert."
else:
_market_session = "after_hours"
_market_note = "After-hours US — prix indicatifs, liquidité réduite."
_budget_eur = float(get_config("trade_budget_eur") or "5000")
_horizon_min = int(get_config("preferred_horizon_min") or "30")
_horizon_max = int(get_config("preferred_horizon_max") or "180")
cycle_meta = {
"current_cycle_ts": _now.isoformat(),
"last_cycle_ts": _last_cycle_ts_str,
"delta_minutes": round(_delta_minutes, 1),
"interval_hours": _interval_hours,
"calibration_label": _calib_label,
"day_of_week": _day_names[_weekday],
"is_weekend": _is_weekend,
"market_session": _market_session,
"market_note": _market_note,
"trade_budget_eur": _budget_eur,
"preferred_horizon_min": _horizon_min,
"preferred_horizon_max": _horizon_max,
}
logger.info(
f"[Cycle {run_id[:16]}] Cycle meta: delta={_delta_minutes:.0f}min depuis dernier cycle"
f" ({_last_cycle_ts_str[:16] if _last_cycle_ts_str else 'premier cycle'})"
)
# ── Step 0: Load portfolio lessons + Super Contexte ──────────────────
portfolio_lessons = get_latest_portfolio_lessons()
# Load Super Contexte (accumulated knowledge base)
try:
from services.database import get_latest_reasoning_state
_reasoning_state = get_latest_reasoning_state()
if _reasoning_state:
if portfolio_lessons is None:
portfolio_lessons = {}
portfolio_lessons["super_context"] = (
f"[Super Contexte v{_reasoning_state.get('version',1)} "
f"du {(_reasoning_state.get('created_at','')[:16])}]\n"
+ _reasoning_state.get("narrative", "")[:800]
)
_synth = _reasoning_state.get("synthesis") or {}
portfolio_lessons["strategic_priorities"] = _synth.get("strategic_priorities", [])
portfolio_lessons["recurring_mistakes"] = [
m.get("mistake", "") for m in _synth.get("recurring_mistakes", [])[:3]
]
logger.info(
f"[Cycle {run_id[:16]}] Super Contexte v{_reasoning_state.get('version')} loaded "
f"({_reasoning_state.get('reports_used',0)} rapports, "
f"{_reasoning_state.get('trades_analyzed',0)} trades)"
)
except Exception as _e:
logger.warning(f"[Cycle] Could not load Super Contexte: {_e}")
if portfolio_lessons:
age_hours = 0
try:
from datetime import datetime as _dt
created = _dt.fromisoformat(portfolio_lessons["created_at"])
age_hours = (_dt.utcnow() - created).total_seconds() / 3600
except Exception:
pass
logger.info(
f"[Cycle {run_id[:16]}] Portfolio lessons loaded "
f"(report from {portfolio_lessons.get('created_at','?')[:10]}, "
f"{age_hours:.0f}h ago, avg_pnl={portfolio_lessons.get('stats', {}).get('avg_pnl_pct')}%)"
)
else:
logger.info(f"[Cycle {run_id[:16]}] No portfolio report yet — cycle runs without performance feedback")
# ── Step 1: Fetch context ─────────────────────────────────────────────
logger.info(f"[Cycle {run_id[:16]}] Step 1: fetching context")
from routers.geopolitical import _news_cache # type: ignore
news = _news_cache.get("data") or fetch_geo_news()
news = ai_score_news_batch(news)
_news_cache["data"] = news
algo_geo_score_obj = compute_geo_risk_score(news)
try:
geo_score_obj = ai_score_geo_risk(news, algo_geo_score_obj, log_meta={"run_id": run_id, "call_type": "geo_risk_score"})
except Exception as _ge:
logger.warning(f"[Cycle {run_id[:16]}] AI geo risk scoring failed, falling back to algo score: {_ge}")
geo_score_obj = {**algo_geo_score_obj, "rationale": "Erreur IA — score algorithmique utilisé tel quel.", "top_risks": []}
geo_score_obj.setdefault("breakdown", algo_geo_score_obj.get("breakdown", {}))
geo_score_val = int(round(geo_score_obj.get("score") or 0))
summary["geo_score"] = geo_score_val
from services.database import save_geo_risk_snapshot
save_geo_risk_snapshot(
run_id=run_id, score=geo_score_obj.get("score", geo_score_val), level=geo_score_obj.get("level", "low"),
breakdown=geo_score_obj.get("breakdown", {}), top_risks=geo_score_obj.get("top_risks", []),
rationale=geo_score_obj.get("rationale", ""),
)
# ── Invalidation trigger detection ────────────────────────────────────
try:
from services.database import get_custom_patterns as _gcp
_active_patterns = _gcp()
_news_headlines = " ".join(
(n.get("title", "") + " " + (n.get("summary", "") or "")).lower()
for n in news[:15]
)
_triggered = []
for _pat in _active_patterns:
_trigger = (_pat.get("invalidation_trigger") or "").lower().strip()
if not _trigger:
continue
# Simple keyword matching: split trigger into words and check majority match
_words = [w for w in _trigger.split() if len(w) > 3]
if _words and sum(1 for w in _words if w in _news_headlines) >= max(1, len(_words) // 2):
_triggered.append({
"pattern_id": _pat["id"],
"pattern_name": _pat["name"],
"trigger": _pat["invalidation_trigger"],
"probability": _pat.get("invalidation_probability"),
})
if _triggered:
logger.warning(
f"[Cycle {run_id[:16]}] ⚠ INVALIDATION TRIGGERS FIRED for "
f"{len(_triggered)} pattern(s): {[t['pattern_name'] for t in _triggered]}"
)
summary["invalidation_alerts"] = _triggered
except Exception as _ie:
logger.debug(f"[Cycle] Invalidation check failed (non-blocking): {_ie}")
quotes = get_all_quotes()
# ── Phase 4A: capture price snapshots for scored news ─────────────────
try:
from services.price_discovery import capture_price_snapshots as _cap_snap
_quotes_flat: Dict[str, float] = {}
for _qs in quotes.values():
for _q in _qs:
if _q.get("symbol") and _q.get("price"):
_quotes_flat[_q["symbol"]] = float(_q["price"])
_n_snaps = _cap_snap(news, _quotes_flat, run_id)
if _n_snaps:
logger.info(f"[Cycle {run_id[:16]}] Phase 4: {_n_snaps} price snapshots captured")
purge_old_price_snapshots(older_than_days=step_cfg["price_snapshot_retention"]["days"])
except Exception as _pd_e:
logger.warning(f"[Cycle] Price snapshot capture failed (non-blocking): {_pd_e}")
gauges = get_macro_gauges()
scenarios = score_macro_scenarios(gauges)
macro_regime = {"gauges": gauges, "scenarios": scenarios}
dominant = scenarios.get("dominant", "incertain")
summary["dominant_regime"] = dominant
# ── Step 1.9: Pre-fetch IV context + snapshots (suggestion rules + gate) ─
iv_context = ""
_iv_snapshots: Dict[str, Dict] = {}
try:
from services.iv_engine import get_iv_context_for_prompt, get_full_iv_snapshot, IV_WATCHLIST
from services.database import get_mtm_trades_with_traces
_mtm_pre = get_mtm_trades_with_traces(days=step_cfg["iv_context"]["lookback_days"])
_trade_tickers_pre = list({
(t.get("underlying") or "").upper()
for t in _mtm_pre.get("all_trades", [])
if t.get("underlying")
})
_iv_tickers_pre = (_trade_tickers_pre + IV_WATCHLIST[:6])[:10]
iv_context = get_iv_context_for_prompt(_iv_tickers_pre)
# Also collect structured snapshots — used by IV gate before log_trade_entries
for _tkr in _iv_tickers_pre:
try:
_iv_snapshots[_tkr] = get_full_iv_snapshot(_tkr)
except Exception:
pass
if iv_context:
logger.info(
f"[Cycle {run_id[:16]}] IV context pre-fetched: {len(_iv_tickers_pre)} tickers, "
f"{len(_iv_snapshots)} snapshots"
)
except Exception as _e:
logger.warning(f"[Cycle] IV context pre-fetch failed (non-blocking): {_e}")
# ── Step 2: Suggest new patterns ──────────────────────────────────────
logger.info(f"[Cycle {run_id[:16]}] Step 2: suggesting patterns")
_reliability_map = {}
try:
from services.database import get_all_pattern_reliability_map
_reliability_map = get_all_pattern_reliability_map()
if _reliability_map:
logger.info(f"[Cycle {run_id[:16]}] Reliability map: {len(_reliability_map)} patterns")
except Exception as _re:
logger.warning(f"[Cycle] Reliability map failed (non-blocking): {_re}")
# ── Phase 4B: compute price absorption from previous snapshots ───────
_absorptions = []
_price_discovery_block = ""
try:
from services.price_discovery import compute_absorptions, build_price_discovery_block
_absorptions = compute_absorptions(
min_age_minutes=step_cfg["absorption_detection"]["min_age_minutes"],
max_age_days=step_cfg["absorption_detection"]["max_age_days"],
)
_price_discovery_block = build_price_discovery_block(_absorptions)
opps = sum(1 for a in _absorptions if a["opportunity"])
if _absorptions:
logger.info(f"[Cycle {run_id[:16]}] Phase 4: {len(_absorptions)} absorptions, {opps} opportunities")
except Exception as _abs_e:
logger.warning(f"[Cycle] Price absorption failed (non-blocking): {_abs_e}")
# ── Phase 2: FRED recent macro releases + economic surprise tracking ────
_fred_releases = []
_fred_block = ""
_surprise_block = ""
try:
from services.fred_fetcher import (
get_fred_recent_releases, build_fred_context_block,
save_fred_releases_to_db, build_economic_surprise_block,
)
_fred_key = get_config("fred_api_key") or ""
_fred_releases = get_fred_recent_releases()
if _fred_releases:
logger.info(f"[Cycle {run_id[:16]}] FRED: {len(_fred_releases)} releases fetched")
# Persist to economic_events with z-score surprise scoring
try:
_saved = save_fred_releases_to_db(_fred_releases, api_key=_fred_key)
if _saved:
logger.info(f"[Cycle {run_id[:16]}] FRED: {_saved} releases saved to economic_events")
except Exception as _dbe:
logger.warning(f"[Cycle] FRED DB save failed (non-blocking): {_dbe}")
# Add surprise scores to release dicts for richer prompt block
if _fred_key:
try:
from services.database import get_recent_economic_surprises
_db_surprises = {ev["series_id"]: ev for ev in get_recent_economic_surprises(days=step_cfg["economic_surprises"]["lookback_days"])}
for rel in _fred_releases:
sid = rel.get("series_id", "")
if sid in _db_surprises:
rel["surprise_zscore"] = _db_surprises[sid].get("surprise_zscore")
except Exception:
pass
_fred_block = build_fred_context_block(_fred_releases)
_surprise_block = build_economic_surprise_block(days=14)
if _surprise_block:
_fred_block = _fred_block + "\n\n" + _surprise_block if _fred_block else _surprise_block
except Exception as _fe:
logger.warning(f"[Cycle] FRED fetch failed (non-blocking): {_fe}")
try:
from services.data_fetcher import get_economic_calendar
calendar = get_economic_calendar()
# Apply decay to news before suggestion (adds decayed_score + age_hours)
from services.ai_analyzer import apply_news_decay as _apply_decay
news = _apply_decay(news)
# ── Tech indicators for top tickers ──────────────────────────────
_tech_block = ""
try:
_ti_enabled = (get_config("tech_indicators_enabled") or "true").lower() == "true"
if _ti_enabled:
from services.technical_indicators import compute_indicators, format_indicators_for_prompt
_ti_list = [s.strip() for s in (get_config("tech_indicators_list") or "rsi,ma,bollinger,atr").split(",") if s.strip()]
_horizon_days = 45 # default mid-range if no trade horizon context yet
_ti_lines = []
# Pick up to 5 tickers from quotes (one per asset class)
_seen_tickers: set = set()
for _cls, _qs in quotes.items():
for _q in _qs[:1]:
_sym = _q.get("symbol", "")
if _sym and _sym not in _seen_tickers:
_seen_tickers.add(_sym)
_ind = compute_indicators(_sym, _horizon_days, enabled_indicators=_ti_list)
_blk = format_indicators_for_prompt(_ind)
if _blk:
_ti_lines.append(_blk)
if len(_seen_tickers) >= 5:
break
if len(_seen_tickers) >= 5:
break
if _ti_lines:
_tech_block = "\n".join(_ti_lines)
except Exception as _te:
logger.warning(f"[Cycle] Tech indicators failed (non-blocking): {_te}")
# ── Portfolio context (open positions + recent moves) ──────────
_portfolio_block = ""
_open_trades_snap = []
_portfolio_conc = {}
try:
_open_trades_snap = get_open_trades_with_moves()
_portfolio_conc = get_portfolio_concentration(_open_trades_snap)
_portfolio_block = build_portfolio_context_block(_open_trades_snap, _portfolio_conc)
logger.info(f"[Cycle {run_id[:16]}] Portfolio context: {len(_open_trades_snap)} open trades")
except Exception as _pe:
logger.warning(f"[Cycle] Portfolio context failed (non-blocking): {_pe}")
# ── Save full context snapshot ─────────────────────────────────
try:
from services.ai_analyzer import apply_news_decay as _apply_decay2, partition_news_by_age as _part
_news_snap = _part(_apply_decay2(news), cycle_meta.get("delta_minutes", 180))
_context_snapshot = {
"cycle_meta": cycle_meta,
"macro_regime": {
"dominant": (macro_regime or {}).get("scenarios", {}).get("dominant"),
"scores": (macro_regime or {}).get("scenarios", {}).get("scores"),
"gauges_summary": {k: v.get("value") for k, v in ((macro_regime or {}).get("gauges", {})).items()},
},
"geo_score": geo_score_obj,
"news_partitioned": {
"inter_cycle": [{"title": n.get("title"), "source": n.get("source"), "decayed_score": n.get("decayed_score"), "age_hours": n.get("age_hours")} for n in _news_snap["inter_cycle"][:10]],
"recent_24h": [{"title": n.get("title"), "source": n.get("source"), "decayed_score": n.get("decayed_score")} for n in _news_snap["recent_24h"][:10]],
"older": [{"title": n.get("title"), "source": n.get("source")} for n in _news_snap["older"][:5]],
},
"fred_releases": _fred_releases,
"price_discovery": [
{k: v for k, v in a.items() if k != "article_hash"}
for a in _absorptions[:10]
],
"tech_indicators_block": _tech_block,
"iv_context_preview": iv_context[:500] if iv_context else "",
"calendar": calendar[:8] if calendar else [],
"quotes_summary": {cls: [{"symbol": q.get("symbol"), "price": q.get("price"), "change_pct": q.get("change_pct")} for q in qs[:3]] for cls, qs in quotes.items()},
"portfolio_open_positions": {
"count": len(_open_trades_snap),
"concentration": _portfolio_conc,
"trades": [
{k: v for k, v in t.items() if k != "id"}
for t in _open_trades_snap
],
},
}
save_cycle_context_snapshot(run_id, _context_snapshot)
logger.info(f"[Cycle {run_id[:16]}] Context snapshot saved")
except Exception as _snap_e:
logger.warning(f"[Cycle] Context snapshot save failed (non-blocking): {_snap_e}")
# ── Build convergence block from previous cycle's scored patterns ──
_convergence_block_for_suggest = ""
try:
from services.database import get_patterns_with_last_score
from services.ai_analyzer import _compute_convergence
_prev_scored = get_patterns_with_last_score()
_prev_with_scores = [
{"pattern_id": p.get("id"), "score": p.get("last_score") or 0,
"recommended_trade": {"underlying": (p.get("suggested_trades") or [{}])[0].get("underlying", "")} if p.get("suggested_trades") else {},
"trade_rankings": [{"underlying": t.get("underlying", "")} for t in (p.get("suggested_trades") or [])],
"geo_trigger": p.get("name", "")}
for p in _prev_scored if p.get("last_score") is not None
]
if _prev_with_scores:
_, _convergence_block_for_suggest = _compute_convergence(_prev_with_scores, _prev_scored)
if _convergence_block_for_suggest:
logger.info(f"[Cycle {run_id[:16]}] Convergence block built from {len(_prev_with_scores)} previously scored patterns")
except Exception as _cbe:
logger.warning(f"[Cycle] Convergence block build failed (non-blocking): {_cbe}")
# ── Build institutional reports block ──────────────────────────
_institutional_block = ""
try:
from services.ai_analyzer import build_institutional_block
_institutional_block = build_institutional_block(days=step_cfg["institutional_block"]["lookback_days"])
if _institutional_block:
logger.info(f"[Cycle {run_id[:16]}] Institutional block injected")
except Exception as _ibe:
logger.warning(f"[Cycle] Institutional block failed (non-blocking): {_ibe}")
# ── COT + Forward Curves (background refresh, non-blocking) ───────────────
try:
from services.cot_fetcher import fetch_all_cot
from services.forward_curve import fetch_forward_curves
from services.database import save_cot_data, save_forward_curves as _sfc
cot_data = fetch_all_cot()
if cot_data:
save_cot_data(cot_data)
curve_data = fetch_forward_curves()
if curve_data:
_sfc(curve_data)
except Exception as _e:
import logging as _logging
_logging.getLogger(__name__).warning(f"COT/Curve refresh failed (non-blocking): {_e}")
suggestions = suggest_patterns_from_market_context(
news, quotes, calendar, macro_regime=macro_regime, geo_score=geo_score_obj,
portfolio_lessons=portfolio_lessons,
reliability_map=_reliability_map or None,
iv_context=iv_context,
cycle_meta=cycle_meta,
tech_indicators_block=_tech_block,
fred_block=_fred_block,
price_discovery_block=_price_discovery_block,
portfolio_context_block=_portfolio_block,
convergence_block=_convergence_block_for_suggest,
run_id=run_id,
)
except Exception as e:
logger.warning(f"[Cycle] Suggestion step failed: {e}")
suggestions = []
summary["patterns_suggested"] = len(suggestions)
logger.info(f"[Cycle {run_id[:16]}] Suggested {len(suggestions)} patterns from AI")
# ── Step 3: Filter by similarity (Embedding cosinus ou Jaccard fallback) ─
existing = get_custom_patterns()
logger.info(f"[Cycle {run_id[:16]}] Step 3: {len(existing)} existing patterns, threshold={sim_threshold}")
added_count = 0
for s in suggestions:
if not _is_duplicate_pattern(s, existing, ai_key, jaccard_threshold=sim_threshold, embed_threshold=step_cfg["duplicate_detection"]["embedding_threshold"]):
# Capture returned ID so the pattern has a valid id for scoring
assigned_id = save_custom_pattern(s)
s["id"] = assigned_id
existing.append(s)
added_count += 1
logger.info(f"[Cycle] Added pattern '{s.get('name')}' id={assigned_id}")
# ── Save suggestion reasoning trace ───────────────────────────
_top_news_ctx = [
{"title": n.get("title", "")[:120], "impact": round(float(n.get("impact_score") or 0), 2), "source": n.get("source", "")}
for n in sorted(news, key=lambda x: -(float(x.get("impact_score") or 0)))[:8]
]
save_reasoning_trace(
run_id=run_id,
trace_type="suggestion",
pattern_id=assigned_id,
input_context={
"geo_score": geo_score_val,
"macro_dominant": dominant,
"macro_scores": scenarios.get("scores", {}),
"top_news": _top_news_ctx,
"cycle_run_id": run_id,
},
output={
"name": s.get("name"),
"description": s.get("description"),
"macro_fit": s.get("macro_fit"),
"expected_move_pct": s.get("expected_move_pct"),
"probability": s.get("probability"),
"horizon_days": s.get("horizon_days"),
"suggested_trades": s.get("suggested_trades", []),
"keywords": s.get("keywords", []),
"triggers": s.get("triggers", []),
},
reasoning_summary=(s.get("macro_fit") or s.get("description") or "")[:300],
geo_score=geo_score_val,
macro_dominant=dominant,
)
else:
logger.debug(f"[Cycle] Filtered '{s.get('name')}' — doublon détecté")
summary["patterns_added"] = added_count
# ── Step 3.1: Classify unclassified patterns (category + signal_direction) ─
try:
from services.database import get_unclassified_patterns, update_pattern_classification
from services.ai_analyzer import classify_patterns_batch
_unclassified = get_unclassified_patterns()
if _unclassified:
logger.info(f"[Cycle {run_id[:16]}] Classifying {len(_unclassified)} unclassified patterns")
_classifications = classify_patterns_batch(_unclassified)
for _cl in _classifications:
if _cl.get("id") and _cl.get("category"):
update_pattern_classification(_cl["id"], _cl["category"], _cl.get("signal_direction", "neutral"))
logger.info(f"[Cycle {run_id[:16]}] Classified {len(_classifications)} patterns")
except Exception as _cle:
logger.warning(f"[Cycle] Pattern classification failed (non-blocking): {_cle}")
# ── Step 3.5: Collect risk cluster context ───────────────────────────
risk_cluster_context = ""
try:
from services.database import get_risk_clusters as _grc
_clusters = _grc()
risk_cluster_context = _clusters.get("risk_prompt_context", "")
if risk_cluster_context:
logger.info(
f"[Cycle {run_id[:16]}] Risk clusters: "
f"{len(_clusters.get('clusters', []))} facteurs, "
f"saturés={_clusters.get('saturated_factors', [])}"
)
except Exception as _re:
logger.warning(f"[Cycle] Risk cluster context failed (non-blocking): {_re}")
# ── Step 3.6: Refresh IV context (with full trade+watchlist scope) ──────
try:
from services.iv_engine import get_iv_context_for_prompt, IV_WATCHLIST
# Collect underlyings from current trade journal + default watchlist
from services.database import get_mtm_trades_with_traces
_mtm = get_mtm_trades_with_traces(days=step_cfg["iv_context"]["lookback_days"])
_trade_tickers = list({
(t.get("underlying") or "").upper()
for t in _mtm.get("all_trades", [])
if t.get("underlying")
})
_iv_tickers = (_trade_tickers + IV_WATCHLIST[:6])[:10]
iv_context = get_iv_context_for_prompt(_iv_tickers)
if iv_context:
logger.info(f"[Cycle {run_id[:16]}] IV context collected for {len(_iv_tickers)} tickers")
# Invalidate the options_vol watchlist cache so the frontend sees fresh data
try:
from routers.options_vol import _iv_cache
stale_keys = [k for k in list(_iv_cache.keys()) if k.startswith("watchlist:") or k.startswith("snapshot:")]
for k in stale_keys:
_iv_cache.pop(k, None)
if stale_keys:
logger.info(f"[Cycle] IV cache invalidated ({len(stale_keys)} entries)")
except Exception:
pass
except Exception as _e:
logger.warning(f"[Cycle] IV context collection failed (non-blocking): {_e}")
# ── Step 4: Score ALL patterns ────────────────────────────────────────
# Verify all patterns have IDs before scoring (guard against stale data)
patterns_with_id = [p for p in existing if p.get("id")]
patterns_without_id = [p.get("name", "?") for p in existing if not p.get("id")]
if patterns_without_id:
logger.warning(f"[Cycle] {len(patterns_without_id)} patterns have no id, skipping: {patterns_without_id}")
logger.info(f"[Cycle {run_id[:16]}] Step 4: scoring {len(patterns_with_id)} patterns (of {len(existing)} total)")
from services.database import get_analysis_config
template = get_analysis_config().get("template") or DEFAULT_ANALYSIS_TEMPLATE
try:
scored = score_patterns_with_context(
patterns=patterns_with_id,
recent_news=news,
quotes_by_class=quotes,
geo_score=geo_score_obj,
template=template,
top_n=len(patterns_with_id),
category_filter=None,
macro_regime=macro_regime,
portfolio_lessons=portfolio_lessons,
iv_context=iv_context,
risk_context=risk_cluster_context,
cycle_meta=cycle_meta,
tech_indicators_block=_tech_block,
fred_block=_fred_block,
price_discovery_block=_price_discovery_block,
portfolio_context_block=_portfolio_block,
institutional_block=_institutional_block,
run_id=run_id,
)
scored_with_id = [s for s in scored if s.get("pattern_id")]
scored_without_id = [s for s in scored if not s.get("pattern_id")]
if scored_without_id:
logger.warning(f"[Cycle] {len(scored_without_id)} scored results have no pattern_id — they will NOT be saved to history")
logger.info(f"[Cycle {run_id[:16]}] Scoring returned {len(scored)} results ({len(scored_with_id)} with valid id)")
except Exception as e:
logger.error(f"[Cycle] Scoring failed: {e}", exc_info=True)
scored = []
summary["patterns_scored"] = len(scored)
# ── Enrich scored patterns with original data not in GPT-4o response ─
# GPT-4o scoring doesn't return expected_move_pct or suggested_trades —
# copy from the original pattern so log_trade_entries can use them.
_pmap = {p.get("id"): p for p in patterns_with_id}
for s in scored:
orig = _pmap.get(s.get("pattern_id", ""), {})
if orig:
if not s.get("expected_move_pct") and orig.get("expected_move_pct"):
s["expected_move_pct"] = orig["expected_move_pct"]
logger.debug(f"[Cycle] Enriched '{orig.get('name')}' expected_move_pct={orig['expected_move_pct']}")
if not s.get("trade_rankings") and not s.get("suggested_trades"):
s["suggested_trades"] = orig.get("suggested_trades", [])
# Propagate category + signal_direction to scored result for display
if orig.get("category") and not s.get("category"):
s["category"] = orig["category"]
if orig.get("signal_direction") and not s.get("signal_direction"):
s["signal_direction"] = orig["signal_direction"]
# ── Log convergence summary ───────────────────────────────────────────
try:
_top_conv = [s for s in scored if s.get("convergence_count", 0) > 0]
if _top_conv:
logger.info(
f"[Cycle {run_id[:16]}] Convergence: {len(_top_conv)} patterns with cross-pattern boost "
f"— top: {_top_conv[0].get('convergence_underlying','?')} ×{_top_conv[0].get('convergence_count',0)+1}"
)
except Exception:
pass
# ── Step 5: Log everything ────────────────────────────────────────────
logger.info(f"[Cycle {run_id[:16]}] Step 5: logging")
scoring_run_id = save_pattern_scores(scored, meta={
"geo_score": geo_score_val,
"total": len(scored),
"cycle_run_id": run_id,
"trigger": trigger,
})
# ── Save scoring reasoning traces (one per scored pattern) ────────────
_macro_scores_ctx = scenarios.get("scores", {})
_asset_bias_ctx = scenarios.get("asset_bias", {}).get(dominant, {})
for sp in scored:
pid = sp.get("pattern_id", "")
if not pid:
continue
orig = _pmap.get(pid, {})
save_reasoning_trace(
run_id=scoring_run_id,
trace_type="scoring",
pattern_id=pid,
input_context={
"geo_score": geo_score_val,
"macro_dominant": dominant,
"macro_scores": _macro_scores_ctx,
"asset_class": sp.get("asset_class") or orig.get("asset_class"),
"asset_bias": _asset_bias_ctx.get(sp.get("asset_class") or orig.get("asset_class", ""), "neutral"),
"expected_move_pct": sp.get("expected_move_pct") or orig.get("expected_move_pct"),
"cycle_run_id": run_id,
},
output={
"score": sp.get("score"),
"confidence": sp.get("confidence"),
"buckets": sp.get("buckets", []),
"key_catalyst": sp.get("key_catalyst"),
"summary": sp.get("summary"),
"trade_rankings": sp.get("trade_rankings", []),
"recommended_trade": sp.get("recommended_trade", {}),
"has_strong_contra": sp.get("has_strong_contra", False),
"geo_trigger": sp.get("geo_trigger"),
},
reasoning_summary=((sp.get("key_catalyst") or "") + " | " + (sp.get("summary") or ""))[:400],
geo_score=geo_score_val,
macro_dominant=dominant,
)
logger.info(f"[Cycle {run_id[:16]}] Saved {len([s for s in scored if s.get('pattern_id')])} reasoning traces")
top_patterns_log = sorted(
[{"pattern_id": sp.get("pattern_id"), "name": sp.get("geo_trigger"), "score": sp.get("score", 0)}
for sp in scored if sp.get("score", 0) > 0],
key=lambda x: -x["score"]
)[:10]
log_geo_alert(geo_score=geo_score_val, top_patterns=top_patterns_log,
news_count=len(news), run_id=scoring_run_id)
_options_assessment = None
# ── IV Gate: block ALERT-verdict trades before logging ────────────────
_iv_gate_blocked: List[Dict] = []
try:
from services.database import get_config as _gc
if (_gc("iv_gate_enabled") or "true").lower() == "true":
# Augment _iv_snapshots with any new tickers from scored that weren't in the pre-fetch
from services.iv_engine import get_full_iv_snapshot as _gfiv
_scored_tickers = {
(tr.get("underlying") or "").upper()
for sp in scored
for tr in (sp.get("trade_rankings") or sp.get("suggested_trades") or [])
if tr.get("underlying")
}
for _stk in _scored_tickers - set(_iv_snapshots.keys()):
try:
_iv_snapshots[_stk] = _gfiv(_stk)
except Exception:
pass
scored, _iv_gate_blocked = _apply_iv_gate(scored, _iv_snapshots)
if _iv_gate_blocked:
summary["iv_gate_blocked"] = len(_iv_gate_blocked)
logger.warning(
f"[IVGate] {len(_iv_gate_blocked)} trade(s) blocked by IV rules: "
+ ", ".join(f"{b['ticker']} {b['strategy']}" for b in _iv_gate_blocked)
)
for _blk in _iv_gate_blocked:
try:
from services.database import log_skipped_trade
log_skipped_trade(
run_id=scoring_run_id,
pattern_id=_blk.get("pattern_id", ""),
pattern_name=_blk.get("pattern_name", ""),
underlying=_blk.get("ticker", ""),
strategy=_blk.get("strategy", ""),
score=_blk.get("score", 0),
expected_move_pct=0,
skip_detail=(
f"[IV_GATE] ALERT — {'; '.join(_blk.get('iv_issues', [])[:2])}. "
f"Stratégie optimale: {_blk.get('optimal_strategy', '?')}"
),
asset_class=_blk.get("asset_class", ""),
)
except Exception:
pass
except Exception as _ige:
logger.warning(f"[IVGate] Failed (non-blocking): {_ige}")
log_trade_entries(run_id=scoring_run_id, scored_patterns=scored, quotes=quotes)
# ── Step 5.2: Options Technical Agent — validate newly logged trades ──
try:
from services.options_technical_agent import assess_logged_trades, save_assessments_to_db
_options_assessment = assess_logged_trades(
scoring_run_id=scoring_run_id,
scored=scored,
ai_key=ai_key,
)
if _options_assessment and _options_assessment.get("assessments"):
save_assessments_to_db(_options_assessment["assessments"], scoring_run_id)
summary["options_assessment"] = {
"n_ok": _options_assessment.get("n_ok", 0),
"n_warn": _options_assessment.get("n_warn", 0),
"n_alert": _options_assessment.get("n_alert", 0),
"global_score": _options_assessment.get("global_score"),
}
logger.info(
f"[OptionsTech] Assessment done — OK={_options_assessment.get('n_ok')} "
f"WARN={_options_assessment.get('n_warn')} ALERT={_options_assessment.get('n_alert')}"
)
except Exception as _ota:
logger.warning(f"[OptionsTech] Agent failed (non-blocking): {_ota}")
# ── Step 5.1: Portfolio monitor — conflict & concentration check ──────
try:
from services.portfolio_risk import analyze_simulation_portfolio
_risk = analyze_simulation_portfolio()
if not step_cfg["portfolio_monitor"]["enabled"]:
logger.info("[PortfolioMonitor] Disabled via cycle_step_config — skipping AI monitor")
elif _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
if not step_cfg["watchlist_auto_add"]["enabled"]:
logger.info("[Watchlist] Auto-add disabled via cycle_step_config — skipping")
else:
try:
from services.database import _normalize_ticker, add_watchlist_ticker, get_watchlist_tickers
from services.iv_engine import _resolve_ticker, bootstrap_iv_history
existing = set(get_watchlist_tickers())
new_proxies = set()
for sp in scored:
for trade in (sp.get("trade_rankings") or sp.get("suggested_trades") or []):
underlying = trade.get("underlying") or sp.get("underlying") or ""
if underlying:
normalized = _normalize_ticker(underlying.upper()) # WHEAT→ZW=F, EUR/USD→EURUSD=X
proxy = _resolve_ticker(normalized) # ZW=F→WEAT, EURUSD=X→FXE
if proxy not in existing:
new_proxies.add(proxy)
for proxy in new_proxies:
if add_watchlist_ticker(proxy, added_by="cycle"):
logger.info(f"[Watchlist] Auto-added new ticker: {proxy}")
log_system_event("INFO", "auto_cycle", f"Nouveau ticker ajouté à la watchlist IV: {proxy}", cycle_id=scoring_run_id, ticker=proxy)
if new_proxies:
bootstrap_iv_history(tickers=list(new_proxies), min_existing=0)
except Exception as _we:
logger.warning(f"[Watchlist] Auto-add failed: {_we}")
gauges_summary = {
k: {"value": v.get("value"), "change_pct": v.get("change_pct"), "label": v.get("label")}
for k, v in gauges.items()
if v.get("value") is not None or v.get("change_pct") is not None
}
log_macro_regime(dominant=dominant, scores=scenarios.get("scores", {}),
reasons=scenarios.get("reasons", {}), gauges_summary=gauges_summary)
# Update macro cache so the UI sees fresh data immediately
from routers.market_data import _macro_cache # type: ignore
import datetime as _dt
_macro_cache["data"] = {"gauges": gauges, "scenarios": scenarios,
"fetched_at": datetime.utcnow().isoformat(), "cached": False}
_macro_cache["ts"] = _dt.datetime.utcnow()
# ── Step 5.5: Bayesian posterior update (Sprint 4.1) ─────────────────
try:
from services.database import update_bayesian_posteriors
_n_updated = update_bayesian_posteriors()
if _n_updated:
logger.info(f"[Cycle {run_id[:16]}] Bayesian posteriors mis à jour : {_n_updated} patterns")
except Exception as _be:
logger.warning(f"[Cycle] Bayesian update failed (non-blocking): {_be}")
# ── Step 5.6: Régime clustering (Sprint 4.2) ──────────────────────────
if not step_cfg["regime_clustering"]["enabled"]:
logger.info("[Cycle] Régime clustering disabled via cycle_step_config — skipping")
else:
try:
from services.database import detect_and_save_regime_clusters
_cluster_result = detect_and_save_regime_clusters(
n_clusters=step_cfg["regime_clustering"]["n_clusters"],
days=step_cfg["regime_clustering"]["lookback_days"],
)
if "current_cluster" in _cluster_result:
logger.info(
f"[Cycle {run_id[:16]}] Régime cluster : {_cluster_result.get('current_label')} "
f"(cluster {_cluster_result.get('current_cluster')}, "
f"anomalie={_cluster_result.get('current_anomaly')})"
)
except Exception as _ce:
logger.warning(f"[Cycle] Régime clustering failed (non-blocking): {_ce}")
# ── Step 5.7: Wavelet signal scan (watchlist instruments) ────────────
_wavelet_results: list = []
try:
from services.wavelet_signals import compute_and_save_wavelet_signals
_wavelet_results = compute_and_save_wavelet_signals(run_id)
summary["wavelet_signals_count"] = len(_wavelet_results)
logger.info(f"[Cycle {run_id[:16]}] Wavelet signals: {len(_wavelet_results)} detected")
except Exception as _we:
logger.warning(f"[Cycle] Wavelet signal scan failed (non-blocking): {_we}")
summary["wavelet_signals_count"] = 0
# ── Step 6: GPT-4o cycle commentary ──────────────────────────────────
logger.info(f"[Cycle {run_id[:16]}] Step 6: generating commentary")
commentary = _generate_cycle_commentary(
scored=scored, dominant=dominant, scenarios=scenarios,
geo_score_val=geo_score_val, news=news, gauges=gauges,
)
# Attach lessons metadata to commentary so UI can display it
if commentary and portfolio_lessons:
try:
import json as _json
c = _json.loads(commentary) if isinstance(commentary, str) else commentary
c["lessons_from_report"] = portfolio_lessons.get("created_at", "")[:16].replace("T", " ")
c["lessons_headline"] = portfolio_lessons.get("headline", "")[:100]
commentary = _json.dumps(c, ensure_ascii=False)
except Exception:
pass
summary["commentary"] = commentary
# ── Finalize ──────────────────────────────────────────────────────────
summary["status"] = "completed"
update_cycle_run(
run_id,
completed_at=datetime.utcnow().isoformat(),
patterns_suggested=summary["patterns_suggested"],
patterns_added=summary["patterns_added"],
patterns_scored=summary["patterns_scored"],
geo_score=geo_score_val,
dominant_regime=dominant,
commentary=commentary,
status="completed",
wavelet_signals_count=summary.get("wavelet_signals_count", 0),
)
_current_status["last_run_at"] = datetime.utcnow().isoformat()
logger.info(f"[Cycle {run_id[:16]}] Completed — {added_count} new patterns, {len(scored)} scored")
# ── Step 6.1: Institutional report absorption tracking ───────────────
try:
from services.database import get_conn as _get_conn
import json as _json
_commentary_text = ""
if commentary:
try:
_c = _json.loads(commentary) if isinstance(commentary, str) else commentary
_commentary_text = " ".join([
str(_c.get("narrative", "")),
str(_c.get("key_catalyst", "")),
" ".join(str(x) for x in (_c.get("risks", []) or [])),
]).lower()
except Exception:
_commentary_text = str(commentary).lower()
if _commentary_text:
_conn = _get_conn()
try:
cutoff = (datetime.utcnow() - timedelta(days=step_cfg["institutional_absorption"]["lookback_days"])).strftime("%Y-%m-%d")
_inst_rows = _conn.execute(
"SELECT id, key_points_json FROM institutional_reports "
"WHERE report_date >= ? AND (absorbed_score IS NULL OR absorbed_score = 0)",
(cutoff,),
).fetchall()
for _ir in _inst_rows:
try:
_kps = _json.loads(_ir["key_points_json"] or "[]")
_kp_words = set(
w for kp in _kps for w in kp.lower().split()
if len(w) > 4
)
if not _kp_words:
continue
_overlap = sum(1 for w in _kp_words if w in _commentary_text)
_score = min(1.0, _overlap / max(len(_kp_words), 1))
if _score > 0:
_conn.execute(
"UPDATE institutional_reports SET absorbed_score=?, injected_in_cycle_id=? WHERE id=?",
(round(_score, 3), run_id, _ir["id"]),
)
except Exception:
pass
_conn.commit()
logger.info(f"[Cycle {run_id[:16]}] Absorption tracked for {len(_inst_rows)} institutional reports")
finally:
_conn.close()
except Exception as _abs_e:
logger.warning(f"[Cycle] Absorption tracking failed (non-blocking): {_abs_e}")
# ── Step 6.5: Generate full cycle report ─────────────────────────────
try:
_cycle_report = _generate_cycle_report(
run_id=run_id,
scored=scored,
dominant=dominant,
scenarios=scenarios,
geo_score_val=geo_score_val,
news=news,
gauges=gauges,
ai_key=ai_key,
added_patterns=[s for s in suggestions if s.get("id")],
scoring_run_id=scoring_run_id,
portfolio_monitor=summary.get("portfolio_monitor"),
commentary=commentary,
options_assessment=_options_assessment,
wavelet_signals=_wavelet_results,
)
if _cycle_report:
from services.database import save_cycle_report
save_cycle_report(run_id, _cycle_report)
logger.info(f"[Cycle {run_id[:16]}] Cycle report saved")
except Exception as _rpe:
logger.warning(f"[Cycle] Cycle report failed (non-blocking): {_rpe}")
# ── Step 7: Auto portfolio snapshot ──────────────────────────────────
# Generate (or refresh) the portfolio report so the NEXT cycle has
# fresh performance lessons. Runs in background to not block the cycle.
import threading
threading.Thread(
target=_auto_portfolio_snapshot,
args=(ai_key,),
daemon=True,
name=f"portfolio-snapshot-{run_id[:8]}",
).start()
except Exception as e:
logger.error(f"[Cycle {run_id[:16]}] Fatal error: {e}", exc_info=True)
try:
from services.database import update_cycle_run
update_cycle_run(run_id, status="error", completed_at=datetime.utcnow().isoformat())
except Exception:
pass
finally:
_current_status["running"] = False
_cycle_lock.release()
return summary
def _generate_cycle_commentary(
scored: List[Dict], dominant: str, scenarios: Dict,
geo_score_val: int, news: List[Dict], gauges: Dict,
) -> Optional[str]:
"""Ask GPT-4o to explain current performance of top/bottom patterns."""
try:
from services.database import get_trade_entry_prices
from services.ai_analyzer import _chat
# Get recent trade P&L for context
entries = get_trade_entry_prices(get_cycle_step_config()["cycle_commentary"]["lookback_days"])
trade_summary = []
for e in entries[:15]:
trade_summary.append({
"pattern": e.get("pattern_name", ""),
"underlying": e.get("underlying", ""),
"strategy": e.get("strategy", ""),
"score_at_entry": e.get("score_at_entry", 0),
"entry_date": e.get("entry_date", ""),
})
# Top 5 scored patterns now
top_scored = sorted(scored, key=lambda x: -(x.get("score") or 0))[:5]
top_scored_summary = [
{"name": s.get("geo_trigger"), "score": s.get("score"), "summary": s.get("summary", "")[:120]}
for s in top_scored
]
# Top recent news headlines
top_news = [{"title": n.get("title", ""), "impact": n.get("impact_score", 0)}
for n in sorted(news, key=lambda x: -(x.get("impact_score") or 0))[:5]]
import json
def gv(key: str) -> str:
v = gauges.get(key, {}).get("value")
return str(round(v, 2)) if v is not None else "N/A"
def gc(key: str) -> str:
v = gauges.get(key, {}).get("change_pct")
return f"{v:+.2f}%" if v is not None else "N/A"
prompt = f"""Tu es un stratège macro-géopolitique senior qui analyse la performance de notre système de détection de patterns.
CONTEXTE DU CYCLE (maintenant):
- Régime dominant: {dominant.upper()} (score: {scenarios.get('scores', {}).get(dominant, 0)}%)
- Score risque géopolitique: {geo_score_val}/100
- VIX: {gv('vix')} | Pente 10Y-3M: {gv('slope_10y3m')}% | DXY: {gc('dxy')} | Brent: {gc('brent')}
- Cuivre: {gc('copper')} | Or: {gc('gold')} | S&P vs 200j: {gv('spx_vs_200d')}%
TOP 5 PATTERNS ACTUELLEMENT LES MIEUX SCORÉS:
{json.dumps(top_scored_summary, ensure_ascii=False, indent=2)}
TRADES LOGUÉS CES 7 DERNIERS JOURS:
{json.dumps(trade_summary, ensure_ascii=False, indent=2)}
NEWS GÉOPOLITIQUES À FORT IMPACT:
{json.dumps(top_news, ensure_ascii=False, indent=2)}
Écris un COMMENTAIRE DE CYCLE concis (4-6 phrases) pour un trader options:
1. Le régime macro confirme-t-il les patterns qui scorent le mieux ?
2. Y a-t-il des news ou événements qui expliquent un écart avec nos prévisions ?
3. Quels patterns/trades méritent attention (confirmation ou invalidation) ?
4. Une recommandation tactique pour le prochain cycle (3h)
Réponds UNIQUEMENT en JSON: {{"commentary": "<ton texte 4-6 phrases>", "key_risk": "<risque principal en 1 phrase>", "top_pattern": "<nom du pattern le plus pertinent maintenant>"}}"""
result = _chat(
"Tu es un stratège macro senior. Analyse concise et actionnable. JSON uniquement.",
prompt,
model="gpt-4o",
json_mode=True,
max_tokens=500,
)
if result and result.get("commentary"):
return json.dumps(result, ensure_ascii=False)
except Exception as e:
logger.warning(f"[Cycle] Commentary generation failed: {e}")
return None
# ── IV Gate ───────────────────────────────────────────────────────────────────
def _apply_iv_gate(
scored: List[Dict],
iv_snapshots: Dict[str, Dict],
) -> tuple:
"""
Filter ALERT-verdict trades from each scored pattern's suggested_trades/trade_rankings
before they reach log_trade_entries.
Returns (filtered_scored, blocked_trades_list).
IVR thresholds are read from app_config (iv_gate_ivr_high, iv_gate_ivr_extreme,
iv_gate_skew_threshold) so they can be tuned from the Config page.
"""
from services.options_technical_agent import assess_strategy_fit
# Read thresholds from config (fall back to hardcoded defaults)
try:
from services.database import get_config as _gc
_ivr_high = float(_gc("iv_gate_ivr_high") or 60)
_ivr_extreme = float(_gc("iv_gate_ivr_extreme") or 80)
_skew_thresh = float(_gc("iv_gate_skew_threshold") or 8)
except Exception:
_ivr_high, _ivr_extreme, _skew_thresh = 60.0, 80.0, 8.0
# Monkey-patch thresholds into the rule engine for this call
import services.options_technical_agent as _ota_mod
_orig_ivr_high = getattr(_ota_mod, "_IVR_HIGH", None)
try:
_ota_mod._IVR_HIGH = _ivr_high
_ota_mod._IVR_EXTREME = _ivr_extreme
_ota_mod._SKEW_THRESH = _skew_thresh
except Exception:
pass
# Detect weekend: IVR=100 on weekends is artificial (weekend premium) — ignore rank
from datetime import datetime as _dt_cls
_weekend_now = _dt_cls.utcnow().weekday() >= 5
all_blocked: List[Dict] = []
for sp in scored:
trade_key = "trade_rankings" if "trade_rankings" in sp else "suggested_trades"
trades = sp.get(trade_key) or []
allowed = []
for trade in trades:
ticker = (trade.get("underlying") or trade.get("ticker") or "").upper()
strategy = trade.get("strategy") or "Long Call"
snap = iv_snapshots.get(ticker, {})
if not snap:
# No IV data → can't block; allow but don't judge
allowed.append(trade)
continue
iv_rank = snap.get("iv_rank")
# On weekends, ATM IV is inflated by weekend premium (market makers can't hedge).
# IVR=99-100 on a weekend is almost always artificial — don't block on it.
if _weekend_now and iv_rank is not None and iv_rank >= 99.0:
iv_rank = None
snap = {**snap, "iv_rank": None}
iv_current_pct = snap.get("iv_current_pct")
iv_min_52w = snap.get("iv_min_52w_pct")
iv_max_52w = snap.get("iv_max_52w_pct")
skew_pct = (snap.get("skew") or {}).get("skew_pct")
term_structure = (snap.get("term_structure") or {}).get("structure")
flow_bias = (snap.get("options_flow") or {}).get("flow_bias")
result = assess_strategy_fit(
strategy=strategy,
iv_rank=iv_rank,
iv_current_pct=iv_current_pct,
iv_min_52w=iv_min_52w,
iv_max_52w=iv_max_52w,
skew_pct=skew_pct,
term_structure=term_structure,
flow_bias=flow_bias,
)
if result["verdict"] == "ALERT":
blocked_entry = {
"ticker": ticker,
"strategy": strategy,
"pattern_id": sp.get("pattern_id"),
"pattern_name": sp.get("geo_trigger") or sp.get("pattern_name") or "",
"score": sp.get("score", 0),
"asset_class": trade.get("asset_class") or sp.get("asset_class") or "",
"iv_rank": iv_rank,
"iv_issues": result["issues"],
"optimal_strategy": result["optimal_strategy"],
"fit_score": result["fit_score"],
}
all_blocked.append(blocked_entry)
logger.warning(
f"[IVGate] BLOCKED {ticker} {strategy} (IVR={iv_rank}, score={result['fit_score']}): "
f"{result['issues'][0][:80] if result['issues'] else 'ALERT'}"
)
else:
allowed.append(trade)
sp[trade_key] = allowed
return scored, all_blocked
# ── Cycle report generation ───────────────────────────────────────────────────
def _generate_cycle_report(
run_id: str,
scored: List[Dict],
dominant: str,
scenarios: Dict,
geo_score_val: int,
news: List[Dict],
gauges: Dict,
ai_key: str,
added_patterns: List[Dict],
scoring_run_id: str,
portfolio_monitor: Optional[Dict],
commentary: Optional[str],
options_assessment: Optional[Dict] = None,
wavelet_signals: Optional[List[Dict]] = None,
) -> Optional[Dict]:
"""
Build the full cycle report dict:
- PnL + VaR snapshots (timestamped with this cycle)
- Delta vs previous cycle (patterns added, trades logged, trades closed)
- GPT-4o context narrative (what was in context, what it used, why)
- Risk summary
"""
import json as _json
try:
from services.database import get_cycle_runs, get_trade_entries_by_run
except ImportError:
from services.database import get_cycle_runs
get_trade_entries_by_run = None
# ── Get previous cycle timestamp ──────────────────────────────────────────
prev_cycle_at: Optional[str] = None
try:
history = get_cycle_runs(limit=5)
prev_cycles = [r for r in history if r["run_id"] != run_id and r.get("status") == "completed"]
if prev_cycles:
prev_cycle_at = prev_cycles[0].get("started_at")
except Exception:
pass
# ── PnL snapshot ──────────────────────────────────────────────────────────
pnl_snapshot_id: Optional[int] = None
pnl_summary: Dict = {}
try:
from services.var_service import save_pnl_snapshot, get_latest_pnl_snapshot
pnl_snapshot_id = save_pnl_snapshot()
snap = get_latest_pnl_snapshot()
if snap:
pnl_summary = {
"total_pnl_pct": snap.get("total_pnl_pct"),
"total_pnl_eur": snap.get("total_pnl_eur"),
"total_capital_eur": snap.get("total_capital_eur"),
"n_open": snap.get("n_open"),
"n_closed": snap.get("n_closed"),
}
logger.info(f"[CycleReport] PnL snapshot saved id={pnl_snapshot_id}")
except Exception as _e:
logger.warning(f"[CycleReport] PnL snapshot failed: {_e}")
# ── VaR snapshot ──────────────────────────────────────────────────────────
var_snapshot_id: Optional[int] = None
var_summary: Dict = {}
try:
from services.var_service import compute_var, save_var_snapshot
_var_cfg = get_cycle_step_config()["var_snapshot"]
var_result = compute_var(
confidence=_var_cfg["confidence"], horizon_days=_var_cfg["horizon_days"],
lookback_days=_var_cfg["lookback_days"], default_iv=_var_cfg["default_iv"],
)
if "error" not in var_result:
var_snapshot_id = save_var_snapshot(var_result, 0.95, 1, 252, 0.20)
var_summary = {
"hist_var_1d_pct": var_result.get("hist_var_1d_pct"),
"hist_cvar_pct": var_result.get("hist_cvar_pct"),
"mc_var_1d_pct": var_result.get("mc_var_1d_pct"),
"n_positions": var_result.get("n_positions"),
}
logger.info(f"[CycleReport] VaR snapshot saved id={var_snapshot_id}")
except Exception as _e:
logger.warning(f"[CycleReport] VaR snapshot failed: {_e}")
# ── Trades delta ──────────────────────────────────────────────────────────
trades_logged: List[Dict] = []
trades_closed: List[Dict] = []
try:
from services.database import get_conn
_conn = get_conn()
# Trades logged this cycle (by scoring_run_id)
_rows = _conn.execute(
"""SELECT underlying, strategy, entry_date, pnl_pct, score_at_entry, pattern_name
FROM trade_entry_prices WHERE run_id=? ORDER BY entry_date DESC""",
(scoring_run_id,),
).fetchall()
trades_logged = [dict(r) for r in _rows]
# Trades closed since previous cycle
if prev_cycle_at:
_closed = _conn.execute(
"""SELECT underlying, strategy, closed_at, pnl_pct, pattern_name
FROM trade_entry_prices
WHERE status='closed' AND closed_at >= ?
ORDER BY closed_at DESC""",
(prev_cycle_at,),
).fetchall()
trades_closed = [dict(r) for r in _closed]
_conn.close()
except Exception as _e:
logger.warning(f"[CycleReport] Delta trades query failed: {_e}")
# ── Context narrative (GPT-4o) ────────────────────────────────────────────
context_narrative: Dict = {}
try:
from services.ai_analyzer import _chat
top_news_ctx = [
{"title": n.get("title", "")[:100], "impact": round(float(n.get("impact_score") or 0), 2)}
for n in sorted(news, key=lambda x: -(float(x.get("impact_score") or 0)))[:8]
]
def _gv(k: str) -> str:
v = gauges.get(k, {}).get("value")
return str(round(v, 2)) if v is not None else "N/A"
def _gc(k: str) -> str:
v = gauges.get(k, {}).get("change_pct")
return f"{v:+.2f}%" if v is not None else "N/A"
new_pattern_names = [p.get("name", "") for p in added_patterns]
top_scored = sorted(scored, key=lambda x: -(x.get("score") or 0))[:5]
ctx_prompt = f"""Tu es un analyste stratégique qui documente le RAISONNEMENT d'un cycle d'analyse géo-macro.
CONTEXTE TRANSMIS À L'IA CE CYCLE:
- Régime macro dominant: {dominant.upper()} (score: {scenarios.get('scores', {}).get(dominant, 0)}%)
- Score risque géopolitique: {geo_score_val}/100
- VIX: {_gv('vix')} | Pente 10Y-3M: {_gv('slope_10y3m')} | S&P/200j: {_gv('spx_vs_200d')}%
- Cuivre: {_gc('copper')} | Or: {_gc('gold')} | DXY: {_gc('dxy')} | Brent: {_gc('brent')}
- Top 8 news géopolitiques transmises: {_json.dumps(top_news_ctx, ensure_ascii=False)}
NOUVELLES IDÉES GÉNÉRÉES CE CYCLE ({len(new_pattern_names)} patterns ajoutés):
{_json.dumps(new_pattern_names, ensure_ascii=False)}
TOP 5 PATTERNS LES MIEUX SCORÉS MAINTENANT:
{_json.dumps([{"name": s.get("geo_trigger","?"), "score": s.get("score"), "catalyst": s.get("key_catalyst","")[:80]} for s in top_scored], ensure_ascii=False)}
Ta tâche: Explique le RAISONNEMENT de ce cycle.
Pour chaque nouvelle idée générée:
1. Quels éléments SPÉCIFIQUES du contexte transmis (news, macro, gauges) l'ont motivée ?
2. Qu'est-ce qui vient de ta connaissance GÉNÉRALE des marchés (pas du contexte transmis) ?
3. Quels signaux étaient les plus déterminants ?
Réponds en JSON avec ce schéma EXACT:
{{
"narrative": "<texte narratif 6-10 phrases expliquant le raisonnement global du cycle>",
"context_driven": ["<signal du contexte qui a motivé une idée spécifique>", ...],
"general_knowledge": ["<connaissance générale utilisée>", ...],
"key_signals": ["<3-5 signaux les plus décisifs>"],
"context_log": {{
"macro_regime": "{dominant}",
"geo_score": {geo_score_val},
"dominant_news": ["<titre news 1>", "<titre news 2>", "<titre news 3>"],
"key_gauges": {{"vix": {_gv('vix')}, "slope": {_gv('slope_10y3m')}, "copper_chg": {_gc('copper')}, "gold_chg": {_gc('gold')}}}
}}
}}"""
result = _chat(
"Tu es un analyste macro-géopolitique. Documente le raisonnement du cycle en JSON.",
ctx_prompt,
model="gpt-4o",
json_mode=True,
max_tokens=800,
)
if result and result.get("narrative"):
context_narrative = result
logger.info(f"[CycleReport] Context narrative generated ({len(result.get('narrative',''))} chars)")
except Exception as _e:
logger.warning(f"[CycleReport] Context narrative failed: {_e}")
context_narrative = {"narrative": "", "context_driven": [], "general_knowledge": [], "key_signals": []}
# ── Parse existing commentary ─────────────────────────────────────────────
commentary_parsed: Dict = {}
if commentary:
try:
commentary_parsed = _json.loads(commentary) if isinstance(commentary, str) else commentary
except Exception:
commentary_parsed = {"commentary": str(commentary)}
# ── Calibration report ───────────────────────────────────────────────────
calibration_report: Dict = {}
try:
from services.database import get_calibration_summary as _get_calib
calib_rows = _get_calib()
if calib_rows:
pure_ai = [r for r in calib_rows if r["source"] == "pure_ai"]
early = [r for r in calib_rows if r["source"] == "early"]
mixed = [r for r in calib_rows if r["source"] == "mixed"]
driven = [r for r in calib_rows if r["source"] == "data_driven"]
with_data = [r for r in calib_rows if (r["n_mature_trades"] or 0) > 0]
avg_w = (
round(sum(r["calibration_weight"] for r in with_data) / len(with_data), 3)
if with_data else 0.0
)
calibration_report = {
"total_patterns": len(calib_rows),
"pure_ai_count": len(pure_ai),
"early_count": len(early),
"mixed_count": len(mixed),
"data_driven_count": len(driven),
"avg_calibration_weight": avg_w,
"avg_calibration_weight_pct": round(avg_w * 100, 1),
"patterns_with_data": len(with_data),
"detail": [
{
"name": r["pattern_name"],
"asset_class": r["asset_class"],
"source": r["source"],
"weight_pct": r["calibration_weight_pct"],
"ai_estimate": r["ai_estimate"],
"observed": r["observed_avg_win_pct"],
"calibrated": r["calibrated_expected_move"],
"n_trades": r["n_mature_trades"],
"win_rate_pct": round((r["bayes_win_rate"] or 0) * 100, 1),
}
for r in calib_rows if (r["n_mature_trades"] or 0) > 0
][:15],
}
logger.info(
f"[CycleReport] Calibration: {len(pure_ai)} pure-AI, "
f"{len(mixed)+len(driven)} with data, avg_weight={avg_w:.1%}"
)
except Exception as _ce:
logger.warning(f"[CycleReport] Calibration report failed: {_ce}")
# ── Assemble full report ──────────────────────────────────────────────────
report = {
"run_id": run_id,
"macro_dominant": dominant,
"geo_score": geo_score_val,
"macro_scores": scenarios.get("scores", {}),
"macro_reasons": scenarios.get("reasons", {}).get(dominant, []),
# Snapshots
"pnl_snapshot_id": pnl_snapshot_id,
"var_snapshot_id": var_snapshot_id,
"pnl_summary": pnl_summary,
"var_summary": var_summary,
# Delta
"patterns_added": len(added_patterns),
"patterns_added_list": [
{"name": p.get("name"), "description": (p.get("description") or "")[:150],
"asset_class": p.get("asset_class"), "macro_fit": p.get("macro_fit"),
"triggers": p.get("triggers", [])[:3]}
for p in added_patterns
],
"trades_logged": len(trades_logged),
"trades_logged_list": trades_logged[:10],
"trades_closed": len(trades_closed),
"trades_closed_list": trades_closed[:10],
"prev_cycle_at": prev_cycle_at,
# Context narrative
"context_narrative": context_narrative,
# Cycle commentary (existing)
"commentary": commentary_parsed,
# Risk
"portfolio_monitor": portfolio_monitor,
"risk_alerts": portfolio_monitor.get("alerts") if portfolio_monitor else None,
# Wavelet signals detected this cycle (watchlist scan, see services.wavelet_signals)
"wavelet_signals": wavelet_signals or [],
# Top scored patterns
"top_scored": [
{"name": s.get("geo_trigger"), "score": s.get("score"),
"summary": (s.get("summary") or "")[:120], "key_catalyst": s.get("key_catalyst", "")}
for s in sorted(scored, key=lambda x: -(x.get("score") or 0))[:5]
],
# Options Technical Agent assessment
"options_technical": {
"global_assessment": (options_assessment or {}).get("global_assessment", ""),
"global_score": (options_assessment or {}).get("global_score"),
"n_ok": (options_assessment or {}).get("n_ok", 0),
"n_warn": (options_assessment or {}).get("n_warn", 0),
"n_alert": (options_assessment or {}).get("n_alert", 0),
"assessments": (options_assessment or {}).get("assessments", []),
} if options_assessment is not None else None,
# Calibration: AI vs observed expected_move blend
"calibration_report": calibration_report,
}
return report
def generate_standalone_report() -> Dict[str, Any]:
"""Cycle Actions — standalone "generate-report" action. _generate_cycle_report()
itself is NOT modified (too tightly coupled to run_cycle_once()'s in-memory
state to safely change) — instead every one of its parameters is
reconstructed from an independent, DB-or-live source:
- scored <- get_last_scores() (config key "last_pattern_scores",
filled by save_pattern_scores() every real cycle)
- news/geo_score <- same live sequence as cycle Step 1
- dominant/scenarios/gauges <- same sequence as the update-regime action
- commentary <- _generate_cycle_commentary(), independently callable
- wavelet_signals <- compute_and_save_wavelet_signals(), independently callable
- portfolio_monitor <- analyze_simulation_portfolio() (+ AI only if alerts)
- added_patterns/options_assessment <- cycle-only artifacts, not
reconstructible after the fact -> empty/None (report is honest, just
thinner on these two fields than a live cycle's report)."""
from services.database import get_config, get_pattern_scores, save_cycle_report
from services.data_fetcher import fetch_geo_news, get_macro_gauges, score_macro_scenarios
from services.geo_analyzer import compute_geo_risk_score
from services.ai_analyzer import ai_score_news_batch, ai_score_geo_risk
run_id = datetime.utcnow().isoformat()
ai_key = get_config("openai_api_key") or ""
last_scores = get_pattern_scores()
scored = last_scores.get("scores") or []
scoring_run_id = last_scores.get("run_id") or run_id
news = ai_score_news_batch(fetch_geo_news())
algo_geo = compute_geo_risk_score(news)
try:
geo_obj = ai_score_geo_risk(news, algo_geo, log_meta={"run_id": run_id, "call_type": "geo_risk_score"})
except Exception:
geo_obj = algo_geo
geo_score_val = int(round(geo_obj.get("score") or 0))
gauges = get_macro_gauges()
scenarios = score_macro_scenarios(gauges)
dominant = scenarios.get("dominant", "incertain")
commentary = None
try:
commentary = _generate_cycle_commentary(scored, dominant, scenarios, geo_score_val, news, gauges)
except Exception as e:
logger.warning(f"[StandaloneReport] Commentary generation failed: {e}")
wavelet_signals: List[Dict] = []
try:
from services.wavelet_signals import compute_and_save_wavelet_signals
wavelet_signals = compute_and_save_wavelet_signals(run_id)
except Exception as e:
logger.warning(f"[StandaloneReport] Wavelet scan failed (non-blocking): {e}")
portfolio_monitor = None
try:
from services.portfolio_risk import analyze_simulation_portfolio
risk = analyze_simulation_portfolio()
if risk.get("alerts"):
portfolio_monitor = _run_portfolio_monitor(risk, run_id)
except Exception as e:
logger.warning(f"[StandaloneReport] Portfolio monitor failed (non-blocking): {e}")
report = _generate_cycle_report(
run_id=run_id, scored=scored, dominant=dominant, scenarios=scenarios,
geo_score_val=geo_score_val, news=news, gauges=gauges, ai_key=ai_key,
added_patterns=[], scoring_run_id=scoring_run_id,
portfolio_monitor=portfolio_monitor, commentary=commentary,
options_assessment=None, wavelet_signals=wavelet_signals,
)
if report:
save_cycle_report(run_id, report)
return {"run_id": run_id, "report": report}
# ── Auto portfolio snapshot ───────────────────────────────────────────────────
def _auto_portfolio_snapshot(ai_key: str) -> None:
"""
Called in a background thread at the end of each cycle.
Fetches live prices, checks if enough trades are priced (P&L ≠ 0),
and if so generates a GPT-4o portfolio report so the NEXT cycle has
fresh performance lessons. Skipped silently if not enough data.
"""
try:
import os
os.environ["OPENAI_API_KEY"] = ai_key
from services.database import (
get_mtm_trades_with_traces, save_ai_report, _trade_maturity,
)
from datetime import date as _date
data = get_mtm_trades_with_traces(days=get_cycle_step_config()["iv_context"]["lookback_days"], limit_movers=10)
all_trades = data.get("all_trades", [])
priced = data.get("priced_count", 0)
# Classify each trade by maturity
def _with_maturity(t: Dict) -> Dict:
try:
dh = (_date.today() - _date.fromisoformat(t["entry_date"])).days
except Exception:
dh = 0
mat = _trade_maturity(dh, t.get("horizon_days") or 90)
return {**t, "days_held": dh, "maturity": mat}
enriched = [_with_maturity(t) for t in all_trades if t.get("pnl_pct") is not None]
trop_tot = [t for t in enriched if t["maturity"]["status"] == "trop_tot"]
en_cours = [t for t in enriched if t["maturity"]["status"] == "debut"]
matures = [t for t in enriched if t["maturity"]["status"] in ("mature", "fin_horizon")]
# Only learn lessons from mature trades (weight > 0.9)
meaningful_mature = [t for t in matures if abs(t.get("pnl_pct", 0)) > 0.1]
if len(meaningful_mature) < 2:
logger.info(
f"[AutoSnapshot] Skipping GPT-4o report: only {len(meaningful_mature)} mature trades "
f"with meaningful P&L (need ≥ 2). "
f"Immatures={len(trop_tot)}, en cours={len(en_cours)}, matures={len(matures)}"
)
# Still try to refresh Super Contexte (independent of portfolio report)
_auto_synthesize_knowledge(ai_key)
return
# Sort matures by P&L for the report
winners = sorted(matures, key=lambda t: t.get("pnl_pct", 0), reverse=True)[:5]
losers = sorted(matures, key=lambda t: t.get("pnl_pct", 0))[:5]
avg_pnl = (sum(t.get("pnl_pct", 0) for t in matures) / len(matures)) if matures else None
stats = {
"total_trades": len(all_trades),
"priced_count": priced,
"avg_pnl_pct": avg_pnl,
"mature_count": len(matures),
"early_count": len(trop_tot) + len(en_cours),
}
# Build maturity context blocks for the prompt
def _trade_line(t: Dict) -> str:
mat = t["maturity"]
return (
f" {t.get('underlying','?')} {t.get('strategy','?')} "
f"P&L={t.get('pnl_pct',0):+.1f}% "
f"[{mat['readable']}] "
f"score_entrée={t.get('score_at_entry','?')}"
)
mature_wins_block = "\n".join(_trade_line(t) for t in winners) or " Aucun"
mature_losses_block = "\n".join(_trade_line(t) for t in losers) or " Aucun"
early_block = "\n".join(
f" {t.get('underlying','?')} {t.get('strategy','?')} "
f"P&L={t.get('pnl_pct',0):+.1f}% [{t['maturity']['readable']}]"
for t in (trop_tot + en_cours)[:6]
) or " Aucun"
avg_str = f"{avg_pnl:+.1f}%" if avg_pnl is not None else "N/A"
from services.ai_analyzer import _chat
prompt = f"""Tu es un stratège macro-géopolitique senior. Rapport post-cycle automatique.
⚠️ RÈGLE FONDAMENTALE DE TIMING :
Les options ont des horizons de 30-90 jours. Un trade de 3 jours ne dit RIEN sur sa performance finale.
Tu dois UNIQUEMENT tirer des leçons des trades MATURES (≥35% de l'horizon écoulé).
Les trades immatures sont listés pour information seulement — n'en tire AUCUNE conclusion de performance.
═══ STATISTIQUES GLOBALES ═══
Période analysée : 90 jours | Trades total: {len(all_trades)} | Pricés: {priced}
Trades MATURES (signal fiable): {len(matures)} | P&L moyen matures: {avg_str}
Trades IMMATURES (trop tôt pour juger): {len(trop_tot) + len(en_cours)}
═══ MATURES — TOP GAINS (signal fiable) ═══
{mature_wins_block}
═══ MATURES — TOP PERTES (signal fiable) ═══
{mature_losses_block}
═══ EN COURS — NE PAS ÉVALUER (trop tôt) ═══
{early_block}
Génère un rapport JSON basé UNIQUEMENT sur les trades matures :
{{
"headline": "<1 phrase résumant la performance des trades matures>",
"regime_assessment": "<alignement régime macro avec nos thèses matures ?>",
"winners_analysis": "<pourquoi les trades matures gagnants ont marché — 2-3 phrases>",
"losers_analysis": "<pourquoi les trades matures perdants ont déçu — 2-3 phrases>",
"key_lessons": ["<leçon 1 tirée des matures>", "<leçon 2>", "<leçon 3>"],
"blind_spots": "<ce que le scoring n'a pas bien capturé sur les trades matures>",
"next_cycle_priorities": "<3 priorités pour le prochain cycle>",
"risk_watch": "<1-2 risques à surveiller>",
"timing_note": "<observation sur les trades immatures — à surveiller mais pas à juger>"
}}"""
result = _chat(
"Tu es un stratège macro senior. Rapport post-cycle concis. JSON uniquement.",
prompt,
model="gpt-4o",
json_mode=True,
max_tokens=800,
)
if not result:
logger.warning("[AutoSnapshot] GPT-4o returned empty response")
return
report_id = save_ai_report(
days=30, stats=stats, winners=winners, losers=losers, report=result,
report_type="portfolio",
)
logger.info(
f"[AutoSnapshot] Portfolio report #{report_id} saved automatically "
f"({len(meaningful_mature)} meaningful mature trades, avg P&L {avg_str})"
)
# ── Auto-synthesize Super Contexte if stale (>6h or never generated) ──
_auto_synthesize_knowledge(ai_key)
except Exception as e:
logger.error(f"[AutoSnapshot] Failed: {e}", exc_info=True)
def _auto_synthesize_knowledge(ai_key: str) -> None:
"""
Synthesize the Super Contexte knowledge base after a portfolio report is generated.
Skipped if the last synthesis is < 6 hours old, to avoid redundant GPT-4o calls.
"""
try:
import os, json as _json
from datetime import datetime as _dt, timedelta as _td
import openai
os.environ["OPENAI_API_KEY"] = ai_key
from services.database import (
get_latest_reasoning_state, save_reasoning_state,
get_all_kb_entries, save_kb_entry,
list_ai_reports, get_mtm_trades_with_traces,
)
synth_cfg = get_cycle_step_config()["knowledge_synthesis"]
if not synth_cfg["enabled"]:
logger.info("[AutoSynth] Disabled via cycle_step_config — skipping")
return
# Skip if last synthesis is younger than the configured threshold
min_hours = synth_cfg["min_hours_between"]
last_state = get_latest_reasoning_state()
if last_state:
try:
last_at = _dt.fromisoformat(last_state["created_at"])
age_h = (_dt.utcnow() - last_at).total_seconds() / 3600
if age_h < min_hours:
logger.info(
f"[AutoSynth] Super Contexte is {age_h:.1f}h old — skipping re-synthesis (threshold: {min_hours}h)"
)
return
except Exception:
pass
reports = list_ai_reports(limit=10)
mtm_data = get_mtm_trades_with_traces(days=get_cycle_step_config()["iv_context"]["lookback_days"])
trades = mtm_data.get("all_trades", []) if isinstance(mtm_data, dict) else []
kb_entries = get_all_kb_entries()
logger.info(
f"[AutoSynth] Starting Super Contexte synthesis "
f"({len(reports)} rapports, {len(trades)} trades, {len(kb_entries)} KB entries)"
)
# Build synthesis prompt (reuse router logic inline to avoid import cycle)
from routers.knowledge import _build_synthesis_prompt
system_msg, user_msg = _build_synthesis_prompt(reports, trades, kb_entries)
client = openai.OpenAI(api_key=ai_key)
resp = client.chat.completions.create(
model="gpt-4o",
messages=[
{"role": "system", "content": system_msg},
{"role": "user", "content": user_msg},
],
temperature=0.3,
max_tokens=2500,
response_format={"type": "json_object"},
)
raw = resp.choices[0].message.content or "{}"
synthesis = _json.loads(raw)
narrative = synthesis.pop("narrative", "Synthèse non disponible.")
state_id = save_reasoning_state(
narrative=narrative,
synthesis=synthesis,
sources_count=len(reports) + len(trades),
reports_used=len(reports),
trades_analyzed=len(trades),
)
# Auto-persist new KB entries from synthesis
added = 0
for regime in synthesis.get("regime_insights", []):
if regime.get("observation"):
save_kb_entry("régimes", f"Régime: {regime.get('regime','?')}", regime["observation"],
regime.get("confidence", 50), "auto-synth")
added += 1
for pattern in synthesis.get("pattern_insights", []):
if pattern.get("observation"):
save_kb_entry("patterns", f"Pattern: {pattern.get('pattern','?')}", pattern["observation"],
pattern.get("confidence", 50), "auto-synth")
added += 1
for mistake in synthesis.get("recurring_mistakes", []):
if mistake.get("mistake"):
save_kb_entry("erreurs", mistake["mistake"][:80],
f"{mistake.get('mistake','')}{mistake.get('mitigation','')}",
70, "auto-synth")
added += 1
logger.info(
f"[AutoSynth] Super Contexte v{state_id} saved automatically "
f"({added} KB entries added)"
)
except Exception as e:
logger.error(f"[AutoSynth] Failed: {e}", exc_info=True)
# ── Scheduler ─────────────────────────────────────────────────────────────────
def _parse_weekend_times(times_str: str):
"""Parse 'HH:MM,HH:MM' into list of (hour, minute) tuples."""
result = []
for t in times_str.split(","):
t = t.strip()
try:
parts = t.split(":")
result.append((int(parts[0]), int(parts[1])))
except Exception:
pass
return result or [(8, 0), (22, 0)]
def _next_weekend_slot(now_utc: datetime, weekend_times):
"""Return (next_dt, wait_secs) for the next weekend cycle time (UTC)."""
from datetime import timedelta
today = now_utc.date()
candidates = []
for day_offset in range(3):
for h, m in weekend_times:
candidate = datetime(today.year, today.month, today.day, h, m) + timedelta(days=day_offset)
if candidate > now_utc + timedelta(minutes=5):
candidates.append(candidate)
if not candidates:
fallback = now_utc + timedelta(hours=12)
return fallback, 12 * 3600
next_slot = min(candidates)
return next_slot, max(60, (next_slot - now_utc).total_seconds())
def _scheduler_loop(stop_event: threading.Event):
"""Background loop that runs the cycle at the configured interval.
Weekday: fires every auto_cycle_hours.
Weekend: fires only at weekend_cycle_times (UTC HH:MM list).
If weekend_cycle_enabled=false, sleeps until Monday.
"""
from services.database import get_config
from datetime import timedelta
while not stop_event.is_set():
try:
interval_hours = float(get_config("auto_cycle_hours") or "3")
weekend_enabled = (get_config("weekend_cycle_enabled") or "true").lower() == "true"
weekend_times_str = get_config("weekend_cycle_times") or "08:00,22:00"
except Exception:
interval_hours, weekend_enabled, weekend_times_str = 3.0, True, "08:00,22:00"
weekend_times = _parse_weekend_times(weekend_times_str)
now_utc = datetime.utcnow()
weekday = now_utc.weekday()
is_weekend = weekday >= 5
if is_weekend and not weekend_enabled:
# Sleep until Monday 00:05 UTC
days_to_monday = 7 - weekday # Sat→2, Sun→1
next_monday = datetime(now_utc.year, now_utc.month, now_utc.day, 0, 5) + timedelta(days=days_to_monday)
wait_secs = max(60, (next_monday - now_utc).total_seconds())
_current_status["next_run_at"] = next_monday.isoformat()
_current_status["interval_hours"] = interval_hours
logger.info(f"[Scheduler] Weekend — cycles désactivés. Reprise lundi {next_monday.strftime('%Y-%m-%d %H:%M')} UTC")
stop_event.wait(timeout=wait_secs)
continue # don't run a cycle, loop back
if is_weekend:
next_slot, wait_secs = _next_weekend_slot(now_utc, weekend_times)
_current_status["next_run_at"] = next_slot.isoformat()
_current_status["interval_hours"] = interval_hours
logger.info(f"[Scheduler] Weekend — prochain cycle à {next_slot.strftime('%H:%M')} UTC ({wait_secs/3600:.1f}h)")
else:
# Account for time already elapsed since last cycle so a restart
# doesn't reset the countdown — fire as soon as the interval is due.
last_run_iso = _current_status.get("last_run_at")
if not last_run_iso:
# Also check DB for the most recent completed run
try:
from services.database import get_cycle_runs
recent = get_cycle_runs(limit=1)
if recent:
last_run_iso = recent[0].get("created_at") or recent[0].get("run_id", "")[:19].replace("T", " ")
except Exception:
pass
elapsed_secs = 0.0
if last_run_iso:
try:
last_dt = datetime.fromisoformat(last_run_iso.replace(" ", "T").rstrip("Z"))
elapsed_secs = (now_utc - last_dt).total_seconds()
except Exception:
pass
wait_secs = max(60, interval_hours * 3600 - elapsed_secs)
next_run = now_utc + timedelta(seconds=wait_secs)
_current_status["next_run_at"] = next_run.isoformat()
_current_status["interval_hours"] = interval_hours
logger.info(f"[Scheduler] Prochain cycle dans {wait_secs/3600:.1f}h ({next_run.strftime('%H:%M')} UTC) — elapsed since last: {elapsed_secs/3600:.1f}h")
stop_event.wait(timeout=wait_secs)
if stop_event.is_set():
break
# Check if still enabled
try:
enabled = (get_config("auto_cycle_enabled") or "false").lower() == "true"
except Exception:
enabled = False
if enabled:
logger.info("[Scheduler] Running scheduled auto-cycle")
try:
run_cycle_once(trigger="auto")
except Exception as e:
logger.error(f"[Scheduler] Cycle error: {e}", exc_info=True)
def start_scheduler():
"""Start the background scheduler thread if auto_cycle is enabled."""
global _cycle_thread, _stop_event
from services.database import get_config
enabled = (get_config("auto_cycle_enabled") or "false").lower() == "true"
_current_status["enabled"] = enabled
if not enabled:
logger.info("[Scheduler] Auto-cycle disabled — skipping scheduler start")
return
if _cycle_thread and _cycle_thread.is_alive():
logger.info("[Scheduler] Already running")
return
_stop_event = threading.Event()
_cycle_thread = threading.Thread(
target=_scheduler_loop,
args=(_stop_event,),
daemon=True,
name="auto-cycle-scheduler",
)
_cycle_thread.start()
logger.info("[Scheduler] Auto-cycle scheduler started")
def stop_scheduler():
"""Stop the background scheduler thread."""
global _cycle_thread
_stop_event.set()
if _cycle_thread:
_cycle_thread.join(timeout=5)
_current_status["enabled"] = False
logger.info("[Scheduler] Stopped")
def restart_scheduler():
"""Restart the scheduler — call after config changes."""
stop_scheduler()
_stop_event.clear()
start_scheduler()
def trigger_manual():
"""Run one cycle immediately in a background thread (non-blocking)."""
t = threading.Thread(target=run_cycle_once, args=("manual",), daemon=True, name="auto-cycle-manual")
t.start()
return t
def get_status() -> Dict[str, Any]:
from services.database import get_config, get_cycle_runs
try:
interval_hours = float(get_config("auto_cycle_hours") or "3")
enabled = (get_config("auto_cycle_enabled") or "false").lower() == "true"
sim_threshold = float(get_config("auto_cycle_similarity_threshold") or "0.30")
min_ev = float(get_config("min_ev_threshold") or "0.0")
min_score = int(get_config("min_score_threshold") or "0")
retention_days = int(get_config("journal_retention_days") or "90")
maturity_pct = int(get_config("maturity_threshold_pct") or "35")
trade_budget_eur = float(get_config("trade_budget_eur") or "5000")
preferred_horizon_min = int(get_config("preferred_horizon_min") or "30")
preferred_horizon_max = int(get_config("preferred_horizon_max") or "180")
except Exception:
interval_hours, enabled, sim_threshold, min_ev, min_score = 3.0, False, 0.30, 0.0, 0
retention_days, maturity_pct = 90, 35
trade_budget_eur, preferred_horizon_min, preferred_horizon_max = 5000.0, 30, 180
recent = get_cycle_runs(limit=1)
last = recent[0] if recent else None
# Enrich last_cycle with the scoring_run_id used for trade_entry_prices
if last:
try:
from services.database import get_conn
_conn = get_conn()
started = (last.get("started_at") or "").replace(" ", "T")
completed = last.get("completed_at") or ""
if started and completed:
row = _conn.execute(
"SELECT run_id FROM trade_entry_prices WHERE run_id >= ? AND run_id <= ?"
" ORDER BY run_id DESC LIMIT 1",
(started, completed),
).fetchone()
if row:
last = dict(last)
last["scoring_run_id"] = row["run_id"]
except Exception:
pass
try:
weekend_enabled = (get_config("weekend_cycle_enabled") or "true").lower() == "true"
weekend_cycle_times = get_config("weekend_cycle_times") or "08:00,22:00"
except Exception:
weekend_enabled, weekend_cycle_times = True, "08:00,22:00"
return {
**_current_status,
"enabled": enabled,
"interval_hours": interval_hours,
"similarity_threshold": sim_threshold,
"min_ev_threshold": min_ev,
"min_score_threshold": min_score,
"journal_retention_days": retention_days,
"maturity_threshold_pct": maturity_pct,
"trade_budget_eur": trade_budget_eur,
"preferred_horizon_min": preferred_horizon_min,
"preferred_horizon_max": preferred_horizon_max,
"weekend_cycle_enabled": weekend_enabled,
"weekend_cycle_times": weekend_cycle_times,
"cycle_step_config": get_cycle_step_config(),
"last_cycle": last,
"scheduler_alive": bool(_cycle_thread and _cycle_thread.is_alive()),
}