+ );
+}
+
+function AdminControls() {
+ const qc = useQueryClient();
+ const fundamentals = useQuery({ queryKey: ['regime', 'fundamentals'], queryFn: getRegimeFundamentals });
+ const config = useQuery({ queryKey: ['regime', 'config'], queryFn: getRegimeConfig });
+
+ const invalidate = () => qc.invalidateQueries({ queryKey: ['regime'] });
+ const refresh = useMutation({ mutationFn: refreshRegimeFundamentals, onSuccess: invalidate });
+ const saveFund = useMutation({ mutationFn: updateRegimeFundamentals, onSuccess: invalidate });
+ const saveConfig = useMutation({ mutationFn: updateRegimeConfig, onSuccess: invalidate });
+
+ return (
+
+
+
+ {monitor.isLoading && (
+ <>
+
+
+ >
+ )}
+
+ {monitor.isError && (
+
monitor.refetch()}>
+ Failed to load: {(monitor.error as Error).message}
+
+ )}
+
+ {monitor.data && !monitor.data.available && (
+
+ Not computed yet — run the “Regime Monitor” job from Admin → Jobs, or wait for the daily pipeline.
+
+ )}
+
+ {monitor.data && monitor.data.available && (
+ <>
+
+ {monitor.data.breakdown &&
}
+ >
+ )}
+
+ {isAdmin &&
}
+
+ );
+}
diff --git a/regime-monitor-anforderungen.md b/regime-monitor-anforderungen.md
new file mode 100644
index 0000000..03b2f32
--- /dev/null
+++ b/regime-monitor-anforderungen.md
@@ -0,0 +1,73 @@
+# Anforderungsdokument — "AI/Tech Regime Change Monitor"
+
+**Ziel:** Ein persönliches Hobby-Tool, das fundamentale *und* kursbasierte Signale überwacht und einen einzigen Wert von **0–100** ausgibt: die geschätzte Wahrscheinlichkeit, dass das KI/Tech-Bullenregime in eine Neubewertung kippt.
+**Zweck:** Disziplinierte Ausstiegs-Entscheidung für spekulative Einzelpositionen (NVDA, MSFT). **Kein** Auto-Trading, **keine** Anlageberatung, **keine** Timing-Garantie.
+
+---
+
+## 1. Scope
+
+- **Beobachtete Instrumente:** SMH (Halbleiter, *schnelles* Frühsignal) + QQQ (breiter, *Bestätigung*) als Regime-Sensoren; SPY, RSP (Marktbreite-Kontext); VIX (Volatilität); Hyperscaler GOOGL, AMZN, META, MSFT (Capex-Signal). Bewusst **keine** Einzelaktien-Trades — das Tool misst das *Regime*, nicht einzelne Titel.
+- **Optionaler "Kanarienvogel":** NVDA als reiner Frühindikator-Input (Lead-Aktie des Sektors, dreht oft vor SMH) — abschaltbar, **keine** Entscheidungsposition.
+- **Read-only.** Tool gibt nur einen Score + Aufschlüsselung aus, führt keine Orders aus.
+- **Lauf-Kadenz:** Kurssignale täglich, Fundamentalsignale quartalsweise (bzw. bei Earnings).
+
+## 2. Output
+
+- **Gesamtscore 0–100** (0 = Regime stabil, 100 = Bruch im Gange) mit Label-Band:
+ - 0–30 stabil · 30–60 beobachten · 60–80 erhöht · 80–100 Bruch sichtbar
+- **Aufschlüsselung pro Signal** (Sub-Score 0–100 + Gewicht + Beitrag).
+- **Trend:** Veränderung des Gesamtscores über 7 und 30 Tage (steigend/fallend).
+- Optional: einfacher Alert, wenn Gesamtscore eine konfigurierbare Schwelle (Default 65) überschreitet.
+
+## 3. Signale
+
+Jedes Signal liefert einen Sub-Score 0–100 (0 = gesund, 100 = Regime bricht). Gewichte in `config` editierbar.
+
+### Kursbasiert (automatisierbar, täglich)
+Grundprinzip: **SMH ist das führende Signal, QQQ die Bestätigung.** Wo beide eingehen, zählt SMH stärker (Default 2:1), damit du Frühwarnung *und* Filter gegen Fehlalarme hast.
+
+| ID | Signal | Logik (Sub-Score 0→100) | Default-Gewicht |
+|----|--------|--------------------------|-----------------|
+| P1 | Trendbruch 200-Tage-MA | Gewichteter Anteil unter der 200-Tage-MA: SMH zählt doppelt, QQQ einfach | 12 |
+| P2 | Death Cross + Slope | 50-Tage-MA unter 200-Tage-MA und 200er-Slope negativ (graduell nach Abstand), SMH führend | 8 |
+| P3 | Drawdown vom 52W-Hoch | max(SMH, QQQ)-Drawdown: 0 % → 0, ≥ 20 % → 100 (linear) | 10 |
+| P4 | Relative Stärke Tech | Trend des Verhältnisses SMH/SPY (Tech underperformt → höher) | 8 |
+| P5 | Volatilität | VIX: ≤ 15 → 0, ≥ 30 → 100 (linear) | 7 |
+| P6 | *Optional:* Kanarienvogel NVDA | NVDA unter 50-Tage-MA bei gleichzeitig noch intaktem SMH (Lead-Divergenz) → Frühwarnung; abschaltbar | 0 (opt. 5) |
+
+### Fundamental (teils manuell, quartalsweise)
+| ID | Signal | Logik (Sub-Score 0→100) | Default-Gewicht |
+|----|--------|--------------------------|-----------------|
+| F1 | Hyperscaler-Capex-Guidance | Manuelle Eingabe je Name: anhebend = 0, haltend = 50, kürzend = 100; Mittel über die 4 | 25 |
+| F2 | Kreditspreads | US High-Yield OAS (FRED `BAMLH0A0HYM2`): Perzentil der letzten 3 J → Score; Ausweitung = höher | 15 |
+| F3 | Earnings-Reaktion | "Good news, stock down": fielen Hyperscaler/SMH im Schnitt trotz Gewinn-Beats nach den letzten Earnings? (Reaktion ±2 Tage, auto oder manuell) | 8 |
+| F4 | Marktbreite | Trend RSP/SPY (gleichgewichtet schlägt kapgewichtet bei Tech-Schwäche → Verschlechterung der Breite → höher) | 7 |
+
+**Gesamtscore = Σ(Sub-Score × Gewicht) / Σ(Gewichte).** Summe Defaults = 100.
+
+## 4. Datenquellen (Vorschlag, alle frei)
+
+- **Kurse/MA/Drawdown/VIX:** `yfinance` (Yahoo Finance). Alternativ deine IBKR-API.
+- **Kreditspreads:** FRED-API (`BAMLH0A0HYM2`), kostenloser API-Key.
+- **Capex-Guidance (F1):** manuell pflegbar in `signals.yaml` (4 Werte/Quartal). Keine zuverlässige Gratis-API; bewusst manuell.
+- **Earnings-Termine/-Reaktion (F3):** `yfinance` earnings dates + Kursreaktion, optional manuell.
+
+## 5. Konfiguration
+
+- `config.yaml`: Gewichte je Signal, Alert-Schwelle, Tickerlisten, Lookback-Fenster.
+- `signals.yaml`: manuelle Eingaben (F1, optional F3).
+- Alle Schwellen/Gewichte ohne Code-Änderung anpassbar.
+
+## 6. Tech-Vorschlag (optional)
+
+- **Python** + `pandas` + `yfinance` + `requests` (FRED) + `pyyaml`.
+- Ausgabe als **CLI-Report** (Tabelle + Gesamtscore) und/oder kleines **Streamlit**-Dashboard mit Gauge + Verlaufschart.
+- Lokal lauffähig, ein `python monitor.py` reicht; Verlauf in lokaler CSV/SQLite für 7/30-Tage-Trend.
+
+## 7. Explizite Nicht-Ziele / Grenzen
+
+- Sagt **keinen** exakten Zeitpunkt voraus; ein hoher Score ≠ garantierter Crash.
+- Die Gewichte sind subjektiv (Garbage-in → Garbage-out): Default ist ein Startpunkt, kein Optimum.
+- Das eindeutige Signal kommt oft erst mit dem Einbruch — das Tool *senkt* die Reaktionszeit, eliminiert sie nicht.
+- Reines Informations-/Disziplin-Werkzeug, keine Finanzberatung.
diff --git a/tests/unit/test_regime_monitor.py b/tests/unit/test_regime_monitor.py
new file mode 100644
index 0000000..1aec47d
--- /dev/null
+++ b/tests/unit/test_regime_monitor.py
@@ -0,0 +1,139 @@
+"""Unit tests for the regime-monitor pure functions and aggregation."""
+
+from __future__ import annotations
+
+from datetime import date, timedelta
+
+from app.services.regime_monitor_service import (
+ DEFAULT_CONFIG,
+ band_for,
+ compute_regime_score,
+ f2_credit_spreads,
+ p1_trend_break,
+ p2_death_cross,
+ p3_drawdown,
+ p4_relative_strength,
+ p5_volatility,
+ p6_canary,
+ _compute_index,
+)
+
+
+def _dated(values: list[float], end: date = date(2026, 6, 26)) -> list[tuple[date, float]]:
+ n = len(values)
+ return [(end - timedelta(days=(n - 1 - i)), v) for i, v in enumerate(values)]
+
+
+# ---------------------------------------------------------------------------
+# Bands
+# ---------------------------------------------------------------------------
+
+def test_band_for():
+ assert band_for(10) == "stable"
+ assert band_for(45) == "watch"
+ assert band_for(70) == "elevated"
+ assert band_for(90) == "breaking"
+
+
+# ---------------------------------------------------------------------------
+# Price sub-scores
+# ---------------------------------------------------------------------------
+
+def test_p1_blends_leader_double():
+ smh_under = [100.0] * 199 + [50.0] # last below its 200-DMA
+ qqq_above = [100.0] * 200 # last at/above its 200-DMA -> healthy
+ score = p1_trend_break(smh_under, qqq_above, leader_weight=2.0)
+ # leader(100) weighted 2, confirm(0) weighted 1 -> 66.7
+ assert round(score, 1) == 66.7
+
+
+def test_p1_none_without_history():
+ assert p1_trend_break([100.0] * 50, [100.0] * 50, 2.0) is None
+
+
+def test_p2_death_cross_bearish_vs_healthy():
+ bearish = [300.0 - i for i in range(260)] # falling: 50 < 200, slope down
+ healthy = [100.0 + i * 0.5 for i in range(260)] # rising: 50 > 200
+ assert p2_death_cross(bearish, bearish, 2.0) > 0
+ assert p2_death_cross(healthy, healthy, 2.0) == 0
+
+
+def test_p3_drawdown_linear():
+ closes = [100.0] * 252 + [80.0] # 20% below the 52w high -> 100
+ assert p3_drawdown(closes, [100.0] * 253) == 100.0
+
+
+def test_p4_relative_strength_direction():
+ falling = [100.0 - i * 0.5 for i in range(70)] # SMH underperforms flat SPY
+ rising = [100.0 + i * 0.5 for i in range(70)]
+ spy = [100.0] * 70
+ assert p4_relative_strength(falling, spy, 60) > 50
+ assert p4_relative_strength(rising, spy, 60) < 50
+
+
+def test_p5_volatility_linear():
+ assert p5_volatility(15) == 0
+ assert p5_volatility(30) == 100
+ assert p5_volatility(22.5) == 50
+ assert p5_volatility(None) is None
+
+
+def test_f2_credit_percentile():
+ rising = [float(i) for i in range(1, 31)] # latest is the max -> ~100th pct
+ assert f2_credit_spreads(rising) == 100.0
+ falling = [float(i) for i in range(30, 0, -1)] # latest is the min
+ assert f2_credit_spreads(falling) < 10
+ assert f2_credit_spreads([1.0] * 5) is None # too short
+
+
+def test_p6_canary_divergence():
+ nvda_weak = [100.0] * 49 + [80.0] # below its 50-DMA
+ smh_intact = [100.0] * 199 + [120.0] # above its 200-DMA
+ assert p6_canary(nvda_weak, smh_intact) == 100.0
+ assert p6_canary([100.0] * 50, smh_intact) == 0.0
+
+
+# ---------------------------------------------------------------------------
+# Aggregation
+# ---------------------------------------------------------------------------
+
+def test_compute_regime_score_excludes_na_and_zero_weight():
+ weights = {"P1": 10, "P2": 0, "F2": 5}
+ subs = {"P1": 80.0, "P2": 50.0, "F2": None}
+ result = compute_regime_score(subs, weights)
+ # Only P1 counts: P2 weight 0, F2 unavailable.
+ assert result["total_score"] == 80.0
+ ids = {row["id"]: row for row in result["breakdown"]}
+ assert "P2" not in ids # zero-weight signals are hidden
+ assert ids["F2"]["available"] is False
+ assert ids["P1"]["contribution"] == 80.0
+
+
+def test_compute_regime_score_contributions_sum_to_total():
+ weights = {"P1": 10, "F2": 10}
+ subs = {"P1": 80.0, "F2": 40.0}
+ result = compute_regime_score(subs, weights)
+ assert result["total_score"] == 60.0
+ total = sum(row["contribution"] for row in result["breakdown"])
+ assert round(total, 1) == 60.0
+
+
+# ---------------------------------------------------------------------------
+# As-of index replay (backfill mechanics)
+# ---------------------------------------------------------------------------
+
+def test_compute_index_as_of_truncates_history():
+ rising = [100.0 + i * 0.2 for i in range(260)]
+ prices = {sym: _dated(rising) for sym in ("SMH", "QQQ", "SPY", "RSP", "NVDA")}
+ overrides = {"f1_score": 50.0, "f3_score": 50.0}
+
+ full = _compute_index(prices, None, None, overrides, DEFAULT_CONFIG, date(2026, 6, 26))
+ by_id = {r["id"]: r for r in full["breakdown"]}
+ assert by_id["P1"]["available"] is True # 200-DMA computable on full history
+ assert 0 <= full["total_score"] <= 100
+ assert full["band"] in {"stable", "watch", "elevated", "breaking"}
+
+ # As-of 250 days earlier: only ~10 bars are in scope -> long-lookback signals n/a.
+ early = _compute_index(prices, None, None, overrides, DEFAULT_CONFIG, date(2026, 6, 26) - timedelta(days=250))
+ early_by_id = {r["id"]: r for r in early["breakdown"]}
+ assert early_by_id["P1"]["available"] is False
diff --git a/tests/unit/test_scheduler.py b/tests/unit/test_scheduler.py
index 68164e0..5541ee7 100644
--- a/tests/unit/test_scheduler.py
+++ b/tests/unit/test_scheduler.py
@@ -87,6 +87,7 @@ class TestConfigureScheduler:
"outcome_evaluator",
"alerts",
"market_regime",
+ "regime_monitor",
"backtest",
"daily_pipeline",
"intraday_pipeline",
@@ -107,6 +108,7 @@ class TestConfigureScheduler:
"data_backfill",
"fundamental_collector",
"market_regime",
+ "regime_monitor",
"outcome_evaluator",
"rr_scanner",
"sentiment_collector",