feat: replace regime monitor with v2 methodology

This commit is contained in:
2026-07-15 09:02:56 +02:00
parent fd21067a40
commit 1d5b1489be
17 changed files with 1599 additions and 1535 deletions
+50 -21
View File
@@ -1,18 +1,19 @@
"""Market-breadth early-warning indicator (from the stored universe OHLCV).
"""Market-breadth state and early-warning indicators.
Breadth is a genuinely *leading* construct: a few mega-caps can keep an index
rising while participation narrows underneath — the classic pre-top divergence.
We measure it from the OHLCV we already store for the whole universe, so it costs
no new data source.
V2 measures an explicit, frozen basket rather than every ticker currently stored
in the database. That keeps the live series reproducible when the wider product
universe changes.
Two layers:
- breadth = % of the universe trading above its own 200-DMA (0-100).
- divergence = an early-warning score (0-100, high = fragile): the benchmark
price rising *while* breadth falls, plus a nudge for already-low breadth.
price holding/rising *while* breadth falls. Absolute low breadth stays in the
State index so it is not counted twice.
This module only *computes* the indicator. It is deliberately NOT wired into the
live regime index yet — the event study measures whether it actually leads before
it earns any weight.
The live monitor uses the breadth level in State and the pure divergence in
Warning. The event study evaluates the latter chronologically.
"""
from __future__ import annotations
@@ -31,9 +32,9 @@ logger = logging.getLogger(__name__)
Series = list[tuple[date, float]]
def _breadth_from_closes(
def _breadth_with_counts(
closes_by_symbol: dict[str, Series], window: int = 200, min_tickers: int = 20
) -> dict[date, float]:
) -> tuple[dict[date, float], dict[date, int]]:
"""Pure core: % of symbols above their own rolling SMA(window), per date.
Each symbol's SMA is computed once with a sliding sum (O(bars)); dates with
@@ -55,11 +56,20 @@ def _breadth_from_closes(
entry[1] += 1
if closes[i] > sma:
entry[0] += 1
return {
values = {
d: round(above / total * 100.0, 2)
for d, (above, total) in counts.items()
if total >= min_tickers
}
eligible = {d: total for d, (_, total) in counts.items() if total >= min_tickers}
return values, eligible
def _breadth_from_closes(
closes_by_symbol: dict[str, Series], window: int = 200, min_tickers: int = 20
) -> dict[date, float]:
"""Compatibility wrapper returning only the breadth percentage series."""
return _breadth_with_counts(closes_by_symbol, window, min_tickers)[0]
def compute_divergence_series(
@@ -67,10 +77,10 @@ def compute_divergence_series(
) -> dict[date, float]:
"""Early-warning score (0-100, high = fragile) per date.
Fragility rises when the benchmark price climbs over ``lookback`` days while
breadth deteriorates over the same window, and is nudged up when the absolute
breadth level is already low. It is the *divergence* (not the level) that
makes this leading.
This is deliberately a pure divergence: it is positive only when benchmark
price holds/rises while breadth falls. Absolute low breadth belongs in the
State score, so it is not counted again here. A 20 percentage-point breadth
deterioration maps to 100.
"""
bench = {d: c for d, c in benchmark_closes}
common = sorted(d for d in bench if d in breadth)
@@ -82,14 +92,19 @@ def compute_divergence_series(
continue
price_ret = (bench[d] / price_past - 1.0) * 100.0 # %
breadth_chg = breadth[d] - breadth[d0] # percentage points
raw = price_ret - breadth_chg # price up & breadth down -> large
score = 50.0 + raw * 2.0 + (50.0 - breadth[d]) * 0.4
deterioration = max(0.0, -breadth_chg)
score = deterioration * 5.0 if price_ret >= 0 else 0.0
out[d] = max(0.0, min(100.0, round(score, 2)))
return out
async def _load_universe_closes(db: AsyncSession) -> dict[str, Series]:
result = await db.execute(select(Ticker).order_by(Ticker.symbol))
async def _load_universe_closes(
db: AsyncSession, symbols: list[str] | None = None
) -> dict[str, Series]:
stmt = select(Ticker).order_by(Ticker.symbol)
if symbols is not None:
stmt = stmt.where(Ticker.symbol.in_(symbols))
result = await db.execute(stmt)
closes_by_symbol: dict[str, Series] = {}
for ticker in result.scalars().all():
try:
@@ -103,13 +118,27 @@ async def _load_universe_closes(db: AsyncSession) -> dict[str, Series]:
async def compute_breadth_series(
db: AsyncSession, window: int = 200, min_tickers: int = 20
db: AsyncSession,
window: int = 200,
min_tickers: int = 20,
symbols: list[str] | None = None,
) -> dict[date, float]:
"""Historical breadth series across the stored universe (for the event study)."""
closes_by_symbol = await _load_universe_closes(db)
"""Historical breadth series across an explicit basket (or all stored names)."""
closes_by_symbol = await _load_universe_closes(db, symbols)
return _breadth_from_closes(closes_by_symbol, window, min_tickers)
async def compute_breadth_details(
db: AsyncSession,
symbols: list[str],
window: int = 200,
min_tickers: int = 20,
) -> tuple[dict[date, float], dict[date, int]]:
"""Breadth values plus the qualifying-member count for snapshot metadata."""
closes_by_symbol = await _load_universe_closes(db, symbols)
return _breadth_with_counts(closes_by_symbol, window, min_tickers)
async def compute_breadth_today(db: AsyncSession) -> float | None:
"""Latest breadth reading (thin wrapper, for future live use)."""
series = await compute_breadth_series(db)