"""Market-breadth early-warning indicator (from the stored universe OHLCV). 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. 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. 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. """ 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_from_closes( closes_by_symbol: dict[str, Series], window: int = 200, min_tickers: int = 20 ) -> dict[date, float]: """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 return { d: round(above / total * 100.0, 2) for d, (above, total) in counts.items() if total >= min_tickers } 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. 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. """ 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 raw = price_ret - breadth_chg # price up & breadth down -> large score = 50.0 + raw * 2.0 + (50.0 - breadth[d]) * 0.4 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)) 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 ) -> dict[date, float]: """Historical breadth series across the stored universe (for the event study).""" closes_by_symbol = await _load_universe_closes(db) return _breadth_from_closes(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)]