feat: replace regime monitor with v2 methodology
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user