Files
OpenFin/backend/services/market_event_detector.py
OpenSquared 97706dea7b feat: AI Desks — configurable agent system for news/technical/eco processing
- New ai_desks table with CRUD (get_all/by_type/upsert/delete)
- ai_desks router: REST API + GET /signal-catalog (7 extensible signals)
- News Desk: semantic dedup via AI (±N days window, system_prompt hint)
- Technical Desk: 4 signal detectors driven by desk config
  (ma_cross, rsi_extreme, bb_squeeze, new_52w_extreme)
- 3 more signals in catalog ready to enable: price_gap, volume_spike, macd_crossover
- market_event_detector.py loads desk configs at runtime, falls back to legacy params
- AIDesks.tsx: full editor UI with signal toggles, param sliders, instrument multi-select
- Sidebar: Bot icon + /ai-desks route

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 23:08:14 +02:00

822 lines
30 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Isolated cycle action: Check New Market Events.
Scans 4 sources and creates market_events for significant findings:
- news : geopolitical/macro news (RSS feeds, rule-scored)
- eco : FRED economic releases with high surprise z-score
- technical: configurable signal catalog driven by Technical Desk
- reports : institutional reports (COT, EIA) with high importance
Desk configs are loaded from ai_desks table at runtime.
"""
import json
import logging
from datetime import datetime, timedelta
from typing import Any, Dict, List, Optional
logger = logging.getLogger(__name__)
WATCH_INSTRUMENTS = [
"SPY", "QQQ", "IWM", "EEM", "EFA",
"GLD", "SLV", "USO", "TLT", "HYG",
"USDJPY=X", "EURUSD=X", "VXX", "NVDA", "BTC-USD",
]
SUBTYPE_FROM_SERIES = {
"UNRATE": "NFP", "PAYEMS": "NFP",
"CPIAUCSL": "CPI", "CPILFESL": "CPI",
"A191RL1Q225SBEA": "GDP", "GDP": "GDP",
"FEDFUNDS": "FOMC", "DFF": "FOMC",
"PCEPILFE": "PCE", "PCEPI": "PCE",
"BAMLH0A0HYM2": "Credit", "BAMLC0A0CM": "Credit",
}
# ── Helpers ───────────────────────────────────────────────────────────────────
def _get_api_key() -> str:
import os
key = os.environ.get("OPENAI_API_KEY", "")
if not key:
from services.database import get_config
key = get_config("openai_api_key") or ""
return key
def _existing_event_keys() -> set:
from services.database import get_all_market_events
return {ev["name"].lower()[:50] for ev in get_all_market_events()}
def _is_dup(name: str, existing: set) -> bool:
return name.lower()[:50] in existing
def _parse_date(raw: str) -> str:
if not raw:
return datetime.utcnow().strftime("%Y-%m-%d")
try:
return datetime.fromisoformat(raw[:19]).strftime("%Y-%m-%d")
except Exception:
pass
try:
from email.utils import parsedate_to_datetime
return parsedate_to_datetime(raw).strftime("%Y-%m-%d")
except Exception:
pass
return raw[:10] if len(raw) >= 10 else datetime.utcnow().strftime("%Y-%m-%d")
def _save_and_evaluate(ev: Dict, existing: set) -> Optional[Dict]:
"""Save a market_event and immediately evaluate instrument impacts."""
from services.database import save_market_event
try:
event_id = save_market_event(ev)
existing.add(ev["name"].lower()[:50])
logger.info(f"[check_events] ✓ saved event #{event_id}: {ev['name']}")
except Exception as e:
logger.error(f"[check_events] save failed for '{ev['name']}': {e}")
return None
try:
from services.impact_service import evaluate_event_impacts
evaluate_event_impacts(event_id, force=False)
except Exception as e:
logger.warning(f"[check_events] impact eval failed for #{event_id}: {e}")
return {"name": ev["name"], "category": ev.get("category", ""), "date": ev.get("start_date", ""), "event_id": event_id}
# ── Semantic deduplication ────────────────────────────────────────────────────
def _semantic_dedup(
title: str,
source: str,
date_str: str,
summary: str,
category: str,
client: Any,
dedup_lookback_days: int = 2,
system_prompt_hint: str = "",
) -> bool:
"""
Ask the AI whether this news already exists in recent market_events.
Returns True if it's a duplicate (should be skipped).
"""
from services.database import get_market_events_near_date
dedup_categories = ["geopolitical", "fundamental", "report"]
if category and category not in dedup_categories:
dedup_categories.append(category)
recent = get_market_events_near_date(date_str, days=dedup_lookback_days, categories=dedup_categories)
if not recent:
return False
recent_block = "\n".join(
f" [{r['start_date']}] {r['name']}{(r.get('description') or '')[:80]}"
for r in recent[:15]
)
hint = f"\nNote du desk: {system_prompt_hint[:200]}" if system_prompt_hint else ""
prompt = f"""Tu es un éditeur de base de données d'événements marchés.{hint}
NOUVELLE NEWS À VÉRIFIER:
- Titre: {title}
- Source: {source}
- Date: {date_str}
- Résumé: {summary[:300]}
ÉVÉNEMENTS EXISTANTS (±{dedup_lookback_days} jours):
{recent_block}
Cette news représente-t-elle le même fait qu'un événement déjà enregistré ?
Réponds JSON: {{"is_duplicate": true/false, "reason": "courte phrase"}}"""
try:
resp = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": prompt}],
response_format={"type": "json_object"},
temperature=0.0,
max_tokens=100,
)
parsed = json.loads(resp.choices[0].message.content)
is_dup = bool(parsed.get("is_duplicate", False))
if is_dup:
logger.debug(f"[dedup] Skipping duplicate: '{title[:40]}'{parsed.get('reason','')}")
return is_dup
except Exception as e:
logger.debug(f"[dedup] AI check failed for '{title[:40]}': {e}")
return False
# ── Source 1: Geopolitical / macro news ──────────────────────────────────────
def _check_news(desk_cfg: Dict[str, Any]) -> List[Dict[str, Any]]:
from services.data_fetcher import fetch_geo_news
min_impact = float(desk_cfg.get("min_impact", 0.55))
lookback_hours = int(desk_cfg.get("lookback_hours", 48))
max_evaluate = int(desk_cfg.get("max_evaluate", 15))
dedup_enabled = bool(desk_cfg.get("dedup_enabled", True))
dedup_days = int(desk_cfg.get("dedup_lookback_days", 2))
system_prompt = desk_cfg.get("_system_prompt", "")
api_key = _get_api_key()
if not api_key:
logger.warning("[check_events/news] no OpenAI key — skipping")
return []
try:
all_news = fetch_geo_news()
except Exception as e:
logger.warning(f"[check_events/news] fetch failed: {e}")
return []
cutoff_dt = datetime.utcnow() - timedelta(hours=lookback_hours)
candidates = []
for n in all_news:
if (n.get("impact_score") or 0) < min_impact:
continue
pub_date = _parse_date(n.get("date", ""))
try:
if datetime.fromisoformat(pub_date) < cutoff_dt:
continue
except Exception:
pass
candidates.append(n)
candidates = candidates[: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/news] OpenAI init failed: {e}")
return []
existing = _existing_event_keys()
created: List[Dict] = []
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", "")
# Semantic dedup before expensive classification call
if dedup_enabled:
if _semantic_dedup(
title, source, pub_date, news_summary,
category="geopolitical",
client=client,
dedup_lookback_days=dedup_days,
system_prompt_hint=system_prompt,
):
continue
prompt = f"""Tu es un analyste macro. Cette news représente-t-elle un événement marché structurant qui mérite un enregistrement permanent ?
TITRE: {title}
SOURCE: {source}
DATE: {n.get('date', '')}
RÉSUMÉ: {news_summary}
SCORE IMPACT (règle): {n.get('impact_score', 0):.2f}
Réponds OUI seulement si c'est un fait avéré, pas une rumeur ou une opinion, et qu'il a un impact macro ou géopolitique mesurable.
FORMAT JSON STRICT:
{{
"qualifies": true/false,
"reason": "une phrase",
"name": "Nom court (≤ 60 chars)",
"category": "geopolitical|event_calendar|fundamental|report",
"sub_type": "ex: Conflit, Tarifs, Sanctions, OPEC+, Crise...",
"description": "1-2 phrases analytiques",
"affected_assets": ["SPY","GLD",...],
"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/news] AI call 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": parsed.get("category", "geopolitical"),
"sub_type": parsed.get("sub_type", ""),
"description": parsed.get("description", title),
"market_impact": "",
"affected_assets": parsed.get("affected_assets", []),
"impact_score": float(parsed.get("impact_score", 0.6)),
"source_refs": [source_ref],
"origin": "detector_news",
}
result = _save_and_evaluate(ev, existing)
if result:
result["source"] = "news"
created.append(result)
return created
# ── Source 2: Eco calendar — FRED surprises ───────────────────────────────────
def _check_eco(desk_cfg: Dict[str, Any]) -> List[Dict[str, Any]]:
from services.database import get_recent_economic_surprises
z_threshold = float(desk_cfg.get("z_threshold", 1.5))
days = int(desk_cfg.get("days", 7))
try:
releases = get_recent_economic_surprises(days=days, min_zscore=z_threshold)
except Exception as e:
logger.warning(f"[check_events/eco] query failed: {e}")
return []
existing = _existing_event_keys()
created: List[Dict] = []
for rel in releases:
z = abs(rel.get("surprise_zscore") or 0)
s_pct = rel.get("surprise_pct") or 0
ev_date = (rel.get("event_date") or "")[:10]
s_id = rel.get("series_id", "")
ev_name_base = rel.get("event_name", s_id)
direction = rel.get("surprise_direction", "neutral")
sign = "+" if s_pct >= 0 else ""
ev_name = f"{ev_name_base} — Surprise {sign}{s_pct:.1f}% ({ev_date[:7]})"
if _is_dup(ev_name, existing):
continue
sub_type = SUBTYPE_FROM_SERIES.get(s_id, s_id[:10]) if s_id else ev_name_base[:10]
level = "long" if z >= 3 else ("medium" if z >= 2 else "short")
assets = rel.get("assets_impacted") or []
if isinstance(assets, str):
try:
assets = json.loads(assets)
except Exception:
assets = []
source_ref = {
"title": f"FRED release: {ev_name_base} ({ev_date})",
"source": "FRED",
"url": f"https://fred.stlouisfed.org/series/{s_id}" if s_id else "",
"date": ev_date,
"original_score": round(min(0.95, 0.35 + z * 0.15), 3),
}
ev = {
"name": ev_name,
"start_date": ev_date,
"level": level,
"category": "event_calendar",
"sub_type": sub_type,
"description": (
f"Surprise {direction} {sign}{s_pct:.1f}% vs baseline "
f"(z-score: {z:.1f}σ). "
f"Réel: {rel.get('actual_value', '?')} {rel.get('actual_unit', '')} "
f"/ Prévision: {rel.get('forecast_value', '?')}."
),
"market_impact": "",
"affected_assets": assets,
"impact_score": min(0.95, 0.35 + z * 0.15),
"actual_value": str(rel.get("actual_value", "")),
"expected_value": str(rel.get("forecast_value", "")),
"surprise_pct": float(s_pct),
"source_refs": [source_ref],
"origin": "detector_eco",
}
result = _save_and_evaluate(ev, existing)
if result:
result["source"] = "eco"
created.append(result)
return created
# ── Technical signal detectors ────────────────────────────────────────────────
def _detect_ma_cross(ticker: str, df: Any, params: Dict, cutoff: str) -> List[Dict]:
"""Golden/Death cross detector for configured MA pairs."""
import pandas as pd
events = []
pairs_cfg = params.get("pairs", [["MA50", "MA200"], ["MA50", "MA100"]])
ma_map = {"MA20": 20, "MA50": 50, "MA100": 100, "MA200": 200}
close = df["Close"].squeeze()
# Pre-compute all required MAs
needed: set = set()
for pair in pairs_cfg:
needed.update(pair)
ma_series: Dict[str, Any] = {}
for lbl in needed:
period = ma_map.get(lbl)
if period and len(df) >= period:
ma_series[lbl] = close.rolling(period).mean()
recent = df.tail(4)
for i in range(1, len(recent)):
date_str = str(recent.index[i])[:10]
if date_str < cutoff:
continue
for fast_lbl, slow_lbl in pairs_cfg:
if fast_lbl not in ma_series or slow_lbl not in ma_series:
continue
fp = ma_series[fast_lbl].iloc[-(len(recent) - i + 1)]
fc = ma_series[fast_lbl].iloc[-(len(recent) - i)]
sp = ma_series[slow_lbl].iloc[-(len(recent) - i + 1)]
sc = ma_series[slow_lbl].iloc[-(len(recent) - i)]
if any(pd.isna(v) for v in [fp, fc, sp, sc]):
continue
if fp < sp and fc >= sc:
kind = "golden"
elif fp > sp and fc <= sc:
kind = "death"
else:
continue
cross_label = "Golden Cross" if kind == "golden" else "Death Cross"
events.append({
"name": f"{ticker} {fast_lbl}/{slow_lbl} {cross_label} ({date_str[:7]})",
"date": date_str,
"direction": "bullish" if kind == "golden" else "bearish",
"sub_type": f"{fast_lbl}/{slow_lbl} Cross",
"score": 0.65 if "MA200" in (fast_lbl, slow_lbl) else 0.45,
"level": "medium" if "MA200" in (fast_lbl, slow_lbl) else "short",
"desc": f"{cross_label}: {fast_lbl} {'au-dessus' if kind=='golden' else 'en-dessous'} de {slow_lbl} sur {ticker}.",
})
return events
def _detect_rsi_extreme(ticker: str, df: Any, params: Dict, cutoff: str) -> List[Dict]:
"""RSI oversold/overbought signal."""
import pandas as pd
period = int(params.get("period", 14))
oversold = float(params.get("oversold", 30))
overbought = float(params.get("overbought", 70))
close = df["Close"].squeeze()
if len(close) < period + 2:
return []
delta = close.diff()
gain = delta.clip(lower=0).rolling(period).mean()
loss = (-delta.clip(upper=0)).rolling(period).mean()
rs = gain / loss.replace(0, float("nan"))
rsi = 100 - (100 / (1 + rs))
events = []
recent = rsi.tail(3)
for i in range(len(recent)):
date_str = str(recent.index[i])[:10]
if date_str < cutoff:
continue
val = recent.iloc[i]
if pd.isna(val):
continue
if val <= oversold:
direction, label = "bullish", "Oversold"
elif val >= overbought:
direction, label = "bearish", "Overbought"
else:
continue
events.append({
"name": f"{ticker} RSI {label} ({date_str[:7]})",
"date": date_str,
"direction": direction,
"sub_type": f"RSI {label}",
"score": 0.50 if abs(val - 50) > 30 else 0.40,
"level": "short",
"desc": f"RSI({period}) à {val:.1f} sur {ticker} — signal {label.lower()} ({direction}).",
})
return events
def _detect_bb_squeeze(ticker: str, df: Any, params: Dict, cutoff: str) -> List[Dict]:
"""Bollinger Band squeeze detector."""
import pandas as pd
period = int(params.get("period", 20))
std_mult = float(params.get("std", 2.0))
width_threshold = float(params.get("width_threshold", 0.05))
close = df["Close"].squeeze()
if len(close) < period + 2:
return []
mid = close.rolling(period).mean()
std = close.rolling(period).std()
upper = mid + std_mult * std
lower = mid - std_mult * std
width = (upper - lower) / mid
events = []
recent = width.tail(3)
for i in range(len(recent)):
date_str = str(recent.index[i])[:10]
if date_str < cutoff:
continue
w = recent.iloc[i]
if pd.isna(w):
continue
if w <= width_threshold:
events.append({
"name": f"{ticker} BB Squeeze ({date_str[:7]})",
"date": date_str,
"direction": "neutral",
"sub_type": "BB Squeeze",
"score": 0.45,
"level": "short",
"desc": f"Bandes de Bollinger({period},{std_mult}) très resserrées sur {ticker} — width={w:.3f}. Explosion de volatilité imminente.",
})
return events
def _detect_52w_extreme(ticker: str, df: Any, params: Dict, cutoff: str) -> List[Dict]:
"""New 52-week high/low detector."""
import pandas as pd
buffer_pct = float(params.get("buffer_pct", 0.5)) / 100
close = df["Close"].squeeze()
if len(close) < 252:
return []
high_52 = close.rolling(252).max()
low_52 = close.rolling(252).min()
events = []
recent_close = close.tail(3)
for i in range(len(recent_close)):
date_str = str(recent_close.index[i])[:10]
if date_str < cutoff:
continue
c = recent_close.iloc[i]
h52 = high_52.iloc[-(3 - i)]
l52 = low_52.iloc[-(3 - i)]
if pd.isna(c) or pd.isna(h52) or pd.isna(l52):
continue
if c >= h52 * (1 - buffer_pct):
events.append({
"name": f"{ticker} Nouveau 52W High ({date_str[:7]})",
"date": date_str,
"direction": "bullish",
"sub_type": "52W High",
"score": 0.60,
"level": "medium",
"desc": f"{ticker} atteint un nouveau plus haut 52 semaines à {c:.2f} (précédent: {h52:.2f}).",
})
elif c <= l52 * (1 + buffer_pct):
events.append({
"name": f"{ticker} Nouveau 52W Low ({date_str[:7]})",
"date": date_str,
"direction": "bearish",
"sub_type": "52W Low",
"score": 0.60,
"level": "medium",
"desc": f"{ticker} atteint un nouveau plus bas 52 semaines à {c:.2f} (précédent: {l52:.2f}).",
})
return events
# ── Source 3: Technical signals ───────────────────────────────────────────────
def _check_technical(desk_cfg: Dict[str, Any]) -> List[Dict[str, Any]]:
try:
import yfinance as yf
import pandas as pd
except ImportError:
logger.warning("[check_events/technical] yfinance/pandas not available")
return []
instruments = desk_cfg.get("_instruments") or WATCH_INSTRUMENTS
lookback_days = int(desk_cfg.get("lookback_days", 7))
signals_config = desk_cfg.get("signals", {})
# Determine which signals are active
def sig_cfg(sig_id: str) -> Optional[Dict]:
c = signals_config.get(sig_id, {})
return c if c.get("enabled", False) else None
ma_cross_cfg = sig_cfg("ma_cross")
rsi_cfg = sig_cfg("rsi_extreme")
bb_cfg = sig_cfg("bb_squeeze")
extreme_52w = sig_cfg("new_52w_extreme")
if not any([ma_cross_cfg, rsi_cfg, bb_cfg, extreme_52w]):
logger.info("[check_events/technical] no active signals in desk config")
return []
existing = _existing_event_keys()
created: List[Dict] = []
cutoff = (datetime.utcnow() - timedelta(days=lookback_days)).strftime("%Y-%m-%d")
for ticker in instruments:
try:
df = yf.download(ticker, period="2y", interval="1d", progress=False, auto_adjust=True)
if df is None or len(df) < 20:
continue
# Flatten MultiIndex if needed (yfinance ≥ 0.2 returns MultiIndex columns)
if hasattr(df.columns, "levels"):
df.columns = df.columns.get_level_values(0)
detected: List[Dict] = []
if ma_cross_cfg and len(df) >= 210:
detected += _detect_ma_cross(ticker, df, ma_cross_cfg, cutoff)
if rsi_cfg:
detected += _detect_rsi_extreme(ticker, df, rsi_cfg, cutoff)
if bb_cfg:
detected += _detect_bb_squeeze(ticker, df, bb_cfg, cutoff)
if extreme_52w and len(df) >= 252:
detected += _detect_52w_extreme(ticker, df, extreme_52w, cutoff)
for sig in detected:
ev_name = sig["name"]
if _is_dup(ev_name, existing):
continue
source_ref = {
"title": f"Technical signal: {ev_name}",
"source": "yfinance/computed",
"url": f"https://finance.yahoo.com/quote/{ticker}",
"date": sig["date"],
"original_score": sig["score"],
}
ev = {
"name": ev_name,
"start_date": sig["date"],
"level": sig["level"],
"category": "technical",
"sub_type": sig["sub_type"],
"description": sig["desc"],
"market_impact": f"Signal {sig['direction']} sur {ticker}",
"affected_assets": [ticker],
"impact_score": sig["score"],
"source_refs": [source_ref],
"origin": "detector_technical",
}
result = _save_and_evaluate(ev, existing)
if result:
result["source"] = "technical"
created.append(result)
except Exception as e:
logger.debug(f"[check_events/technical] {ticker} failed: {e}")
return created
# ── Source 4: Institutional reports ──────────────────────────────────────────
def _check_reports(days: int = 7, min_importance: int = 3) -> List[Dict[str, Any]]:
from services.database import get_conn
try:
cutoff = (datetime.utcnow() - timedelta(days=days)).strftime("%Y-%m-%d")
conn = get_conn()
rows = conn.execute(
"""SELECT * FROM institutional_reports
WHERE report_date >= ? AND importance >= ?
ORDER BY importance DESC, report_date DESC
LIMIT 15""",
(cutoff, min_importance),
).fetchall()
conn.close()
reports = [dict(r) for r in rows]
except Exception as e:
logger.warning(f"[check_events/reports] query failed: {e}")
return []
existing = _existing_event_keys()
created: List[Dict] = []
for rpt in reports:
title = rpt.get("title", "")
rpt_type = rpt.get("report_type", "Report")
rpt_date = (rpt.get("report_date") or "")[:10]
if not title or _is_dup(title, existing):
continue
ev_name = title[:60]
summary = rpt.get("ai_summary") or rpt.get("trading_implications", "")
try:
kp = json.loads(rpt.get("key_points_json") or "[]")
if kp:
summary = " ".join(kp[:2]) + " " + summary
except Exception:
pass
assets: List[str] = []
for sig_col, asset_list in [
("signal_energy", ["USO", "XOM"]),
("signal_metals", ["GLD", "SLV"]),
("signal_indices", ["SPY", "QQQ"]),
("signal_forex", ["EURUSD=X", "USDJPY=X"]),
]:
if rpt.get(sig_col, "neutral") not in ("neutral", "", None):
assets.extend(asset_list)
source_ref = {
"title": title,
"source": rpt.get("source", rpt_type),
"url": "",
"date": rpt_date,
"original_score": round(min(0.9, 0.3 + rpt.get("importance", 2) * 0.12), 3),
}
ev = {
"name": ev_name,
"start_date": rpt_date,
"level": "medium" if rpt.get("importance", 2) >= 4 else "short",
"category": "report",
"sub_type": rpt_type.upper(),
"description": summary[:500] or f"Rapport {rpt_type} du {rpt_date}.",
"market_impact": rpt.get("trading_implications", ""),
"affected_assets": list(set(assets)),
"impact_score": min(0.9, 0.3 + rpt.get("importance", 2) * 0.12),
"source_refs": [source_ref],
"origin": "detector_report",
}
result = _save_and_evaluate(ev, existing)
if result:
result["source"] = "reports"
created.append(result)
return created
# ── Main entry point ──────────────────────────────────────────────────────────
def check_new_market_events(
sources: Optional[List[str]] = None,
# Legacy overrides (used when called from cycle_actions without a desk)
news_impact_min: float = 0.55,
news_lookback_hours: int = 48,
eco_z_threshold: float = 1.5,
eco_days: int = 7,
technical_lookback_days: int = 7,
report_days: int = 7,
report_min_importance: int = 3,
) -> Dict[str, Any]:
"""
Scans all (or selected) sources, creates market_events with source_refs,
and immediately evaluates instrument impacts.
Desk configs from ai_desks table override legacy params when available.
"""
if sources is None:
sources = ["news", "eco", "technical", "reports"]
# Load desk configs (fall back to legacy params if no active desk found)
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")
except Exception as e:
logger.warning(f"[check_events] Could not load desk configs: {e}")
news_desk = tech_desk = eco_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["_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,
})
eco_cfg = _desk_cfg(eco_desk, {
"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"]]},
"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},
},
})
results: Dict[str, Any] = {
"news": [], "eco": [], "technical": [], "reports": [],
"total_created": 0,
"ran_at": datetime.utcnow().isoformat(),
}
if "news" in sources:
try:
results["news"] = _check_news(news_cfg)
except Exception as e:
logger.error(f"[check_events] news 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'])}"
)
return results