feat: cycle

This commit is contained in:
OpenSquared
2026-07-15 12:03:02 +02:00
parent ce9c0b53a9
commit 2d474c9194
9 changed files with 471 additions and 67 deletions

View File

@@ -1,8 +1,8 @@
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from typing import Optional
from typing import Any, Dict, Optional
from services.database import get_cycle_runs, get_cycle_run, set_config, get_config, list_cycle_context_snapshots, get_cycle_context_snapshot, get_ai_call_logs
from services.auto_cycle import get_status, trigger_manual, restart_scheduler
from services.auto_cycle import get_status, trigger_manual, restart_scheduler, CYCLE_STEP_CATALOG
router = APIRouter(prefix="/api/cycle", tags=["cycle"])
@@ -13,6 +13,14 @@ def cycle_status():
return get_status()
@router.get("/step-catalog")
def cycle_step_catalog():
"""Self-describing catalog of every configurable cycle step (mirrors
GET /api/ai-desks/signal-catalog) — lets Config.tsx render a generic
form instead of hand-coded sliders per knob."""
return {"steps": CYCLE_STEP_CATALOG}
@router.get("/history")
def cycle_history(limit: int = 20):
"""List recent cycle runs."""
@@ -51,6 +59,7 @@ class CycleConfigRequest(BaseModel):
maturity_threshold_pct: Optional[int] = None
weekend_cycle_enabled: Optional[bool] = None
weekend_cycle_times: Optional[str] = None # "HH:MM,HH:MM" in UTC
cycle_step_config: Optional[Dict[str, Dict[str, Any]]] = None # {step_id: {param: value}}
@router.post("/config")
@@ -102,6 +111,18 @@ def update_cycle_config(req: CycleConfigRequest):
if not re.match(r'^(\d{2}:\d{2})(,\d{2}:\d{2})*$', req.weekend_cycle_times.strip()):
raise HTTPException(400, "weekend_cycle_times must be 'HH:MM' or 'HH:MM,HH:MM,...'")
set_config("weekend_cycle_times", req.weekend_cycle_times.strip())
if req.cycle_step_config is not None:
import json
known_ids = {step["id"] for step in CYCLE_STEP_CATALOG}
unknown = set(req.cycle_step_config.keys()) - known_ids
if unknown:
raise HTTPException(400, f"Unknown cycle step id(s): {sorted(unknown)}")
# Merge over whatever is already saved so a partial update from the UI
# never wipes out other steps' settings.
current = json.loads(get_config("cycle_step_config") or "{}")
for step_id, params in req.cycle_step_config.items():
current[step_id] = {**current.get(step_id, {}), **params}
set_config("cycle_step_config", json.dumps(current))
# Restart scheduler to pick up changes
restart_scheduler()

View File

@@ -32,6 +32,14 @@ def geo_news(force_refresh: bool = False):
@router.get("/risk-score")
def risk_score():
"""Frozen per-cycle AI-judged score (services.ai_analyzer.ai_score_geo_risk,
saved once per auto-cycle run in geo_risk_snapshots) — no longer
recomputed live on every request. Falls back to a live algorithmic
compute only if no cycle has ever run yet (e.g. fresh install)."""
from services.database import get_latest_geo_risk_snapshot
snapshot = get_latest_geo_risk_snapshot()
if snapshot:
return snapshot
news = _news_cache["data"] or fetch_geo_news()
return compute_geo_risk_score(news)

View File

