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>
This commit is contained in:
@@ -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,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user