feat: macro regime + 30 historical events + regime confidence fix

- Add macro_regime (goldilocks/stagflation/recession/etc.) to every instrument snapshot via get_macro_gauges() + score_macro_scenarios()
- RegimeCard now shows global macro cycle section (emoji + label + top-3 scenarios) above technical signals
- Fix _detect_regime() confidence: capped at 85% max; add late-bull (dist_MA200 > 20%) and correction-in-bull (MA50 > MA200 but momentum < -3%) detection so regime no longer locks at 100%
- Add macro_events_bootstrap.py with 30 curated historical events (FOMC 2022-2025, CPI surprises, Ukraine/Hamas/Iran geopolitics, BOJ pivots, Bitcoin ETF, Liberation Day tariffs, SVB crisis, etc.)
- POST /api/timeline/bootstrap-macro endpoint (idempotent, deduplicates by name)
- Fix event date filter in _get_relevant_events(): overlap logic instead of start-only filter — events extending into the chart window are now included
- EventTimelineStrip: add "Signaux Techniques" fallback row for events not matched by any driver keyword (MA crossovers are now always visible)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
OpenSquared
2026-06-24 23:24:24 +02:00
parent aa81598278
commit aec9ced74f
4 changed files with 495 additions and 17 deletions

View File

@@ -326,23 +326,46 @@ def _detect_regime(df: pd.DataFrame, config: Dict) -> Dict[str, Any]:
(momentum_20d is not None and abs(momentum_20d) < 1.0)
)
# Late-bull flag: strongly extended above MA200 — risk of reversal
is_late_bull = (
bull_score > 0.6 and
dist_ma200 is not None and dist_ma200 > 20
)
# Correction-in-bull flag: price above MA200 but momentum turning
is_correction_in_bull = (
ma50_above_ma200 is True and
momentum_20d is not None and momentum_20d < -3.0
)
# ── Map to 5 regime slots ──────────────────────────────────────────────────
# Slot 0 = bullish, 1 = bearish, 2 = transition, 3 = volatile, 4 = late/consolidation
raw_scores = [0.0] * 5
# Bullish
raw_scores[0] = max(0.0, bull_score)
if is_correction_in_bull:
# Price still above MA200 but momentum rolling over — partial weight to transition
raw_scores[0] = max(0.0, bull_score * 0.5)
raw_scores[2] = 0.6
elif is_late_bull:
# Extended above MA200: some probability we're in late / consolidation regime
raw_scores[0] = max(0.0, bull_score * 0.55)
raw_scores[4] = bull_score * 0.5
else:
# Bullish
raw_scores[0] = max(0.0, bull_score)
# Bearish
raw_scores[1] = max(0.0, -bull_score)
# Transition
raw_scores[2] = 0.6 if is_transition else 0.0
# Transition (overrides if flagged)
if not is_correction_in_bull:
raw_scores[2] = 0.6 if is_transition else 0.0
# Volatile
raw_scores[3] = 0.7 if is_volatile else 0.0
# Late cycle / consolidation — moderate bull_score but high dist_ma200
if 0.0 < bull_score < 0.3 and dist_ma200 is not None and dist_ma200 > 5:
raw_scores[4] = 0.5
else:
raw_scores[4] = max(0.0, 0.3 - abs(bull_score)) if not is_transition else 0.0
if not is_late_bull and not is_correction_in_bull:
if 0.0 < bull_score < 0.3 and dist_ma200 is not None and dist_ma200 > 5:
raw_scores[4] = 0.5
else:
raw_scores[4] = max(0.0, 0.3 - abs(bull_score)) if not is_transition else 0.0
# If volatile, suppress the others a bit
if is_volatile:
@@ -364,7 +387,8 @@ def _detect_regime(df: pd.DataFrame, config: Dict) -> Dict[str, Any]:
norm_scores = norm_scores[:n_labels]
best_idx = int(np.argmax(norm_scores))
confidence = _round(norm_scores[best_idx], 3) or 0.0
# Cap confidence: max 85% for technical regime (always some model uncertainty)
confidence = _round(min(norm_scores[best_idx], 0.85), 3) or 0.0
scores_dict = {}
for i, label in enumerate(regime_labels):
@@ -515,13 +539,26 @@ def _get_relevant_events(
filtered = []
for ev in all_events:
ev_start = str(ev.get("start_date", "") or "")
ev_end = str(ev.get("end_date", "") or ev_start)
ev_end = str(ev.get("end_date", "") or "")
# Date range filter
if from_date and ev_start and ev_start < from_date:
continue
# Date range overlap filter:
# Include if event overlaps with [from_date, to_date]
# An event overlaps if: ev_start <= to_date AND (ev_end >= from_date OR ev_end is empty)
if to_date and ev_start and ev_start > to_date:
continue
if from_date and ev_end and ev_end < from_date:
continue
# If ev_end is empty (point event), include if start is within [-6 months, to_date]
if from_date and not ev_end:
# Allow events whose start is up to 6 months before the chart window
import datetime
try:
start_dt = datetime.date.fromisoformat(ev_start)
from_dt = datetime.date.fromisoformat(from_date)
if (from_dt - start_dt).days > 180:
continue
except Exception:
pass
# Keyword / asset relevance
ev_name = (ev.get("name") or ev.get("event_name") or "").lower()
@@ -614,11 +651,34 @@ async def get_snapshot(
logger.warning(f"[instrument_service] Event filtering error: {e}")
events = []
# Macro regime (global cycle: goldilocks / stagflation / recession / etc.)
macro_regime: Dict[str, Any] = {}
try:
from services.data_fetcher import get_macro_gauges, score_macro_scenarios
gauges = get_macro_gauges()
macro_raw = score_macro_scenarios(gauges)
dominant = macro_raw.get("dominant", "incertain")
meta = macro_raw.get("meta", {})
ranked = macro_raw.get("ranked", [])
macro_regime = {
"dominant": dominant,
"label": meta.get(dominant, {}).get("label", dominant) if isinstance(meta, dict) else dominant,
"color": meta.get(dominant, {}).get("color", "#6b7280") if isinstance(meta, dict) else "#6b7280",
"emoji": meta.get(dominant, {}).get("emoji", "🌍") if isinstance(meta, dict) else "🌍",
"scores": macro_raw.get("scores", {}),
"ranked": ranked[:5],
"asset_bias": (macro_raw.get("asset_bias") or {}).get(dominant, {}),
}
except Exception as _me:
logger.warning(f"[instrument_service] Macro regime fetch error: {_me}")
macro_regime = {"dominant": "incertain", "label": "Incertain", "scores": {}}
return {
"instrument": config,
"price_data": price_data,
"indicators": indicators,
"regime": regime,
"macro_regime": macro_regime,
"trend": trend,
"events": events,
"current_price": current_price,