Scan of every module and exported symbol, with each candidate verified by hand
rather than trusted from the scan.
Deleted outright:
frontend/src/lib/fundamentals.ts (112 lines, 12 exports) — imported by
nothing, including FundamentalsPanel, which reads backend values. It mirrors
scoring_service._compute_fundamental_score, so it is the same *kind* of
thing as lib/qualification.ts — but nothing consumes it, so it mirrored
nothing and could drift out of sync unnoticed.
Skeleton.SkeletonLine, paperTrades.getEquityCurve, regime.regimeColor
breadth_service.compute_breadth_today — self-described "thin wrapper, for
future live use"; that future did not arrive.
Kept, but unexported — used inside their own module, so the dead part was the
public surface, not the code: Button.Spinner, exitPlan.SETUP_STOP_ATR_MULTIPLIER,
client.ApiError.
Three things the scan flagged that are NOT dead, recorded so the next sweep does
not re-raise them:
RegimeChart.tsx — lazy(() => import(...)) in RegimePage, so it looks orphaned
to any importer-graph scan. Deleting it would break the risk page.
qualification.ts MIN_TARGET_PROBABILITY / liveRiskReward — that file is a live
mirror of app/services/qualification.py used in five places, and the
constant is exported to document the backend value it tracks.
ssl_bootstrap.ssl_status — called from an inline python snippet inside
scripts/run_tier1_macbook.sh, invisible to a .py-only search.
No orphaned backend modules across app/.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
151 lines
5.9 KiB
Python
151 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.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)
|