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:
@@ -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] = []
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
Reference in New Issue
Block a user