Files
signal-platform/app/services/momentum_service.py
T
dennisthiessen 605f95098c
Deploy / lint (push) Successful in 6s
Deploy / test (push) Successful in 47s
Deploy / deploy (push) Successful in 24s
momentum gate: long-only + wire the percentile onto live setups
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>
2026-06-24 07:07:38 +02:00

64 lines
2.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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 (0100, 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