feat: cycle
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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()),
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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'] }),
|
||||
})
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user