feat: 6 AI desks complets — fundamental, report, sentiment (options triggers)

- 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 <noreply@anthropic.com>
This commit is contained in:
OpenSquared
2026-06-26 09:21:29 +02:00
parent 97706dea7b
commit a12d7a1ef3
4 changed files with 670 additions and 90 deletions

View File

@@ -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] = []

View File

@@ -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:

View File

@@ -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

View File

@@ -17,6 +17,7 @@ interface SignalDef {
id: string
label: string
description: string
desk_type?: string
params: Record<string, SignalParam>
}
@@ -38,17 +39,21 @@ const ALL_INSTRUMENTS = [
]
const TYPE_LABELS: Record<string, string> = {
news: 'News',
technical: 'Technical',
eco: 'Économique',
report: 'Report',
news: 'News',
fundamental: 'Fondamental',
technical: 'Technical',
eco: 'Économique',
report: 'Report',
sentiment: 'Sentiment',
}
const TYPE_COLORS: Record<string, string> = {
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<string, any>
onChange: (c: Record<string, any>) => 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 (
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs text-slate-400 block mb-1">Score min d'impact</label>
<input
type="number" min={0} max={1} step={0.05}
value={config.min_impact ?? 0.45}
onChange={e => 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"
/>
</div>
<div>
<label className="text-xs text-slate-400 block mb-1">Lookback heures</label>
<input
type="number" min={24} max={168}
value={config.lookback_hours ?? 72}
onChange={e => 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"
/>
</div>
</div>
<div>
<div className="text-xs text-slate-400 mb-2">Types d'événements filtrés</div>
<div className="flex flex-wrap gap-1.5">
{FOCUS_TYPES.map(t => (
<button
key={t}
onClick={() => toggleFocus(t)}
className={clsx(
'px-2 py-0.5 rounded text-xs border transition-colors capitalize',
focus.includes(t)
? 'bg-amber-900/40 border-amber-600/60 text-amber-300'
: 'bg-dark-800 border-slate-700/40 text-slate-500 hover:border-slate-600',
)}
>
{t}
</button>
))}
</div>
</div>
<div className="flex items-center gap-3">
<button onClick={() => set('dedup_enabled', !config.dedup_enabled)} className="shrink-0">
{config.dedup_enabled
? <ToggleRight className="w-5 h-5 text-amber-400" />
: <ToggleLeft className="w-5 h-5 text-slate-600" />}
</button>
<span className="text-sm text-slate-300">Déduplication sémantique</span>
{config.dedup_enabled && (
<div className="flex items-center gap-2 ml-auto">
<span className="text-xs text-slate-500">Fenêtre ± jours</span>
<input
type="number" min={1} max={7}
value={config.dedup_lookback_days ?? 3}
onChange={e => 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"
/>
</div>
)}
</div>
</div>
)
}
function ReportConfig({
config,
onChange,
}: {
config: Record<string, any>
onChange: (c: Record<string, any>) => void
}) {
const set = (k: string, v: any) => onChange({ ...config, [k]: v })
return (
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs text-slate-400 block mb-1">Jours lookback</label>
<input
type="number" min={1} max={30}
value={config.days ?? 7}
onChange={e => 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"
/>
</div>
<div>
<label className="text-xs text-slate-400 block mb-1">Importance min (1-5)</label>
<input
type="number" min={1} max={5}
value={config.min_importance ?? 3}
onChange={e => 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"
/>
</div>
</div>
)
}
// ── Desk editor ───────────────────────────────────────────────────────────────
function DeskEditor({
@@ -369,39 +486,55 @@ function DeskEditor({
{/* Type-specific config */}
<div>
<div className="text-xs text-slate-400 mb-2">Configuration</div>
{d.type === 'news' && (
<NewsConfig
config={d.config}
onChange={c => set('config', c)}
/>
{(d.type === 'news') && (
<NewsConfig config={d.config} onChange={c => set('config', c)} />
)}
{d.type === 'fundamental' && (
<FundamentalConfig config={d.config} onChange={c => set('config', c)} />
)}
{d.type === 'eco' && (
<EcoConfig
config={d.config}
onChange={c => set('config', c)}
/>
<EcoConfig config={d.config} onChange={c => set('config', c)} />
)}
{d.type === 'technical' && catalog.length > 0 && (
<div className="space-y-2">
{catalog.map(sig => (
<SignalToggle
key={sig.id}
signal={sig}
value={d.config.signals?.[sig.id] ?? { enabled: false }}
onChange={v => updateSignal(sig.id, v)}
/>
))}
<div className="pt-1">
<label className="text-xs text-slate-400 block mb-1">Lookback jours</label>
<input
type="number" min={1} max={30}
value={d.config.lookback_days ?? 7}
onChange={e => 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' && (
<ReportConfig config={d.config} onChange={c => 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 (
<div className="space-y-2">
{deskSignals.map(sig => (
<SignalToggle
key={sig.id}
signal={sig}
value={d.config.signals?.[sig.id] ?? { enabled: false }}
onChange={v => updateSignal(sig.id, v)}
/>
))}
{d.type === 'technical' && (
<div className="pt-1">
<label className="text-xs text-slate-400 block mb-1">Lookback jours</label>
<input
type="number" min={1} max={30}
value={d.config.lookback_days ?? 7}
onChange={e => 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"
/>
</div>
)}
{d.type === 'sentiment' && (
<div className="pt-1">
<label className="text-xs text-slate-400 block mb-1">Lookback jours</label>
<input
type="number" min={1} max={14}
value={d.config.lookback_days ?? 5}
onChange={e => 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"
/>
</div>
)}
</div>
</div>
)}
)
})()}
</div>
{/* Actions */}