"""Cross-sectional 12-1 momentum ranking for the universe. The activation gate selects the top ``min_momentum_percentile`` of the universe by 12-1 month momentum (return from ~12 months ago to ~1 month ago — the one price signal the backtest showed sorts forward returns). The daily scan ranks every ticker and stores each setup's percentile (see ``rr_scanner_service``), so the live list, the Track Record's qualified stats, and outcome evaluation all gate on the same value. """ from __future__ import annotations import json import logging 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__) # 12-1 momentum: ~12 months of daily history (252 bars) with the last ~1 month # (21 bars) skipped. Matches the backtest's _signal_values / _window_setups. _MOM_LOOKBACK = 252 _MOM_SKIP = 21 def compute_12_1_momentum(closes: list[float]) -> float | None: """Return over the window ending ~1 month ago, starting ~12 months ago. None when there isn't a full year of history.""" if len(closes) >= _MOM_LOOKBACK + 1 and closes[-(_MOM_LOOKBACK + 1)] > 0: return closes[-(_MOM_SKIP + 1)] / closes[-(_MOM_LOOKBACK + 1)] - 1.0 return None async def compute_momentum_percentiles(db: AsyncSession) -> dict[str, float]: """Compute each ticker's 12-1 momentum and rank the universe into a ``{symbol: percentile}`` map (0–100, 100 = strongest momentum). Tickers without a full year of history are absent (can't be ranked).""" result = await db.execute(select(Ticker).order_by(Ticker.symbol)) tickers = list(result.scalars().all()) momentum: dict[str, float] = {} for ticker in tickers: try: records = await query_ohlcv(db, ticker.symbol) except Exception: logger.exception("Momentum fetch failed for %s", ticker.symbol) continue m = compute_12_1_momentum([float(r.close) for r in records]) if m is not None: momentum[ticker.symbol] = m ranked = sorted(momentum, key=lambda s: momentum[s]) n = len(ranked) percentiles = { sym: round((rank / (n - 1) * 100.0) if n > 1 else 100.0, 2) for rank, sym in enumerate(ranked) } logger.info(json.dumps({"event": "momentum_ranked", "tickers": n})) return percentiles