promote residual momentum ranking
This commit is contained in:
@@ -118,14 +118,30 @@ def test_assigns_raw_and_residual_percentiles_independently():
|
||||
assert by_resid[0.10] == 0.0
|
||||
|
||||
|
||||
def test_activation_percentile_prefers_residual_with_raw_fallback():
|
||||
cands = [
|
||||
{"momentum_percentile": 80.0, "residual_momentum_percentile": 95.0},
|
||||
{"momentum_percentile": 70.0, "residual_momentum_percentile": None},
|
||||
]
|
||||
|
||||
bt._assign_activation_momentum_percentiles(cands)
|
||||
|
||||
assert cands[0][bt.PRODUCTION_PERCENTILE_KEY] == 95.0
|
||||
assert cands[1][bt.PRODUCTION_PERCENTILE_KEY] == 70.0
|
||||
|
||||
|
||||
def test_strategy_variants_keep_only_current_research_candidates():
|
||||
variants = {cfg["variant"]: cfg for cfg in bt.STRATEGY_VARIANTS}
|
||||
|
||||
assert "production_raw_80_fixed10" not in variants
|
||||
assert "raw_80_regime_scaled" not in variants
|
||||
assert "residual_80_regime_scaled" not in variants
|
||||
assert "residual_90_fixed10" not in variants
|
||||
assert variants["raw_90_fixed15"]["max_positions"] == 15
|
||||
assert variants["residual_80_fixed20"]["max_positions"] == 20
|
||||
assert "raw_90_fixed15" not in variants
|
||||
assert "residual_80_fixed20" not in variants
|
||||
assert variants["production_residual_80_fixed10"]["percentile_key"] == bt.PRODUCTION_PERCENTILE_KEY
|
||||
assert variants["legacy_raw_80_fixed10"]["percentile_key"] == bt.RAW_PERCENTILE_KEY
|
||||
assert variants["residual_80_fixed15"]["max_positions"] == 15
|
||||
assert all(cfg["risk_scale"] is None for cfg in bt.STRATEGY_VARIANTS)
|
||||
|
||||
|
||||
@@ -136,6 +152,7 @@ def test_strategy_variant_sims_emit_fixed_variants_without_mutating_qualified(mo
|
||||
"direction": "long",
|
||||
"momentum_percentile": 90.0,
|
||||
"residual_momentum_percentile": 91.0,
|
||||
"activation_momentum_percentile": 91.0,
|
||||
}]
|
||||
calls = []
|
||||
|
||||
@@ -168,34 +185,31 @@ def test_strategy_variant_sims_emit_fixed_variants_without_mutating_qualified(mo
|
||||
|
||||
assert [r["variant"] for r in rows] == [cfg["variant"] for cfg in bt.STRATEGY_VARIANTS]
|
||||
assert all(call["exit_policy"] == "hold" for call in calls)
|
||||
assert any(call["ranking_key"] == "residual_momentum_percentile" for call in calls)
|
||||
assert any(call["max_positions"] == 20 for call in calls)
|
||||
assert any(call["ranking_key"] == bt.PRODUCTION_PERCENTILE_KEY for call in calls)
|
||||
assert any(call["ranking_key"] == bt.RAW_PERCENTILE_KEY for call in calls)
|
||||
assert any(call["max_positions"] == 15 for call in calls)
|
||||
assert cands[0]["qualified"] is False
|
||||
|
||||
|
||||
def test_build_research_recommendation_applies_promotion_rules():
|
||||
report = {
|
||||
"strategy_variants": {"variants": [
|
||||
{"variant": "production_raw_80_fixed10", "label": "Base", "sharpe": 1.20,
|
||||
"max_drawdown_pct": 20.0, "cagr_pct": 30.0},
|
||||
{"variant": "residual_80_fixed10", "label": "Residual", "sharpe": 1.35,
|
||||
"max_drawdown_pct": 21.0, "cagr_pct": 31.0, "risk_scale": None},
|
||||
{"variant": "residual_80_fixed20", "label": "Residual 20", "sharpe": 1.40,
|
||||
"max_drawdown_pct": 20.5, "cagr_pct": 32.0, "risk_scale": None},
|
||||
{"variant": "production_residual_80_fixed10", "label": "Base", "sharpe": 1.40,
|
||||
"max_drawdown_pct": 20.0, "cagr_pct": 32.0, "skipped_book_full": 7},
|
||||
{"variant": "residual_80_fixed15", "label": "Capacity", "sharpe": 1.39,
|
||||
"max_drawdown_pct": 20.0, "cagr_pct": 32.0, "skipped_book_full": 0},
|
||||
{"variant": "raw_90_fixed10", "label": "Cutoff 90", "sharpe": 1.25,
|
||||
"max_drawdown_pct": 19.0, "cagr_pct": 28.0},
|
||||
{"variant": "raw_90_fixed15", "label": "Cutoff 90 / 15", "sharpe": 1.30,
|
||||
"max_drawdown_pct": 18.0, "cagr_pct": 29.0},
|
||||
]},
|
||||
}
|
||||
|
||||
rec = bt._build_research_recommendation(report)
|
||||
by_topic = {item["topic"]: item for item in rec["items"]}
|
||||
|
||||
assert by_topic["residual_momentum"]["candidate"] is True
|
||||
assert "Residual 20" in by_topic["residual_momentum"]["text"]
|
||||
assert by_topic["cutoff_90"]["candidate"] is True
|
||||
assert "Cutoff 90 / 15" in by_topic["cutoff_90"]["text"]
|
||||
assert by_topic["capacity_15"]["candidate"] is False
|
||||
assert "not needed yet" in by_topic["capacity_15"]["text"]
|
||||
assert by_topic["cutoff_90"]["candidate"] is False
|
||||
assert "Cutoff 90" in by_topic["cutoff_90"]["text"]
|
||||
|
||||
|
||||
class TestStopFillR:
|
||||
@@ -305,6 +319,7 @@ def _acand(
|
||||
"confidence": conf,
|
||||
"action": action,
|
||||
"momentum_percentile": mp,
|
||||
"activation_momentum_percentile": mp,
|
||||
"direction": direction,
|
||||
"meets_core": meets,
|
||||
"risk_level": "Low",
|
||||
@@ -380,6 +395,7 @@ def _sim_cand(
|
||||
"stop": stop,
|
||||
"target": target,
|
||||
"momentum_percentile": mp,
|
||||
"activation_momentum_percentile": mp,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Unit tests for the cross-sectional 12-1 momentum ranking."""
|
||||
"""Unit tests for the cross-sectional activation momentum ranking."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -35,6 +35,21 @@ async def _seed(session, symbol: str, rate: float, n: int = 280) -> None:
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def _seed_closes(session, symbol: str, closes: list[float]) -> None:
|
||||
t = Ticker(symbol=symbol)
|
||||
session.add(t)
|
||||
await session.flush()
|
||||
base = date(2024, 1, 1)
|
||||
for i, close in enumerate(closes):
|
||||
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
|
||||
|
||||
@@ -47,7 +62,11 @@ def test_compute_momentum_value():
|
||||
assert m > 0
|
||||
|
||||
|
||||
async def test_ranks_universe_into_percentiles(session):
|
||||
async def test_ranks_universe_into_raw_percentiles_when_benchmark_missing(session, monkeypatch):
|
||||
async def no_benchmark(_db):
|
||||
return {}
|
||||
|
||||
monkeypatch.setattr(ms, "_load_activation_benchmark", no_benchmark)
|
||||
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
|
||||
@@ -58,7 +77,31 @@ async def test_ranks_universe_into_percentiles(session):
|
||||
assert pct["LOW"] == 0.0
|
||||
|
||||
|
||||
async def test_short_history_ticker_is_unranked(session):
|
||||
async def test_ranks_universe_into_residual_percentiles_when_benchmark_available(session, monkeypatch):
|
||||
base = date(2024, 1, 1)
|
||||
n = 280
|
||||
benchmark = {base + timedelta(days=i): 100.0 * (1.001 ** i) for i in range(n)}
|
||||
|
||||
async def with_benchmark(_db):
|
||||
return benchmark
|
||||
|
||||
monkeypatch.setattr(ms, "_load_activation_benchmark", with_benchmark)
|
||||
market = [benchmark[base + timedelta(days=i)] for i in range(n)]
|
||||
await _seed_closes(session, "DRIFT", [market[i] * (1.0008 ** i) for i in range(n)])
|
||||
await _seed_closes(session, "BETA", market)
|
||||
await _seed_closes(session, "LAG", [market[i] * (0.9992 ** i) for i in range(n)])
|
||||
|
||||
pct = await ms.compute_momentum_percentiles(session)
|
||||
assert pct["DRIFT"] == 100.0
|
||||
assert pct["BETA"] == 50.0
|
||||
assert pct["LAG"] == 0.0
|
||||
|
||||
|
||||
async def test_short_history_ticker_is_unranked(session, monkeypatch):
|
||||
async def no_benchmark(_db):
|
||||
return {}
|
||||
|
||||
monkeypatch.setattr(ms, "_load_activation_benchmark", no_benchmark)
|
||||
await _seed(session, "LONG", rate=1.005)
|
||||
await _seed(session, "SHORTHX", rate=1.005, n=100) # < 1y → no momentum
|
||||
|
||||
@@ -67,5 +110,9 @@ async def test_short_history_ticker_is_unranked(session):
|
||||
assert "SHORTHX" not in pct
|
||||
|
||||
|
||||
async def test_empty_universe_returns_empty(session):
|
||||
async def test_empty_universe_returns_empty(session, monkeypatch):
|
||||
async def no_benchmark(_db):
|
||||
return {}
|
||||
|
||||
monkeypatch.setattr(ms, "_load_activation_benchmark", no_benchmark)
|
||||
assert await ms.compute_momentum_percentiles(session) == {}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"""Tests for sentiment-collection scoping (``_get_sentiment_priority_tickers``).
|
||||
|
||||
A dashboard 'top pick' is the highest-momentum *qualified* long setup. Sentiment
|
||||
can never move a ticker's momentum percentile (the gate's core axis) — only its
|
||||
A dashboard 'top pick' is the highest residual-momentum *qualified* long setup. Sentiment
|
||||
can never move a ticker's activation percentile (the gate's core axis) — only its
|
||||
confidence and EV ranking. So the tickers that are, or could become with positive
|
||||
sentiment, a top pick are exactly the momentum leaders that already carry a
|
||||
sentiment, a top pick are exactly the residual-momentum leaders that already carry a
|
||||
tradeable long setup over the R:R floor. These tests pin that priority tier
|
||||
(always refreshed, cap-exempt) and the capped filler tier behind it.
|
||||
"""
|
||||
|
||||
Reference in New Issue
Block a user