replace EV activation gate with cross-sectional 12-1 momentum ranking
The 5-year backtest confirmed the EV gate adds negative value (high threshold = worst expectancy) and that 12-1 month momentum is the one price signal with a plausible, right-signed cross-sectional IC (~0.05). So "qualified" now means: clears the R:R + confidence floors AND the ticker ranks in the top `min_momentum_percentile` of the universe by 12-1 momentum that week. - qualification.py: drop expected_value_r / the EV gate; add a momentum-percentile gate (duck-typed `momentum_percentile`, only enforced when attached + threshold set, else defers to floors). Mirrored in frontend qualification.ts. - activation config/schema: min_expected_value -> min_momentum_percentile (default 80 = top quintile). ActivationSettings, DashboardPage (ranks/【shows】 momentum instead of EV), and the BacktestPanel sweep follow. - backtest: rank each ISO week's universe by 12-1 momentum, assign a percentile, and qualify the top slice; the sweep now sweeps the percentile cutoff. Also offload the backtest's per-ticker compute to a worker thread so the heavy ~5y run no longer blocks the API event loop (the "backend offline" flicker). Production setups don't carry momentum_percentile yet — wiring the scanner to attach it (a universe momentum-rank step) is the next step; until then the live gate defers to floors while the backtest measures the momentum selection. 330 backend tests pass; frontend build clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -59,7 +59,7 @@ class TickerUniverseUpdate(BaseModel):
|
|||||||
|
|
||||||
class ActivationConfigUpdate(BaseModel):
|
class ActivationConfigUpdate(BaseModel):
|
||||||
"""Activation gate: what counts as an actionable signal."""
|
"""Activation gate: what counts as an actionable signal."""
|
||||||
min_expected_value: float | None = Field(default=None, ge=-1, le=10)
|
min_momentum_percentile: float | None = Field(default=None, ge=0, le=100)
|
||||||
min_rr: float | None = Field(default=None, ge=0)
|
min_rr: float | None = Field(default=None, ge=0)
|
||||||
min_confidence: float | None = Field(default=None, ge=0, le=100)
|
min_confidence: float | None = Field(default=None, ge=0, le=100)
|
||||||
min_target_probability: float | None = Field(default=None, ge=0, le=100)
|
min_target_probability: float | None = Field(default=None, ge=0, le=100)
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ SUPPORTED_TICKER_UNIVERSES = {"sp500", "nasdaq100", "nasdaq_all"}
|
|||||||
# confidence are floors; high-conviction / clean-read / target-probability are
|
# confidence are floors; high-conviction / clean-read / target-probability are
|
||||||
# optional tighteners (off by default — turn on to be more selective).
|
# optional tighteners (off by default — turn on to be more selective).
|
||||||
_ACTIVATION_FLOAT_KEYS: dict[str, str] = {
|
_ACTIVATION_FLOAT_KEYS: dict[str, str] = {
|
||||||
"min_expected_value": "activation_min_expected_value",
|
"min_momentum_percentile": "activation_min_momentum_percentile",
|
||||||
"min_rr": "activation_min_rr",
|
"min_rr": "activation_min_rr",
|
||||||
"min_confidence": "activation_min_confidence",
|
"min_confidence": "activation_min_confidence",
|
||||||
"min_target_probability": "activation_min_target_probability",
|
"min_target_probability": "activation_min_target_probability",
|
||||||
@@ -53,7 +53,7 @@ _ACTIVATION_BOOL_KEYS: dict[str, str] = {
|
|||||||
"exclude_conflicts": "activation_exclude_conflicts",
|
"exclude_conflicts": "activation_exclude_conflicts",
|
||||||
}
|
}
|
||||||
ACTIVATION_DEFAULTS: dict[str, float | bool] = {
|
ACTIVATION_DEFAULTS: dict[str, float | bool] = {
|
||||||
"min_expected_value": 0.15,
|
"min_momentum_percentile": 80.0,
|
||||||
"min_rr": 1.2,
|
"min_rr": 1.2,
|
||||||
"min_confidence": 55.0,
|
"min_confidence": 55.0,
|
||||||
"min_target_probability": 0.0,
|
"min_target_probability": 0.0,
|
||||||
@@ -201,8 +201,8 @@ async def update_activation_config(
|
|||||||
db: AsyncSession, updates: dict[str, float | bool]
|
db: AsyncSession, updates: dict[str, float | bool]
|
||||||
) -> dict[str, float | bool]:
|
) -> dict[str, float | bool]:
|
||||||
"""Update the activation gate. Accepts public keys; only supplied keys change."""
|
"""Update the activation gate. Accepts public keys; only supplied keys change."""
|
||||||
if "min_expected_value" in updates and not -1.0 <= updates["min_expected_value"] <= 10.0:
|
if "min_momentum_percentile" in updates and not 0 <= updates["min_momentum_percentile"] <= 100:
|
||||||
raise ValidationError("min_expected_value must be between -1 and 10 (R units)")
|
raise ValidationError("min_momentum_percentile must be between 0 and 100")
|
||||||
if "min_rr" in updates and updates["min_rr"] < 0:
|
if "min_rr" in updates and updates["min_rr"] < 0:
|
||||||
raise ValidationError("min_rr must be >= 0")
|
raise ValidationError("min_rr must be >= 0")
|
||||||
if "min_confidence" in updates and not 0 <= updates["min_confidence"] <= 100:
|
if "min_confidence" in updates and not 0 <= updates["min_confidence"] <= 100:
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ held neutral here — this calibrates the price/S-R/probability machinery only.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import math
|
import math
|
||||||
@@ -40,7 +41,6 @@ from app.services.outcome_service import (
|
|||||||
from app.services.price_service import query_ohlcv
|
from app.services.price_service import query_ohlcv
|
||||||
from app.services.qualification import (
|
from app.services.qualification import (
|
||||||
best_target_probability,
|
best_target_probability,
|
||||||
expected_value_r,
|
|
||||||
setup_qualifies,
|
setup_qualifies,
|
||||||
)
|
)
|
||||||
from app.services.recommendation_service import (
|
from app.services.recommendation_service import (
|
||||||
@@ -110,6 +110,14 @@ def _window_setups(
|
|||||||
if entry <= 0:
|
if entry <= 0:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
# 12-1 month momentum (skip the last month) — the universe ranks on this.
|
||||||
|
# None until a year of history exists; such setups can't qualify on momentum.
|
||||||
|
mom_12_1 = (
|
||||||
|
closes[-22] / closes[-253] - 1.0
|
||||||
|
if len(closes) >= 253 and closes[-253] > 0
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
atr = compute_atr(highs, lows, closes)["atr"]
|
atr = compute_atr(highs, lows, closes)["atr"]
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -180,13 +188,12 @@ def _window_setups(
|
|||||||
stop_loss=stop,
|
stop_loss=stop,
|
||||||
entry_price=entry,
|
entry_price=entry,
|
||||||
)
|
)
|
||||||
# meets_core = clears every gate EXCEPT the expected-value floor, so the
|
# meets_core = clears every gate EXCEPT the cross-sectional momentum
|
||||||
# report can sweep the min_expected_value threshold without re-replaying.
|
# percentile, which can only be assigned once all tickers' setups for a
|
||||||
core_config = {**activation, "min_expected_value": float("-inf")}
|
# week are known. run_backtest ranks momentum and finalizes `qualified`.
|
||||||
|
core_config = {**activation, "min_momentum_percentile": 0.0}
|
||||||
meets_core = setup_qualifies(setup_ns, core_config)
|
meets_core = setup_qualifies(setup_ns, core_config)
|
||||||
ev = expected_value_r(setup_ns)
|
|
||||||
best_prob = best_target_probability(setup_ns)
|
best_prob = best_target_probability(setup_ns)
|
||||||
min_ev = float(activation.get("min_expected_value", 0.0))
|
|
||||||
out.append({
|
out.append({
|
||||||
"direction": direction,
|
"direction": direction,
|
||||||
"entry": entry,
|
"entry": entry,
|
||||||
@@ -196,11 +203,10 @@ def _window_setups(
|
|||||||
"confidence": confidences[direction],
|
"confidence": confidences[direction],
|
||||||
"primary_prob": float(primary["probability"]),
|
"primary_prob": float(primary["probability"]),
|
||||||
"best_prob": best_prob,
|
"best_prob": best_prob,
|
||||||
"ev": ev,
|
"momentum": mom_12_1,
|
||||||
"meets_core": meets_core,
|
"meets_core": meets_core,
|
||||||
"action": action,
|
"action": action,
|
||||||
"risk_level": risk_level,
|
"risk_level": risk_level,
|
||||||
"qualified": meets_core and ev is not None and ev >= min_ev,
|
|
||||||
})
|
})
|
||||||
return out
|
return out
|
||||||
|
|
||||||
@@ -230,17 +236,18 @@ def _replay_ticker(symbol: str, records: list, config: dict, activation: dict) -
|
|||||||
realized_r = -1.0
|
realized_r = -1.0
|
||||||
else: # expired
|
else: # expired
|
||||||
realized_r = 0.0
|
realized_r = 0.0
|
||||||
|
iso = records[i].date.isocalendar()
|
||||||
candidates.append({
|
candidates.append({
|
||||||
"symbol": symbol,
|
"symbol": symbol,
|
||||||
"date": records[i].date.isoformat(),
|
"date": records[i].date.isoformat(),
|
||||||
|
"iso_week": (iso[0], iso[1]),
|
||||||
"direction": s["direction"],
|
"direction": s["direction"],
|
||||||
"rr": s["rr"],
|
"rr": s["rr"],
|
||||||
"confidence": s["confidence"],
|
"confidence": s["confidence"],
|
||||||
"primary_prob": s["primary_prob"],
|
"primary_prob": s["primary_prob"],
|
||||||
"best_prob": s["best_prob"],
|
"best_prob": s["best_prob"],
|
||||||
"ev": s["ev"],
|
"momentum": s["momentum"],
|
||||||
"meets_core": s["meets_core"],
|
"meets_core": s["meets_core"],
|
||||||
"qualified": s["qualified"],
|
|
||||||
"outcome": outcome,
|
"outcome": outcome,
|
||||||
"target_hit": target_hit,
|
"target_hit": target_hit,
|
||||||
"realized_r": realized_r,
|
"realized_r": realized_r,
|
||||||
@@ -484,6 +491,49 @@ def _signal_evaluation(collected: dict) -> list[dict]:
|
|||||||
return rows
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def _process_ticker(
|
||||||
|
symbol: str,
|
||||||
|
records: list,
|
||||||
|
config: dict,
|
||||||
|
activation: dict,
|
||||||
|
collected: dict,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""The CPU-bound per-ticker work — replay + signal accumulation — bundled so
|
||||||
|
run_backtest can hand it to a worker thread. Mutates ``collected``."""
|
||||||
|
cands = _replay_ticker(symbol, records, config, activation)
|
||||||
|
_accumulate_signal_series(records, collected)
|
||||||
|
return cands
|
||||||
|
|
||||||
|
|
||||||
|
def _assign_momentum_percentiles(candidates: list[dict]) -> None:
|
||||||
|
"""Per ISO week, rank candidates by their ticker's 12-1 momentum and attach a
|
||||||
|
0–100 ``momentum_percentile`` (100 = highest momentum in the universe that
|
||||||
|
week). Candidates whose momentum is unknown (insufficient lookback) get None
|
||||||
|
and therefore can't clear a momentum gate. Mutates ``candidates``."""
|
||||||
|
by_week: dict = defaultdict(list)
|
||||||
|
for c in candidates:
|
||||||
|
if c.get("momentum") is not None:
|
||||||
|
by_week[c["iso_week"]].append(c)
|
||||||
|
for group in by_week.values():
|
||||||
|
ordered = sorted(group, key=lambda c: c["momentum"])
|
||||||
|
n = len(ordered)
|
||||||
|
for rank, c in enumerate(ordered):
|
||||||
|
c["momentum_percentile"] = (rank / (n - 1) * 100.0) if n > 1 else 100.0
|
||||||
|
for c in candidates:
|
||||||
|
c.setdefault("momentum_percentile", None)
|
||||||
|
|
||||||
|
|
||||||
|
def _momentum_qualifies(cand: dict, threshold: float) -> bool:
|
||||||
|
"""Whether a candidate clears the floors (meets_core) and the momentum gate.
|
||||||
|
Threshold 0 disables the momentum gate (floors only)."""
|
||||||
|
if not cand["meets_core"]:
|
||||||
|
return False
|
||||||
|
if threshold <= 0:
|
||||||
|
return True
|
||||||
|
mp = cand.get("momentum_percentile")
|
||||||
|
return mp is not None and mp >= threshold
|
||||||
|
|
||||||
|
|
||||||
async def run_backtest(
|
async def run_backtest(
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
progress_cb: Callable[[int, int, str], None] | None = None,
|
progress_cb: Callable[[int, int, str], None] | None = None,
|
||||||
@@ -504,29 +554,50 @@ async def run_backtest(
|
|||||||
progress_cb(index, total, ticker.symbol)
|
progress_cb(index, total, ticker.symbol)
|
||||||
try:
|
try:
|
||||||
records = await query_ohlcv(db, ticker.symbol)
|
records = await query_ohlcv(db, ticker.symbol)
|
||||||
candidates.extend(_replay_ticker(ticker.symbol, records, config, activation))
|
# Detach the ORM rows to plain objects in the event loop (safe to read
|
||||||
_accumulate_signal_series(records, collected)
|
# here), then run the heavy replay in a worker thread. The compute is
|
||||||
|
# CPU-bound and used to block the event loop — and the API server with
|
||||||
|
# it — for the whole run; offloading lets CPython hand the GIL back to
|
||||||
|
# the loop every few ms so health checks / page loads stay responsive.
|
||||||
|
bars = [
|
||||||
|
SimpleNamespace(
|
||||||
|
date=r.date,
|
||||||
|
open=float(r.open),
|
||||||
|
high=float(r.high),
|
||||||
|
low=float(r.low),
|
||||||
|
close=float(r.close),
|
||||||
|
volume=int(r.volume),
|
||||||
|
)
|
||||||
|
for r in records
|
||||||
|
]
|
||||||
|
cands = await asyncio.to_thread(
|
||||||
|
_process_ticker, ticker.symbol, bars, config, activation, collected
|
||||||
|
)
|
||||||
|
candidates.extend(cands)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Backtest replay failed for %s", ticker.symbol)
|
logger.exception("Backtest replay failed for %s", ticker.symbol)
|
||||||
|
|
||||||
if progress_cb is not None and total:
|
if progress_cb is not None and total:
|
||||||
progress_cb(total, total, "")
|
progress_cb(total, total, "")
|
||||||
|
|
||||||
|
# Cross-sectional momentum: rank every week's universe, then "qualified" means
|
||||||
|
# floors + top ``min_momentum_percentile`` by 12-1 momentum.
|
||||||
|
_assign_momentum_percentiles(candidates)
|
||||||
|
current_min_pct = float(activation.get("min_momentum_percentile", 80.0))
|
||||||
|
for c in candidates:
|
||||||
|
c["qualified"] = _momentum_qualifies(c, current_min_pct)
|
||||||
|
|
||||||
qualified = [c for c in candidates if c["qualified"]]
|
qualified = [c for c in candidates if c["qualified"]]
|
||||||
longs = [c for c in qualified if c["direction"] == "long"]
|
longs = [c for c in qualified if c["direction"] == "long"]
|
||||||
shorts = [c for c in qualified if c["direction"] == "short"]
|
shorts = [c for c in qualified if c["direction"] == "short"]
|
||||||
|
|
||||||
# Threshold sweep: re-apply the gate at several min_expected_value values
|
# Threshold sweep: re-apply the momentum gate at several percentile cutoffs
|
||||||
# (holding the other conditions fixed) so the trade-off between how many
|
# (floors held fixed) so the trade-off between how many setups qualify and
|
||||||
# setups qualify and their expectancy is visible without re-replaying.
|
# their expectancy is visible without re-replaying. 0 = floors only.
|
||||||
current_min_ev = float(activation.get("min_expected_value", 0.15))
|
|
||||||
sweep = []
|
sweep = []
|
||||||
for threshold in (0.4, 0.3, 0.25, 0.2, 0.15, 0.1, 0.05, 0.0):
|
for threshold in (90.0, 80.0, 70.0, 60.0, 50.0, 0.0):
|
||||||
cands = [
|
cands = [c for c in candidates if _momentum_qualifies(c, threshold)]
|
||||||
c for c in candidates
|
sweep.append({"min_momentum_percentile": threshold, **_bucket_stats(cands)})
|
||||||
if c["meets_core"] and c["ev"] is not None and c["ev"] >= threshold
|
|
||||||
]
|
|
||||||
sweep.append({"min_expected_value": threshold, **_bucket_stats(cands)})
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"generated_at": datetime.now(timezone.utc).isoformat(),
|
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||||||
@@ -541,7 +612,7 @@ async def run_backtest(
|
|||||||
"long": _bucket_stats(longs),
|
"long": _bucket_stats(longs),
|
||||||
"short": _bucket_stats(shorts),
|
"short": _bucket_stats(shorts),
|
||||||
},
|
},
|
||||||
"min_expected_value": current_min_ev,
|
"min_momentum_percentile": current_min_pct,
|
||||||
"sweep": sweep,
|
"sweep": sweep,
|
||||||
"calibration": _calibration(candidates),
|
"calibration": _calibration(candidates),
|
||||||
"signal_eval": _signal_evaluation(collected),
|
"signal_eval": _signal_evaluation(collected),
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
"""Shared definition of a 'qualified' (actionable) trade setup.
|
"""Shared definition of a 'qualified' (actionable) trade setup.
|
||||||
|
|
||||||
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 gate is
|
performance stats (server) and mirrored on the frontend. The core selection is
|
||||||
expected value (in R): a setup must promise positive, probability-weighted
|
cross-sectional momentum: a setup's ticker must rank in the top
|
||||||
asymmetry, not just a fat-but-improbable target or a likely-but-thin one. R:R
|
``min_momentum_percentile`` of the universe by 12-1 month momentum — the one
|
||||||
and confidence remain as floors, and conviction/conflict/target-probability
|
signal the backtest showed actually sorts forward returns. R:R and confidence
|
||||||
survive as optional tighteners (off by default).
|
remain as floors, and conviction/conflict/target-probability survive as optional
|
||||||
|
tighteners (off by default). The momentum percentile is computed across the
|
||||||
|
universe and attached to each setup upstream; when it's absent the gate falls
|
||||||
|
back to the floors.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -22,37 +25,6 @@ def best_target_probability(setup: Any) -> float:
|
|||||||
return max(probs, default=0.0)
|
return max(probs, default=0.0)
|
||||||
|
|
||||||
|
|
||||||
def primary_target_probability(setup: Any) -> float | None:
|
|
||||||
"""Probability of the starred primary target (the one the headline R:R refers
|
|
||||||
to). Falls back to the best target's probability when none is flagged primary,
|
|
||||||
and None when there are no targets at all (probability unknowable).
|
|
||||||
"""
|
|
||||||
targets = getattr(setup, "targets", None) or []
|
|
||||||
primary = next(
|
|
||||||
(t for t in targets if isinstance(t, dict) and t.get("is_primary")), None
|
|
||||||
)
|
|
||||||
if primary is not None:
|
|
||||||
return float(primary.get("probability", 0.0))
|
|
||||||
probs = [float(t.get("probability", 0.0)) for t in targets if isinstance(t, dict)]
|
|
||||||
return max(probs) if probs else None
|
|
||||||
|
|
||||||
|
|
||||||
def expected_value_r(setup: Any) -> float | None:
|
|
||||||
"""Expected value per unit of risk, in R: ``p·(R:R) − (1 − p)``.
|
|
||||||
|
|
||||||
``p`` is the primary target's hit probability. This single number captures
|
|
||||||
"is this worth taking": it rewards both a good payoff ratio and a likely
|
|
||||||
target, so a fat-but-improbable target can't outrank a solid, probable one —
|
|
||||||
and a high R:R no longer fights a high probability the way the old separate
|
|
||||||
gates did. Returns None when no target probability is known.
|
|
||||||
"""
|
|
||||||
p = primary_target_probability(setup)
|
|
||||||
if p is None:
|
|
||||||
return None
|
|
||||||
p = p / 100.0
|
|
||||||
return p * setup.rr_ratio - (1.0 - p)
|
|
||||||
|
|
||||||
|
|
||||||
def live_risk_reward(setup: Any, current_price: float) -> float | None:
|
def live_risk_reward(setup: Any, current_price: float) -> float | None:
|
||||||
"""R:R recomputed from the CURRENT price, not the (possibly stale) entry.
|
"""R:R recomputed from the CURRENT price, not the (possibly stale) entry.
|
||||||
|
|
||||||
@@ -77,10 +49,10 @@ def setup_qualifies(setup: Any, config: dict) -> bool:
|
|||||||
``setup`` is duck-typed: any object exposing rr_ratio, confidence_score,
|
``setup`` is duck-typed: any object exposing rr_ratio, confidence_score,
|
||||||
recommended_action, risk_level and a ``targets`` list of dicts.
|
recommended_action, risk_level and a ``targets`` list of dicts.
|
||||||
|
|
||||||
Gate order: R:R floor → freshness (live R:R) → confidence floor → expected
|
Gate order: R:R floor → freshness (live R:R) → confidence floor → momentum
|
||||||
value (the core test) → optional conviction / conflict / target-probability
|
percentile (the core selection) → optional conviction / conflict /
|
||||||
tighteners. ``min_expected_value`` defaults to -inf for callers that pass a
|
target-probability tighteners. ``min_momentum_percentile`` defaults to 0 (off)
|
||||||
legacy config without the key, so they behave exactly as before.
|
for callers that pass a legacy config without the key.
|
||||||
"""
|
"""
|
||||||
if setup.rr_ratio < config["min_rr"]:
|
if setup.rr_ratio < config["min_rr"]:
|
||||||
return False
|
return False
|
||||||
@@ -94,13 +66,15 @@ 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
|
||||||
# Expected value (R): the core gate. Only enforced when computable — setups
|
# Cross-sectional momentum: the core selection. A setup's ticker must rank in
|
||||||
# without target probabilities (e.g. legacy historical rows) defer to the
|
# the top ``min_momentum_percentile`` of the universe by 12-1 momentum. Only
|
||||||
# R:R + confidence floors above rather than being silently dropped.
|
# enforced when a percentile is attached (live setups / backtest) and a
|
||||||
min_ev = float(config.get("min_expected_value", float("-inf")))
|
# threshold is set; callers that don't attach it defer to the floors above.
|
||||||
ev = expected_value_r(setup)
|
min_pct = float(config.get("min_momentum_percentile", 0.0))
|
||||||
if ev is not None and ev < min_ev:
|
if min_pct > 0:
|
||||||
return False
|
momentum_percentile = getattr(setup, "momentum_percentile", None)
|
||||||
|
if momentum_percentile is not None and momentum_percentile < min_pct:
|
||||||
|
return False
|
||||||
if config.get("require_high_conviction"):
|
if config.get("require_high_conviction"):
|
||||||
if (setup.recommended_action or "") not in HIGH_CONVICTION_ACTIONS:
|
if (setup.recommended_action or "") not in HIGH_CONVICTION_ACTIONS:
|
||||||
return False
|
return False
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useActivationSettings, useUpdateActivationSettings } from '../../hooks/
|
|||||||
import { SkeletonTable } from '../ui/Skeleton';
|
import { SkeletonTable } from '../ui/Skeleton';
|
||||||
|
|
||||||
const DEFAULTS: ActivationConfig = {
|
const DEFAULTS: ActivationConfig = {
|
||||||
min_expected_value: 0.15,
|
min_momentum_percentile: 80,
|
||||||
min_rr: 1.2,
|
min_rr: 1.2,
|
||||||
min_confidence: 55,
|
min_confidence: 55,
|
||||||
min_target_probability: 0,
|
min_target_probability: 0,
|
||||||
@@ -40,26 +40,27 @@ export function ActivationSettings() {
|
|||||||
<h3 className="text-sm font-semibold text-gray-200">Activation Gate</h3>
|
<h3 className="text-sm font-semibold text-gray-200">Activation Gate</h3>
|
||||||
<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 test is
|
Signals "Qualified only" view, and the Track Record's qualified stats. The core selection is
|
||||||
<span className="text-gray-300"> expected value</span> — probability-weighted asymmetry —
|
<span className="text-gray-300"> cross-sectional momentum</span> — the ticker must rank in the
|
||||||
so R:R and target probability no longer fight each other. All setups are still evaluated
|
top slice of the universe by 12-1 month momentum, the one signal the backtest showed predicts
|
||||||
regardless; tune the EV floor against the Track Record's EV sweep to see what actually wins.
|
forward returns. R:R and confidence stay as floors. Tune the cutoff against the Track Record's
|
||||||
|
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 Expected Value (R)</span>
|
<span className="text-xs text-gray-400">Min Momentum Percentile</span>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min={-1}
|
min={0}
|
||||||
max={10}
|
max={100}
|
||||||
step={0.05}
|
step={5}
|
||||||
value={form.min_expected_value}
|
value={form.min_momentum_percentile}
|
||||||
onChange={(e) => setForm((prev) => ({ ...prev, min_expected_value: 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">p·R:R − (1−p), in R. 0.15 ≈ +0.15× risk/trade. The core gate.</span>
|
<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>
|
||||||
</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>
|
||||||
@@ -89,7 +90,7 @@ export function ActivationSettings() {
|
|||||||
|
|
||||||
<div className="border-t border-white/[0.06] pt-4">
|
<div className="border-t border-white/[0.06] pt-4">
|
||||||
<p className="text-xs font-medium uppercase tracking-widest text-gray-500">Optional tighteners</p>
|
<p className="text-xs font-medium uppercase tracking-widest text-gray-500">Optional tighteners</p>
|
||||||
<p className="mt-1 text-[11px] text-gray-600">Off by default — turn on to be more selective on top of the EV gate.</p>
|
<p className="mt-1 text-[11px] text-gray-600">Off by default — turn on to be more selective on top of the momentum gate.</p>
|
||||||
<div className="mt-3 grid gap-3 md:grid-cols-3">
|
<div className="mt-3 grid gap-3 md:grid-cols-3">
|
||||||
<label className="block space-y-1">
|
<label className="block space-y-1">
|
||||||
<span className="text-xs text-gray-400">Min Target Probability (%)</span>
|
<span className="text-xs text-gray-400">Min Target Probability (%)</span>
|
||||||
|
|||||||
@@ -184,19 +184,19 @@ export function BacktestPanel() {
|
|||||||
{report.sweep && report.sweep.length > 0 && (
|
{report.sweep && report.sweep.length > 0 && (
|
||||||
<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">
|
||||||
Min expected-value sweep
|
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 expected-value gate (other
|
How many setups qualify — and how they perform — at each momentum-rank cutoff (floors
|
||||||
gate conditions held fixed). EV is in R: 0.15 means +0.15× your risk per trade on
|
held fixed). 80 = only the top 20% of the universe by 12-1 momentum each week; 0 =
|
||||||
average. 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>
|
||||||
<div className="glass overflow-x-auto">
|
<div className="glass overflow-x-auto">
|
||||||
<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 EV (R)</th>
|
<th className="px-4 py-2.5">Min momentum %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>
|
||||||
@@ -207,12 +207,12 @@ export function BacktestPanel() {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{report.sweep.map((row) => {
|
{report.sweep.map((row) => {
|
||||||
const current = Math.abs(row.min_expected_value - report.min_expected_value) < 0.001;
|
const current = Math.abs(row.min_momentum_percentile - report.min_momentum_percentile) < 0.001;
|
||||||
return (
|
return (
|
||||||
<tr key={row.min_expected_value} className={`border-b border-white/[0.04] ${current ? 'bg-blue-400/10' : ''}`}>
|
<tr key={row.min_momentum_percentile} className={`border-b border-white/[0.04] ${current ? 'bg-blue-400/10' : ''}`}>
|
||||||
<td className="num px-4 py-2.5 text-gray-200">
|
<td className="num px-4 py-2.5 text-gray-200">
|
||||||
{current && <span className="mr-1 text-blue-300">★</span>}
|
{current && <span className="mr-1 text-blue-300">★</span>}
|
||||||
{row.min_expected_value.toFixed(2)}
|
{row.min_momentum_percentile.toFixed(0)}
|
||||||
</td>
|
</td>
|
||||||
<td className="num px-4 py-2.5 text-right text-gray-200">{row.total}</td>
|
<td className="num px-4 py-2.5 text-right text-gray-200">{row.total}</td>
|
||||||
<td className="num px-4 py-2.5 text-right text-emerald-400">{row.wins}</td>
|
<td className="num px-4 py-2.5 text-right text-emerald-400">{row.wins}</td>
|
||||||
|
|||||||
@@ -13,21 +13,6 @@ export function primaryTargetProbability(setup: TradeSetup): number | null {
|
|||||||
return setup.targets?.length ? bestTargetProbability(setup) : null;
|
return setup.targets?.length ? bestTargetProbability(setup) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Expected value per unit of risk, in R. Probability-weighted payoff:
|
|
||||||
* EV = p·(R:R) − (1 − p)
|
|
||||||
* where p is the primary target's hit probability. This is the single "is this
|
|
||||||
* worth taking" number — it rewards both a good payoff ratio and a likely
|
|
||||||
* target, so a fat-but-improbable target can't outrank a solid, probable one.
|
|
||||||
* Returns null when no target probability is known.
|
|
||||||
*/
|
|
||||||
export function expectedValueR(setup: TradeSetup): number | null {
|
|
||||||
const prob = primaryTargetProbability(setup);
|
|
||||||
if (prob == null) return null;
|
|
||||||
const p = prob / 100;
|
|
||||||
return p * setup.rr_ratio - (1 - p);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** R:R recomputed from the current price (0 if no reward/risk left). */
|
/** R:R recomputed from the current price (0 if no reward/risk left). */
|
||||||
export function liveRiskReward(setup: TradeSetup, currentPrice: number): number {
|
export function liveRiskReward(setup: TradeSetup, currentPrice: number): number {
|
||||||
const reward = setup.direction === 'long' ? setup.target - currentPrice : currentPrice - setup.target;
|
const reward = setup.direction === 'long' ? setup.target - currentPrice : currentPrice - setup.target;
|
||||||
@@ -48,10 +33,12 @@ 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;
|
||||||
// Expected value (R) is the core gate. Only enforced when computable — setups
|
// Cross-sectional momentum is the core selection — only enforced when a
|
||||||
// without target probabilities defer to the R:R + confidence floors above.
|
// percentile is attached and a threshold is set; otherwise defer to the floors.
|
||||||
const ev = expectedValueR(setup);
|
if (config.min_momentum_percentile > 0 && setup.momentum_percentile != null
|
||||||
if (ev != null && ev < config.min_expected_value) return false;
|
&& setup.momentum_percentile < config.min_momentum_percentile) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
if (config.require_high_conviction && !HIGH_CONVICTION_ACTIONS.has(setup.recommended_action ?? '')) {
|
if (config.require_high_conviction && !HIGH_CONVICTION_ACTIONS.has(setup.recommended_action ?? '')) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -64,7 +51,9 @@ export function qualifiesSetup(setup: TradeSetup, config: ActivationConfig): boo
|
|||||||
|
|
||||||
/** 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 = [`EV ≥ ${config.min_expected_value.toFixed(2)}R`, `R:R ≥ ${config.min_rr.toFixed(1)}`, `conf ≥ ${config.min_confidence.toFixed(0)}%`];
|
const parts = [];
|
||||||
|
if (config.min_momentum_percentile > 0) parts.push(`top ${(100 - config.min_momentum_percentile).toFixed(0)}% momentum`);
|
||||||
|
parts.push(`R:R ≥ ${config.min_rr.toFixed(1)}`, `conf ≥ ${config.min_confidence.toFixed(0)}%`);
|
||||||
if (config.require_high_conviction) parts.push('high-conviction');
|
if (config.require_high_conviction) parts.push('high-conviction');
|
||||||
if (config.exclude_conflicts) parts.push('clean');
|
if (config.exclude_conflicts) parts.push('clean');
|
||||||
if (config.min_target_probability > 0) parts.push(`target ≥ ${config.min_target_probability.toFixed(0)}%`);
|
if (config.min_target_probability > 0) parts.push(`target ≥ ${config.min_target_probability.toFixed(0)}%`);
|
||||||
|
|||||||
@@ -134,6 +134,7 @@ export interface TradeSetup {
|
|||||||
outcome_date: string | null;
|
outcome_date: string | null;
|
||||||
evaluated_at: string | null;
|
evaluated_at: string | null;
|
||||||
current_price: number | null;
|
current_price: number | null;
|
||||||
|
momentum_percentile?: number | null;
|
||||||
recommendation_summary?: RecommendationSummary;
|
recommendation_summary?: RecommendationSummary;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,7 +159,7 @@ export interface PerformanceStats {
|
|||||||
|
|
||||||
// Activation gate: what counts as an actionable signal
|
// Activation gate: what counts as an actionable signal
|
||||||
export interface ActivationConfig {
|
export interface ActivationConfig {
|
||||||
min_expected_value: number;
|
min_momentum_percentile: number;
|
||||||
min_rr: number;
|
min_rr: number;
|
||||||
min_confidence: number;
|
min_confidence: number;
|
||||||
min_target_probability: number;
|
min_target_probability: number;
|
||||||
@@ -221,7 +222,7 @@ export interface BacktestCalibrationRow {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface BacktestSweepRow extends BacktestBucket {
|
export interface BacktestSweepRow extends BacktestBucket {
|
||||||
min_expected_value: number;
|
min_momentum_percentile: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BacktestSignalEvalRow {
|
export interface BacktestSignalEvalRow {
|
||||||
@@ -244,7 +245,7 @@ export interface BacktestReport {
|
|||||||
overall_qualified: BacktestBucket;
|
overall_qualified: BacktestBucket;
|
||||||
overall_all: BacktestBucket;
|
overall_all: BacktestBucket;
|
||||||
by_direction: Record<string, BacktestBucket>;
|
by_direction: Record<string, BacktestBucket>;
|
||||||
min_expected_value: number;
|
min_momentum_percentile: number;
|
||||||
sweep: BacktestSweepRow[];
|
sweep: BacktestSweepRow[];
|
||||||
calibration: BacktestCalibrationRow[];
|
calibration: BacktestCalibrationRow[];
|
||||||
signal_eval?: BacktestSignalEvalRow[];
|
signal_eval?: BacktestSignalEvalRow[];
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { OpenTradesPanel } from '../components/dashboard/OpenTradesPanel';
|
|||||||
import { SkeletonCard, SkeletonTable } from '../components/ui/Skeleton';
|
import { SkeletonCard, SkeletonTable } from '../components/ui/Skeleton';
|
||||||
import { formatPrice } from '../lib/format';
|
import { formatPrice } from '../lib/format';
|
||||||
import { recommendationActionLabel } from '../lib/recommendation';
|
import { recommendationActionLabel } from '../lib/recommendation';
|
||||||
import { qualifiesSetup, activationSummary, primaryTargetProbability, expectedValueR } from '../lib/qualification';
|
import { qualifiesSetup, activationSummary, primaryTargetProbability } from '../lib/qualification';
|
||||||
import type { TradeSetup } from '../lib/types';
|
import type { TradeSetup } from '../lib/types';
|
||||||
|
|
||||||
function fmtR(value: number | null): string {
|
function fmtR(value: number | null): string {
|
||||||
@@ -69,12 +69,12 @@ 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 expected value (R) so the best opportunity sits at the top.
|
// Rank by 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 ?? [];
|
||||||
return [...pool]
|
return [...pool]
|
||||||
.sort((a, b) => (expectedValueR(b) ?? -Infinity) - (expectedValueR(a) ?? -Infinity))
|
.sort((a, b) => (b.momentum_percentile ?? -Infinity) - (a.momentum_percentile ?? -Infinity))
|
||||||
.slice(0, 5);
|
.slice(0, 5);
|
||||||
}, [showingQualified, qualifiedSetups, trades.data]);
|
}, [showingQualified, qualifiedSetups, trades.data]);
|
||||||
|
|
||||||
@@ -176,13 +176,12 @@ 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">Exp. Value</th>
|
<th className="px-4 py-3 text-right">Momentum</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>
|
||||||
<tbody>
|
<tbody>
|
||||||
{topSetups.map((setup, i) => {
|
{topSetups.map((setup, i) => {
|
||||||
const ev = expectedValueR(setup);
|
|
||||||
const isTopPick = i === 0;
|
const isTopPick = i === 0;
|
||||||
return (
|
return (
|
||||||
<tr
|
<tr
|
||||||
@@ -212,8 +211,8 @@ export default function DashboardPage() {
|
|||||||
return p != null ? `${Math.round(p)}%` : '—';
|
return p != null ? `${Math.round(p)}%` : '—';
|
||||||
})()}
|
})()}
|
||||||
</td>
|
</td>
|
||||||
<td className={`num px-4 py-3 text-right font-semibold ${rColor(ev)}`}>
|
<td className="num px-4 py-3 text-right font-semibold text-gray-200">
|
||||||
{fmtR(ev)}
|
{setup.momentum_percentile != null ? `${Math.round(setup.momentum_percentile)}%ile` : '—'}
|
||||||
</td>
|
</td>
|
||||||
<td className="hidden px-4 py-3 text-xs text-gray-400 md:table-cell">
|
<td className="hidden px-4 py-3 text-xs text-gray-400 md:table-cell">
|
||||||
{recommendationActionLabel(setup.recommended_action)}
|
{recommendationActionLabel(setup.recommended_action)}
|
||||||
@@ -225,7 +224,7 @@ export default function DashboardPage() {
|
|||||||
</table>
|
</table>
|
||||||
<div className="flex items-center justify-between border-t border-white/[0.04] px-4 py-2.5">
|
<div className="flex items-center justify-between border-t border-white/[0.04] px-4 py-2.5">
|
||||||
<span className="text-[11px] text-gray-500">
|
<span className="text-[11px] text-gray-500">
|
||||||
Exp. Value = probability-weighted payoff per unit of risk
|
Momentum = ticker's 12-1 month rank across the universe (higher = stronger)
|
||||||
</span>
|
</span>
|
||||||
<Link to="/signals" className="text-xs font-medium text-blue-300 hover:text-blue-200 transition-colors">
|
<Link to="/signals" className="text-xs font-medium text-blue-300 hover:text-blue-200 transition-colors">
|
||||||
All setups →
|
All setups →
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ class TestActivationConfig:
|
|||||||
async def test_defaults_when_unset(self, session: AsyncSession):
|
async def test_defaults_when_unset(self, session: AsyncSession):
|
||||||
config = await get_activation_config(session)
|
config = await get_activation_config(session)
|
||||||
assert config == {
|
assert config == {
|
||||||
"min_expected_value": 0.15,
|
"min_momentum_percentile": 80.0,
|
||||||
"min_rr": 1.2,
|
"min_rr": 1.2,
|
||||||
"min_confidence": 55.0,
|
"min_confidence": 55.0,
|
||||||
"min_target_probability": 0.0,
|
"min_target_probability": 0.0,
|
||||||
@@ -35,13 +35,13 @@ class TestActivationConfig:
|
|||||||
|
|
||||||
async def test_update_and_read_back(self, session: AsyncSession):
|
async def test_update_and_read_back(self, session: AsyncSession):
|
||||||
updated = await update_activation_config(
|
updated = await update_activation_config(
|
||||||
session, {"min_expected_value": 0.25, "min_confidence": 60.0}
|
session, {"min_momentum_percentile": 70.0, "min_confidence": 60.0}
|
||||||
)
|
)
|
||||||
assert updated["min_expected_value"] == 0.25
|
assert updated["min_momentum_percentile"] == 70.0
|
||||||
assert updated["min_confidence"] == 60.0
|
assert updated["min_confidence"] == 60.0
|
||||||
|
|
||||||
config = await get_activation_config(session)
|
config = await get_activation_config(session)
|
||||||
assert config["min_expected_value"] == 0.25
|
assert config["min_momentum_percentile"] == 70.0
|
||||||
assert config["min_confidence"] == 60.0
|
assert config["min_confidence"] == 60.0
|
||||||
|
|
||||||
async def test_partial_update_keeps_other_value(self, session: AsyncSession):
|
async def test_partial_update_keeps_other_value(self, session: AsyncSession):
|
||||||
@@ -50,9 +50,9 @@ class TestActivationConfig:
|
|||||||
assert config["min_rr"] == 1.2 # default untouched
|
assert config["min_rr"] == 1.2 # default untouched
|
||||||
assert config["min_confidence"] == 80.0
|
assert config["min_confidence"] == 80.0
|
||||||
|
|
||||||
async def test_rejects_out_of_range_expected_value(self, session: AsyncSession):
|
async def test_rejects_out_of_range_momentum_percentile(self, session: AsyncSession):
|
||||||
with pytest.raises(ValidationError):
|
with pytest.raises(ValidationError):
|
||||||
await update_activation_config(session, {"min_expected_value": 50.0})
|
await update_activation_config(session, {"min_momentum_percentile": 150.0})
|
||||||
|
|
||||||
async def test_conviction_flags_round_trip(self, session: AsyncSession):
|
async def test_conviction_flags_round_trip(self, session: AsyncSession):
|
||||||
await update_activation_config(
|
await update_activation_config(
|
||||||
|
|||||||
@@ -113,8 +113,8 @@ async def test_run_backtest_smoke(session):
|
|||||||
# the oscillating series should yield at least some resolved setups
|
# the oscillating series should yield at least some resolved setups
|
||||||
assert report["candidates"] >= 1
|
assert report["candidates"] >= 1
|
||||||
|
|
||||||
# sweep: lowering the EV threshold can only add qualifiers, never remove them
|
# sweep: lowering the momentum-percentile cutoff can only add qualifiers
|
||||||
sweep = sorted(report["sweep"], key=lambda r: r["min_expected_value"], reverse=True)
|
sweep = sorted(report["sweep"], key=lambda r: r["min_momentum_percentile"], reverse=True)
|
||||||
counts = [r["total"] for r in sweep]
|
counts = [r["total"] for r in sweep]
|
||||||
assert counts == sorted(counts) # ascending as threshold descends
|
assert counts == sorted(counts) # ascending as threshold descends
|
||||||
# every calibration row is internally consistent
|
# every calibration row is internally consistent
|
||||||
|
|||||||
@@ -1,20 +1,15 @@
|
|||||||
"""Unit tests for the activation qualification predicate (EV-based gate)."""
|
"""Unit tests for the activation qualification predicate (momentum-based gate)."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
|
|
||||||
from app.services.qualification import (
|
from app.services.qualification import best_target_probability, setup_qualifies
|
||||||
best_target_probability,
|
|
||||||
expected_value_r,
|
|
||||||
primary_target_probability,
|
|
||||||
setup_qualifies,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Default gate: expected value is the core test; conviction/conflict/target-prob
|
# Default gate: floors only; the momentum selection is off (0). Conviction /
|
||||||
# are optional tighteners, off here.
|
# conflict / target-probability are optional tighteners, off here.
|
||||||
DEFAULT_GATE = {
|
DEFAULT_GATE = {
|
||||||
"min_expected_value": 0.15,
|
"min_momentum_percentile": 0.0,
|
||||||
"min_rr": 1.2,
|
"min_rr": 1.2,
|
||||||
"min_confidence": 55.0,
|
"min_confidence": 55.0,
|
||||||
"min_target_probability": 0.0,
|
"min_target_probability": 0.0,
|
||||||
@@ -22,9 +17,12 @@ DEFAULT_GATE = {
|
|||||||
"exclude_conflicts": False,
|
"exclude_conflicts": False,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Strict gate: every optional tightener turned on (the old shipped defaults).
|
# Gate with the cross-sectional momentum selection on (top quintile).
|
||||||
|
MOMENTUM_GATE = {**DEFAULT_GATE, "min_momentum_percentile": 80.0}
|
||||||
|
|
||||||
|
# Strict gate: every optional tightener turned on.
|
||||||
STRICT_GATE = {
|
STRICT_GATE = {
|
||||||
"min_expected_value": 0.0,
|
"min_momentum_percentile": 0.0,
|
||||||
"min_rr": 2.0,
|
"min_rr": 2.0,
|
||||||
"min_confidence": 70.0,
|
"min_confidence": 70.0,
|
||||||
"min_target_probability": 60.0,
|
"min_target_probability": 60.0,
|
||||||
@@ -45,59 +43,17 @@ def _setup(**kwargs):
|
|||||||
return SimpleNamespace(**base)
|
return SimpleNamespace(**base)
|
||||||
|
|
||||||
|
|
||||||
class TestExpectedValue:
|
class TestFloors:
|
||||||
def test_uses_primary_target_not_best(self):
|
def test_passes_floors(self):
|
||||||
s = _setup(
|
|
||||||
rr_ratio=1.5,
|
|
||||||
targets=[
|
|
||||||
{"probability": 80.0},
|
|
||||||
{"probability": 30.0, "is_primary": True},
|
|
||||||
],
|
|
||||||
)
|
|
||||||
# EV from the primary (30%): 0.3*1.5 - 0.7 = -0.25
|
|
||||||
assert expected_value_r(s) == -0.25
|
|
||||||
assert primary_target_probability(s) == 30.0
|
|
||||||
|
|
||||||
def test_falls_back_to_best_when_no_primary_flag(self):
|
|
||||||
s = _setup(rr_ratio=2.0, targets=[{"probability": 40.0}, {"probability": 60.0}])
|
|
||||||
assert primary_target_probability(s) == 60.0
|
|
||||||
# 0.6*2.0 - 0.4 = 0.8
|
|
||||||
assert abs(expected_value_r(s) - 0.8) < 1e-9
|
|
||||||
|
|
||||||
def test_none_when_no_targets(self):
|
|
||||||
assert expected_value_r(_setup(targets=[])) is None
|
|
||||||
assert primary_target_probability(_setup(targets=[])) is None
|
|
||||||
|
|
||||||
|
|
||||||
class TestSetupQualifies:
|
|
||||||
def test_positive_ev_setup_passes(self):
|
|
||||||
# primary 50% @ rr 3.0 → EV = 1.0
|
|
||||||
assert setup_qualifies(_setup(), DEFAULT_GATE) is True
|
assert setup_qualifies(_setup(), DEFAULT_GATE) is True
|
||||||
|
|
||||||
def test_negative_ev_fails(self):
|
|
||||||
# primary 30% @ rr 1.3 → EV = -0.31, below the 0.15 floor
|
|
||||||
s = _setup(rr_ratio=1.3, targets=[{"probability": 30.0, "is_primary": True}])
|
|
||||||
assert setup_qualifies(s, DEFAULT_GATE) is False
|
|
||||||
|
|
||||||
def test_thin_positive_ev_below_floor_fails(self):
|
|
||||||
# Positive but thin: 0.45*1.3 - 0.55 = 0.035, under the 0.15 floor.
|
|
||||||
s = _setup(rr_ratio=1.3, targets=[{"probability": 45.0, "is_primary": True}])
|
|
||||||
assert setup_qualifies(s, DEFAULT_GATE) is False
|
|
||||||
|
|
||||||
def test_low_rr_floor_fails(self):
|
def test_low_rr_floor_fails(self):
|
||||||
assert setup_qualifies(_setup(rr_ratio=1.0), DEFAULT_GATE) is False
|
assert setup_qualifies(_setup(rr_ratio=1.0), DEFAULT_GATE) is False
|
||||||
|
|
||||||
def test_low_confidence_fails(self):
|
def test_low_confidence_fails(self):
|
||||||
assert setup_qualifies(_setup(confidence_score=40.0), DEFAULT_GATE) is False
|
assert setup_qualifies(_setup(confidence_score=40.0), DEFAULT_GATE) is False
|
||||||
|
|
||||||
def test_no_targets_defers_to_rr_and_confidence(self):
|
|
||||||
# No probability → EV uncomputable → not blocked on EV; passes on floors.
|
|
||||||
assert setup_qualifies(_setup(targets=[]), DEFAULT_GATE) is True
|
|
||||||
# ...but still subject to the rr/confidence floors.
|
|
||||||
assert setup_qualifies(_setup(targets=[], rr_ratio=1.0), DEFAULT_GATE) is False
|
|
||||||
|
|
||||||
def test_conviction_and_conflict_ignored_by_default(self):
|
def test_conviction_and_conflict_ignored_by_default(self):
|
||||||
# Moderate action + medium risk still pass when tighteners are off.
|
|
||||||
s = _setup(recommended_action="LONG_MODERATE", risk_level="Medium")
|
s = _setup(recommended_action="LONG_MODERATE", risk_level="Medium")
|
||||||
assert setup_qualifies(s, DEFAULT_GATE) is True
|
assert setup_qualifies(s, DEFAULT_GATE) is True
|
||||||
|
|
||||||
@@ -113,11 +69,26 @@ class TestSetupQualifies:
|
|||||||
s = _setup(direction="long", target=120.0, stop_loss=95.0, current_price=94.0)
|
s = _setup(direction="long", target=120.0, stop_loss=95.0, current_price=94.0)
|
||||||
assert setup_qualifies(s, DEFAULT_GATE) is False
|
assert setup_qualifies(s, DEFAULT_GATE) is False
|
||||||
|
|
||||||
def test_missing_min_ev_key_skips_ev(self):
|
|
||||||
# Legacy callers without min_expected_value: EV defaults to -inf (no floor).
|
class TestMomentumGate:
|
||||||
legacy = {k: v for k, v in DEFAULT_GATE.items() if k != "min_expected_value"}
|
def test_top_momentum_passes(self):
|
||||||
s = _setup(rr_ratio=1.3, targets=[{"probability": 30.0, "is_primary": True}])
|
assert setup_qualifies(_setup(momentum_percentile=92.0), MOMENTUM_GATE) is True
|
||||||
assert setup_qualifies(s, legacy) is True
|
|
||||||
|
def test_below_threshold_fails(self):
|
||||||
|
assert setup_qualifies(_setup(momentum_percentile=50.0), MOMENTUM_GATE) is False
|
||||||
|
|
||||||
|
def test_missing_percentile_defers_to_floors(self):
|
||||||
|
# No percentile attached (e.g. production not yet wired) → the momentum
|
||||||
|
# gate is skipped and the setup still clears on the floors.
|
||||||
|
assert setup_qualifies(_setup(), MOMENTUM_GATE) is True
|
||||||
|
|
||||||
|
def test_threshold_zero_disables_gate(self):
|
||||||
|
# min_momentum_percentile 0 → a low-momentum name still passes.
|
||||||
|
assert setup_qualifies(_setup(momentum_percentile=10.0), DEFAULT_GATE) is True
|
||||||
|
|
||||||
|
def test_missing_key_defaults_off(self):
|
||||||
|
legacy = {k: v for k, v in DEFAULT_GATE.items() if k != "min_momentum_percentile"}
|
||||||
|
assert setup_qualifies(_setup(momentum_percentile=10.0), legacy) is True
|
||||||
|
|
||||||
|
|
||||||
class TestStrictTighteners:
|
class TestStrictTighteners:
|
||||||
|
|||||||
Reference in New Issue
Block a user