Files
signal-platform/app/services/breadth_service.py
T
dennisthiessenandClaude Opus 5 6501b7e9a0 feat(tickers): record delisting instead of deleting the symbol
Retiring a symbol meant delete_ticker or bootstrap_universe(prune_missing),
both of which cascade through OHLCV, setups and scores. That destroys exactly
the history four research documents already apologise for: today's tracked
universe projected backward is survivorship-biased, and hard-deleting every
delisted name is what causes it. Keeping the rows preserves the option to fix
that — it does not fix it, which needs the replay to model a delisting as an
exit event.

tickers gains delisted_on / delisted_reason (migration 032). NULL means
actively traded.

The filter is opt-in via ticker_service.active_only rather than folded into a
shared getter: the registry and admin views deliberately keep delisted rows so
the delisting is visible, and a silent default would undo that. Applied to the
live path only — scanner, momentum ranking, scoring, breadth, fundamentals
candidates, SEC universe, earnings import, ingestion loops. run_backtest keeps
them on purpose.

Detection runs off OHLCV staleness, not off the SEC fundamentals import: that
importer stalls for days on unrelated Company-Facts gaps and would take
detection down with it. On a stale symbol the scheduler asks SEC for a Form
25/25-NSE/15 and retires it only on a hit, so a halt or a rename (SATS->ECHO)
keeps the existing warning. The probe waits 3 stale days so a market-data
outage cannot turn into one SEC request per symbol per run.

Safe to automate because it is reversible: clear_delisted un-retires a false
positive, where a delete had already taken the history.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 16:58:51 +02:00

152 lines
5.9 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 import ticker_service
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 = ticker_service.active_only(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)