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:
@@ -0,0 +1,71 @@
|
||||
"""Unit tests for the cross-sectional 12-1 momentum ranking."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, timedelta
|
||||
|
||||
import pytest
|
||||
|
||||
from app.models.ohlcv import OHLCVRecord
|
||||
from app.models.ticker import Ticker
|
||||
from app.services import momentum_service as ms
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def session():
|
||||
from tests.conftest import _test_session_factory
|
||||
|
||||
async with _test_session_factory() as s:
|
||||
yield s
|
||||
|
||||
|
||||
async def _seed(session, symbol: str, rate: float, n: int = 280) -> None:
|
||||
t = Ticker(symbol=symbol)
|
||||
session.add(t)
|
||||
await session.flush()
|
||||
base = date(2024, 1, 1)
|
||||
for i in range(n):
|
||||
close = 100.0 * (rate ** i)
|
||||
session.add(OHLCVRecord(
|
||||
ticker_id=t.id,
|
||||
date=base + timedelta(days=i),
|
||||
open=close, high=close, low=close, close=close,
|
||||
volume=1_000_000,
|
||||
))
|
||||
await session.commit()
|
||||
|
||||
|
||||
def test_compute_momentum_insufficient_history():
|
||||
assert ms.compute_12_1_momentum([100.0] * 100) is None
|
||||
|
||||
|
||||
def test_compute_momentum_value():
|
||||
closes = [100.0 * (1.01 ** i) for i in range(300)]
|
||||
m = ms.compute_12_1_momentum(closes)
|
||||
# 12-1 momentum skips the last month: close[-22] / close[-253] - 1
|
||||
assert m == closes[-22] / closes[-253] - 1.0
|
||||
assert m > 0
|
||||
|
||||
|
||||
async def test_ranks_universe_into_percentiles(session):
|
||||
await _seed(session, "HIGH", rate=1.010) # strong uptrend → top momentum
|
||||
await _seed(session, "MID", rate=1.002)
|
||||
await _seed(session, "LOW", rate=0.999) # declining → bottom momentum
|
||||
|
||||
pct = await ms.compute_momentum_percentiles(session)
|
||||
assert pct["HIGH"] == 100.0
|
||||
assert pct["MID"] == 50.0
|
||||
assert pct["LOW"] == 0.0
|
||||
|
||||
|
||||
async def test_short_history_ticker_is_unranked(session):
|
||||
await _seed(session, "LONG", rate=1.005)
|
||||
await _seed(session, "SHORTHX", rate=1.005, n=100) # < 1y → no momentum
|
||||
|
||||
pct = await ms.compute_momentum_percentiles(session)
|
||||
assert "LONG" in pct
|
||||
assert "SHORTHX" not in pct
|
||||
|
||||
|
||||
async def test_empty_universe_returns_empty(session):
|
||||
assert await ms.compute_momentum_percentiles(session) == {}
|
||||
@@ -90,6 +90,15 @@ class TestMomentumGate:
|
||||
legacy = {k: v for k, v in DEFAULT_GATE.items() if k != "min_momentum_percentile"}
|
||||
assert setup_qualifies(_setup(momentum_percentile=10.0), legacy) is True
|
||||
|
||||
def test_short_excluded_when_gate_active(self):
|
||||
# The momentum edge is long-only — a short never qualifies while the gate
|
||||
# is on, even on a top-momentum name.
|
||||
assert setup_qualifies(_setup(direction="short", momentum_percentile=95.0), MOMENTUM_GATE) is False
|
||||
|
||||
def test_short_allowed_when_gate_off(self):
|
||||
# With the momentum gate disabled, shorts pass on the floors as before.
|
||||
assert setup_qualifies(_setup(direction="short", momentum_percentile=10.0), DEFAULT_GATE) is True
|
||||
|
||||
|
||||
class TestStrictTighteners:
|
||||
def test_clean_high_conviction_passes(self):
|
||||
|
||||
Reference in New Issue
Block a user