From a12d7a1ef31e92e4090b23084d9a75cfe53a12cf Mon Sep 17 00:00:00 2001 From: OpenSquared Date: Fri, 26 Jun 2026 09:21:29 +0200 Subject: [PATCH] =?UTF-8?q?feat:=206=20AI=20desks=20complets=20=E2=80=94?= =?UTF-8?q?=20fundamental,=20report,=20sentiment=20(options=20triggers)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fundamental Desk: filtre corporate news (layoffs, M&A, earnings, credit) avec prompt dédié + dedup sémantique - Report Desk: wiring desk config (days, min_importance, system_prompt) - Sentiment Desk: 5 signaux VIX/SKEW pour options lab (vix_level thresholds, vix_spike %, vix_term_structure, vvix_extreme, skew_extreme) Chaque event contient options_note actionnable (vente puts, straddles, calendar spreads) - check_new_market_events() couvre les 6 sources, charge desk configs dynamiquement - Signal catalog: 12 signaux (7 technical + 5 sentiment), filtrés par desk_type dans UI - AIDesks.tsx: FundamentalConfig + ReportConfig + SignalToggle filtré par desk type - 3 nouveaux desks seedés dans init_db (idempotent) Co-Authored-By: Claude Sonnet 4.6 --- backend/routers/ai_desks.py | 53 ++- backend/services/database.py | 55 +++ backend/services/market_event_detector.py | 445 +++++++++++++++++++--- frontend/src/pages/AIDesks.tsx | 207 ++++++++-- 4 files changed, 670 insertions(+), 90 deletions(-) diff --git a/backend/routers/ai_desks.py b/backend/routers/ai_desks.py index fb46307..21644a3 100644 --- a/backend/routers/ai_desks.py +++ b/backend/routers/ai_desks.py @@ -76,20 +76,71 @@ SIGNAL_CATALOG: List[Dict[str, Any]] = [ "id": "macd_crossover", "label": "Croisement MACD", "description": "Croisement de la ligne MACD avec la ligne signal", + "desk_type": "technical", "params": { "fast": {"type": "int", "label": "EMA rapide", "default": 12, "min": 5, "max": 30}, "slow": {"type": "int", "label": "EMA lente", "default": 26, "min": 15, "max": 60}, "signal": {"type": "int", "label": "Signal", "default": 9, "min": 3, "max": 20}, }, }, + # ── Sentiment signals ─────────────────────────────────────────────────── + { + "id": "vix_level", + "label": "VIX — franchissement de seuil", + "description": "Signal quand le VIX franchit un niveau clé (20, 25, 30, 35, 45)", + "desk_type": "sentiment", + "params": { + "thresholds": {"type": "multi_int", "label": "Niveaux VIX", "default": [20, 25, 30, 35, 45]}, + }, + }, + { + "id": "vix_spike", + "label": "VIX — spike journalier", + "description": "VIX progresse de X% ou plus en une séance", + "desk_type": "sentiment", + "params": { + "min_pct_change": {"type": "float", "label": "Variation min %", "default": 15.0, "min": 5.0, "max": 50.0}, + }, + }, + { + "id": "vix_term_structure", + "label": "VIX — inversion structure de terme", + "description": "VIX9D > VIX (peur concentrée court terme)", + "desk_type": "sentiment", + "params": { + "inversion_threshold": {"type": "float", "label": "Ratio VIX9D/VIX min", "default": 1.05, "min": 1.0, "max": 1.5}, + }, + }, + { + "id": "vvix_extreme", + "label": "VVIX extrême", + "description": "VVIX (volatilité du VIX) dépasse un seuil", + "desk_type": "sentiment", + "params": { + "threshold": {"type": "float", "label": "Seuil VVIX", "default": 100.0, "min": 80.0, "max": 150.0}, + }, + }, + { + "id": "skew_extreme", + "label": "CBOE SKEW extrême", + "description": "SKEW index très haut (protection tail coûteuse) ou très bas (complaisance)", + "desk_type": "sentiment", + "params": { + "low_threshold": {"type": "float", "label": "Seuil bas", "default": 120.0, "min": 100.0, "max": 130.0}, + "high_threshold": {"type": "float", "label": "Seuil haut", "default": 145.0, "min": 135.0, "max": 170.0}, + }, + }, ] # ── Schemas ─────────────────────────────────────────────────────────────────── +DESK_TYPES = {"news", "fundamental", "technical", "eco", "report", "sentiment"} + + class AIDeskUpsert(BaseModel): name: str - type: str + type: str # one of DESK_TYPES active: bool = True system_prompt: str = "" instruments: List[str] = [] diff --git a/backend/services/database.py b/backend/services/database.py index ab11f33..63be42e 100644 --- a/backend/services/database.py +++ b/backend/services/database.py @@ -1030,6 +1030,61 @@ def init_db(): "instruments": json.dumps(["SPY","TLT","GLD","EURUSD=X","USO","HYG"]), "config": json.dumps({"z_threshold": 1.5, "days": 7}), }, + { + "name": "Fundamental Desk", + "type": "fundamental", + "active": 1, + "system_prompt": ( + "Tu identifies les événements fondamentaux CORPORATE qui impactent les marchés : " + "licenciements massifs, M&A, révisions de guidance, downgrades crédit, amendes réglementaires, " + "résultats earnings surprenants. Ignore les rumeurs et spéculations. " + "Concentre-toi sur les faits avérés avec impact sectoriel ou macro mesurable." + ), + "instruments": json.dumps(["SPY","QQQ","HYG","NVDA","GS","AAPL","XOM","BTC-USD"]), + "config": json.dumps({ + "min_impact": 0.45, + "lookback_hours": 72, + "max_evaluate": 20, + "dedup_enabled": True, + "dedup_lookback_days": 3, + "focus_types": ["layoffs","earnings","ma","credit","regulatory","guidance"], + }), + }, + { + "name": "Report Desk", + "type": "report", + "active": 1, + "system_prompt": ( + "Tu analyses les rapports institutionnels (COT, EIA, inventaires) pour en extraire " + "les signaux de positionnement et de flux qui impactent les marchés de matières premières " + "et les devises. Identifie les retournements de tendance dans les positions spéculatives." + ), + "instruments": json.dumps(["USO","GLD","SLV","UNG","EURUSD=X","USDJPY=X","XOM"]), + "config": json.dumps({"days": 7, "min_importance": 3}), + }, + { + "name": "Sentiment Desk — Options Lab", + "type": "sentiment", + "active": 1, + "system_prompt": ( + "Tu es spécialiste des indicateurs de sentiment de marché pour le trading d'options. " + "Tu détectes les régimes de peur/euphorie extrêmes qui créent des opportunités de vol. " + "VIX > 25 = zone de vente de puts cash-secured. VIX > 35 = opportunités rares sur straddles. " + "SKEW > 140 = marché paye cher pour la protection downside. " + "Inversion terme structure VIX (front > back) = peur concentrée court terme." + ), + "instruments": json.dumps(["SPY","QQQ","VXX","TLT","HYG","GLD"]), + "config": json.dumps({ + "lookback_days": 5, + "signals": { + "vix_level": {"enabled": True, "thresholds": [20, 25, 30, 35, 45]}, + "vix_spike": {"enabled": True, "min_pct_change": 15.0}, + "vix_term_structure": {"enabled": True, "inversion_threshold": 1.05}, + "vvix_extreme": {"enabled": True, "threshold": 100.0}, + "skew_extreme": {"enabled": True, "low_threshold": 120.0, "high_threshold": 145.0}, + }, + }), + }, ] for _desk in _AI_DESK_DEFAULTS: try: diff --git a/backend/services/market_event_detector.py b/backend/services/market_event_detector.py index 8cd2ea4..dac7069 100644 --- a/backend/services/market_event_detector.py +++ b/backend/services/market_event_detector.py @@ -642,11 +642,347 @@ def _check_technical(desk_cfg: Dict[str, Any]) -> List[Dict[str, Any]]: return created -# ── Source 4: Institutional reports ────────────────────────────────────────── +# ── Source 4: Fundamental news ─────────────────────────────────────────────── -def _check_reports(days: int = 7, min_importance: int = 3) -> List[Dict[str, Any]]: +def _check_fundamental(desk_cfg: Dict[str, Any]) -> List[Dict[str, Any]]: + """Corporate fundamental events: layoffs, M&A, earnings, credit, regulatory.""" + from services.data_fetcher import fetch_geo_news + + 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)) + dedup_enabled = bool(desk_cfg.get("dedup_enabled", True)) + dedup_days = int(desk_cfg.get("dedup_lookback_days", 3)) + system_prompt = desk_cfg.get("_system_prompt", "") + focus_types = desk_cfg.get("focus_types", ["layoffs","earnings","ma","credit","regulatory","guidance"]) + + api_key = _get_api_key() + if not api_key: + return [] + + try: + all_news = fetch_geo_news() + except Exception as e: + logger.warning(f"[check_events/fundamental] fetch failed: {e}") + return [] + + cutoff_dt = datetime.utcnow() - timedelta(hours=lookback_hours) + candidates = [ + n for n in all_news + if (n.get("impact_score") or 0) >= min_impact + and _parse_date(n.get("date", "")) >= cutoff_dt.strftime("%Y-%m-%d") + ][:max_evaluate] + + if not candidates: + return [] + + try: + from openai import OpenAI + client = OpenAI(api_key=api_key) + except Exception as e: + logger.warning(f"[check_events/fundamental] OpenAI init failed: {e}") + return [] + + existing = _existing_event_keys() + created: List[Dict] = [] + + focus_str = ", ".join(focus_types) + + for n in candidates: + title = n.get("title", "") + if not title or _is_dup(title, existing): + continue + + pub_date = _parse_date(n.get("date", "")) + news_summary = str(n.get("summary", ""))[:400] + source = n.get("source", "") + + if dedup_enabled: + if _semantic_dedup(title, source, pub_date, news_summary, + category="fundamental", client=client, + dedup_lookback_days=dedup_days, + system_prompt_hint=system_prompt): + continue + + prompt = f"""Tu es un analyste fondamental corporate. Cette news représente-t-elle un événement +fondamental CORPORATE structurant (types attendus: {focus_str}) ? + +TITRE: {title} +SOURCE: {source} +DATE: {n.get('date', '')} +RÉSUMÉ: {news_summary} + +Réponds OUI uniquement si c'est un fait avéré avec impact mesurable sur un secteur ou sur les indices. +Ignore les géopolitiques purs (guerres, sanctions) — ceux-là sont traités par le News Desk. + +FORMAT JSON STRICT: +{{ + "qualifies": true/false, + "fundamental_type": "layoffs|earnings|ma|credit|regulatory|guidance|other", + "reason": "une phrase", + "name": "Nom court (≤ 60 chars)", + "company_sector": "ex: Tech, Energy, Financials, ou ticker si connu", + "description": "1-2 phrases analytiques", + "affected_assets": ["QQQ","HYG",...], + "impact_score": 0.5, + "level": "short|medium|long" +}}""" + + try: + resp = client.chat.completions.create( + model="gpt-4o-mini", + messages=[{"role": "user", "content": prompt}], + response_format={"type": "json_object"}, + temperature=0.1, + max_tokens=350, + ) + parsed = json.loads(resp.choices[0].message.content) + except Exception as e: + logger.debug(f"[check_events/fundamental] AI failed for '{title[:40]}': {e}") + continue + + if not parsed.get("qualifies"): + continue + + ev_name = (parsed.get("name") or title)[:60] + if _is_dup(ev_name, existing): + continue + + source_ref = { + "title": title, + "source": source, + "url": n.get("url") or n.get("link", ""), + "date": pub_date, + "original_score": round(float(n.get("impact_score", 0)), 3), + } + + ev = { + "name": ev_name, + "start_date": pub_date, + "level": parsed.get("level", "short"), + "category": "fundamental", + "sub_type": parsed.get("fundamental_type", "other"), + "description": parsed.get("description", title), + "market_impact": f"Secteur: {parsed.get('company_sector','')}", + "affected_assets": parsed.get("affected_assets", []), + "impact_score": float(parsed.get("impact_score", 0.5)), + "source_refs": [source_ref], + "origin": "detector_fundamental", + } + result = _save_and_evaluate(ev, existing) + if result: + result["source"] = "fundamental" + created.append(result) + + return created + + +# ── Source 5: Sentiment signals (Options Lab triggers) ──────────────────────── + +def _check_sentiment(desk_cfg: Dict[str, Any]) -> List[Dict[str, Any]]: + """ + VIX family + SKEW signals — generates sentiment market_events that feed + directly into the options lab as volatility regime triggers. + """ + try: + import yfinance as yf + import pandas as pd + except ImportError: + logger.warning("[check_events/sentiment] yfinance not available") + return [] + + lookback_days = int(desk_cfg.get("lookback_days", 5)) + signals_config = desk_cfg.get("signals", {}) + + def sig_on(sig_id: str) -> Optional[Dict]: + c = signals_config.get(sig_id, {}) + return c if c.get("enabled", True) else None + + vix_level_cfg = sig_on("vix_level") + vix_spike_cfg = sig_on("vix_spike") + vix_ts_cfg = sig_on("vix_term_structure") + vvix_cfg = sig_on("vvix_extreme") + skew_cfg = sig_on("skew_extreme") + + if not any([vix_level_cfg, vix_spike_cfg, vix_ts_cfg, vvix_cfg, skew_cfg]): + return [] + + existing = _existing_event_keys() + created: List[Dict] = [] + cutoff = (datetime.utcnow() - timedelta(days=lookback_days)).strftime("%Y-%m-%d") + + # ── Fetch VIX family ────────────────────────────────────────────────────── + tickers = {"VIX": "^VIX", "VIX9D": "^VIX9D", "VIX3M": "^VIX3M", "VVIX": "^VVIX", "SKEW": "^SKEW"} + series: Dict[str, Any] = {} + for lbl, sym in tickers.items(): + try: + df = yf.download(sym, period="30d", interval="1d", progress=False, auto_adjust=True) + if df is not None and len(df) > 0: + if hasattr(df.columns, "levels"): + df.columns = df.columns.get_level_values(0) + series[lbl] = df["Close"].squeeze().dropna() + except Exception as e: + logger.debug(f"[sentiment] {sym} download failed: {e}") + + vix = series.get("VIX") + if vix is None or len(vix) < 2: + logger.warning("[check_events/sentiment] VIX data unavailable") + return [] + + def _emit(name: str, date_str: str, direction: str, sub_type: str, + score: float, desc: str, assets: List[str], options_note: str = ""): + if _is_dup(name, existing): + return + ev = { + "name": name, + "start_date": date_str, + "level": "short", + "category": "sentiment", + "sub_type": sub_type, + "description": desc + (f" {options_note}" if options_note else ""), + "market_impact": options_note, + "affected_assets": assets, + "impact_score": score, + "source_refs": [{ + "title": f"Sentiment signal: {name}", + "source": "CBOE/yfinance", + "url": "https://www.cboe.com/tradable_products/vix/", + "date": date_str, + "original_score": score, + }], + "origin": "detector_sentiment", + } + result = _save_and_evaluate(ev, existing) + if result: + result["source"] = "sentiment" + created.append(result) + + # ── VIX level threshold crossings ───────────────────────────────────────── + if vix_level_cfg: + thresholds = vix_level_cfg.get("thresholds", [20, 25, 30, 35, 45]) + recent = vix.tail(lookback_days + 2) + for i in range(1, len(recent)): + date_str = str(recent.index[i])[:10] + if date_str < cutoff: + continue + prev_v, curr_v = float(recent.iloc[i-1]), float(recent.iloc[i]) + for lvl in thresholds: + name = None + if prev_v < lvl <= curr_v: + name = f"VIX franchit {lvl} à la hausse ({date_str[:7]})" + options_note = ( + "Opportunité: vente de puts cash-secured sur SPY." if lvl < 25 + else "Régime de peur — évaluer straddles ou risk reversals." + ) + _emit(name, date_str, "bearish", f"VIX >{lvl}", + min(0.9, 0.4 + lvl * 0.01), + f"VIX dépasse {lvl} (précédent: {prev_v:.1f} → {curr_v:.1f}). Entrée en régime de volatilité élevée.", + ["VXX","SPY","QQQ","TLT"], options_note) + elif prev_v >= lvl > curr_v: + name = f"VIX repasse sous {lvl} ({date_str[:7]})" + _emit(name, date_str, "bullish", f"VIX <{lvl}", + 0.45, + f"VIX revient sous {lvl} ({prev_v:.1f} → {curr_v:.1f}). Détente de la volatilité.", + ["SPY","QQQ","VXX"], + "Opportunité: rachat de protection ou fermeture de couvertures.") + + # ── VIX spike intraday / daily ──────────────────────────────────────────── + if vix_spike_cfg: + min_pct = float(vix_spike_cfg.get("min_pct_change", 15.0)) + recent = vix.tail(lookback_days + 1) + for i in range(1, len(recent)): + date_str = str(recent.index[i])[:10] + if date_str < cutoff: + continue + prev_v, curr_v = float(recent.iloc[i-1]), float(recent.iloc[i]) + if prev_v <= 0: + continue + pct_chg = (curr_v - prev_v) / prev_v * 100 + if abs(pct_chg) >= min_pct: + direction = "bearish" if pct_chg > 0 else "bullish" + sign = "+" if pct_chg > 0 else "" + name = f"VIX spike {sign}{pct_chg:.0f}% ({date_str[:7]})" + _emit(name, date_str, direction, "VIX Spike", + min(0.85, 0.4 + abs(pct_chg) * 0.01), + f"VIX variation journalière de {sign}{pct_chg:.1f}% ({prev_v:.1f} → {curr_v:.1f}). Choc de volatilité {'haussier' if pct_chg>0 else 'baissier'}.", + ["VXX","SPY","QQQ","TLT","GLD"], + "Signal pour stratégies de vol à court terme.") + + # ── VIX term structure inversion (VIX9D > VIX) ──────────────────────────── + if vix_ts_cfg and "VIX9D" in series: + threshold = float(vix_ts_cfg.get("inversion_threshold", 1.05)) + vix9d = series["VIX9D"] + common_idx = vix.index.intersection(vix9d.index) + if len(common_idx) >= 2: + for dt_idx in common_idx[-lookback_days:]: + date_str = str(dt_idx)[:10] + if date_str < cutoff: + continue + ratio = float(vix9d[dt_idx]) / float(vix[dt_idx]) if float(vix[dt_idx]) > 0 else 0 + if ratio >= threshold: + name = f"VIX Term Structure Inversée — Peur court terme ({date_str[:7]})" + if not _is_dup(name, existing): + _emit(name, date_str, "bearish", "VIX Inversion", + 0.70, + f"VIX9D ({float(vix9d[dt_idx]):.1f}) > VIX ({float(vix[dt_idx]):.1f}) — ratio {ratio:.2f}. La peur est concentrée sur le très court terme.", + ["VXX","SPY","TLT"], + "Stratégie: calendar spread bear — acheter protection courte vs vendre moyenne échéance.") + + # ── VVIX extreme ────────────────────────────────────────────────────────── + if vvix_cfg and "VVIX" in series: + threshold = float(vvix_cfg.get("threshold", 100.0)) + vvix = series["VVIX"] + recent = vvix.tail(lookback_days) + for i in range(len(recent)): + date_str = str(recent.index[i])[:10] + if date_str < cutoff: + continue + val = float(recent.iloc[i]) + if val >= threshold: + name = f"VVIX extrême {val:.0f} ({date_str[:7]})" + _emit(name, date_str, "bearish", "VVIX Extreme", + min(0.80, 0.45 + (val - threshold) * 0.005), + f"VVIX à {val:.1f} (seuil: {threshold}) — volatilité de la volatilité extrême. Marché très incertain sur la direction du VIX.", + ["VXX","SPY","QQQ"], + "Éviter les positions directionnelles sur vol. Stratégies non-directionnelles.") + + # ── SKEW extreme ────────────────────────────────────────────────────────── + if skew_cfg and "SKEW" in series: + low_thr = float(skew_cfg.get("low_threshold", 120.0)) + high_thr = float(skew_cfg.get("high_threshold", 145.0)) + skew = series["SKEW"] + recent = skew.tail(lookback_days) + for i in range(len(recent)): + date_str = str(recent.index[i])[:10] + if date_str < cutoff: + continue + val = float(recent.iloc[i]) + if val >= high_thr: + name = f"SKEW extrême haussier {val:.0f} ({date_str[:7]})" + _emit(name, date_str, "bearish", "SKEW Extreme", + 0.65, + f"CBOE SKEW à {val:.1f} — marché paye très cher pour les puts out-of-the-money. Couverture tail-risk forte.", + ["SPY","QQQ","TLT"], + "Skew élevé → vente de put spreads attractive (prime élevée sur strikes bas).") + elif val <= low_thr: + name = f"SKEW très bas {val:.0f} — complaisance ({date_str[:7]})" + _emit(name, date_str, "bullish", "SKEW Low", + 0.55, + f"CBOE SKEW à {val:.1f} — marché peu préoccupé par les risques tail. Signal de complaisance.", + ["VXX","SPY"], + "Skew bas → acheter protection bon marché (puts OTM relativement peu chers).") + + return created + + +# ── Source 6: Institutional reports ────────────────────────────────────────── + +def _check_reports(desk_cfg: Dict[str, Any]) -> List[Dict[str, Any]]: from services.database import get_conn + days = int(desk_cfg.get("days", 7)) + min_importance = int(desk_cfg.get("min_importance", 3)) + try: cutoff = (datetime.utcnow() - timedelta(days=days)).strftime("%Y-%m-%d") conn = get_conn() @@ -726,7 +1062,7 @@ def _check_reports(days: int = 7, min_importance: int = 3) -> List[Dict[str, Any def check_new_market_events( sources: Optional[List[str]] = None, - # Legacy overrides (used when called from cycle_actions without a desk) + # Legacy overrides — used when called without an active desk news_impact_min: float = 0.55, news_lookback_hours: int = 48, eco_z_threshold: float = 1.5, @@ -736,86 +1072,91 @@ def check_new_market_events( report_min_importance: int = 3, ) -> Dict[str, Any]: """ - Scans all (or selected) sources, creates market_events with source_refs, - and immediately evaluates instrument impacts. + Scans all (or selected) sources for all 6 market_event categories. Desk configs from ai_desks table override legacy params when available. + Sources: news, fundamental, eco, technical, reports, sentiment """ if sources is None: - sources = ["news", "eco", "technical", "reports"] + sources = ["news", "fundamental", "eco", "technical", "reports", "sentiment"] - # Load desk configs (fall back to legacy params if no active desk found) + # Load desk configs from DB try: from services.database import get_ai_desk_by_type - news_desk = get_ai_desk_by_type("news") - tech_desk = get_ai_desk_by_type("technical") - eco_desk = get_ai_desk_by_type("eco") + news_desk = get_ai_desk_by_type("news") + fundamental_desk = get_ai_desk_by_type("fundamental") + tech_desk = get_ai_desk_by_type("technical") + eco_desk = get_ai_desk_by_type("eco") + report_desk = get_ai_desk_by_type("report") + sentiment_desk = get_ai_desk_by_type("sentiment") except Exception as e: logger.warning(f"[check_events] Could not load desk configs: {e}") - news_desk = tech_desk = eco_desk = None + news_desk = fundamental_desk = tech_desk = eco_desk = report_desk = sentiment_desk = None def _desk_cfg(desk: Optional[Dict], fallback: Dict) -> Dict: if not desk: return fallback cfg = dict(desk.get("config") or {}) - cfg["_instruments"] = desk.get("instruments") or None + cfg["_instruments"] = desk.get("instruments") or None cfg["_system_prompt"] = desk.get("system_prompt") or "" return cfg news_cfg = _desk_cfg(news_desk, { - "min_impact": news_impact_min, - "lookback_hours": news_lookback_hours, - "max_evaluate": 15, - "dedup_enabled": False, - "dedup_lookback_days": 2, + "min_impact": news_impact_min, "lookback_hours": news_lookback_hours, + "max_evaluate": 15, "dedup_enabled": False, "dedup_lookback_days": 2, + }) + fundamental_cfg = _desk_cfg(fundamental_desk, { + "min_impact": 0.45, "lookback_hours": 72, "max_evaluate": 20, + "dedup_enabled": True, "dedup_lookback_days": 3, }) eco_cfg = _desk_cfg(eco_desk, { - "z_threshold": eco_z_threshold, - "days": eco_days, + "z_threshold": eco_z_threshold, "days": eco_days, }) tech_cfg = _desk_cfg(tech_desk, { "lookback_days": technical_lookback_days, "signals": { - "ma_cross": {"enabled": True, "pairs": [["MA50", "MA200"], ["MA50", "MA100"]]}, + "ma_cross": {"enabled": True, "pairs": [["MA50","MA200"],["MA50","MA100"]]}, "rsi_extreme": {"enabled": True, "period": 14, "oversold": 30, "overbought": 70}, "bb_squeeze": {"enabled": True, "period": 20, "std": 2.0, "width_threshold": 0.05}, "new_52w_extreme": {"enabled": True, "buffer_pct": 0.5}, }, }) + report_cfg = _desk_cfg(report_desk, { + "days": report_days, "min_importance": report_min_importance, + }) + sentiment_cfg = _desk_cfg(sentiment_desk, { + "lookback_days": 5, + "signals": { + "vix_level": {"enabled": True, "thresholds": [20, 25, 30, 35, 45]}, + "vix_spike": {"enabled": True, "min_pct_change": 15.0}, + "vix_term_structure": {"enabled": True, "inversion_threshold": 1.05}, + "vvix_extreme": {"enabled": True, "threshold": 100.0}, + "skew_extreme": {"enabled": True, "low_threshold": 120.0, "high_threshold": 145.0}, + }, + }) - results: Dict[str, Any] = { - "news": [], "eco": [], "technical": [], "reports": [], - "total_created": 0, - "ran_at": datetime.utcnow().isoformat(), - } + ALL_SOURCES = ["news", "fundamental", "eco", "technical", "reports", "sentiment"] + results: Dict[str, Any] = {s: [] for s in ALL_SOURCES} + results["total_created"] = 0 + results["ran_at"] = datetime.utcnow().isoformat() - if "news" in sources: + runners = [ + ("news", lambda: _check_news(news_cfg)), + ("fundamental", lambda: _check_fundamental(fundamental_cfg)), + ("eco", lambda: _check_eco(eco_cfg)), + ("technical", lambda: _check_technical(tech_cfg)), + ("reports", lambda: _check_reports(report_cfg)), + ("sentiment", lambda: _check_sentiment(sentiment_cfg)), + ] + + for src, fn in runners: + if src not in sources: + continue try: - results["news"] = _check_news(news_cfg) + results[src] = fn() except Exception as e: - logger.error(f"[check_events] news source error: {e}") + logger.error(f"[check_events] {src} source error: {e}") - if "eco" in sources: - try: - results["eco"] = _check_eco(eco_cfg) - except Exception as e: - logger.error(f"[check_events] eco source error: {e}") - - if "technical" in sources: - try: - results["technical"] = _check_technical(tech_cfg) - except Exception as e: - logger.error(f"[check_events] technical source error: {e}") - - if "reports" in sources: - try: - results["reports"] = _check_reports(days=report_days, min_importance=report_min_importance) - except Exception as e: - logger.error(f"[check_events] reports source error: {e}") - - results["total_created"] = sum(len(results[s]) for s in ["news", "eco", "technical", "reports"]) - logger.info( - f"[check_events] Done — {results['total_created']} new events: " - f"news={len(results['news'])} eco={len(results['eco'])} " - f"technical={len(results['technical'])} reports={len(results['reports'])}" - ) + results["total_created"] = sum(len(results[s]) for s in ALL_SOURCES) + counts = " ".join(f"{s}={len(results[s])}" for s in ALL_SOURCES) + logger.info(f"[check_events] Done — {results['total_created']} new events: {counts}") return results diff --git a/frontend/src/pages/AIDesks.tsx b/frontend/src/pages/AIDesks.tsx index b79bfa8..1a2afe7 100644 --- a/frontend/src/pages/AIDesks.tsx +++ b/frontend/src/pages/AIDesks.tsx @@ -17,6 +17,7 @@ interface SignalDef { id: string label: string description: string + desk_type?: string params: Record } @@ -38,17 +39,21 @@ const ALL_INSTRUMENTS = [ ] const TYPE_LABELS: Record = { - news: 'News', - technical: 'Technical', - eco: 'Économique', - report: 'Report', + news: 'News', + fundamental: 'Fondamental', + technical: 'Technical', + eco: 'Économique', + report: 'Report', + sentiment: 'Sentiment', } const TYPE_COLORS: Record = { - news: 'text-blue-400 bg-blue-900/20 border-blue-700/40', - technical: 'text-cyan-400 bg-cyan-900/20 border-cyan-700/40', - eco: 'text-emerald-400 bg-emerald-900/20 border-emerald-700/40', - report: 'text-violet-400 bg-violet-900/20 border-violet-700/40', + news: 'text-blue-400 bg-blue-900/20 border-blue-700/40', + fundamental: 'text-amber-400 bg-amber-900/20 border-amber-700/40', + technical: 'text-cyan-400 bg-cyan-900/20 border-cyan-700/40', + eco: 'text-emerald-400 bg-emerald-900/20 border-emerald-700/40', + report: 'text-violet-400 bg-violet-900/20 border-violet-700/40', + sentiment: 'text-rose-400 bg-rose-900/20 border-rose-700/40', } // ── Subcomponents ───────────────────────────────────────────────────────────── @@ -271,6 +276,118 @@ function EcoConfig({ } +function FundamentalConfig({ + config, + onChange, +}: { + config: Record + onChange: (c: Record) => void +}) { + const set = (k: string, v: any) => onChange({ ...config, [k]: v }) + const FOCUS_TYPES = ['layoffs', 'earnings', 'ma', 'credit', 'regulatory', 'guidance'] + const focus: string[] = config.focus_types ?? FOCUS_TYPES + const toggleFocus = (t: string) => { + const next = focus.includes(t) ? focus.filter(x => x !== t) : [...focus, t] + set('focus_types', next) + } + return ( +
+
+
+ + set('min_impact', parseFloat(e.target.value))} + className="w-full bg-dark-900 border border-slate-700/40 rounded px-2 py-1.5 text-sm text-white" + /> +
+
+ + set('lookback_hours', parseInt(e.target.value))} + className="w-full bg-dark-900 border border-slate-700/40 rounded px-2 py-1.5 text-sm text-white" + /> +
+
+
+
Types d'événements filtrés
+
+ {FOCUS_TYPES.map(t => ( + + ))} +
+
+
+ + Déduplication sémantique + {config.dedup_enabled && ( +
+ Fenêtre ± jours + set('dedup_lookback_days', parseInt(e.target.value))} + className="w-14 bg-dark-900 border border-slate-700/40 rounded px-2 py-1 text-xs text-white" + /> +
+ )} +
+
+ ) +} + + +function ReportConfig({ + config, + onChange, +}: { + config: Record + onChange: (c: Record) => void +}) { + const set = (k: string, v: any) => onChange({ ...config, [k]: v }) + return ( +
+
+ + set('days', parseInt(e.target.value))} + className="w-full bg-dark-900 border border-slate-700/40 rounded px-2 py-1.5 text-sm text-white" + /> +
+
+ + set('min_importance', parseInt(e.target.value))} + className="w-full bg-dark-900 border border-slate-700/40 rounded px-2 py-1.5 text-sm text-white" + /> +
+
+ ) +} + + // ── Desk editor ─────────────────────────────────────────────────────────────── function DeskEditor({ @@ -369,39 +486,55 @@ function DeskEditor({ {/* Type-specific config */}
Configuration
- {d.type === 'news' && ( - set('config', c)} - /> + {(d.type === 'news') && ( + set('config', c)} /> + )} + {d.type === 'fundamental' && ( + set('config', c)} /> )} {d.type === 'eco' && ( - set('config', c)} - /> + set('config', c)} /> )} - {d.type === 'technical' && catalog.length > 0 && ( -
- {catalog.map(sig => ( - updateSignal(sig.id, v)} - /> - ))} -
- - set('config', { ...d.config, lookback_days: parseInt(e.target.value) })} - className="w-24 bg-dark-900 border border-slate-700/40 rounded px-2 py-1.5 text-sm text-white" - /> + {d.type === 'report' && ( + set('config', c)} /> + )} + {(d.type === 'technical' || d.type === 'sentiment') && catalog.length > 0 && (() => { + const deskSignals = catalog.filter(s => !s.desk_type || s.desk_type === d.type) + return ( +
+ {deskSignals.map(sig => ( + updateSignal(sig.id, v)} + /> + ))} + {d.type === 'technical' && ( +
+ + set('config', { ...d.config, lookback_days: parseInt(e.target.value) })} + className="w-24 bg-dark-900 border border-slate-700/40 rounded px-2 py-1.5 text-sm text-white" + /> +
+ )} + {d.type === 'sentiment' && ( +
+ + set('config', { ...d.config, lookback_days: parseInt(e.target.value) })} + className="w-24 bg-dark-900 border border-slate-700/40 rounded px-2 py-1.5 text-sm text-white" + /> +
+ )}
-
- )} + ) + })()}
{/* Actions */}