@@ -1734,6 +1734,66 @@ JSON: {{"items": [{{"i":<int>,"impact_score":<float>,"dir_energy":"...","dir_met
return news_items
# ── Holistic geopolitical risk score (AI judgment, cycle-frozen) ──────────────
def ai_score_geo_risk(news: List[Dict], algo_score: Dict, log_meta: Optional[Dict] = None) -> Dict:
"""Holistic 0-100 geopolitical risk assessment by the AI. Takes the
already AI-scored news list plus compute_geo_risk_score()'s deterministic
category-weighted formula as a reference baseline (not a value to just
copy) — lets the AI actually judge severity/de-escalation/context instead
of a rigid weighted sum, while staying anchored enough not to swing
wildly cycle to cycle. Called once per auto-cycle (services/auto_cycle.py
Step 1); the result is frozen in DB until the next cycle runs."""
if not get_client() or not news:
return {
"score": algo_score.get("score", 0), "level": algo_score.get("level", "low"),
"rationale": "IA indisponible — score algorithmique utilisé tel quel.",
"top_risks": [],
}
top_news = sorted(news, key=lambda n: -(n.get("impact_score") or 0))[:15]
compact = [
{"title": n.get("title", ""), "category": n.get("category", ""), "impact": round(n.get("impact_score") or 0, 2)}
for n in top_news
]
user = f"""Evalue le niveau de risque geopolitique global actuel pour les marches financiers, sur une echelle de 0 a 100.
Score algorithmique de reference (formule ponderee par categorie, a titre indicatif seulement — exerce ton propre jugement, ne le recopie pas mecaniquement) : {algo_score.get('score')}/100 ({algo_score.get('level')}).
Repartition par categorie : {json.dumps(algo_score.get('breakdown', {}), ensure_ascii=False)}
Top actualites (triees par impact) :
{json.dumps(compact, ensure_ascii=False)}
Consignes :
- Un score eleve doit refleter un risque REEL et actuel pour les marches (escalade militaire active, rupture commerciale majeure, crise politique...), pas juste un volume de news.
- Un evenement de desescalade/resolution doit FAIRE BAISSER le score meme si son impact brut est eleve.
- Ne t'ancre pas mecaniquement sur le score algorithmique si le contexte reel (titres) justifie un score different.
- rationale : 2-3 phrases en francais expliquant precisement pourquoi ce score, en citant les evenements les plus determinants.
- top_risks : 3 a 5 items les plus determinants pour ce score (titres courts).
JSON: {{"score": <0-100 float>, "level": "low"|"medium"|"high"|"extreme", "rationale": "...", "top_risks": ["...", ...]}}"""
result = _chat(
"Tu es un analyste geopolitique senior qui evalue le risque marche global, pas evenement par evenement.",
user, model="gpt-4o", json_mode=True, max_tokens=700, log_meta=log_meta,
)
if not result:
return {
"score": algo_score.get("score", 0), "level": algo_score.get("level", "low"),
"rationale": "Erreur IA — score algorithmique utilisé tel quel.",
"top_risks": [],
}
score = max(0.0, min(100.0, float(result.get("score", algo_score.get("score", 0)))))
return {
"score": round(score, 1),
"level": result.get("level") or algo_score.get("level", "low"),
"rationale": result.get("rationale", ""),
"top_risks": result.get("top_risks", []) or [],
}
# ── Re-score news batch with AI ───────────────────────────────────────────────
def ai_rescore_news(news_items: List[Dict]) -> List[Dict]:

View File

@@ -49,7 +49,7 @@ def _max_similarity_vs_existing(candidate_kws: List[str], existing: List[Dict])
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
_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(
@@ -57,6 +57,7 @@ def _is_duplicate_pattern(
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.
@@ -78,7 +79,7 @@ def _is_duplicate_pattern(
candidate_id=candidate.get("id"),
)
if sim > 0: # embedding worked
return sim >= _EMBED_SIM_THRESHOLD
return sim >= embed_threshold
except Exception as _emb_err:
logger.debug(f"[Cycle] Embedding failed, fallback Jaccard: {_emb_err}")
@@ -87,6 +88,90 @@ def _is_duplicate_pattern(
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:
@@ -181,7 +266,7 @@ def run_cycle_once(trigger: str = "auto") -> Dict[str, Any]:
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, _chat, DEFAULT_ANALYSIS_TEMPLATE,
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,
@@ -206,6 +291,7 @@ def run_cycle_once(trigger: str = "auto") -> Dict[str, Any]:
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
@@ -222,7 +308,7 @@ def run_cycle_once(trigger: str = "auto") -> Dict[str, Any]:
except Exception:
pass
_interval_hours = float(get_config("auto_cycle_interval_hours") or "3")
_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:
@@ -329,10 +415,23 @@ def run_cycle_once(trigger: str = "auto") -> Dict[str, Any]:
news = ai_score_news_batch(news)
_news_cache["data"] = news
geo_score_obj = compute_geo_risk_score(news)
geo_score_val = int(geo_score_obj.get("score") or 0)
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
@@ -377,7 +476,7 @@ def run_cycle_once(trigger: str = "auto") -> Dict[str, Any]:
_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=14)
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}")
@@ -393,7 +492,7 @@ def run_cycle_once(trigger: str = "auto") -> Dict[str, Any]:
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=90)
_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", [])
@@ -430,7 +529,10 @@ def run_cycle_once(trigger: str = "auto") -> Dict[str, Any]:
_price_discovery_block = ""
try:
from services.price_discovery import compute_absorptions, build_price_discovery_block
_absorptions = compute_absorptions(min_age_minutes=30.0, max_age_days=7)
_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:
@@ -462,7 +564,7 @@ def run_cycle_once(trigger: str = "auto") -> Dict[str, Any]:
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=60)}
_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:
@@ -588,7 +690,7 @@ def run_cycle_once(trigger: str = "auto") -> Dict[str, Any]:
_institutional_block = ""
try:
from services.ai_analyzer import build_institutional_block
_institutional_block = build_institutional_block(days=7)
_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:
@@ -634,7 +736,7 @@ def run_cycle_once(trigger: str = "auto") -> Dict[str, Any]:
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):
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)
@@ -713,7 +815,7 @@ def run_cycle_once(trigger: str = "auto") -> Dict[str, Any]:
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=90)
_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", [])
@@ -744,7 +846,8 @@ def run_cycle_once(trigger: str = "auto") -> Dict[str, Any]:
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)")
template = get_config("analysis_template") or DEFAULT_ANALYSIS_TEMPLATE
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,
@@ -941,7 +1044,9 @@ def run_cycle_once(trigger: str = "auto") -> Dict[str, Any]:
try:
from services.portfolio_risk import analyze_simulation_portfolio
_risk = analyze_simulation_portfolio()
if _risk.get("alerts"):
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:
@@ -957,27 +1062,30 @@ def run_cycle_once(trigger: str = "auto") -> Dict[str, Any]:
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
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}")
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")}
@@ -1004,17 +1112,23 @@ def run_cycle_once(trigger: str = "auto") -> Dict[str, Any]:
logger.warning(f"[Cycle] Bayesian update failed (non-blocking): {_be}")
# ── Step 5.6: Régime clustering (Sprint 4.2) ──────────────────────────
try:
from services.database import detect_and_save_regime_clusters
_cluster_result = detect_and_save_regime_clusters(n_clusters=4, days=180)
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')})"
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"],
)
except Exception as _ce:
logger.warning(f"[Cycle] Régime clustering failed (non-blocking): {_ce}")
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 = []
@@ -1081,7 +1195,7 @@ def run_cycle_once(trigger: str = "auto") -> Dict[str, Any]:
if _commentary_text:
_conn = _get_conn()
try:
cutoff = (datetime.utcnow() - timedelta(days=14)).strftime("%Y-%m-%d")
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)",
@@ -1171,8 +1285,8 @@ def _generate_cycle_commentary(
from services.database import get_trade_entry_prices
from services.ai_analyzer import _chat
# Get recent trade P&L for context (last 7 days)
entries = get_trade_entry_prices(7)
# 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({
@@ -1416,7 +1530,11 @@ def _generate_cycle_report(
var_summary: Dict = {}
try:
from services.var_service import compute_var, save_var_snapshot
var_result = compute_var(confidence=0.95, horizon_days=1, lookback_days=252, default_iv=0.20)
_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 = {
@@ -1654,7 +1772,7 @@ def _auto_portfolio_snapshot(ai_key: str) -> None:
)
from datetime import date as _date
data = get_mtm_trades_with_traces(days=90, limit_movers=10)
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)
@@ -1799,22 +1917,28 @@ def _auto_synthesize_knowledge(ai_key: str) -> None:
list_ai_reports, get_mtm_trades_with_traces,
)
# Skip if last synthesis < 6 hours ago
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 < 6:
if age_h < min_hours:
logger.info(
f"[AutoSynth] Super Contexte is {age_h:.1f}h old — skipping re-synthesis (threshold: 6h)"
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=90)
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()
@@ -2104,6 +2228,7 @@ def get_status() -> Dict[str, Any]:
"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()),
}

