feat:: causal lab
This commit is contained in:
@@ -321,6 +321,11 @@ class RecommendRequest(BaseModel):
|
|||||||
market_event_id: int
|
market_event_id: int
|
||||||
|
|
||||||
|
|
||||||
|
class InstantiateRequest(BaseModel):
|
||||||
|
market_event_id: int
|
||||||
|
template_id: int
|
||||||
|
|
||||||
|
|
||||||
class AnalyzeRequest(BaseModel):
|
class AnalyzeRequest(BaseModel):
|
||||||
market_event_id: int
|
market_event_id: int
|
||||||
template_id: int
|
template_id: int
|
||||||
@@ -635,6 +640,97 @@ def recommend_template(body: RecommendRequest):
|
|||||||
raise HTTPException(500, str(e))
|
raise HTTPException(500, str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/causal-lab/instantiate")
|
||||||
|
def instantiate_template(body: InstantiateRequest):
|
||||||
|
"""
|
||||||
|
GPT-4o-mini suggère les valeurs des nœuds user_input du template
|
||||||
|
en fonction des spécificités de l'événement (surprise +/-, magnitude, catégorie…).
|
||||||
|
Retourne { inputs: { node_id: value }, rationale: str }
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from services.database import get_conn
|
||||||
|
conn = get_conn()
|
||||||
|
_init(conn)
|
||||||
|
|
||||||
|
ev_row = conn.execute("SELECT * FROM market_events WHERE id = ?", (body.market_event_id,)).fetchone()
|
||||||
|
tmpl_row = conn.execute("SELECT * FROM causal_graph_templates WHERE id = ?", (body.template_id,)).fetchone()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
if not ev_row or not tmpl_row:
|
||||||
|
raise HTTPException(404, "event ou template introuvable")
|
||||||
|
|
||||||
|
event = dict(ev_row)
|
||||||
|
template = dict(tmpl_row)
|
||||||
|
graph_json = json.loads(template.get("graph_json") or "{}")
|
||||||
|
input_mapping = graph_json.get("input_mapping", {})
|
||||||
|
|
||||||
|
# Only user_input nodes need to be suggested (auto-sourced nodes are pulled from DB)
|
||||||
|
user_nodes = {
|
||||||
|
k: v for k, v in input_mapping.items()
|
||||||
|
if not v.get("source") or v["source"] == "user_input"
|
||||||
|
}
|
||||||
|
|
||||||
|
if not user_nodes:
|
||||||
|
return {"inputs": {}, "rationale": "Aucun nœud manuel — tout est auto-récupéré depuis la BD"}
|
||||||
|
|
||||||
|
prompt = f"""Tu es un analyste financier. Tu dois configurer un graphe causal pour un événement de marché précis.
|
||||||
|
|
||||||
|
Événement : {event.get('name')}
|
||||||
|
Catégorie : {event.get('category')} / {event.get('sub_type', '')}
|
||||||
|
Date : {event.get('start_date')}
|
||||||
|
Description : {(event.get('description') or '')[:400]}
|
||||||
|
Score impact : {event.get('impact_score', 0.5)}
|
||||||
|
Valeur réelle : {event.get('actual_value')} | Valeur attendue : {event.get('expected_value')} | Surprise % : {event.get('surprise_pct')}
|
||||||
|
|
||||||
|
Template : {template.get('name')} (catégorie : {template.get('category')})
|
||||||
|
|
||||||
|
Nœuds à configurer (entrées manuelles) :
|
||||||
|
{json.dumps(user_nodes, indent=2, ensure_ascii=False)}
|
||||||
|
|
||||||
|
Règles :
|
||||||
|
- Surprise NÉGATIVE → valeurs négatives pour les nœuds de surprise
|
||||||
|
- Surprise POSITIVE → valeurs positives
|
||||||
|
- Utilise les plages "range" si fournies (ex: [-3, 3] pour z-score)
|
||||||
|
- Magnitude proportionnelle à l'ampleur de la surprise
|
||||||
|
- Si surprise_pct disponible, utilise-la pour calibrer (ex: -29.1% → nœud_surprise ≈ -2.5)
|
||||||
|
|
||||||
|
Retourne UNIQUEMENT un JSON : {{ "node_id": valeur_numerique, ... }}
|
||||||
|
Inclure uniquement les nœuds listés ci-dessus."""
|
||||||
|
|
||||||
|
client = _openai_client()
|
||||||
|
resp = client.chat.completions.create(
|
||||||
|
model="gpt-4o-mini",
|
||||||
|
messages=[{"role": "user", "content": prompt}],
|
||||||
|
response_format={"type": "json_object"},
|
||||||
|
temperature=0.2,
|
||||||
|
max_tokens=400,
|
||||||
|
)
|
||||||
|
raw = resp.choices[0].message.content or "{}"
|
||||||
|
suggested = json.loads(raw)
|
||||||
|
|
||||||
|
# Validate: only keep known nodes with numeric values
|
||||||
|
validated: dict = {}
|
||||||
|
for k, v in suggested.items():
|
||||||
|
if k in user_nodes:
|
||||||
|
try:
|
||||||
|
validated[k] = float(v)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
surprise = event.get("surprise_pct")
|
||||||
|
rationale = (
|
||||||
|
f"Surprise {'+' if (surprise or 0) >= 0 else ''}{surprise:.1f}%" if surprise is not None
|
||||||
|
else "Calibration basée sur la description de l'événement"
|
||||||
|
)
|
||||||
|
return {"inputs": validated, "rationale": rationale}
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[causal_lab] instantiate: {e}")
|
||||||
|
raise HTTPException(500, str(e))
|
||||||
|
|
||||||
|
|
||||||
@router.post("/api/causal-lab/analyze")
|
@router.post("/api/causal-lab/analyze")
|
||||||
def analyze_event(body: AnalyzeRequest):
|
def analyze_event(body: AnalyzeRequest):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -51,12 +51,13 @@ router = APIRouter(prefix="/api/actions", tags=["Cycle Actions"])
|
|||||||
class CheckEventsRequest(BaseModel):
|
class CheckEventsRequest(BaseModel):
|
||||||
sources: Optional[List[str]] = None # ['news','eco','technical','reports']
|
sources: Optional[List[str]] = None # ['news','eco','technical','reports']
|
||||||
news_impact_min: float = 0.55
|
news_impact_min: float = 0.55
|
||||||
news_lookback_hours: int = 48
|
|
||||||
eco_z_threshold: float = 1.5
|
eco_z_threshold: float = 1.5
|
||||||
eco_days: int = 7
|
|
||||||
technical_lookback_days: int = 7
|
technical_lookback_days: int = 7
|
||||||
report_days: int = 7
|
report_days: int = 7
|
||||||
report_min_importance: int = 3
|
report_min_importance: int = 3
|
||||||
|
# Unified date window for news + eco (replaces eco_days + news_lookback_hours)
|
||||||
|
date_from: Optional[str] = None # ISO date, e.g. "2026-06-21"
|
||||||
|
date_to: Optional[str] = None # ISO date, e.g. "2026-06-28"
|
||||||
|
|
||||||
|
|
||||||
class ActionResult(BaseModel):
|
class ActionResult(BaseModel):
|
||||||
@@ -151,12 +152,12 @@ def check_market_events(req: CheckEventsRequest):
|
|||||||
result = check_new_market_events(
|
result = check_new_market_events(
|
||||||
sources=req.sources,
|
sources=req.sources,
|
||||||
news_impact_min=req.news_impact_min,
|
news_impact_min=req.news_impact_min,
|
||||||
news_lookback_hours=req.news_lookback_hours,
|
|
||||||
eco_z_threshold=req.eco_z_threshold,
|
eco_z_threshold=req.eco_z_threshold,
|
||||||
eco_days=req.eco_days,
|
|
||||||
technical_lookback_days=req.technical_lookback_days,
|
technical_lookback_days=req.technical_lookback_days,
|
||||||
report_days=req.report_days,
|
report_days=req.report_days,
|
||||||
report_min_importance=req.report_min_importance,
|
report_min_importance=req.report_min_importance,
|
||||||
|
date_from=req.date_from,
|
||||||
|
date_to=req.date_to,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
_release("check-market-events")
|
_release("check-market-events")
|
||||||
@@ -204,12 +205,12 @@ def check_market_events_background(
|
|||||||
check_new_market_events(
|
check_new_market_events(
|
||||||
sources=req.sources,
|
sources=req.sources,
|
||||||
news_impact_min=req.news_impact_min,
|
news_impact_min=req.news_impact_min,
|
||||||
news_lookback_hours=req.news_lookback_hours,
|
|
||||||
eco_z_threshold=req.eco_z_threshold,
|
eco_z_threshold=req.eco_z_threshold,
|
||||||
eco_days=req.eco_days,
|
|
||||||
technical_lookback_days=req.technical_lookback_days,
|
technical_lookback_days=req.technical_lookback_days,
|
||||||
report_days=req.report_days,
|
report_days=req.report_days,
|
||||||
report_min_importance=req.report_min_importance,
|
report_min_importance=req.report_min_importance,
|
||||||
|
date_from=req.date_from,
|
||||||
|
date_to=req.date_to,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[action/check-market-events/bg] {e}")
|
logger.error(f"[action/check-market-events/bg] {e}")
|
||||||
|
|||||||
@@ -528,9 +528,14 @@ def _get_relevant_events(
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
from services.database import get_conn
|
from services.database import get_conn
|
||||||
|
from services.causal_graphs import init_tables
|
||||||
conn = get_conn()
|
conn = get_conn()
|
||||||
|
init_tables(conn) # ensure causal_event_analyses exists
|
||||||
rows = conn.execute("""
|
rows = conn.execute("""
|
||||||
SELECT e.*,
|
SELECT e.*,
|
||||||
|
(SELECT GROUP_CONCAT(DISTINCT a.instrument)
|
||||||
|
FROM causal_event_analyses a
|
||||||
|
WHERE a.market_event_id = e.id) AS analyzed_instruments,
|
||||||
(SELECT a.template_id FROM causal_event_analyses a WHERE a.market_event_id = e.id ORDER BY a.id DESC LIMIT 1) AS template_id,
|
(SELECT a.template_id FROM causal_event_analyses a WHERE a.market_event_id = e.id ORDER BY a.id DESC LIMIT 1) AS template_id,
|
||||||
(SELECT a.id FROM causal_event_analyses a WHERE a.market_event_id = e.id ORDER BY a.id DESC LIMIT 1) AS analysis_id
|
(SELECT a.id FROM causal_event_analyses a WHERE a.market_event_id = e.id ORDER BY a.id DESC LIMIT 1) AS analysis_id
|
||||||
FROM market_events e
|
FROM market_events e
|
||||||
@@ -581,6 +586,7 @@ def _get_relevant_events(
|
|||||||
filtered.append({
|
filtered.append({
|
||||||
"id": ev.get("id"),
|
"id": ev.get("id"),
|
||||||
"template_id": ev.get("template_id"),
|
"template_id": ev.get("template_id"),
|
||||||
|
"analyzed_instruments": ev.get("analyzed_instruments"), # comma-sep, e.g. "EURUSD,SP500"
|
||||||
"date": ev_start,
|
"date": ev_start,
|
||||||
"end_date": ev.get("end_date") or None,
|
"end_date": ev.get("end_date") or None,
|
||||||
"title": ev.get("name") or ev.get("event_name") or "",
|
"title": ev.get("name") or ev.get("event_name") or "",
|
||||||
|
|||||||
@@ -187,12 +187,13 @@ Réponds JSON: {{"is_duplicate": true/false, "reason": "courte phrase"}}"""
|
|||||||
def _check_news(desk_cfg: Dict[str, Any]) -> List[Dict[str, Any]]:
|
def _check_news(desk_cfg: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||||||
from services.data_fetcher import fetch_geo_news
|
from services.data_fetcher import fetch_geo_news
|
||||||
|
|
||||||
min_impact = float(desk_cfg.get("min_impact", 0.55))
|
min_impact = float(desk_cfg.get("min_impact", 0.55))
|
||||||
lookback_hours = int(desk_cfg.get("lookback_hours", 48))
|
max_evaluate = int(desk_cfg.get("max_evaluate", 15))
|
||||||
max_evaluate = int(desk_cfg.get("max_evaluate", 15))
|
dedup_enabled = bool(desk_cfg.get("dedup_enabled", True))
|
||||||
dedup_enabled = bool(desk_cfg.get("dedup_enabled", True))
|
dedup_days = int(desk_cfg.get("dedup_lookback_days", 2))
|
||||||
dedup_days = int(desk_cfg.get("dedup_lookback_days", 2))
|
system_prompt = desk_cfg.get("_system_prompt", "")
|
||||||
system_prompt = desk_cfg.get("_system_prompt", "")
|
date_from = desk_cfg.get("date_from")
|
||||||
|
date_to = desk_cfg.get("date_to")
|
||||||
|
|
||||||
api_key = _get_api_key()
|
api_key = _get_api_key()
|
||||||
if not api_key:
|
if not api_key:
|
||||||
@@ -205,14 +206,22 @@ def _check_news(desk_cfg: Dict[str, Any]) -> List[Dict[str, Any]]:
|
|||||||
logger.warning(f"[check_events/news] fetch failed: {e}")
|
logger.warning(f"[check_events/news] fetch failed: {e}")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
cutoff_dt = datetime.utcnow() - timedelta(hours=lookback_hours)
|
# Build cutoff from date_from; date_to used as upper bound
|
||||||
|
try:
|
||||||
|
cutoff_from = datetime.fromisoformat(date_from) if date_from else datetime.utcnow() - timedelta(days=7)
|
||||||
|
cutoff_to = datetime.fromisoformat(date_to) if date_to else datetime.utcnow()
|
||||||
|
except Exception:
|
||||||
|
cutoff_from = datetime.utcnow() - timedelta(days=7)
|
||||||
|
cutoff_to = datetime.utcnow()
|
||||||
|
|
||||||
candidates = []
|
candidates = []
|
||||||
for n in all_news:
|
for n in all_news:
|
||||||
if (n.get("impact_score") or 0) < min_impact:
|
if (n.get("impact_score") or 0) < min_impact:
|
||||||
continue
|
continue
|
||||||
pub_date = _parse_date(n.get("date", ""))
|
pub_date = _parse_date(n.get("date", ""))
|
||||||
try:
|
try:
|
||||||
if datetime.fromisoformat(pub_date) < cutoff_dt:
|
pub_dt = datetime.fromisoformat(pub_date)
|
||||||
|
if pub_dt < cutoff_from or pub_dt > cutoff_to:
|
||||||
continue
|
continue
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
@@ -329,7 +338,8 @@ FORMAT JSON STRICT:
|
|||||||
def _check_ff_calendar_surprises(
|
def _check_ff_calendar_surprises(
|
||||||
currencies: List[str],
|
currencies: List[str],
|
||||||
min_impact: str,
|
min_impact: str,
|
||||||
days: int,
|
date_from: str,
|
||||||
|
date_to: str,
|
||||||
min_surprise_pct: float,
|
min_surprise_pct: float,
|
||||||
lookback_releases: int,
|
lookback_releases: int,
|
||||||
create_evt: bool,
|
create_evt: bool,
|
||||||
@@ -347,7 +357,6 @@ def _check_ff_calendar_surprises(
|
|||||||
"low": ("high", "medium", "low"),
|
"low": ("high", "medium", "low"),
|
||||||
}
|
}
|
||||||
allowed_impacts = impact_map.get(min_impact, ("high", "medium"))
|
allowed_impacts = impact_map.get(min_impact, ("high", "medium"))
|
||||||
cutoff = (datetime.utcnow() - timedelta(days=days)).strftime("%Y-%m-%d")
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
conn = get_conn()
|
conn = get_conn()
|
||||||
@@ -360,11 +369,12 @@ def _check_ff_calendar_surprises(
|
|||||||
WHERE currency IN ({ccy_ph})
|
WHERE currency IN ({ccy_ph})
|
||||||
AND impact IN ({imp_ph})
|
AND impact IN ({imp_ph})
|
||||||
AND event_date >= ?
|
AND event_date >= ?
|
||||||
|
AND event_date <= ?
|
||||||
AND actual_value IS NOT NULL
|
AND actual_value IS NOT NULL
|
||||||
AND forecast_value IS NOT NULL
|
AND forecast_value IS NOT NULL
|
||||||
ORDER BY event_date DESC
|
ORDER BY event_date DESC
|
||||||
LIMIT 200""",
|
LIMIT 200""",
|
||||||
(*currencies, *allowed_impacts, cutoff),
|
(*currencies, *allowed_impacts, date_from, date_to),
|
||||||
).fetchall()
|
).fetchall()
|
||||||
conn.close()
|
conn.close()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -457,28 +467,45 @@ def _check_ff_calendar_surprises(
|
|||||||
|
|
||||||
|
|
||||||
def _check_eco(desk_cfg: Dict[str, Any]) -> List[Dict[str, Any]]:
|
def _check_eco(desk_cfg: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||||||
from services.database import get_recent_economic_surprises, get_conn
|
from services.database import get_conn
|
||||||
|
|
||||||
z_threshold = float(desk_cfg.get("z_threshold", 1.5))
|
z_threshold = float(desk_cfg.get("z_threshold", 1.5))
|
||||||
days = int(desk_cfg.get("days", 7))
|
date_from = desk_cfg.get("date_from") or (datetime.utcnow() - timedelta(days=7)).strftime("%Y-%m-%d")
|
||||||
|
date_to = desk_cfg.get("date_to") or datetime.utcnow().strftime("%Y-%m-%d")
|
||||||
currencies = list(desk_cfg.get("currencies") or ["USD", "EUR", "GBP", "JPY"])
|
currencies = list(desk_cfg.get("currencies") or ["USD", "EUR", "GBP", "JPY"])
|
||||||
min_impact = str(desk_cfg.get("min_impact", "medium")).lower()
|
min_impact = str(desk_cfg.get("min_impact", "medium")).lower()
|
||||||
create_evt = bool(desk_cfg.get("create_market_event", True))
|
create_evt = bool(desk_cfg.get("create_market_event", True))
|
||||||
lookback_releases = int(desk_cfg.get("lookback_releases", 3))
|
lookback_releases = int(desk_cfg.get("lookback_releases", 3))
|
||||||
|
|
||||||
min_rank = _IMPACT_RANKS.get(min_impact, 2)
|
min_rank = _IMPACT_RANKS.get(min_impact, 2)
|
||||||
# ff_calendar surprise threshold: z_threshold used as rough proxy (×10 → % equivalent)
|
ff_surprise_min = max(10.0, z_threshold * 10)
|
||||||
ff_surprise_min = max(10.0, z_threshold * 10)
|
|
||||||
|
|
||||||
existing = _existing_event_keys()
|
existing = _existing_event_keys()
|
||||||
created: List[Dict] = []
|
created: List[Dict] = []
|
||||||
|
|
||||||
# ── FRED path (USD only) ──────────────────────────────────────────────────
|
# ── Local economic_events table (replaces FRED API call) ─────────────────
|
||||||
if "USD" in currencies:
|
if "USD" in currencies:
|
||||||
try:
|
try:
|
||||||
releases = get_recent_economic_surprises(days=days, min_zscore=z_threshold)
|
conn = get_conn()
|
||||||
|
rows = conn.execute(
|
||||||
|
"""SELECT * FROM economic_events
|
||||||
|
WHERE event_date >= ? AND event_date <= ?
|
||||||
|
AND ABS(COALESCE(surprise_zscore, 0)) >= ?
|
||||||
|
ORDER BY event_date DESC, ABS(COALESCE(surprise_zscore, 0)) DESC
|
||||||
|
LIMIT 50""",
|
||||||
|
(date_from, date_to, z_threshold),
|
||||||
|
).fetchall()
|
||||||
|
conn.close()
|
||||||
|
releases = []
|
||||||
|
for r in rows:
|
||||||
|
d = dict(r)
|
||||||
|
try:
|
||||||
|
d["assets_impacted"] = json.loads(d.get("assets_impacted") or "[]")
|
||||||
|
except Exception:
|
||||||
|
d["assets_impacted"] = []
|
||||||
|
releases.append(d)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"[check_events/eco] FRED query failed: {e}")
|
logger.warning(f"[check_events/eco] local query failed: {e}")
|
||||||
releases = []
|
releases = []
|
||||||
|
|
||||||
for rel in releases:
|
for rel in releases:
|
||||||
@@ -572,7 +599,8 @@ def _check_eco(desk_cfg: Dict[str, Any]) -> List[Dict[str, Any]]:
|
|||||||
created += _check_ff_calendar_surprises(
|
created += _check_ff_calendar_surprises(
|
||||||
currencies=non_usd,
|
currencies=non_usd,
|
||||||
min_impact=min_impact,
|
min_impact=min_impact,
|
||||||
days=days,
|
date_from=date_from,
|
||||||
|
date_to=date_to,
|
||||||
min_surprise_pct=ff_surprise_min,
|
min_surprise_pct=ff_surprise_min,
|
||||||
lookback_releases=lookback_releases,
|
lookback_releases=lookback_releases,
|
||||||
create_evt=create_evt,
|
create_evt=create_evt,
|
||||||
@@ -860,13 +888,14 @@ def _check_fundamental(desk_cfg: Dict[str, Any]) -> List[Dict[str, Any]]:
|
|||||||
"""Corporate fundamental events: layoffs, M&A, earnings, credit, regulatory."""
|
"""Corporate fundamental events: layoffs, M&A, earnings, credit, regulatory."""
|
||||||
from services.data_fetcher import fetch_geo_news
|
from services.data_fetcher import fetch_geo_news
|
||||||
|
|
||||||
min_impact = float(desk_cfg.get("min_impact", 0.45))
|
min_impact = float(desk_cfg.get("min_impact", 0.45))
|
||||||
lookback_hours = int(desk_cfg.get("lookback_hours", 72))
|
max_evaluate = int(desk_cfg.get("max_evaluate", 20))
|
||||||
max_evaluate = int(desk_cfg.get("max_evaluate", 20))
|
dedup_enabled = bool(desk_cfg.get("dedup_enabled", True))
|
||||||
dedup_enabled = bool(desk_cfg.get("dedup_enabled", True))
|
dedup_days = int(desk_cfg.get("dedup_lookback_days", 3))
|
||||||
dedup_days = int(desk_cfg.get("dedup_lookback_days", 3))
|
system_prompt = desk_cfg.get("_system_prompt", "")
|
||||||
system_prompt = desk_cfg.get("_system_prompt", "")
|
focus_types = desk_cfg.get("focus_types", ["layoffs","earnings","ma","credit","regulatory","guidance"])
|
||||||
focus_types = desk_cfg.get("focus_types", ["layoffs","earnings","ma","credit","regulatory","guidance"])
|
date_from = desk_cfg.get("date_from") or (datetime.utcnow() - timedelta(days=7)).strftime("%Y-%m-%d")
|
||||||
|
date_to = desk_cfg.get("date_to") or datetime.utcnow().strftime("%Y-%m-%d")
|
||||||
|
|
||||||
api_key = _get_api_key()
|
api_key = _get_api_key()
|
||||||
if not api_key:
|
if not api_key:
|
||||||
@@ -878,11 +907,10 @@ def _check_fundamental(desk_cfg: Dict[str, Any]) -> List[Dict[str, Any]]:
|
|||||||
logger.warning(f"[check_events/fundamental] fetch failed: {e}")
|
logger.warning(f"[check_events/fundamental] fetch failed: {e}")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
cutoff_dt = datetime.utcnow() - timedelta(hours=lookback_hours)
|
|
||||||
candidates = [
|
candidates = [
|
||||||
n for n in all_news
|
n for n in all_news
|
||||||
if (n.get("impact_score") or 0) >= min_impact
|
if (n.get("impact_score") or 0) >= min_impact
|
||||||
and _parse_date(n.get("date", "")) >= cutoff_dt.strftime("%Y-%m-%d")
|
and date_from <= _parse_date(n.get("date", "")) <= date_to
|
||||||
][:max_evaluate]
|
][:max_evaluate]
|
||||||
|
|
||||||
if not candidates:
|
if not candidates:
|
||||||
@@ -1617,11 +1645,12 @@ def _check_macro_gauges(desk_cfg: Dict[str, Any]) -> List[Dict[str, Any]]:
|
|||||||
|
|
||||||
def check_new_market_events(
|
def check_new_market_events(
|
||||||
sources: Optional[List[str]] = None,
|
sources: Optional[List[str]] = None,
|
||||||
|
# Unified date window — overrides eco_days/news_lookback_hours when provided
|
||||||
|
date_from: Optional[str] = None,
|
||||||
|
date_to: Optional[str] = None,
|
||||||
# Legacy overrides — used when called without an active desk
|
# Legacy overrides — used when called without an active desk
|
||||||
news_impact_min: float = 0.55,
|
news_impact_min: float = 0.55,
|
||||||
news_lookback_hours: int = 48,
|
|
||||||
eco_z_threshold: float = 1.5,
|
eco_z_threshold: float = 1.5,
|
||||||
eco_days: int = 7,
|
|
||||||
technical_lookback_days: int = 7,
|
technical_lookback_days: int = 7,
|
||||||
report_days: int = 7,
|
report_days: int = 7,
|
||||||
report_min_importance: int = 3,
|
report_min_importance: int = 3,
|
||||||
@@ -1655,17 +1684,27 @@ def check_new_market_events(
|
|||||||
cfg["_system_prompt"] = desk.get("system_prompt") or ""
|
cfg["_system_prompt"] = desk.get("system_prompt") or ""
|
||||||
return cfg
|
return cfg
|
||||||
|
|
||||||
|
# Compute default date_from fallback from desk config or 7-day default
|
||||||
|
_now = datetime.utcnow()
|
||||||
|
_date_to = date_to or _now.strftime("%Y-%m-%d")
|
||||||
|
_date_from = date_from or (_now - timedelta(days=7)).strftime("%Y-%m-%d")
|
||||||
|
|
||||||
news_cfg = _desk_cfg(news_desk, {
|
news_cfg = _desk_cfg(news_desk, {
|
||||||
"min_impact": news_impact_min, "lookback_hours": news_lookback_hours,
|
"min_impact": news_impact_min,
|
||||||
"max_evaluate": 15, "dedup_enabled": False, "dedup_lookback_days": 2,
|
"max_evaluate": 15, "dedup_enabled": False, "dedup_lookback_days": 2,
|
||||||
})
|
})
|
||||||
|
news_cfg["date_from"] = _date_from
|
||||||
|
news_cfg["date_to"] = _date_to
|
||||||
|
|
||||||
fundamental_cfg = _desk_cfg(fundamental_desk, {
|
fundamental_cfg = _desk_cfg(fundamental_desk, {
|
||||||
"min_impact": 0.45, "lookback_hours": 72, "max_evaluate": 20,
|
"min_impact": 0.45, "max_evaluate": 20,
|
||||||
"dedup_enabled": True, "dedup_lookback_days": 3,
|
"dedup_enabled": True, "dedup_lookback_days": 3,
|
||||||
})
|
})
|
||||||
|
fundamental_cfg["date_from"] = _date_from
|
||||||
|
fundamental_cfg["date_to"] = _date_to
|
||||||
|
|
||||||
eco_cfg = _desk_cfg(eco_desk, {
|
eco_cfg = _desk_cfg(eco_desk, {
|
||||||
"z_threshold": eco_z_threshold,
|
"z_threshold": eco_z_threshold,
|
||||||
"days": eco_days,
|
|
||||||
"gauge_signals": {
|
"gauge_signals": {
|
||||||
"regime_transition": {"enabled": True},
|
"regime_transition": {"enabled": True},
|
||||||
"yield_curve_inversion": {"enabled": True, "threshold": 0.0},
|
"yield_curve_inversion": {"enabled": True, "threshold": 0.0},
|
||||||
@@ -1674,6 +1713,8 @@ def check_new_market_events(
|
|||||||
"gold_copper_ratio": {"enabled": True, "fear_threshold": 700, "growth_threshold": 500},
|
"gold_copper_ratio": {"enabled": True, "fear_threshold": 700, "growth_threshold": 500},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
eco_cfg["date_from"] = _date_from
|
||||||
|
eco_cfg["date_to"] = _date_to
|
||||||
tech_cfg = _desk_cfg(tech_desk, {
|
tech_cfg = _desk_cfg(tech_desk, {
|
||||||
"lookback_days": technical_lookback_days,
|
"lookback_days": technical_lookback_days,
|
||||||
"signals": {
|
"signals": {
|
||||||
|
|||||||
@@ -122,27 +122,44 @@ function BootstrapAction({ action }: { action: ActionDef }) {
|
|||||||
interface CheckEventsConfig {
|
interface CheckEventsConfig {
|
||||||
sources: string[]
|
sources: string[]
|
||||||
news_impact_min: number
|
news_impact_min: number
|
||||||
news_lookback_hours: number
|
|
||||||
eco_z_threshold: number
|
eco_z_threshold: number
|
||||||
eco_days: number
|
|
||||||
technical_lookback_days: number
|
technical_lookback_days: number
|
||||||
report_days: number
|
report_days: number
|
||||||
report_min_importance: number
|
report_min_importance: number
|
||||||
|
date_from: string
|
||||||
|
date_to: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_CONFIG: CheckEventsConfig = {
|
const SCAN_PRESETS = [
|
||||||
sources: ['news', 'eco', 'technical', 'reports'],
|
{ label: '6h', hours: 6 },
|
||||||
news_impact_min: 0.55,
|
{ label: '12h', hours: 12 },
|
||||||
news_lookback_hours: 48,
|
{ label: '24h', hours: 24 },
|
||||||
eco_z_threshold: 1.5,
|
{ label: '48h', hours: 48 },
|
||||||
eco_days: 7,
|
{ label: '72h', hours: 72 },
|
||||||
technical_lookback_days: 7,
|
{ label: '7j', hours: 168 },
|
||||||
report_days: 7,
|
]
|
||||||
report_min_importance: 3,
|
|
||||||
|
function presetDates(hours: number): { date_from: string; date_to: string } {
|
||||||
|
const now = new Date()
|
||||||
|
const from = new Date(now.getTime() - hours * 3_600_000)
|
||||||
|
const fmt = (d: Date) => d.toISOString().slice(0, 10)
|
||||||
|
return { date_from: fmt(from), date_to: fmt(now) }
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeDefaultConfig(): CheckEventsConfig {
|
||||||
|
return {
|
||||||
|
sources: ['news', 'eco', 'technical', 'reports'],
|
||||||
|
news_impact_min: 0.55,
|
||||||
|
eco_z_threshold: 1.5,
|
||||||
|
technical_lookback_days: 7,
|
||||||
|
report_days: 7,
|
||||||
|
report_min_importance: 3,
|
||||||
|
...presetDates(48),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function CheckEventsPanel() {
|
function CheckEventsPanel() {
|
||||||
const [config, setConfig] = useState<CheckEventsConfig>(DEFAULT_CONFIG)
|
const [config, setConfig] = useState<CheckEventsConfig>(makeDefaultConfig)
|
||||||
const [running, setRunning] = useState(false)
|
const [running, setRunning] = useState(false)
|
||||||
const [result, setResult] = useState<ActionResult | null>(null)
|
const [result, setResult] = useState<ActionResult | null>(null)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
@@ -215,14 +232,46 @@ function CheckEventsPanel() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showConfig && (
|
{showConfig && (
|
||||||
<div className="px-5 pb-4 border-t border-slate-700/30 pt-4 grid grid-cols-2 gap-4 text-sm">
|
<div className="px-5 pb-5 border-t border-slate-700/30 pt-4 space-y-4 text-sm">
|
||||||
|
|
||||||
|
{/* Fenêtre de scan */}
|
||||||
|
<div>
|
||||||
|
<div className="text-slate-400 mb-2 text-xs uppercase tracking-wide">Fenêtre de scan (news + éco)</div>
|
||||||
|
<div className="flex flex-wrap gap-1.5 mb-2">
|
||||||
|
{SCAN_PRESETS.map(p => {
|
||||||
|
const { date_from, date_to } = presetDates(p.hours)
|
||||||
|
const active = config.date_from === date_from && config.date_to === date_to
|
||||||
|
return (
|
||||||
|
<button key={p.label}
|
||||||
|
onClick={() => setConfig(c => ({ ...c, ...presetDates(p.hours) }))}
|
||||||
|
className={`px-3 py-1 rounded text-xs border transition-colors ${
|
||||||
|
active
|
||||||
|
? 'bg-blue-600/30 border-blue-500/50 text-blue-300'
|
||||||
|
: 'border-slate-700 text-slate-500 hover:border-slate-500 hover:text-slate-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{p.label}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 items-center">
|
||||||
|
<input type="date" value={config.date_from}
|
||||||
|
onChange={e => setConfig(c => ({ ...c, date_from: e.target.value }))}
|
||||||
|
className="bg-dark-900 border border-slate-700 rounded px-2 py-1 text-slate-200 text-xs flex-1" />
|
||||||
|
<span className="text-slate-600 text-xs">→</span>
|
||||||
|
<input type="date" value={config.date_to}
|
||||||
|
onChange={e => setConfig(c => ({ ...c, date_to: e.target.value }))}
|
||||||
|
className="bg-dark-900 border border-slate-700 rounded px-2 py-1 text-slate-200 text-xs flex-1" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sources */}
|
||||||
<div>
|
<div>
|
||||||
<div className="text-slate-400 mb-2 text-xs uppercase tracking-wide">Sources actives</div>
|
<div className="text-slate-400 mb-2 text-xs uppercase tracking-wide">Sources actives</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{['news', 'eco', 'technical', 'reports'].map(s => (
|
{['news', 'eco', 'technical', 'reports'].map(s => (
|
||||||
<button
|
<button key={s} onClick={() => toggleSource(s)}
|
||||||
key={s}
|
|
||||||
onClick={() => toggleSource(s)}
|
|
||||||
className={`px-3 py-1 rounded-full text-xs border transition-colors ${
|
className={`px-3 py-1 rounded-full text-xs border transition-colors ${
|
||||||
config.sources.includes(s)
|
config.sources.includes(s)
|
||||||
? 'bg-emerald-600/30 border-emerald-500/50 text-emerald-300'
|
? 'bg-emerald-600/30 border-emerald-500/50 text-emerald-300'
|
||||||
@@ -234,38 +283,29 @@ function CheckEventsPanel() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
|
{/* Advanced */}
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
<label className="flex flex-col gap-1">
|
<label className="flex flex-col gap-1">
|
||||||
<span className="text-xs text-slate-400">News impact min</span>
|
<span className="text-xs text-slate-400">News impact min</span>
|
||||||
<input type="number" step="0.05" min="0" max="1"
|
<input type="number" step="0.05" min="0" max="1"
|
||||||
value={config.news_impact_min}
|
value={config.news_impact_min}
|
||||||
onChange={e => setConfig(c => ({ ...c, news_impact_min: +e.target.value }))}
|
onChange={e => setConfig(c => ({ ...c, news_impact_min: +e.target.value }))}
|
||||||
className="bg-dark-900 border border-slate-700 rounded px-2 py-1 text-slate-200 text-sm w-full"
|
className="bg-dark-900 border border-slate-700 rounded px-2 py-1 text-slate-200 text-sm w-full" />
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="flex flex-col gap-1">
|
|
||||||
<span className="text-xs text-slate-400">News lookback (h)</span>
|
|
||||||
<input type="number" step="12" min="6" max="168"
|
|
||||||
value={config.news_lookback_hours}
|
|
||||||
onChange={e => setConfig(c => ({ ...c, news_lookback_hours: +e.target.value }))}
|
|
||||||
className="bg-dark-900 border border-slate-700 rounded px-2 py-1 text-slate-200 text-sm w-full"
|
|
||||||
/>
|
|
||||||
</label>
|
</label>
|
||||||
<label className="flex flex-col gap-1">
|
<label className="flex flex-col gap-1">
|
||||||
<span className="text-xs text-slate-400">Éco z-score min</span>
|
<span className="text-xs text-slate-400">Éco z-score min</span>
|
||||||
<input type="number" step="0.25" min="0.5" max="5"
|
<input type="number" step="0.25" min="0.5" max="5"
|
||||||
value={config.eco_z_threshold}
|
value={config.eco_z_threshold}
|
||||||
onChange={e => setConfig(c => ({ ...c, eco_z_threshold: +e.target.value }))}
|
onChange={e => setConfig(c => ({ ...c, eco_z_threshold: +e.target.value }))}
|
||||||
className="bg-dark-900 border border-slate-700 rounded px-2 py-1 text-slate-200 text-sm w-full"
|
className="bg-dark-900 border border-slate-700 rounded px-2 py-1 text-slate-200 text-sm w-full" />
|
||||||
/>
|
|
||||||
</label>
|
</label>
|
||||||
<label className="flex flex-col gap-1">
|
<label className="flex flex-col gap-1">
|
||||||
<span className="text-xs text-slate-400">Report importance min</span>
|
<span className="text-xs text-slate-400">Report importance min</span>
|
||||||
<input type="number" step="1" min="1" max="5"
|
<input type="number" step="1" min="1" max="5"
|
||||||
value={config.report_min_importance}
|
value={config.report_min_importance}
|
||||||
onChange={e => setConfig(c => ({ ...c, report_min_importance: +e.target.value }))}
|
onChange={e => setConfig(c => ({ ...c, report_min_importance: +e.target.value }))}
|
||||||
className="bg-dark-900 border border-slate-700 rounded px-2 py-1 text-slate-200 text-sm w-full"
|
className="bg-dark-900 border border-slate-700 rounded px-2 py-1 text-slate-200 text-sm w-full" />
|
||||||
/>
|
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ interface LinePoint { time: string; value: number }
|
|||||||
|
|
||||||
interface SnapshotEvent {
|
interface SnapshotEvent {
|
||||||
id?: number; template_id?: number | null
|
id?: number; template_id?: number | null
|
||||||
|
analyzed_instruments?: string | null // comma-sep: "EURUSD,SP500" — set when causal analysis exists
|
||||||
date: string; end_date: string | null; title: string; level: string
|
date: string; end_date: string | null; title: string; level: string
|
||||||
category: string; sub_type?: string; description: string; impact_score: number
|
category: string; sub_type?: string; description: string; impact_score: number
|
||||||
expected_value?: string | null; actual_value?: string | null
|
expected_value?: string | null; actual_value?: string | null
|
||||||
@@ -686,11 +687,12 @@ function EventTimeline({
|
|||||||
return () => obs.disconnect()
|
return () => obs.disconnect()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// Only events that have a causal graph linked to this instrument
|
// Only events that have an instantiated analysis for this instrument
|
||||||
|
// Uses analyzed_instruments (actual DB analyses) — not template.instruments (theoretical)
|
||||||
const linked = events.filter(ev => {
|
const linked = events.filter(ev => {
|
||||||
if (!ev.template_id) return false
|
if (!ev.analyzed_instruments) return false
|
||||||
const tmpl = templates.find(t => t.id === ev.template_id)
|
const insts = ev.analyzed_instruments.split(',')
|
||||||
return tmpl != null && causalInsts.some(ci => tmpl.instruments.includes(ci))
|
return causalInsts.some(ci => insts.includes(ci))
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!priceData.length || !linked.length) {
|
if (!priceData.length || !linked.length) {
|
||||||
@@ -781,11 +783,11 @@ function EventGraphCards({
|
|||||||
}) {
|
}) {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
||||||
// Events that have a causal graph template touching this instrument
|
// Events that have an instantiated causal analysis for this instrument
|
||||||
const linkedEvents = events.filter(ev => {
|
const linkedEvents = events.filter(ev => {
|
||||||
if (!ev.template_id) return false
|
if (!ev.analyzed_instruments) return false
|
||||||
const tmpl = templates.find(t => t.id === ev.template_id)
|
const insts = ev.analyzed_instruments.split(',')
|
||||||
return tmpl != null && causalInsts.some(ci => tmpl.instruments.includes(ci))
|
return causalInsts.some(ci => insts.includes(ci))
|
||||||
})
|
})
|
||||||
|
|
||||||
// Unique templates from those linked events
|
// Unique templates from those linked events
|
||||||
|
|||||||
@@ -335,8 +335,11 @@ function EventDetail({
|
|||||||
const [selTmpl, setSelTmpl] = useState(0)
|
const [selTmpl, setSelTmpl] = useState(0)
|
||||||
const [instrument, setInstrument] = useState('EURUSD')
|
const [instrument, setInstrument] = useState('EURUSD')
|
||||||
const [loadingRec, setLoadingRec] = useState(false)
|
const [loadingRec, setLoadingRec] = useState(false)
|
||||||
|
const [loadingInst, setLoadingInst] = useState(false)
|
||||||
const [loadingAn, setLoadingAn] = useState(false)
|
const [loadingAn, setLoadingAn] = useState(false)
|
||||||
const [recMsg, setRecMsg] = useState<string | null>(null)
|
const [recMsg, setRecMsg] = useState<string | null>(null)
|
||||||
|
const [instInputs, setInstInputs] = useState<Record<string, number>>({})
|
||||||
|
const [instMsg, setInstMsg] = useState<string | null>(null)
|
||||||
const [anResult, setAnResult] = useState<{ score: number | null; preds: Record<string, number> } | null>(null)
|
const [anResult, setAnResult] = useState<{ score: number | null; preds: Record<string, number> } | null>(null)
|
||||||
|
|
||||||
const loadDetail = useCallback(async () => {
|
const loadDetail = useCallback(async () => {
|
||||||
@@ -409,8 +412,24 @@ function EventDetail({
|
|||||||
onClose()
|
onClose()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const instantiate = async (tmplId: number) => {
|
||||||
|
if (!tmplId) return
|
||||||
|
setLoadingInst(true); setInstMsg(null); setInstInputs({})
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/causal-lab/instantiate', {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ market_event_id: detail.id, template_id: tmplId }),
|
||||||
|
})
|
||||||
|
const d = await r.json()
|
||||||
|
if (!r.ok) throw new Error(d.detail || r.statusText)
|
||||||
|
setInstInputs(d.inputs || {})
|
||||||
|
setInstMsg(d.rationale || null)
|
||||||
|
} catch (e: any) { setInstMsg(`✗ ${(e as any).message}`) }
|
||||||
|
finally { setLoadingInst(false) }
|
||||||
|
}
|
||||||
|
|
||||||
const recommendTemplate = async () => {
|
const recommendTemplate = async () => {
|
||||||
setLoadingRec(true); setRecMsg(null); setAnResult(null)
|
setLoadingRec(true); setRecMsg(null); setAnResult(null); setInstInputs({}); setInstMsg(null)
|
||||||
try {
|
try {
|
||||||
const r = await fetch('/api/causal-lab/recommend', {
|
const r = await fetch('/api/causal-lab/recommend', {
|
||||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
@@ -422,10 +441,11 @@ function EventDetail({
|
|||||||
if (rec?.template_id) {
|
if (rec?.template_id) {
|
||||||
setSelTmpl(rec.template_id)
|
setSelTmpl(rec.template_id)
|
||||||
setRecMsg(`✓ ${rec.template_name}${rec.confidence ? ` (${Math.round(rec.confidence * 100)}%)` : ''}`)
|
setRecMsg(`✓ ${rec.template_name}${rec.confidence ? ` (${Math.round(rec.confidence * 100)}%)` : ''}`)
|
||||||
|
await instantiate(rec.template_id)
|
||||||
} else {
|
} else {
|
||||||
setRecMsg('Aucun template correspondant — créez-en un dans l\'Editeur')
|
setRecMsg('Aucun template — créez-en un dans l\'Editeur')
|
||||||
}
|
}
|
||||||
} catch (e: any) { setRecMsg(`✗ ${e.message}`) }
|
} catch (e: any) { setRecMsg(`✗ ${(e as any).message}`) }
|
||||||
finally { setLoadingRec(false) }
|
finally { setLoadingRec(false) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -435,13 +455,13 @@ function EventDetail({
|
|||||||
try {
|
try {
|
||||||
const r = await fetch('/api/causal-lab/analyze', {
|
const r = await fetch('/api/causal-lab/analyze', {
|
||||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ market_event_id: detail.id, template_id: selTmpl, instrument, inputs: {}, coef_overrides: {} }),
|
body: JSON.stringify({ market_event_id: detail.id, template_id: selTmpl, instrument, inputs: instInputs, coef_overrides: {} }),
|
||||||
})
|
})
|
||||||
const d = await r.json()
|
const d = await r.json()
|
||||||
if (!r.ok) throw new Error(d.detail || r.statusText)
|
if (!r.ok) throw new Error(d.detail || r.statusText)
|
||||||
setAnResult({ score: d.activation?.score ?? null, preds: d.node_values ?? {} })
|
setAnResult({ score: d.activation?.score ?? null, preds: d.node_values ?? {} })
|
||||||
await loadAnalyses()
|
await loadAnalyses()
|
||||||
} catch (e: any) { setAnResult({ score: null, preds: { error: -1 } }) }
|
} catch (e: any) { setAnResult({ score: null, preds: {} }) }
|
||||||
finally { setLoadingAn(false) }
|
finally { setLoadingAn(false) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -738,6 +758,37 @@ function EventDetail({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Instantiation status + inputs preview */}
|
||||||
|
{selTmpl > 0 && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => instantiate(selTmpl)}
|
||||||
|
disabled={loadingInst}
|
||||||
|
className="flex items-center gap-1 text-xs text-slate-500 hover:text-purple-400 disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
{loadingInst ? <Loader2 size={9} className="animate-spin" /> : <Sparkles size={9} />}
|
||||||
|
{loadingInst ? 'Calibration IA…' : 'Calibrer (IA)'}
|
||||||
|
</button>
|
||||||
|
{instMsg && !instMsg.startsWith('✗') && (
|
||||||
|
<span className="text-xs text-slate-600 truncate">{instMsg}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{Object.keys(instInputs).length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{Object.entries(instInputs).map(([k, v]) => (
|
||||||
|
<span key={k} className={clsx(
|
||||||
|
'text-xs font-mono px-1.5 py-0.5 rounded border',
|
||||||
|
v > 0 ? 'text-emerald-400 border-emerald-800/50 bg-emerald-900/20' : v < 0 ? 'text-red-400 border-red-800/50 bg-red-900/20' : 'text-slate-500 border-slate-700/50'
|
||||||
|
)}>
|
||||||
|
{k}: {v > 0 ? '+' : ''}{v.toFixed(2)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Instrument + analyze */}
|
{/* Instrument + analyze */}
|
||||||
<div className="flex gap-1.5">
|
<div className="flex gap-1.5">
|
||||||
<select
|
<select
|
||||||
|
|||||||
Reference in New Issue
Block a user