The LLM-sourced capex/earnings observations carried 12+8 of 100 Warning points, so both pegged at 100 produced a Warning of 20.0 -- below the event study's 25.3 alarm threshold and still inside the "stable" band. The reading was arithmetically incapable of changing anything on screen, which is why refreshing it appeared to do nothing. They are now a qualitative overlay reported beside the scores rather than diluted into them. Calibrated against the 408 v2 sessions to 2026-07-24, reproduced offline from Alpaca + FRED; the harness matched the stored prod distribution exactly before any parameter was changed. State: - P3 used dd_pct * 5, reaching 100 at a 20% drawdown -- the 90th percentile of the observed distribution -- so 39/408 sessions sat at exactly 100 with no resolution left during the part of a selloff that matters most. Replaced with anchored breakpoints keeping headroom past the observed 36% maximum, blended 2:1 like P1/P2 instead of max(). P3's realized share of State falls from 65% to 40%, matching its nominal weight. - Credit level is now anchors-only. ICE capped FRED's BAMLH0A0HYM2 at a rolling 3-year window in April 2026, silently turning the 10-year percentile leg into a 3-year one that scored 20 points of stress at an OAS of 3.5 -- the level its own anchors call "mild". The anchors already encode the long-run distribution. Warning: - Added HY OAS 20-session widening (25%). The level is pinned at zero below the 3.5 anchor; its rate of change is not. - Divergence tapers to a 0.35 floor instead of a hard price_ret >= 0 gate, which zeroed the sensor through every decline: on 2026-07-24 the basket shed 10 points of participation in 20 sessions and Warning printed exactly 0. - The event study and the live monitor now share one sensor definition, so they cannot silently drift apart. Bands are per axis (State 20/50/80, Warning 20/40/60) with quadrant dividers at 50/40; v2 Warning never exceeded 64.9 against a shared 60, leaving that half of the quadrant unreachable. Realized shares: State 73/15/8/3%, Warning 69/20/8/3%. Snapshots now record credit_history_days and vix_history_days -- the percentile defect went unnoticed for months because nothing asserted the window the code claimed. Cutover: the first run rebuilds 400 sessions automatically; the Event Study job must be re-run, as its cached report self-invalidates on the methodology check. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
159 lines
6.1 KiB
Python
159 lines
6.1 KiB
Python
"""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.
|
|
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 holding/rising *while* breadth falls. Absolute low breadth stays in the
|
|
State index so it is not counted twice.
|
|
|
|
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
|
|
|
|
import logging
|
|
from datetime import date
|
|
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.models.ticker import Ticker
|
|
from app.services.price_service import query_ohlcv
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
Series = list[tuple[date, float]]
|
|
|
|
|
|
def _breadth_with_counts(
|
|
closes_by_symbol: dict[str, Series], window: int = 200, min_tickers: int = 20
|
|
) -> 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
|
|
fewer than ``min_tickers`` qualifying names are dropped (too thin to trust).
|
|
"""
|
|
counts: dict[date, list[int]] = {} # date -> [above, total]
|
|
for series in closes_by_symbol.values():
|
|
ordered = sorted(series, key=lambda x: x[0])
|
|
dates = [d for d, _ in ordered]
|
|
closes = [c for _, c in ordered]
|
|
if len(closes) < window:
|
|
continue
|
|
running = sum(closes[:window])
|
|
for i in range(window - 1, len(closes)):
|
|
if i >= window:
|
|
running += closes[i] - closes[i - window]
|
|
sma = running / window
|
|
entry = counts.setdefault(dates[i], [0, 0])
|
|
entry[1] += 1
|
|
if closes[i] > sma:
|
|
entry[0] += 1
|
|
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]
|
|
|
|
|
|
# Breadth deterioration counts fully when price masks it (true divergence, the
|
|
# dangerous pre-top case) and at CONFIRMED_FLOOR when price falls with it.
|
|
# v2 used a hard ``price_ret >= 0`` cliff, which zeroed the sensor during every
|
|
# decline -- so on 2026-07-24, with the basket shedding 10 percentage points
|
|
# above their 200-DMA in 20 sessions, Warning read exactly 0. Breadth *level*
|
|
# lives in State but breadth *velocity* appears nowhere else, so partial credit
|
|
# here is not double counting.
|
|
DIVERGENCE_CONFIRMED_FLOOR = 0.35
|
|
DIVERGENCE_TAPER_PCT = 3.0
|
|
|
|
|
|
def compute_divergence_series(
|
|
breadth: dict[date, float], benchmark_closes: Series, lookback: int = 20
|
|
) -> dict[date, float]:
|
|
"""Early-warning score (0-100, high = fragile) per date.
|
|
|
|
A 20 percentage-point breadth deterioration maps to 100 when the benchmark
|
|
is flat or rising, tapering to ``DIVERGENCE_CONFIRMED_FLOOR`` of that once
|
|
the benchmark is down ``DIVERGENCE_TAPER_PCT`` or more over the window.
|
|
"""
|
|
bench = {d: c for d, c in benchmark_closes}
|
|
common = sorted(d for d in bench if d in breadth)
|
|
out: dict[date, float] = {}
|
|
for i in range(lookback, len(common)):
|
|
d, d0 = common[i], common[i - lookback]
|
|
price_past = bench[d0]
|
|
if price_past <= 0:
|
|
continue
|
|
price_ret = (bench[d] / price_past - 1.0) * 100.0 # %
|
|
breadth_chg = breadth[d] - breadth[d0] # percentage points
|
|
deterioration = max(0.0, -breadth_chg)
|
|
taper = max(0.0, min(1.0, (price_ret + DIVERGENCE_TAPER_PCT) / DIVERGENCE_TAPER_PCT))
|
|
gate = DIVERGENCE_CONFIRMED_FLOOR + (1.0 - DIVERGENCE_CONFIRMED_FLOOR) * taper
|
|
out[d] = max(0.0, min(100.0, round(deterioration * 5.0 * gate, 2)))
|
|
return out
|
|
|
|
|
|
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:
|
|
records = await query_ohlcv(db, ticker.symbol)
|
|
except Exception:
|
|
logger.exception("Breadth: OHLCV load failed for %s", ticker.symbol)
|
|
continue
|
|
if records:
|
|
closes_by_symbol[ticker.symbol] = [(r.date, float(r.close)) for r in records]
|
|
return closes_by_symbol
|
|
|
|
|
|
async def compute_breadth_series(
|
|
db: AsyncSession,
|
|
window: int = 200,
|
|
min_tickers: int = 20,
|
|
symbols: list[str] | None = None,
|
|
) -> dict[date, float]:
|
|
"""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)
|
|
if not series:
|
|
return None
|
|
return series[max(series)]
|