View File

@@ -341,6 +341,20 @@ def init_db():
top_patterns_json TEXT NOT NULL DEFAULT '[]',
news_count INTEGER DEFAULT 0
)""")
# Geo risk score — one AI-judged snapshot per cycle run, insert-only, never
# mutated. Frontend reads only the latest row instead of recomputing live.
c.execute("""CREATE TABLE IF NOT EXISTS geo_risk_snapshots (
id INTEGER PRIMARY KEY AUTOINCREMENT,
run_id TEXT NOT NULL,
computed_at TEXT DEFAULT (datetime('now')),
score REAL NOT NULL,
level TEXT NOT NULL,
breakdown_json TEXT DEFAULT '{}',
top_risks_json TEXT DEFAULT '[]',
ai_rationale TEXT DEFAULT '',
source TEXT DEFAULT 'ai'
)""")
try:
c.execute("CREATE INDEX IF NOT EXISTS idx_gah_ts ON geo_alert_history(timestamp DESC)")
except Exception:
@@ -1908,6 +1922,33 @@ def get_geo_alert_history(days: int = 30) -> List[Dict[str, Any]]:
return result
def save_geo_risk_snapshot(run_id: str, score: float, level: str, breakdown: Dict[str, Any],
top_risks: List[Any], rationale: str, source: str = "ai") -> None:
conn = get_conn()
conn.execute(
"""INSERT INTO geo_risk_snapshots (run_id, score, level, breakdown_json, top_risks_json, ai_rationale, source)
VALUES (?, ?, ?, ?, ?, ?, ?)""",
(run_id, score, level, json.dumps(breakdown or {}), json.dumps(top_risks or []), rationale or "", source),
)
conn.commit()
conn.close()
def get_latest_geo_risk_snapshot() -> Optional[Dict[str, Any]]:
conn = get_conn()
row = conn.execute(
"SELECT * FROM geo_risk_snapshots ORDER BY computed_at DESC LIMIT 1"
).fetchone()
conn.close()
if not row:
return None
d = dict(row)
d["breakdown"] = json.loads(d.pop("breakdown_json", "{}") or "{}")
d["top_risks"] = json.loads(d.pop("top_risks_json", "[]") or "[]")
d["rationale"] = d.pop("ai_rationale", "")
return d
def _normalize_yf_ticker(ticker: str) -> str:
"""Normalize ticker for yfinance.
- USD/KRW → USDKRW=X (slash-format forex pairs from GPT-4o)
@@ -2047,7 +2088,10 @@ def log_trade_entries(run_id: str, scored_patterns: List[Dict[str, Any]], quotes
import logging as _logging
_log = _logging.getLogger(__name__)
profiles = get_risk_profiles(enabled_only=True)
_log.info(f"[TradeLog] run_id={run_id} scored_patterns={len(scored_patterns)} profiles={len(profiles)}")
min_score_threshold = int(get_config("min_score_threshold") or 0)
min_ev_threshold = float(get_config("min_ev_threshold") or 0.0)
_log.info(f"[TradeLog] run_id={run_id} scored_patterns={len(scored_patterns)} profiles={len(profiles)} "
f"min_score={min_score_threshold} min_ev={min_ev_threshold}")
# Load original patterns as fallback for expected_move_pct
# (GPT-4o scored output doesn't include this field)
@@ -2174,6 +2218,26 @@ def log_trade_entries(run_id: str, scored_patterns: List[Dict[str, Any]], quotes
ev_gross, ev_net, trade_score = _compute_trade_score(eff_score, exp_move)
# Global floor — applies on top of the per-profile score/gain match above.
if eff_score < min_score_threshold or ev_net < min_ev_threshold:
skipped_no_profile += 1
_log.debug(
f"[TradeLog] SKIP {underlying} score={eff_score} ev_net={ev_net:.2f}"
f"below global floor (min_score={min_score_threshold}, min_ev={min_ev_threshold})"
)
_trade_ac = trade.get("asset_class") or sp.get("asset_class") or _orig.get("asset_class") or ""
try:
log_skipped_trade(
run_id=run_id, pattern_id=pid, pattern_name=pattern_name,
underlying=underlying, strategy=strategy, score=eff_score,
expected_move_pct=exp_move,
skip_detail=f"below global floor: score={eff_score}<{min_score_threshold} or ev_net={ev_net:.2f}<{min_ev_threshold}",
asset_class=_trade_ac,
)
except Exception:
pass
continue
ticker_key = _normalize_ticker(underlying.upper())
entry_price = price_map.get(ticker_key)
horizon = int(

View File

@@ -107,6 +107,7 @@ export const useEcoCalendar = (params: { period?: string; limit?: number; impact
queryKey: ['eco-calendar', params],
queryFn: () => api.get('/eco/calendar', { params: { period: 'recent', limit: 50, impacts: 'high,medium', ...params } }).then(r => r.data),
staleTime: 5 * 60_000,
refetchInterval: 5 * 60_000,
})
// ── Instruments Watchlist (Dashboard "radar" card) ────────────────────────────
@@ -484,6 +485,16 @@ export const useCycleStatus = () =>
refetchInterval: (query) => ((query.state.data as any)?.running ? 5_000 : 30_000),
})
export interface CycleStepParam { type: 'bool' | 'int' | 'float'; label?: string; default: unknown; min?: number; max?: number }
export interface CycleStepDef { id: string; label: string; description: string; group: string; params: Record<string, CycleStepParam> }
export const useCycleStepCatalog = () =>
useQuery({
queryKey: ['cycle-step-catalog'],
queryFn: () => api.get('/cycle/step-catalog').then(r => r.data.steps as CycleStepDef[]),
staleTime: Infinity,
})
export const useCycleHistory = (limit = 20) =>
useQuery({
queryKey: ['cycle-history', limit],
@@ -494,7 +505,7 @@ export const useCycleHistory = (limit = 20) =>
export const useUpdateCycleConfig = () => {
const qc = useQueryClient()
return useMutation({
mutationFn: (cfg: { enabled?: boolean; interval_hours?: number; similarity_threshold?: number; min_ev_threshold?: number; min_score_threshold?: number; trade_budget_eur?: number; preferred_horizon_min?: number; preferred_horizon_max?: number; journal_retention_days?: number; maturity_threshold_pct?: number; weekend_cycle_enabled?: boolean; weekend_cycle_times?: string }) =>
mutationFn: (cfg: { enabled?: boolean; interval_hours?: number; similarity_threshold?: number; min_ev_threshold?: number; min_score_threshold?: number; trade_budget_eur?: number; preferred_horizon_min?: number; preferred_horizon_max?: number; journal_retention_days?: number; maturity_threshold_pct?: number; weekend_cycle_enabled?: boolean; weekend_cycle_times?: string; cycle_step_config?: Record<string, Record<string, unknown>> }) =>
api.post('/cycle/config', cfg).then(r => r.data),
onSuccess: () => qc.invalidateQueries({ queryKey: ['cycle-status'] }),
})

View File

@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useSources, useUpdateSources, useUpdateApiKeys, useConfig, useAiStatus, useAnalysisConfig, useSaveAnalysisConfig, useCycleStatus, useUpdateCycleConfig, useTriggerCycle, useRiskProfiles, useUpsertProfile, useDeleteProfile, useExitDefaults, useSaveExitDefaults, useOptionsGate, useSaveOptionsGate, useTechIndicatorsConfig, useSaveTechIndicatorsConfig, useInstrumentsWatchlist, useAddWatchlistInstrument, useRemoveWatchlistInstrument, validateTicker } from '../hooks/useApi'
import { useSources, useUpdateSources, useUpdateApiKeys, useConfig, useAiStatus, useAnalysisConfig, useSaveAnalysisConfig, useCycleStatus, useUpdateCycleConfig, useTriggerCycle, useCycleStepCatalog, useRiskProfiles, useUpsertProfile, useDeleteProfile, useExitDefaults, useSaveExitDefaults, useOptionsGate, useSaveOptionsGate, useTechIndicatorsConfig, useSaveTechIndicatorsConfig, useInstrumentsWatchlist, useAddWatchlistInstrument, useRemoveWatchlistInstrument, validateTicker, type CycleStepDef } from '../hooks/useApi'
import { Settings, Key, Globe, CheckCircle, XCircle, AlertCircle, Save, Eye, EyeOff, Brain, SlidersHorizontal, RefreshCw, Zap, Plus, Trash2, Pencil, X, Lock, Gauge, DollarSign, TrendingUp, ShieldAlert, DatabaseBackup, Radar } from 'lucide-react'
import clsx from 'clsx'
@@ -409,6 +409,65 @@ function WatchlistCard() {
)
}
// ── Cycle step config — generic renderer driven by GET /api/cycle/step-catalog ─
// Same declarative pattern as AI Desks' SIGNAL_CATALOG/SignalToggle: adding a new
// knob to CYCLE_STEP_CATALOG (backend) needs zero new frontend code here.
function CycleStepRow({
step, value, onChange,
}: {
step: CycleStepDef
value: Record<string, any>
onChange: (v: Record<string, any>) => void
}) {
const [open, setOpen] = useState(false)
const hasToggle = 'enabled' in step.params
const enabled = hasToggle ? (value.enabled ?? step.params.enabled.default) : true
const otherParams = Object.entries(step.params).filter(([k]) => k !== 'enabled')
const update = (key: string, val: any) => onChange({ ...value, [key]: val })
return (
<div className={clsx('rounded-lg border transition-colors', enabled ? 'border-cyan-700/40 bg-cyan-900/10' : 'border-slate-700/30 bg-dark-800/60')}>
<div className="flex items-center gap-3 px-3 py-2.5">
{hasToggle ? (
<button onClick={() => update('enabled', !enabled)} className="shrink-0 text-xs px-2 py-1 rounded border font-semibold"
style={{ borderColor: enabled ? '#22d3ee88' : '#47556955', color: enabled ? '#22d3ee' : '#64748b' }}>
{enabled ? 'ON' : 'OFF'}
</button>
) : (
<span className="w-2 h-2 rounded-full bg-slate-600 shrink-0" />
)}
<div className="flex-1 min-w-0">
<div className={clsx('text-sm font-medium', enabled ? 'text-white' : 'text-slate-500')}>{step.label}</div>
<div className="text-xs text-slate-600 truncate">{step.description}</div>
</div>
{enabled && otherParams.length > 0 && (
<button onClick={() => setOpen(o => !o)} className="text-slate-500 hover:text-slate-300 text-xs shrink-0">
{open ? '▲' : '▼'}
</button>
)}
</div>
{open && enabled && otherParams.length > 0 && (
<div className="px-4 pb-3 space-y-2 border-t border-slate-700/20 pt-2">
{otherParams.map(([key, p]) => (
<div key={key} className="flex items-center gap-3">
<label className="text-xs text-slate-400 w-40 shrink-0">{p.label ?? key}</label>
<input
type="number"
min={p.min}
max={p.max}
step={p.type === 'float' ? 0.01 : 1}
value={value[key] ?? p.default}
onChange={e => update(key, p.type === 'float' ? parseFloat(e.target.value) : parseInt(e.target.value))}
className="w-28 bg-dark-900 border border-slate-700/40 rounded px-2 py-1 text-xs text-white"
/>
</div>
))}
</div>
)}
</div>
)
}
export default function Config() {
const { data: sources, isLoading } = useSources()
const { data: config } = useConfig()
@@ -517,6 +576,8 @@ export default function Config() {
const [maturityThreshold, setMaturityThreshold] = useState(35)
const [weekendEnabled, setWeekendEnabled] = useState(true)
const [weekendTimes, setWeekendTimes] = useState<string[]>(['08:00', '22:00'])
const [stepConfig, setStepConfig] = useState<Record<string, Record<string, any>>>({})
const { data: stepCatalog } = useCycleStepCatalog()
useEffect(() => {
if (cs) {
setCycleEnabled(cs.enabled ?? false)
@@ -532,6 +593,7 @@ export default function Config() {
setWeekendEnabled(cs.weekend_cycle_enabled ?? true)
const times = (cs.weekend_cycle_times || '08:00,22:00').split(',').map((t: string) => t.trim()).filter(Boolean)
setWeekendTimes(times)
if (cs.cycle_step_config) setStepConfig(cs.cycle_step_config)
}
}, [cs])
@@ -1060,6 +1122,7 @@ export default function Config() {
maturity_threshold_pct: maturityThreshold,
weekend_cycle_enabled: weekendEnabled,
weekend_cycle_times: weekendTimes.length > 0 ? weekendTimes.join(',') : '08:00,22:00',
cycle_step_config: stepConfig,
},
{ onSuccess: () => { refetchCycle(); setSavedMsg('Auto-cycle configured'); setTimeout(() => setSavedMsg(''), 2000) } }
)}
@@ -1078,6 +1141,41 @@ export default function Config() {
</div>
</div>
{/* ── Étapes du cycle ── */}
<div className="card">
<h2 className="text-base font-bold text-white flex items-center gap-2 mb-1">
<SlidersHorizontal className="w-4 h-4 text-cyan-400" /> Étapes du cycle
</h2>
<p className="text-xs text-slate-500 mb-4">
Chaque étape du cycle automatique (y compris en mode planifié) lit ces réglages
activer/désactiver ou ajuster une fenêtre ici change réellement ce qui se passe au prochain cycle.
</p>
{['portfolio', 'ai', 'data'].map(group => {
const groupSteps = (stepCatalog ?? []).filter(s => s.group === group)
if (groupSteps.length === 0) return null
return (
<div key={group} className="mb-4 last:mb-0">
<div className="text-[10px] uppercase tracking-wide text-slate-600 mb-2">
{group === 'portfolio' ? 'Portefeuille' : group === 'ai' ? 'Analyse IA' : 'Données & fenêtres'}
</div>
<div className="space-y-2">
{groupSteps.map(step => (
<CycleStepRow
key={step.id}
step={step}
value={stepConfig[step.id] ?? {}}
onChange={v => setStepConfig(prev => ({ ...prev, [step.id]: v }))}
/>
))}
</div>
</div>
)
})}
<div className="mt-2 text-[10px] text-slate-600">
Ces réglages sont appliqués via le bouton "Apply" ci-dessus (Auto-Cycle Intelligence).
</div>
</div>
{/* ── VaR & PnL Schedulers ── */}
<div className="card">
<h2 className="text-base font-bold text-white flex items-center gap-2 mb-4">

View File

@@ -61,6 +61,11 @@ const formatDateShort = (dateStr: string) => {
return d.toLocaleDateString('fr-FR', { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' })
}
const formatTimeShort = (isoStr: string) => {
const d = new Date(isoStr.includes('Z') || isoStr.includes('+') ? isoStr : isoStr.replace(' ', 'T') + 'Z')
return d.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })
}
function ViewToggle({ value, onChange }: { value: 'simulated' | 'portfolio'; onChange: (v: 'simulated' | 'portfolio') => void }) {
const click = (v: 'simulated' | 'portfolio') => (e: React.MouseEvent) => {
e.preventDefault(); e.stopPropagation(); onChange(v)
@@ -330,17 +335,26 @@ export default function Dashboard() {
<div className={clsx('text-sm font-semibold mt-1', gauge.color)}>{gauge.label}</div>
</div>
<div className="text-right space-y-0.5 mt-1 shrink-0">
{riskScore.top_risks?.map(([cat, val]) => (
<div key={cat} className="flex items-center justify-end gap-1.5 text-[9px]">
<span className="text-slate-500 capitalize truncate max-w-[86px]">{(cat as string).replace('_', ' ')}</span>
<span className="text-slate-400 font-mono w-7 text-right">{Math.round((val as number) * 100)}%</span>
</div>
))}
{Object.entries(riskScore.breakdown ?? {})
.sort((a, b) => b[1] - a[1])
.slice(0, 3)
.map(([cat, val]) => (
<div key={cat} className="flex items-center justify-end gap-1.5 text-[9px]">
<span className="text-slate-500 capitalize truncate max-w-[86px]">{cat.replace('_', ' ')}</span>
<span className="text-slate-400 font-mono w-7 text-right">{Math.round(val)}%</span>
</div>
))}
</div>
</div>
<div className="mt-3 bg-dark-700 rounded-full h-2 shrink-0">
<div className={clsx('h-2 rounded-full', gauge.bg)} style={{ width: `${riskScore.score}%` }} />
</div>
{riskScore.computed_at && (
<div className="mt-1.5 text-[9px] text-slate-600 shrink-0">
Score IA figé au dernier cycle ({formatTimeShort(riskScore.computed_at)})
{riskScore.rationale && <span className="text-slate-500"> · {riskScore.rationale}</span>}
</div>
)}
{topNews.length > 0 && (
<div className="mt-2.5 pt-2 border-t border-slate-700/30 flex flex-col flex-1 min-h-0">
<div className="flex items-center gap-1 text-[9px] text-slate-600 mb-1 shrink-0">

View File

@@ -35,7 +35,10 @@ export interface GeoRiskScore {
score: number
level: RiskLevel
breakdown: Record<string, number>
top_risks: [string, number][]
top_risks: string[]
computed_at?: string
rationale?: string
run_id?: string
}
export interface GeoPattern {