promote residual momentum ranking
Deploy / lint (push) Successful in 7s
Deploy / test (push) Successful in 1m8s
Deploy / deploy (push) Successful in 35s

This commit is contained in:
2026-07-02 21:00:39 +02:00
parent 849489a4b5
commit aadec7d403
21 changed files with 310 additions and 185 deletions
+10 -10
View File
@@ -13,8 +13,8 @@ Scheduled pipelines turn raw prices into a ranked, gated list of tradeable setup
Once a day (default 07:00). Steps run **in dependency order**, each consuming the previous step's fresh output: Once a day (default 07:00). Steps run **in dependency order**, each consuming the previous step's fresh output:
1. **OHLCV** — fetch the latest daily bars for every tracked ticker (Alpaca); new tickers backfill ~5 years. 1. **OHLCV** — fetch the latest daily bars for every tracked ticker (Alpaca); new tickers backfill ~5 years.
2. **Sentiment** — fetch sentiment for the names that matter and are stale (> 5 days): top-pick feeders (momentum leaders with a tradeable long setup), the watchlist, and open paper trades, plus a top-N-by-composite discovery net. Runs *before* the scan so the scan sees fresh sentiment. 2. **Sentiment** — fetch sentiment for the names that matter and are stale (> 5 days): top-pick feeders (residual-momentum leaders with a tradeable long setup), the watchlist, and open paper trades, plus a top-N-by-composite discovery net. Runs *before* the scan so the scan sees fresh sentiment.
3. **R:R Scan** — recompute S/R zones, the 5-dimension scores and long/short setups (ATR stops, S/R targets) for every ticker, and attach each ticker's 121 momentum percentile. 3. **R:R Scan** — recompute S/R zones, the 5-dimension scores and long/short setups (ATR stops, S/R targets) for every ticker, and attach each ticker's residual 121 momentum activation percentile.
4. **Outcome Eval** — resolve setups that hit target/stop or expired (default 30 trading days) and auto-close paper trades per the exit policy (default: hold 30 trading days with the initial stop — the backtest-validated exit). 4. **Outcome Eval** — resolve setups that hit target/stop or expired (default 30 trading days) and auto-close paper trades per the exit policy (default: hold 30 trading days with the initial stop — the backtest-validated exit).
5. **Market Regime** — recompute the regime index (breadth/trend). 5. **Market Regime** — recompute the regime index (breadth/trend).
6. **Regime Monitor** — observational early-warning snapshot (VIX, credit spreads via FRED); feeds nothing else. 6. **Regime Monitor** — observational early-warning snapshot (VIX, credit spreads via FRED); feeds nothing else.
@@ -33,8 +33,8 @@ Fundamentals (weekly, early Monday) · Alerts (hourly, Telegram) · Backtest (we
1. **Composite score** — technical, S/R-quality, sentiment, fundamental and momentum sub-scores (0100) combine into a weighted composite (weights configurable; missing dimensions re-normalize). 1. **Composite score** — technical, S/R-quality, sentiment, fundamental and momentum sub-scores (0100) combine into a weighted composite (weights configurable; missing dimensions re-normalize).
2. **Setups** — the scanner builds long/short setups with ATR stops and S/R targets, then adds a confidence score, conflict flags and a target reach-probability. 2. **Setups** — the scanner builds long/short setups with ATR stops and S/R targets, then adds a confidence score, conflict flags and a target reach-probability.
3. **Activation gate** — a setup *qualifies* only if it clears the R:R floor **and** ranks in the top momentum percentile of the universe (the validated edge is long-only momentum; the confidence floor was ablated to zero effect and defaults off). 3. **Activation gate** — a setup *qualifies* only if it clears the R:R floor **and** ranks in the top residual-momentum percentile of the universe (the validated edge is long-only; the confidence floor was ablated to zero effect and defaults off).
4. **Top pick** — the highest-momentum qualified setup; highlighted on the Dashboard and labelled on the ticker page. 4. **Top pick** — the highest residual-momentum qualified setup; highlighted on the Dashboard and labelled on the ticker page.
## Strategy Status — What's Validated and What Isn't ## Strategy Status — What's Validated and What Isn't
@@ -42,7 +42,7 @@ Fundamentals (weekly, early Monday) · Alerts (hourly, Telegram) · Backtest (we
| Component | Verdict | Evidence | | Component | Verdict | Evidence |
|---|---|---| |---|---|---|
| **12-1 cross-sectional momentum** (the activation gate, long-only) | **The only demonstrated edge — in-sample** | Qualified setups beat the all-setups baseline after costs; rank-IC ≈ 0.05. Residual 12-1 momentum is now evaluated as a research signal, but is not production ranking yet | | **Residual 12-1 cross-sectional momentum** (the activation gate, long-only) | **Production ranking — in-sample edge** | Promoted July 2026 after the portfolio variant beat raw 80 on CAGR, Sharpe and drawdown. Raw 12-1 remains a fallback only when benchmark data is unavailable |
| S/R setup engine (ATR stops, S/R targets, reach-probability) | **Filter/execution context, not the exit** | R:R/room-to-run still earns its keep as a filter, but S/R targets underperform the time exit. The probability model is display-only | | S/R setup engine (ATR stops, S/R targets, reach-probability) | **Filter/execution context, not the exit** | R:R/room-to-run still earns its keep as a filter, but S/R targets underperform the time exit. The probability model is display-only |
| Composite score + 5 dimensions | **Display/ranking only** | Sub-scores are hand-built heuristics; none has a measured IC. Note: the "momentum" *dimension* is 5/20-day ROC — NOT the validated 12-1 factor (that lives in `momentum_service`) | | Composite score + 5 dimensions | **Display/ranking only** | Sub-scores are hand-built heuristics; none has a measured IC. Note: the "momentum" *dimension* is 5/20-day ROC — NOT the validated 12-1 factor (that lives in `momentum_service`) |
| LLM sentiment | Display + a bounded composite adjustment (± weight × 100 pts around neutral 50) | Deliberately kept out of the setup engine; no point-in-time history to validate against yet | | LLM sentiment | Display + a bounded composite adjustment (± weight × 100 pts around neutral 50) | Deliberately kept out of the setup engine; no point-in-time history to validate against yet |
@@ -50,7 +50,7 @@ Fundamentals (weekly, early Monday) · Alerts (hourly, Telegram) · Backtest (we
| Short setups | **Excluded while the momentum gate is active** | Backtest showed shorts fight the trend and drag expectancy | | Short setups | **Excluded while the momentum gate is active** | Backtest showed shorts fight the trend and drag expectancy |
| Expected-value gate (removed June 2026) | Degenerate — do not resurrect | Structurally favored distant lottery targets; selected *worse*-than-random setups | | Expected-value gate (removed June 2026) | Degenerate — do not resurrect | Structurally favored distant lottery targets; selected *worse*-than-random setups |
Caveats on the momentum result: in-sample, roughly one market regime, costs/slippage approximated at 0.1% per side, and the factor is beta-heavy (6-month volatility often posts the top IC — that's beta, not alpha). The **out-of-sample proof is the forward paper-trade record**: Signals → Track Record compares live qualified expectancy against the backtest. Caveats on the momentum result: in-sample, roughly one market regime, costs/slippage approximated at 0.1% per side, and residual momentum still needs SPY benchmark history to compute. The **out-of-sample proof is the forward paper-trade record**: Signals → Track Record compares live qualified expectancy against the backtest.
### The iron rule for strategy changes ### The iron rule for strategy changes
@@ -64,14 +64,14 @@ Corollaries: never let an unvalidated score gate setups; the outcome evaluator m
### Highest-value next experiments (in order) ### Highest-value next experiments (in order)
1. **Residual momentum portfolio variants** — compare raw vs beta-adjusted 12-1 momentum in the strategy-variant simulator before changing production ranking. 1. **Raw 90 challenger** keep comparing raw 12-1 momentum at cutoff 90 against production residual 80; promote only if it beats residual production on Sharpe and drawdown without a meaningful CAGR hit.
2. **Capacity checks for promising variants** — compare max-position variants for raw 90 and residual 80 so a good-looking row is not just a book-slot artifact. 2. **Capacity check** — keep only the residual 80 / max 15 portfolio row as a guardrail; max 20 and raw max 15 added no information in the July 2026 run.
3. **Signal context snapshots** — accumulate point-in-time composite/sentiment/fundamental context for every new setup so the discretionary overlay can be tested forward-only. 3. **Signal context snapshots** — accumulate point-in-time composite/sentiment/fundamental context for every new setup so the discretionary overlay can be tested forward-only.
4. **More breadth, not more history** — widening the ranked universe (e.g. `nasdaq_all`) strengthens each week's cross-section and the IC t-stat, even if only the top slice is traded. (Deeper history was considered and declined.) 4. **More breadth, not more history** — widening the ranked universe (e.g. `nasdaq_all`) strengthens each week's cross-section and the IC t-stat, even if only the top slice is traded. (Deeper history was considered and declined.)
## Key Use Cases ## Key Use Cases
- **Find today's best long setup.** On the **Dashboard**, the *Top Setups* table lists qualified setups ranked by momentum with the #1 flagged "Top pick". Each row opens the ticker page for the chart, scores, S/R targets and entry/stop. - **Find today's best long setup.** On the **Dashboard**, the *Top Setups* table lists qualified setups ranked by residual momentum with the #1 flagged "Top pick". Each row opens the ticker page for the chart, scores, S/R targets and entry/stop.
- **Track a trade you took.** Mark a setup as a **paper trade**: it's marked-to-market against the latest close, auto-closed by the active exit policy (default: 30 trading days with the initial stop), and its sentiment stays fresh while open. *Signals → Track Record* shows the realized edge. - **Track a trade you took.** Mark a setup as a **paper trade**: it's marked-to-market against the latest close, auto-closed by the active exit policy (default: 30 trading days with the initial stop), and its sentiment stays fresh while open. *Signals → Track Record* shows the realized edge.
## Stack ## Stack
@@ -412,7 +412,7 @@ Context for whoever — human or AI — continues this work. The owner pushes st
| Concern | File | | Concern | File |
|---|---| |---|---|
| Composite + 5 dimension scores, weights | `app/services/scoring_service.py` | | Composite + 5 dimension scores, weights | `app/services/scoring_service.py` |
| 12-1 momentum ranking (the validated factor) | `app/services/momentum_service.py` | | Residual 12-1 momentum ranking (the validated activation factor) | `app/services/momentum_service.py` |
| Setup construction (ATR stop, S/R targets) | `app/services/rr_scanner_service.py` | | Setup construction (ATR stop, S/R targets) | `app/services/rr_scanner_service.py` |
| Confidence, targets, reach-probability, action | `app/services/recommendation_service.py` | | Confidence, targets, reach-probability, action | `app/services/recommendation_service.py` |
| Activation gate predicate (mirrored in TS) | `app/services/qualification.py` | | Activation gate predicate (mirrored in TS) | `app/services/qualification.py` |
+1 -1
View File
@@ -51,7 +51,7 @@ class Settings(BaseSettings):
# Sentiment search-budget controls (Gemini grounding free tier = 5000/month). # Sentiment search-budget controls (Gemini grounding free tier = 5000/month).
# Scope (see _get_sentiment_priority_tickers): everything that matters is always # Scope (see _get_sentiment_priority_tickers): everything that matters is always
# refreshed in full — open paper trades + the curated watchlist + top-pick # refreshed in full — open paper trades + the curated watchlist + top-pick
# feeders (momentum leaders with a tradeable long setup) — plus a top-N composite # feeders (residual-momentum leaders with a tradeable long setup) — plus a top-N composite
# discovery net. No per-run cap: the set is naturally bounded (watchlist <= 20, # discovery net. No per-run cap: the set is naturally bounded (watchlist <= 20,
# composite <= top_composite), so a full refresh stays well inside the free tier. # composite <= top_composite), so a full refresh stays well inside the free tier.
# Skip anything refreshed within fresh_hours (5 days: sentiment shifts slowly and # Skip anything refreshed within fresh_hours (5 days: sentiment shifts slowly and
+2 -2
View File
@@ -10,8 +10,8 @@ class BenchmarkPrice(Base):
"""Daily close for a benchmark index (e.g. SPY), used to compute trade alpha. """Daily close for a benchmark index (e.g. SPY), used to compute trade alpha.
A standalone price series, deliberately NOT a tracked ``Ticker`` — so the A standalone price series, deliberately NOT a tracked ``Ticker`` — so the
benchmark never enters the scanner, the momentum-percentile ranking, or the benchmark never becomes a trade candidate or rankings-table row. Its closes
rankings table. One row per (symbol, date). are used for residual momentum and trade alpha. One row per (symbol, date).
""" """
__tablename__ = "benchmark_prices" __tablename__ = "benchmark_prices"
+3 -2
View File
@@ -26,8 +26,9 @@ class TradeSetup(Base):
) )
confidence_score: Mapped[float | None] = mapped_column(Float, nullable=True) confidence_score: Mapped[float | None] = mapped_column(Float, nullable=True)
# Ticker's 12-1 momentum percentile across the universe at detection time # Ticker's activation momentum percentile across the universe at detection
# (0100, 100 = strongest). Drives the activation gate's core selection. # time. Since July 2026 this is residual 12-1 momentum when benchmark data is
# available, with raw 12-1 as a fallback.
momentum_percentile: Mapped[float | None] = mapped_column(Float, nullable=True) momentum_percentile: Mapped[float | None] = mapped_column(Float, nullable=True)
targets_json: Mapped[str | None] = mapped_column(Text, nullable=True) targets_json: Mapped[str | None] = mapped_column(Text, nullable=True)
conflict_flags_json: Mapped[str | None] = mapped_column(Text, nullable=True) conflict_flags_json: Mapped[str | None] = mapped_column(Text, nullable=True)
+6 -6
View File
@@ -222,11 +222,11 @@ async def _get_ohlcv_priority_tickers(db: AsyncSession) -> list[str]:
async def _get_top_pick_feeder_ids(db: AsyncSession) -> set[int]: async def _get_top_pick_feeder_ids(db: AsyncSession) -> set[int]:
"""Ticker ids whose latest LONG setup makes them a top-pick feeder. """Ticker ids whose latest LONG setup makes them a top-pick feeder.
A dashboard 'top pick' is the highest-momentum *qualified* setup. Sentiment A dashboard 'top pick' is the highest residual-momentum *qualified* setup.
can never move a ticker's momentum percentile (the gate's core axis) — only Sentiment can never move a ticker's activation percentile (the gate's core
its confidence and EV ranking. So the only tickers that are, or could become axis) — only its confidence and EV ranking. So the only tickers that are, or
with positive sentiment, a top pick are momentum leaders that already have a could become with positive sentiment, a top pick are residual-momentum leaders
tradeable long setup clearing the R:R floor. That set is exactly: that already have a tradeable long setup clearing the R:R floor. That set is exactly:
latest long setup with momentum_percentile >= gate AND rr_ratio >= floor. latest long setup with momentum_percentile >= gate AND rr_ratio >= floor.
@@ -311,7 +311,7 @@ async def _get_sentiment_priority_tickers(db: AsyncSession) -> list[str]:
is always fully covered. The two tiers only affect ORDER, so a mid-run provider is always fully covered. The two tiers only affect ORDER, so a mid-run provider
rate limit still lands the names we care about first: rate limit still lands the names we care about first:
Priority: top-pick feeders (momentum leaders with a tradeable long setup, see Priority: top-pick feeders (residual-momentum leaders with a tradeable long setup, see
``_get_top_pick_feeder_ids``) + the curated watchlist + open paper trades — ``_get_top_pick_feeder_ids``) + the curated watchlist + open paper trades —
the set we never want shown without sentiment. the set we never want shown without sentiment.
Filler: top-N by composite — a cheap discovery net for names not yet covered. Filler: top-N by composite — a cheap discovery net for names not yet covered.
+2 -2
View File
@@ -39,8 +39,8 @@ SUPPORTED_TICKER_UNIVERSES = {"sp500", "nasdaq100", "nasdaq_all"}
# Track Record's qualified stats. The outcome evaluator deliberately ignores # Track Record's qualified stats. The outcome evaluator deliberately ignores
# these — every setup is evaluated so the gate itself can be validated. # these — every setup is evaluated so the gate itself can be validated.
# #
# The core selection is cross-sectional 12-1 momentum (top percentile of the # The core selection is residual cross-sectional 12-1 momentum (top percentile
# universe, long-only). R:R and confidence are floors; high-conviction / # of the universe, long-only). R:R and confidence are floors; high-conviction /
# clean-read are optional tighteners (off by default). # clean-read are optional tighteners (off by default).
_ACTIVATION_FLOAT_KEYS: dict[str, str] = { _ACTIVATION_FLOAT_KEYS: dict[str, str] = {
"min_momentum_percentile": "activation_min_momentum_percentile", "min_momentum_percentile": "activation_min_momentum_percentile",
+65 -75
View File
@@ -93,6 +93,9 @@ ATR_MULTIPLIER = 1.5
# signal, not the outcome of a target/stop structure built on top of one. # signal, not the outcome of a target/stop structure built on top of one.
MIN_CROSS_SECTION = 20 # min tickers present in a week to score that week MIN_CROSS_SECTION = 20 # min tickers present in a week to score that week
MIN_RELIABLE_PERIODS = 12 # min non-overlapping windows before a signal's IC is trusted MIN_RELIABLE_PERIODS = 12 # min non-overlapping windows before a signal's IC is trusted
PRODUCTION_PERCENTILE_KEY = "activation_momentum_percentile"
RAW_PERCENTILE_KEY = "momentum_percentile"
RESIDUAL_PERCENTILE_KEY = "residual_momentum_percentile"
def _wrap_levels(level_dicts: list[dict]) -> list[Any]: def _wrap_levels(level_dicts: list[dict]) -> list[Any]:
@@ -845,12 +848,26 @@ def _assign_momentum_percentiles(candidates: list[dict]) -> None:
def _assign_residual_momentum_percentiles(candidates: list[dict]) -> None: def _assign_residual_momentum_percentiles(candidates: list[dict]) -> None:
"""Research-only residual-momentum percentile used by strategy variants.""" """Residual-momentum percentile promoted to production activation ranking."""
_assign_signal_percentiles( _assign_signal_percentiles(
candidates, "residual_momentum", "residual_momentum_percentile" candidates, "residual_momentum", RESIDUAL_PERCENTILE_KEY
) )
def _assign_activation_momentum_percentiles(candidates: list[dict]) -> None:
"""Production activation rank: residual 12-1 when available, raw fallback.
The raw fallback mirrors the live scanner's behavior when benchmark history
is unavailable. In normal backtests, SPY is loaded and this is residual.
"""
for c in candidates:
c[PRODUCTION_PERCENTILE_KEY] = (
c.get(RESIDUAL_PERCENTILE_KEY)
if c.get(RESIDUAL_PERCENTILE_KEY) is not None
else c.get(RAW_PERCENTILE_KEY)
)
def _momentum_qualifies(cand: dict, threshold: float) -> bool: def _momentum_qualifies(cand: dict, threshold: float) -> bool:
"""Whether a candidate clears the floors (meets_core) and the momentum gate. """Whether a candidate clears the floors (meets_core) and the momentum gate.
Threshold 0 disables the momentum gate (floors only). The gate is long-only: Threshold 0 disables the momentum gate (floors only). The gate is long-only:
@@ -861,7 +878,7 @@ def _momentum_qualifies(cand: dict, threshold: float) -> bool:
return True return True
if cand["direction"] == "short": if cand["direction"] == "short":
return False return False
mp = cand.get("momentum_percentile") mp = cand.get(PRODUCTION_PERCENTILE_KEY)
return mp is not None and mp >= threshold return mp is not None and mp >= threshold
@@ -890,7 +907,7 @@ def _gate_ablation(candidates: list[dict], activation: dict, threshold: float) -
return True return True
if c["direction"] == "short": if c["direction"] == "short":
return False return False
mp = c.get("momentum_percentile") mp = c.get(PRODUCTION_PERCENTILE_KEY)
return mp is not None and mp >= threshold return mp is not None and mp >= threshold
def rr_ok(c: dict) -> bool: def rr_ok(c: dict) -> bool:
@@ -965,7 +982,7 @@ def _simulate_portfolio(
hold_days: int, hold_days: int,
*, *,
qualified_fn: Callable[[dict], bool] | None = None, qualified_fn: Callable[[dict], bool] | None = None,
ranking_key: str = "momentum_percentile", ranking_key: str = PRODUCTION_PERCENTILE_KEY,
max_positions: int = SIM_MAX_POSITIONS, max_positions: int = SIM_MAX_POSITIONS,
risk_per_trade: float = SIM_RISK_PER_TRADE, risk_per_trade: float = SIM_RISK_PER_TRADE,
) -> dict | None: ) -> dict | None:
@@ -1189,9 +1206,18 @@ def _simulate_portfolio(
STRATEGY_VARIANTS: tuple[dict, ...] = ( STRATEGY_VARIANTS: tuple[dict, ...] = (
{ {
"variant": "production_raw_80_fixed10", "variant": "production_residual_80_fixed10",
"label": "Production raw 80 / max 10", "label": "Production residual 80 / max 10",
"percentile_key": "momentum_percentile", "percentile_key": PRODUCTION_PERCENTILE_KEY,
"cutoff": 80.0,
"max_positions": 10,
"risk_per_trade": 0.01,
"risk_scale": None,
},
{
"variant": "legacy_raw_80_fixed10",
"label": "Legacy raw 80 / max 10",
"percentile_key": RAW_PERCENTILE_KEY,
"cutoff": 80.0, "cutoff": 80.0,
"max_positions": 10, "max_positions": 10,
"risk_per_trade": 0.01, "risk_per_trade": 0.01,
@@ -1200,52 +1226,16 @@ STRATEGY_VARIANTS: tuple[dict, ...] = (
{ {
"variant": "raw_90_fixed10", "variant": "raw_90_fixed10",
"label": "Raw 90 / max 10", "label": "Raw 90 / max 10",
"percentile_key": "momentum_percentile", "percentile_key": RAW_PERCENTILE_KEY,
"cutoff": 90.0, "cutoff": 90.0,
"max_positions": 10, "max_positions": 10,
"risk_per_trade": 0.01, "risk_per_trade": 0.01,
"risk_scale": None, "risk_scale": None,
}, },
{
"variant": "raw_90_fixed15",
"label": "Raw 90 / max 15",
"percentile_key": "momentum_percentile",
"cutoff": 90.0,
"max_positions": 15,
"risk_per_trade": 0.01,
"risk_scale": None,
},
{
"variant": "residual_80_fixed10",
"label": "Residual 80 / max 10",
"percentile_key": "residual_momentum_percentile",
"cutoff": 80.0,
"max_positions": 10,
"risk_per_trade": 0.01,
"risk_scale": None,
},
{ {
"variant": "residual_80_fixed15", "variant": "residual_80_fixed15",
"label": "Residual 80 / max 15", "label": "Residual 80 / max 15 capacity check",
"percentile_key": "residual_momentum_percentile", "percentile_key": PRODUCTION_PERCENTILE_KEY,
"cutoff": 80.0,
"max_positions": 15,
"risk_per_trade": 0.01,
"risk_scale": None,
},
{
"variant": "residual_80_fixed20",
"label": "Residual 80 / max 20",
"percentile_key": "residual_momentum_percentile",
"cutoff": 80.0,
"max_positions": 20,
"risk_per_trade": 0.01,
"risk_scale": None,
},
{
"variant": "raw_80_fixed15",
"label": "Raw 80 / max 15",
"percentile_key": "momentum_percentile",
"cutoff": 80.0, "cutoff": 80.0,
"max_positions": 15, "max_positions": 15,
"risk_per_trade": 0.01, "risk_per_trade": 0.01,
@@ -1295,7 +1285,7 @@ def _strategy_variant_sims(
rows.append({ rows.append({
"variant": cfg["variant"], "variant": cfg["variant"],
"label": cfg["label"], "label": cfg["label"],
"ranking": "residual" if "residual" in percentile_key else "raw", "ranking": "raw" if percentile_key == RAW_PERCENTILE_KEY else "residual",
"cutoff": cutoff, "cutoff": cutoff,
"max_positions": int(cfg["max_positions"]), "max_positions": int(cfg["max_positions"]),
"risk_per_trade_pct": round(float(cfg["risk_per_trade"]) * 100, 2), "risk_per_trade_pct": round(float(cfg["risk_per_trade"]) * 100, 2),
@@ -1312,14 +1302,12 @@ def _pct_loss(base: float | None, candidate: float | None) -> float | None:
def _build_research_recommendation(report: dict) -> dict: def _build_research_recommendation(report: dict) -> dict:
"""Advisory rules for research variants. These are deliberately conservative: """Advisory rules for the remaining research variants after residual promotion."""
production only changes later if a portfolio variant beats the baseline under
transparent drawdown/Sharpe/CAGR constraints."""
variants = { variants = {
v.get("variant"): v v.get("variant"): v
for v in (report.get("strategy_variants") or {}).get("variants", []) for v in (report.get("strategy_variants") or {}).get("variants", [])
} }
base = variants.get("production_raw_80_fixed10") base = variants.get("production_residual_80_fixed10")
items: list[dict] = [] items: list[dict] = []
if base is None: if base is None:
return { return {
@@ -1331,25 +1319,25 @@ def _build_research_recommendation(report: dict) -> dict:
base_dd = base.get("max_drawdown_pct") base_dd = base.get("max_drawdown_pct")
base_cagr = base.get("cagr_pct") base_cagr = base.get("cagr_pct")
residuals = [ capacity = variants.get("residual_80_fixed15")
v for key, v in variants.items()
if key.startswith("residual_80_") and v.get("risk_scale") is None
]
residual = max(residuals, key=lambda v: v.get("sharpe") or -999, default=None)
if ( if (
residual and base_sharpe is not None and residual.get("sharpe") is not None capacity and base_sharpe is not None and base_cagr is not None
and base_dd is not None and residual.get("max_drawdown_pct") is not None and capacity.get("sharpe") is not None and capacity.get("cagr_pct") is not None
and capacity.get("max_drawdown_pct") is not None and base_dd is not None
): ):
sharpe_delta = residual["sharpe"] - base_sharpe candidate = (
dd_delta = residual["max_drawdown_pct"] - base_dd capacity["sharpe"] > base_sharpe
candidate = sharpe_delta >= 0.10 and dd_delta <= 2.0 and capacity["cagr_pct"] > base_cagr
and capacity["max_drawdown_pct"] <= base_dd + 1.0
)
items.append({ items.append({
"topic": "residual_momentum", "topic": "capacity_15",
"candidate": candidate, "candidate": candidate,
"text": ( "text": (
f"Residual momentum {'is a promotion candidate' if candidate else 'stays research-only'}: " f"Max-15 capacity {'is worth promoting' if candidate else 'is not needed yet'}: "
f"{residual['label']} Sharpe {residual['sharpe']:.2f} vs {base_sharpe:.2f}, " f"Sharpe {capacity['sharpe']:.2f} vs {base_sharpe:.2f}, "
f"drawdown {residual['max_drawdown_pct']:.1f}% vs {base_dd:.1f}%." f"CAGR {capacity['cagr_pct']:+.1f}% vs {base_cagr:+.1f}%, "
f"skipped {capacity.get('skipped_book_full', 0)} vs {base.get('skipped_book_full', 0)}."
), ),
}) })
@@ -1385,8 +1373,8 @@ def _build_research_recommendation(report: dict) -> dict:
return { return {
"items": items, "items": items,
"note": ( "note": (
"Advisory only. Production changes require a variant to pass the rule " "Residual 12-1 momentum is now the production activation rank. "
"and then be adopted explicitly in a later strategy-version change." "Remaining rows are research comparisons only."
), ),
} }
@@ -1473,7 +1461,8 @@ def _build_recommendation(report: dict) -> dict:
"text": f"Gate: keep the {label} (worth {delta:+.2f}R/trade under the hold exit).", "text": f"Gate: keep the {label} (worth {delta:+.2f}R/trade under the hold exit).",
}) })
# Momentum cutoff: best per-trade net among the active-gate sweep rows. # Activation cutoff: best per-trade net among the promoted residual-momentum
# sweep rows.
sweep_rows = [ sweep_rows = [
r for r in report.get("sweep") or [] r for r in report.get("sweep") or []
if r.get("net_avg_r") is not None and (r.get("min_momentum_percentile") or 0) > 0 if r.get("net_avg_r") is not None and (r.get("min_momentum_percentile") or 0) > 0
@@ -1483,7 +1472,7 @@ def _build_recommendation(report: dict) -> dict:
items.append({ items.append({
"topic": "cutoff", "topic": "cutoff",
"text": ( "text": (
f"Momentum cutoff: {best_cut['min_momentum_percentile']:.0f} has the best " f"Residual-momentum cutoff: {best_cut['min_momentum_percentile']:.0f} has the best "
f"per-trade net ({best_cut['net_avg_r']:+.2f}R over {best_cut['total']} setups)." f"per-trade net ({best_cut['net_avg_r']:+.2f}R over {best_cut['total']} setups)."
), ),
}) })
@@ -1653,9 +1642,11 @@ async def run_backtest(
progress_cb(total, total, "") progress_cb(total, total, "")
# Cross-sectional momentum: rank every week's universe, then "qualified" means # Cross-sectional momentum: rank every week's universe, then "qualified" means
# floors + top ``min_momentum_percentile`` by 12-1 momentum. # floors + top ``min_momentum_percentile`` by promoted residual 12-1 momentum
# (raw 12-1 fallback only when benchmark data is unavailable).
_assign_momentum_percentiles(candidates) _assign_momentum_percentiles(candidates)
_assign_residual_momentum_percentiles(candidates) _assign_residual_momentum_percentiles(candidates)
_assign_activation_momentum_percentiles(candidates)
current_min_pct = float(activation.get("min_momentum_percentile", 80.0)) current_min_pct = float(activation.get("min_momentum_percentile", 80.0))
for c in candidates: for c in candidates:
c["qualified"] = _momentum_qualifies(c, current_min_pct) c["qualified"] = _momentum_qualifies(c, current_min_pct)
@@ -1779,10 +1770,9 @@ async def run_backtest(
"strategy_variants": { "strategy_variants": {
"variants": strategy_variant_rows, "variants": strategy_variant_rows,
"note": ( "note": (
"Research-only hold-to-horizon portfolio variants. These compare " "Research-only hold-to-horizon portfolio variants. Production now "
"raw vs residual momentum ranking, cutoff 80 vs 90, and max 10/15/20 " "uses residual 12-1 momentum at cutoff 80; the remaining rows compare "
"position capacity. They do not change live " "the legacy raw rank, raw cutoff 90, and one max-15 capacity check."
"qualification or paper-trade behavior."
), ),
}, },
"signal_eval": _signal_evaluation(collected), "signal_eval": _signal_evaluation(collected),
+2 -2
View File
@@ -3,8 +3,8 @@
Fetches the S&P 500 proxy (SPY) daily closes via Alpaca and persists them, so Fetches the S&P 500 proxy (SPY) daily closes via Alpaca and persists them, so
paper-trade alpha — a trade's return minus the benchmark's return over the same paper-trade alpha — a trade's return minus the benchmark's return over the same
holding period — can be computed. The benchmark is a standalone series, NOT a holding period — can be computed. The benchmark is a standalone series, NOT a
tracked ``Ticker``, so it never contaminates the scanner, momentum-percentile tracked ``Ticker``; its closes feed residual momentum and alpha, but it never
ranking, or rankings. becomes a trade candidate or rankings-table row.
""" """
from __future__ import annotations from __future__ import annotations
+88 -15
View File
@@ -1,17 +1,18 @@
"""Cross-sectional 12-1 momentum ranking for the universe. """Cross-sectional residual 12-1 momentum ranking for the universe.
The activation gate selects the top ``min_momentum_percentile`` of 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 by residual 12-1 month momentum: the stock's 12-1 return after subtracting its
price signal the backtest showed sorts forward returns). The daily scan ranks estimated benchmark beta contribution over the same formation window. The daily
every ticker and stores each setup's percentile (see ``rr_scanner_service``), so scan ranks every ticker and stores each setup's percentile (see
the live list, the Track Record's qualified stats, and outcome evaluation all gate ``rr_scanner_service``), so the live list, the Track Record's qualified stats,
on the same value. and outcome evaluation all gate on the same value.
""" """
from __future__ import annotations from __future__ import annotations
import json import json
import logging import logging
from datetime import date
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -35,29 +36,101 @@ def compute_12_1_momentum(closes: list[float]) -> float | None:
return None return None
def compute_residual_12_1_momentum(
dates: list[date],
closes: list[float],
benchmark_closes: dict[date, float],
) -> float | None:
"""12-1 momentum after removing linear benchmark exposure.
Estimate beta from daily stock/benchmark returns over the standard 12-1
formation window, then sum stock return minus beta * benchmark return. No
intercept is subtracted: fitting an intercept over the same window would make
residuals sum to roughly zero and destroy the ranking signal.
"""
i = len(closes) - 1
if not benchmark_closes or len(dates) != len(closes) or i - _MOM_LOOKBACK < 0:
return None
stock_rets: list[float] = []
market_rets: list[float] = []
for k in range(i - _MOM_LOOKBACK + 1, i - _MOM_SKIP + 1):
prev_close = closes[k - 1]
bench_prev = benchmark_closes.get(dates[k - 1])
bench_cur = benchmark_closes.get(dates[k])
if prev_close <= 0 or bench_prev is None or bench_cur is None or bench_prev <= 0:
continue
stock_rets.append(closes[k] / prev_close - 1.0)
market_rets.append(bench_cur / bench_prev - 1.0)
if len(stock_rets) < 100:
return None
mean_market = sum(market_rets) / len(market_rets)
mean_stock = sum(stock_rets) / len(stock_rets)
var_market = sum((x - mean_market) ** 2 for x in market_rets)
if var_market <= 0:
return None
cov = sum(
(stock_rets[k] - mean_stock) * (market_rets[k] - mean_market)
for k in range(len(stock_rets))
)
beta = cov / var_market
return sum(stock_rets[k] - beta * market_rets[k] for k in range(len(stock_rets)))
async def _load_activation_benchmark(db: AsyncSession) -> dict[date, float]:
"""Load SPY closes for residual momentum; refresh once if the table is empty."""
try:
from app.services.benchmark_service import load_benchmark_closes, refresh_benchmark_prices
closes = await load_benchmark_closes(db)
if closes:
return closes
await refresh_benchmark_prices(db)
return await load_benchmark_closes(db)
except Exception:
logger.exception("Residual momentum benchmark load failed; falling back to raw momentum")
return {}
async def compute_momentum_percentiles(db: AsyncSession) -> dict[str, float]: async def compute_momentum_percentiles(db: AsyncSession) -> dict[str, float]:
"""Compute each ticker's 12-1 momentum and rank the universe into a """Compute each ticker's activation momentum rank.
``{symbol: percentile}`` map (0100, 100 = strongest momentum). Tickers
without a full year of history are absent (can't be ranked).""" Production uses residual 12-1 momentum when benchmark data is available. If
SPY data is absent, fall back to raw 12-1 momentum rather than disabling the
scanner. Tickers without enough stock/benchmark history are absent.
"""
result = await db.execute(select(Ticker).order_by(Ticker.symbol)) result = await db.execute(select(Ticker).order_by(Ticker.symbol))
tickers = list(result.scalars().all()) tickers = list(result.scalars().all())
momentum: dict[str, float] = {} benchmark_closes = await _load_activation_benchmark(db)
using_residual = len(benchmark_closes) >= _MOM_LOOKBACK
values: dict[str, float] = {}
for ticker in tickers: for ticker in tickers:
try: try:
records = await query_ohlcv(db, ticker.symbol) records = await query_ohlcv(db, ticker.symbol)
except Exception: except Exception:
logger.exception("Momentum fetch failed for %s", ticker.symbol) logger.exception("Momentum fetch failed for %s", ticker.symbol)
continue continue
m = compute_12_1_momentum([float(r.close) for r in records]) closes = [float(r.close) for r in records]
if m is not None: value = (
momentum[ticker.symbol] = m compute_residual_12_1_momentum([r.date for r in records], closes, benchmark_closes)
if using_residual
else compute_12_1_momentum(closes)
)
if value is not None:
values[ticker.symbol] = value
ranked = sorted(momentum, key=lambda s: momentum[s]) ranked = sorted(values, key=lambda s: values[s])
n = len(ranked) n = len(ranked)
percentiles = { percentiles = {
sym: round((rank / (n - 1) * 100.0) if n > 1 else 100.0, 2) sym: round((rank / (n - 1) * 100.0) if n > 1 else 100.0, 2)
for rank, sym in enumerate(ranked) for rank, sym in enumerate(ranked)
} }
logger.info(json.dumps({"event": "momentum_ranked", "tickers": n})) logger.info(json.dumps({
"event": "momentum_ranked",
"signal": "residual_12_1" if using_residual else "raw_12_1_fallback",
"tickers": n,
}))
return percentiles return percentiles
+13 -13
View File
@@ -2,12 +2,12 @@
A single predicate, driven by the admin activation config, used by the A single predicate, driven by the admin activation config, used by the
performance stats (server) and mirrored on the frontend. The core selection is performance stats (server) and mirrored on the frontend. The core selection is
cross-sectional momentum: a setup's ticker must rank in the top residual cross-sectional momentum: a setup's ticker must rank in the top
``min_momentum_percentile`` of the universe by 12-1 month momentum — the one ``min_momentum_percentile`` of the universe by beta-adjusted 12-1 month momentum.
signal the backtest showed actually sorts forward returns. R:R and confidence R:R and confidence remain as floors, and conviction/conflict survive as optional
remain as floors, and conviction/conflict survive as optional tighteners (off by tighteners (off by default). The activation percentile is computed across the
default). The momentum percentile is computed across the universe and attached to universe and attached to each setup upstream; when it's absent the gate falls
each setup upstream; when it's absent the gate falls back to the floors. back to the floors.
""" """
from __future__ import annotations from __future__ import annotations
@@ -65,12 +65,12 @@ def setup_qualifies(setup: Any, config: dict) -> bool:
return False return False
if (setup.confidence_score or 0.0) < config["min_confidence"]: if (setup.confidence_score or 0.0) < config["min_confidence"]:
return False return False
# Cross-sectional momentum: the core selection. A setup's ticker must rank in # Residual cross-sectional momentum: the core selection. A setup's ticker
# the top ``min_momentum_percentile`` of the universe by 12-1 momentum. The # must rank in the top ``min_momentum_percentile`` of the universe by
# validated edge is long-only, so while the gate is active shorts (which fight # beta-adjusted 12-1 momentum. The validated edge is long-only, so while the
# the trend) never qualify. The percentile floor is only enforced when a # gate is active shorts (which fight the trend) never qualify. The percentile
# percentile is attached (live setups / backtest); callers that don't attach # floor is only enforced when a percentile is attached (live setups /
# it defer to the floors above. # backtest); callers that don't attach it defer to the floors above.
min_pct = float(config.get("min_momentum_percentile", 0.0)) min_pct = float(config.get("min_momentum_percentile", 0.0))
if min_pct > 0: if min_pct > 0:
if (getattr(setup, "direction", "long") or "long") == "short": if (getattr(setup, "direction", "long") or "long") == "short":
@@ -81,7 +81,7 @@ def setup_qualifies(setup: Any, config: dict) -> bool:
# A NEUTRAL recommendation means the engine found no clear directional setup — # A NEUTRAL recommendation means the engine found no clear directional setup —
# not an actionable signal, so by default it doesn't qualify (and can't be a # not an actionable signal, so by default it doesn't qualify (and can't be a
# top pick). ``exclude_neutral`` defaults on; turn it off to also count # top pick). ``exclude_neutral`` defaults on; turn it off to also count
# no-clear-direction momentum leaders. # no-clear-direction residual momentum leaders.
if config.get("exclude_neutral"): if config.get("exclude_neutral"):
if (setup.recommended_action or "NEUTRAL") == "NEUTRAL": if (setup.recommended_action or "NEUTRAL") == "NEUTRAL":
return False return False
+7 -6
View File
@@ -31,7 +31,7 @@ from app.services.recommendation_service import enhance_trade_setup
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
STRATEGY_VERSION = "momentum_12_1_rr_time_v1" STRATEGY_VERSION = "residual_momentum_12_1_rr_time_v2"
async def _get_ticker(db: AsyncSession, symbol: str) -> Ticker: async def _get_ticker(db: AsyncSession, symbol: str) -> Ticker:
@@ -219,9 +219,9 @@ async def scan_ticker(
) -> list[TradeSetup]: ) -> 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 ``momentum_percentile`` is the ticker's residual 12-1 momentum activation
(computed by the caller), stored on each setup so the activation gate can rank across the universe (computed by the caller), stored on each setup so
select the top slice.""" the activation gate can select the top slice."""
ticker = await _get_ticker(db, symbol) ticker = await _get_ticker(db, symbol)
records = await query_ohlcv(db, symbol) records = await query_ohlcv(db, symbol)
@@ -393,8 +393,9 @@ async def scan_all_tickers(
tickers = list(result.scalars().all()) tickers = list(result.scalars().all())
total = len(tickers) total = len(tickers)
# Rank the universe by 12-1 momentum up front so each new setup carries its # Rank the universe by residual 12-1 momentum up front so each new setup
# ticker's percentile (used by the activation gate). Best-effort. # carries its activation percentile. Best-effort; the ranker falls back to
# raw 12-1 momentum only if benchmark data is unavailable.
try: try:
from app.services import momentum_service from app.services import momentum_service
+2 -2
View File
@@ -173,8 +173,8 @@ async def _enrich_entry(
"dimensions": dims, "dimensions": dims,
"rr_ratio": setup.rr_ratio if setup else None, "rr_ratio": setup.rr_ratio if setup else None,
"rr_direction": setup.direction if setup else None, "rr_direction": setup.direction if setup else None,
# 12-1 cross-sectional momentum percentile (the top-pick selector); ticker- # Residual 12-1 activation percentile (the top-pick selector); ticker-level,
# level, so any of the ticker's setups carries the same value. # so any of the ticker's setups carries the same value.
"momentum_percentile": setup.momentum_percentile if setup else None, "momentum_percentile": setup.momentum_percentile if setup else None,
"sr_levels": sr_levels, "sr_levels": sr_levels,
"last_close": last_close, "last_close": last_close,
@@ -41,16 +41,16 @@ export function ActivationSettings() {
<p className="mt-1 text-xs text-gray-500"> <p className="mt-1 text-xs text-gray-500">
What counts as a signal worth acting on. Drives the Dashboard's "Qualified" metric, the What counts as a signal worth acting on. Drives the Dashboard's "Qualified" metric, the
Signals "Qualified only" view, and the Track Record's qualified stats. The core selection is Signals "Qualified only" view, and the Track Record's qualified stats. The core selection is
<span className="text-gray-300"> cross-sectional momentum</span> the ticker must rank in the <span className="text-gray-300"> residual cross-sectional momentum</span> the ticker must rank in the
top slice of the universe by 12-1 month momentum, the one signal the backtest showed predicts top slice of the universe by beta-adjusted 12-1 month momentum, the production signal promoted
forward returns. R:R and confidence stay as floors. Tune the cutoff against the Track Record's from the backtest. R:R and confidence stay as floors. Tune the cutoff against the Track Record's
momentum sweep to see what actually wins. momentum sweep to see what actually wins.
</p> </p>
</div> </div>
<div className="grid gap-4 md:grid-cols-3"> <div className="grid gap-4 md:grid-cols-3">
<label className="block space-y-1"> <label className="block space-y-1">
<span className="text-xs text-gray-400">Min Momentum Percentile</span> <span className="text-xs text-gray-400">Min Residual Momentum Percentile</span>
<input <input
type="number" type="number"
min={0} min={0}
@@ -60,7 +60,7 @@ export function ActivationSettings() {
onChange={(e) => setForm((prev) => ({ ...prev, min_momentum_percentile: Number(e.target.value) }))} onChange={(e) => setForm((prev) => ({ ...prev, min_momentum_percentile: Number(e.target.value) }))}
className="w-full input-glass px-3 py-2 text-sm" className="w-full input-glass px-3 py-2 text-sm"
/> />
<span className="text-[11px] text-gray-600">Ticker's 12-1 momentum rank. 80 = top 20% of the universe. 0 disables. The core gate.</span> <span className="text-[11px] text-gray-600">Ticker's residual 12-1 momentum rank. 80 = top 20% of the universe. 0 disables. The core gate.</span>
</label> </label>
<label className="block space-y-1"> <label className="block space-y-1">
<span className="text-xs text-gray-400">Min Risk:Reward (1 : x)</span> <span className="text-xs text-gray-400">Min Risk:Reward (1 : x)</span>
@@ -100,7 +100,7 @@ export function ActivationSettings() {
Require a directional call (exclude NEUTRAL) Require a directional call (exclude NEUTRAL)
<span className="mt-0.5 block text-[11px] text-gray-500"> <span className="mt-0.5 block text-[11px] text-gray-500">
On by default. A NEUTRAL ("No Clear Setup") recommendation isn't a tradeable signal, so it On by default. A NEUTRAL ("No Clear Setup") recommendation isn't a tradeable signal, so it
never qualifies or becomes a top pick. Turn off to also count no-clear-direction momentum leaders. never qualifies or becomes a top pick. Turn off to also count no-clear-direction residual momentum leaders.
</span> </span>
</span> </span>
</label> </label>
@@ -308,11 +308,11 @@ export function BacktestPanel() {
{report.sweep && report.sweep.length > 0 && report.sweep[0].min_momentum_percentile != null && ( {report.sweep && report.sweep.length > 0 && report.sweep[0].min_momentum_percentile != null && (
<div> <div>
<p className="mb-2 text-xs font-medium uppercase tracking-widest text-gray-500"> <p className="mb-2 text-xs font-medium uppercase tracking-widest text-gray-500">
Momentum-percentile sweep Residual-momentum percentile sweep
</p> </p>
<p className="mb-2 text-[11px] text-gray-500"> <p className="mb-2 text-[11px] text-gray-500">
How many setups qualify and how they perform at each momentum-rank cutoff (floors How many setups qualify and how they perform at each production-rank cutoff (floors
held fixed). 80 = only the top 20% of the universe by 12-1 momentum each week; 0 = held fixed). 80 = only the top 20% of the universe by residual 12-1 momentum each week; 0 =
floors only. Lower = more trades, watch that expectancy holds. Your current setting is floors only. Lower = more trades, watch that expectancy holds. Your current setting is
highlighted; set it in Admin Settings Activation. highlighted; set it in Admin Settings Activation.
</p> </p>
@@ -320,7 +320,7 @@ export function BacktestPanel() {
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="border-b border-white/[0.06] text-left text-xs uppercase tracking-wider text-gray-500"> <tr className="border-b border-white/[0.06] text-left text-xs uppercase tracking-wider text-gray-500">
<th className="px-4 py-2.5">Min momentum %ile</th> <th className="px-4 py-2.5">Min residual %ile</th>
<th className="px-4 py-2.5 text-right">Qualified</th> <th className="px-4 py-2.5 text-right">Qualified</th>
<th className="px-4 py-2.5 text-right">Wins</th> <th className="px-4 py-2.5 text-right">Wins</th>
<th className="px-4 py-2.5 text-right">Losses</th> <th className="px-4 py-2.5 text-right">Losses</th>
@@ -541,10 +541,7 @@ export function BacktestPanel() {
Strategy variants Strategy variants
</p> </p>
<p className="mb-2 text-[11px] text-gray-500"> <p className="mb-2 text-[11px] text-gray-500">
{report.strategy_variants.note ?? 'Research-only portfolio variants.'}{' '} {report.strategy_variants.note ?? 'Research-only portfolio variants.'}
<span className="text-gray-300">
Residual momentum stays research-only until a variant beats production under the promotion rules.
</span>
</p> </p>
<div className="glass overflow-x-auto"> <div className="glass overflow-x-auto">
<table className="w-full text-sm"> <table className="w-full text-sm">
@@ -24,7 +24,7 @@ export interface FieldPoint {
interface StandingMatrixProps { interface StandingMatrixProps {
symbol: string; symbol: string;
composite: number | null; // X for the highlighted dot (authoritative, from the scores endpoint) composite: number | null; // X for the highlighted dot (authoritative, from the scores endpoint)
momentum: number | null; // Y for the highlighted dot (the ticker's 12-1 momentum percentile) momentum: number | null; // Y for the highlighted dot (residual 12-1 momentum percentile)
field: FieldPoint[]; // every tracked ticker, for the background cloud field: FieldPoint[]; // every tracked ticker, for the background cloud
gateMomentum: number; // Y divider = the activation gate's momentum percentile gateMomentum: number; // Y divider = the activation gate's momentum percentile
status: 'top-pick' | 'qualified' | 'none'; status: 'top-pick' | 'qualified' | 'none';
@@ -186,7 +186,7 @@ export default function StandingMatrix({
<p className="mt-1 text-sm leading-snug text-gray-400">{v.note}</p> <p className="mt-1 text-sm leading-snug text-gray-400">{v.note}</p>
<div className="mt-3 space-y-1 text-xs text-gray-500"> <div className="mt-3 space-y-1 text-xs text-gray-500">
<StatRow label="Quality (composite)" value={`${Math.round(here.composite)}`} /> <StatRow label="Quality (composite)" value={`${Math.round(here.composite)}`} />
<StatRow label="Momentum percentile" value={`${Math.round(here.momentum)}`} /> <StatRow label="Residual momentum percentile" value={`${Math.round(here.momentum)}`} />
{confidence != null && <StatRow label="Long confidence" value={`${Math.round(confidence)}%`} />} {confidence != null && <StatRow label="Long confidence" value={`${Math.round(confidence)}%`} />}
</div> </div>
</> </>
@@ -206,7 +206,7 @@ export default function StandingMatrix({
</div> </div>
<p className="mt-2 text-[11px] leading-relaxed text-gray-600"> <p className="mt-2 text-[11px] leading-relaxed text-gray-600">
Each dot is a tracked ticker; <span className="text-gray-300">this one is highlighted</span>. The dashed line is the Each dot is a tracked ticker; <span className="text-gray-300">this one is highlighted</span>. The dashed line is the
activation gate ({Math.round(gate)}th-pct momentum) above it qualifies for a top pick. Click any peer to open it. activation gate ({Math.round(gate)}th-pct residual momentum) above it qualifies for a top pick. Click any peer to open it.
</p> </p>
</div> </div>
); );
+5 -5
View File
@@ -33,9 +33,9 @@ export function qualifiesSetup(setup: TradeSetup, config: ActivationConfig): boo
return false; return false;
} }
if ((setup.confidence_score ?? 0) < config.min_confidence) return false; if ((setup.confidence_score ?? 0) < config.min_confidence) return false;
// Cross-sectional momentum is the core selection (long-only). While the gate is // Residual cross-sectional momentum is the core selection (long-only). While
// active, shorts never qualify; the percentile floor is enforced only when a // the gate is active, shorts never qualify; the percentile floor is enforced
// percentile is attached, otherwise defer to the floors. // only when a percentile is attached, otherwise defer to the floors.
if (config.min_momentum_percentile > 0) { if (config.min_momentum_percentile > 0) {
if (setup.direction === 'short') return false; if (setup.direction === 'short') return false;
if (setup.momentum_percentile != null && setup.momentum_percentile < config.min_momentum_percentile) { if (setup.momentum_percentile != null && setup.momentum_percentile < config.min_momentum_percentile) {
@@ -53,7 +53,7 @@ export function qualifiesSetup(setup: TradeSetup, config: ActivationConfig): boo
/** /**
* Symbol of the current single 'top pick' — the #1 row the dashboard highlights: * Symbol of the current single 'top pick' — the #1 row the dashboard highlights:
* the highest 12-1 momentum percentile among qualified setups (or among all * the highest residual 12-1 momentum percentile among qualified setups (or among all
* setups when none qualify). Returns null when there are no setups. Keep in step * setups when none qualify). Returns null when there are no setups. Keep in step
* with the Top Setups ranking in DashboardPage. * with the Top Setups ranking in DashboardPage.
*/ */
@@ -74,7 +74,7 @@ export function topPickSymbol(
/** Short human summary of the active gate, e.g. for tooltips/labels. */ /** Short human summary of the active gate, e.g. for tooltips/labels. */
export function activationSummary(config: ActivationConfig): string { export function activationSummary(config: ActivationConfig): string {
const parts = []; const parts = [];
if (config.min_momentum_percentile > 0) parts.push(`top ${(100 - config.min_momentum_percentile).toFixed(0)}% momentum`); if (config.min_momentum_percentile > 0) parts.push(`top ${(100 - config.min_momentum_percentile).toFixed(0)}% residual momentum`);
parts.push(`R:R ≥ ${config.min_rr.toFixed(1)}`, `conf ≥ ${config.min_confidence.toFixed(0)}%`); parts.push(`R:R ≥ ${config.min_rr.toFixed(1)}`, `conf ≥ ${config.min_confidence.toFixed(0)}%`);
if (config.exclude_neutral) parts.push('directional'); if (config.exclude_neutral) parts.push('directional');
if (config.require_high_conviction) parts.push('high-conviction'); if (config.require_high_conviction) parts.push('high-conviction');
+2 -2
View File
@@ -77,7 +77,7 @@ export default function DashboardPage() {
); );
// Show qualified setups first; fall back to the full list when none qualify. // Show qualified setups first; fall back to the full list when none qualify.
// Rank by 12-1 momentum percentile so the strongest names sit at the top. // Rank by residual 12-1 momentum percentile so the strongest names sit at the top.
const showingQualified = qualifiedSetups.length > 0; const showingQualified = qualifiedSetups.length > 0;
const topSetups: TradeSetup[] = useMemo(() => { const topSetups: TradeSetup[] = useMemo(() => {
const pool = showingQualified ? qualifiedSetups : trades.data ?? []; const pool = showingQualified ? qualifiedSetups : trades.data ?? [];
@@ -214,7 +214,7 @@ export default function DashboardPage() {
<th className="px-4 py-3 text-right">Entry</th> <th className="px-4 py-3 text-right">Entry</th>
<th className="px-4 py-3 text-right">R:R</th> <th className="px-4 py-3 text-right">R:R</th>
<th className="px-4 py-3 text-right">Target&nbsp;Prob</th> <th className="px-4 py-3 text-right">Target&nbsp;Prob</th>
<th className="px-4 py-3 text-right">Momentum</th> <th className="px-4 py-3 text-right">Residual Mom.</th>
<th className="hidden px-4 py-3 md:table-cell">Action</th> <th className="hidden px-4 py-3 md:table-cell">Action</th>
</tr> </tr>
</thead> </thead>
+2 -2
View File
@@ -216,7 +216,7 @@ export default function TickerDetailPage() {
[setupsForSymbol], [setupsForSymbol],
); );
// Standing matrix: this ticker's momentum percentile + long confidence (from its // Standing matrix: this ticker's residual momentum percentile + long confidence (from its
// setup), the field (every ticker's composite × momentum) for the cloud, and // setup), the field (every ticker's composite × momentum) for the cloud, and
// whether it qualifies / is the top pick. // whether it qualifies / is the top pick.
const myMomentum = longSetup?.momentum_percentile ?? shortSetup?.momentum_percentile ?? null; const myMomentum = longSetup?.momentum_percentile ?? shortSetup?.momentum_percentile ?? null;
@@ -296,7 +296,7 @@ export default function TickerDetailPage() {
<StatusPill <StatusPill
tone="blue" tone="blue"
label="★ Top Pick" label="★ Top Pick"
title="Current top pick — highest-momentum qualified setup right now" title="Current top pick — highest residual-momentum qualified setup right now"
/> />
)} )}
{hasOpenTrade && ( {hasOpenTrade && (
+32 -16
View File
@@ -118,14 +118,30 @@ def test_assigns_raw_and_residual_percentiles_independently():
assert by_resid[0.10] == 0.0 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(): def test_strategy_variants_keep_only_current_research_candidates():
variants = {cfg["variant"]: cfg for cfg in bt.STRATEGY_VARIANTS} 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 "raw_80_regime_scaled" not in variants
assert "residual_80_regime_scaled" not in variants assert "residual_80_regime_scaled" not in variants
assert "residual_90_fixed10" not in variants assert "residual_90_fixed10" not in variants
assert variants["raw_90_fixed15"]["max_positions"] == 15 assert "raw_90_fixed15" not in variants
assert variants["residual_80_fixed20"]["max_positions"] == 20 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) 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", "direction": "long",
"momentum_percentile": 90.0, "momentum_percentile": 90.0,
"residual_momentum_percentile": 91.0, "residual_momentum_percentile": 91.0,
"activation_momentum_percentile": 91.0,
}] }]
calls = [] 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 [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 all(call["exit_policy"] == "hold" for call in calls)
assert any(call["ranking_key"] == "residual_momentum_percentile" for call in calls) assert any(call["ranking_key"] == bt.PRODUCTION_PERCENTILE_KEY for call in calls)
assert any(call["max_positions"] == 20 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 assert cands[0]["qualified"] is False
def test_build_research_recommendation_applies_promotion_rules(): def test_build_research_recommendation_applies_promotion_rules():
report = { report = {
"strategy_variants": {"variants": [ "strategy_variants": {"variants": [
{"variant": "production_raw_80_fixed10", "label": "Base", "sharpe": 1.20, {"variant": "production_residual_80_fixed10", "label": "Base", "sharpe": 1.40,
"max_drawdown_pct": 20.0, "cagr_pct": 30.0}, "max_drawdown_pct": 20.0, "cagr_pct": 32.0, "skipped_book_full": 7},
{"variant": "residual_80_fixed10", "label": "Residual", "sharpe": 1.35, {"variant": "residual_80_fixed15", "label": "Capacity", "sharpe": 1.39,
"max_drawdown_pct": 21.0, "cagr_pct": 31.0, "risk_scale": None}, "max_drawdown_pct": 20.0, "cagr_pct": 32.0, "skipped_book_full": 0},
{"variant": "residual_80_fixed20", "label": "Residual 20", "sharpe": 1.40,
"max_drawdown_pct": 20.5, "cagr_pct": 32.0, "risk_scale": None},
{"variant": "raw_90_fixed10", "label": "Cutoff 90", "sharpe": 1.25, {"variant": "raw_90_fixed10", "label": "Cutoff 90", "sharpe": 1.25,
"max_drawdown_pct": 19.0, "cagr_pct": 28.0}, "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) rec = bt._build_research_recommendation(report)
by_topic = {item["topic"]: item for item in rec["items"]} by_topic = {item["topic"]: item for item in rec["items"]}
assert by_topic["residual_momentum"]["candidate"] is True assert by_topic["capacity_15"]["candidate"] is False
assert "Residual 20" in by_topic["residual_momentum"]["text"] assert "not needed yet" in by_topic["capacity_15"]["text"]
assert by_topic["cutoff_90"]["candidate"] is True assert by_topic["cutoff_90"]["candidate"] is False
assert "Cutoff 90 / 15" in by_topic["cutoff_90"]["text"] assert "Cutoff 90" in by_topic["cutoff_90"]["text"]
class TestStopFillR: class TestStopFillR:
@@ -305,6 +319,7 @@ def _acand(
"confidence": conf, "confidence": conf,
"action": action, "action": action,
"momentum_percentile": mp, "momentum_percentile": mp,
"activation_momentum_percentile": mp,
"direction": direction, "direction": direction,
"meets_core": meets, "meets_core": meets,
"risk_level": "Low", "risk_level": "Low",
@@ -380,6 +395,7 @@ def _sim_cand(
"stop": stop, "stop": stop,
"target": target, "target": target,
"momentum_percentile": mp, "momentum_percentile": mp,
"activation_momentum_percentile": mp,
} }
+51 -4
View File
@@ -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 from __future__ import annotations
@@ -35,6 +35,21 @@ async def _seed(session, symbol: str, rate: float, n: int = 280) -> None:
await session.commit() 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(): def test_compute_momentum_insufficient_history():
assert ms.compute_12_1_momentum([100.0] * 100) is None assert ms.compute_12_1_momentum([100.0] * 100) is None
@@ -47,7 +62,11 @@ def test_compute_momentum_value():
assert m > 0 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, "HIGH", rate=1.010) # strong uptrend → top momentum
await _seed(session, "MID", rate=1.002) await _seed(session, "MID", rate=1.002)
await _seed(session, "LOW", rate=0.999) # declining → bottom momentum 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 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, "LONG", rate=1.005)
await _seed(session, "SHORTHX", rate=1.005, n=100) # < 1y → no momentum 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 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) == {} assert await ms.compute_momentum_percentiles(session) == {}
+3 -3
View File
@@ -1,9 +1,9 @@
"""Tests for sentiment-collection scoping (``_get_sentiment_priority_tickers``). """Tests for sentiment-collection scoping (``_get_sentiment_priority_tickers``).
A dashboard 'top pick' is the highest-momentum *qualified* long setup. Sentiment A dashboard 'top pick' is the highest residual-momentum *qualified* long setup. Sentiment
can never move a ticker's momentum percentile (the gate's core axis) — only its 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 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 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. (always refreshed, cap-exempt) and the capped filler tier behind it.
""" """