605f95098c
Part 1 — long-only. The momentum edge is long top-momentum; the gate was
qualifying shorts on high-momentum names (fighting the trend), which showed as
the -0.13R Short(qual.) drag. While the gate is active, shorts no longer qualify
(backend qualification, backtest _momentum_qualifies, and the frontend mirror).
Part 2 — production wiring. Live setups now carry a real momentum rank, so the
dashboard, the Track Record's qualified stats, and outcome evaluation all gate on
the same value instead of deferring to floors:
- new momentum_service.compute_momentum_percentiles: 12-1 momentum per ticker,
ranked across the universe into a {symbol: percentile} map.
- the daily R:R scan ranks the universe up front and stores each setup's
percentile (new trade_setups.momentum_percentile column, migration 010).
- enhance_trade_setup mutates the same row, so the percentile is preserved;
_trade_setup_to_dict + TradeSetupResponse expose it to the API.
Until a fresh scan runs, pre-existing setups have a null percentile and the gate
falls back to floors for them (longs) / excludes them (shorts) — they fill in on
the next scan. 341 backend tests pass; frontend build clean.
Needs the alembic upgrade (migration 010) on deploy.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
64 lines
2.4 KiB
Python
64 lines
2.4 KiB
Python
"""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
|