promote residual momentum ranking
This commit is contained in:
@@ -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 12‑1 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 12‑1 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 (0–100) combine into a weighted composite (weights configurable; missing dimensions re-normalize).
|
1. **Composite score** — technical, S/R-quality, sentiment, fundamental and momentum sub-scores (0–100) 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
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
# (0–100, 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
@@ -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.
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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,9 +848,23 @@ 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)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -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),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 (0–100, 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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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 Prob</th>
|
<th className="px-4 py-3 text-right">Target 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>
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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) == {}
|
||||||
|
|||||||
@@ -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.
|
||||||
"""
|
"""
|
||||||
|
|||||||
Reference in New Issue
Block a user