momentum gate: long-only + wire the percentile onto live setups
Deploy / lint (push) Successful in 6s
Deploy / test (push) Successful in 47s
Deploy / deploy (push) Successful in 24s

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>
This commit is contained in:
2026-06-24 07:07:38 +02:00
parent 7060b9a019
commit 605f95098c
10 changed files with 221 additions and 11 deletions
+4 -1
View File
@@ -580,11 +580,14 @@ def _assign_momentum_percentiles(candidates: list[dict]) -> None:
def _momentum_qualifies(cand: dict, threshold: float) -> bool:
"""Whether a candidate clears the floors (meets_core) and the momentum gate.
Threshold 0 disables the momentum gate (floors only)."""
Threshold 0 disables the momentum gate (floors only). The gate is long-only:
while it's active, shorts (fighting the trend) never qualify."""
if not cand["meets_core"]:
return False
if threshold <= 0:
return True
if cand["direction"] == "short":
return False
mp = cand.get("momentum_percentile")
return mp is not None and mp >= threshold
+63
View File
@@ -0,0 +1,63 @@
"""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
+7 -3
View File
@@ -67,11 +67,15 @@ def setup_qualifies(setup: Any, config: dict) -> bool:
if (setup.confidence_score or 0.0) < config["min_confidence"]:
return False
# Cross-sectional momentum: the core selection. A setup's ticker must rank in
# the top ``min_momentum_percentile`` of the universe by 12-1 momentum. Only
# enforced when a percentile is attached (live setups / backtest) and a
# threshold is set; callers that don't attach it defer to the floors above.
# the top ``min_momentum_percentile`` of the universe by 12-1 momentum. The
# validated edge is long-only, so while the gate is active shorts (which fight
# the trend) never qualify. The percentile floor is only enforced when a
# percentile is attached (live setups / backtest); callers that don't attach
# it defer to the floors above.
min_pct = float(config.get("min_momentum_percentile", 0.0))
if min_pct > 0:
if (getattr(setup, "direction", "long") or "long") == "short":
return False
momentum_percentile = getattr(setup, "momentum_percentile", None)
if momentum_percentile is not None and momentum_percentile < min_pct:
return False
+21 -2
View File
@@ -81,8 +81,13 @@ async def scan_ticker(
symbol: str,
rr_threshold: float = 1.5,
atr_multiplier: float = 1.5,
momentum_percentile: float | None = None,
) -> list[TradeSetup]:
"""Scan a single ticker for trade setups meeting the R:R threshold."""
"""Scan a single ticker for trade setups meeting the R:R threshold.
``momentum_percentile`` is the ticker's 12-1 momentum rank across the universe
(computed by the caller), stored on each setup so the activation gate can
select the top slice."""
ticker = await _get_ticker(db, symbol)
records = await query_ohlcv(db, symbol)
@@ -169,6 +174,7 @@ async def scan_ticker(
rr_ratio=round(best_candidate_rr, 4),
composite_score=round(composite_score, 4),
detected_at=now,
momentum_percentile=momentum_percentile,
))
if levels_below:
@@ -202,6 +208,7 @@ async def scan_ticker(
rr_ratio=round(best_candidate_rr, 4),
composite_score=round(composite_score, 4),
detected_at=now,
momentum_percentile=momentum_percentile,
))
available_directions = {s.direction for s in setups}
@@ -249,6 +256,16 @@ async def scan_all_tickers(
tickers = list(result.scalars().all())
total = len(tickers)
# Rank the universe by 12-1 momentum up front so each new setup carries its
# ticker's percentile (used by the activation gate). Best-effort.
try:
from app.services import momentum_service
percentiles = await momentum_service.compute_momentum_percentiles(db)
except Exception:
logger.exception("Momentum ranking refresh failed")
percentiles = {}
all_setups: list[TradeSetup] = []
for index, ticker in enumerate(tickers):
if progress_callback is not None:
@@ -267,7 +284,8 @@ async def scan_all_tickers(
logger.exception("Error refreshing scores for %s", ticker.symbol)
setups = await scan_ticker(
db, ticker.symbol, rr_threshold, atr_multiplier
db, ticker.symbol, rr_threshold, atr_multiplier,
momentum_percentile=percentiles.get(ticker.symbol),
)
all_setups.extend(setups)
except Exception:
@@ -410,4 +428,5 @@ def _trade_setup_to_dict(setup: TradeSetup, symbol: str, current_price: float |
"outcome_date": setup.outcome_date,
"evaluated_at": setup.evaluated_at,
"current_price": current_price,
"momentum_percentile": setup.momentum_percentile,
}