Activation gate: primary target probability floor (>= 20%)
A qualified setup's primary target must now clear MIN_TARGET_PROBABILITY
(20%), shared with the primary-selection floor in recommendation_service
and mirrored in the frontend gate. Closes the read-time hole where a
stale pre-c7a198b row starring a far lottery target (probability pinned
at the 3% clamp floor, R:R inflated by the same distance) qualified
forever: the scanner emits no replacement row and live R:R never decays.
A/B backtest vs c7a198b baseline (same July-3 snapshot): 7 of 1096
qualified setups removed; qualified net avg R 0.202 -> 0.207, hold
Sharpe 2.00 -> 2.02, CAGR +48.8% -> +49.6%, max DD unchanged at -15.8%.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -5,9 +5,11 @@ performance stats (server) and mirrored on the frontend. The core selection is
|
|||||||
residual 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 beta-adjusted 12-1 month momentum.
|
``min_momentum_percentile`` of the universe by beta-adjusted 12-1 month momentum.
|
||||||
R:R and confidence remain as floors, and conviction/conflict survive as optional
|
R:R and confidence remain as floors, and conviction/conflict survive as optional
|
||||||
tighteners (off by default). Qualified setups must also have a probability-backed
|
tighteners (off by default). Qualified setups must also have a primary target
|
||||||
target; otherwise a mathematically high R:R can be driven by a fragile target
|
with at least ``MIN_TARGET_PROBABILITY`` reach probability: a primary below the
|
||||||
with no independent validation.
|
floor is a lottery target whose distance inflates R:R, so it would otherwise
|
||||||
|
game the min_rr gate (the model clamps probabilities at 3%, and far targets pin
|
||||||
|
there while their live R:R stays high forever).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -16,6 +18,13 @@ from typing import Any
|
|||||||
|
|
||||||
HIGH_CONVICTION_ACTIONS = {"LONG_HIGH", "SHORT_HIGH"}
|
HIGH_CONVICTION_ACTIONS = {"LONG_HIGH", "SHORT_HIGH"}
|
||||||
|
|
||||||
|
# Floor for the primary target's reach probability, shared with the primary
|
||||||
|
# target selection in recommendation_service and mirrored in the frontend
|
||||||
|
# (qualification.ts). Under the two-barrier model a fair-race 1.5:1 target sits
|
||||||
|
# near ~34% before drift adjustments, so 20% only excludes targets the model
|
||||||
|
# itself considers long shots.
|
||||||
|
MIN_TARGET_PROBABILITY = 20.0
|
||||||
|
|
||||||
|
|
||||||
def _action_direction(action: str | None) -> str:
|
def _action_direction(action: str | None) -> str:
|
||||||
if not action or action == "NEUTRAL":
|
if not action or action == "NEUTRAL":
|
||||||
@@ -85,7 +94,8 @@ def setup_qualifies(setup: Any, config: dict) -> bool:
|
|||||||
live_rr = live_risk_reward(setup, float(current_price))
|
live_rr = live_risk_reward(setup, float(current_price))
|
||||||
if live_rr is not None and live_rr < config["min_rr"]:
|
if live_rr is not None and live_rr < config["min_rr"]:
|
||||||
return False
|
return False
|
||||||
if primary_target_probability(setup) is None:
|
target_probability = primary_target_probability(setup)
|
||||||
|
if target_probability is None or target_probability < MIN_TARGET_PROBABILITY:
|
||||||
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
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from app.models.settings import SystemSetting
|
|||||||
from app.models.sr_level import SRLevel
|
from app.models.sr_level import SRLevel
|
||||||
from app.models.ticker import Ticker
|
from app.models.ticker import Ticker
|
||||||
from app.models.trade_setup import TradeSetup
|
from app.models.trade_setup import TradeSetup
|
||||||
|
from app.services.qualification import MIN_TARGET_PROBABILITY
|
||||||
from app.services.sr_service import cluster_sr_zones
|
from app.services.sr_service import cluster_sr_zones
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -575,10 +576,10 @@ def build_recommendation_snapshot(
|
|||||||
|
|
||||||
|
|
||||||
PRIMARY_TARGET_MIN_RR = 1.5
|
PRIMARY_TARGET_MIN_RR = 1.5
|
||||||
# Below this the target is a lottery ticket: under the two-barrier model a
|
# Below this the target is a lottery ticket. Shared with the activation gate
|
||||||
# fair-race 1.5:1 target sits near ~34% before drift adjustments, so 20% only
|
# (qualification.MIN_TARGET_PROBABILITY) so the primary selection and the gate
|
||||||
# excludes targets the model itself considers long shots.
|
# agree on what counts as a probability-backed target.
|
||||||
PRIMARY_TARGET_MIN_PROBABILITY = 20.0
|
PRIMARY_TARGET_MIN_PROBABILITY = MIN_TARGET_PROBABILITY
|
||||||
|
|
||||||
|
|
||||||
def _select_primary_target(
|
def _select_primary_target(
|
||||||
|
|||||||
@@ -2,6 +2,13 @@ import type { ActivationConfig, TradeSetup } from './types';
|
|||||||
|
|
||||||
const HIGH_CONVICTION_ACTIONS = new Set(['LONG_HIGH', 'SHORT_HIGH']);
|
const HIGH_CONVICTION_ACTIONS = new Set(['LONG_HIGH', 'SHORT_HIGH']);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Floor for the primary target's reach probability — mirrors
|
||||||
|
* MIN_TARGET_PROBABILITY in app/services/qualification.py. A primary below
|
||||||
|
* this is a lottery target whose distance inflates R:R past the min_rr gate.
|
||||||
|
*/
|
||||||
|
export const MIN_TARGET_PROBABILITY = 20;
|
||||||
|
|
||||||
function actionDirection(action: TradeSetup['recommended_action']): 'long' | 'short' | 'neutral' {
|
function actionDirection(action: TradeSetup['recommended_action']): 'long' | 'short' | 'neutral' {
|
||||||
if (!action || action === 'NEUTRAL') return 'neutral';
|
if (!action || action === 'NEUTRAL') return 'neutral';
|
||||||
if (action.startsWith('LONG')) return 'long';
|
if (action.startsWith('LONG')) return 'long';
|
||||||
@@ -40,7 +47,7 @@ export function qualifiesSetup(setup: TradeSetup, config: ActivationConfig): boo
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const targetProbability = primaryTargetProbability(setup);
|
const targetProbability = primaryTargetProbability(setup);
|
||||||
if (targetProbability == null || targetProbability <= 0) return false;
|
if (targetProbability == null || targetProbability < MIN_TARGET_PROBABILITY) return false;
|
||||||
if ((setup.confidence_score ?? 0) < config.min_confidence) return false;
|
if ((setup.confidence_score ?? 0) < config.min_confidence) return false;
|
||||||
// Residual cross-sectional momentum is the core selection (long-only). While
|
// Residual cross-sectional momentum is the core selection (long-only). While
|
||||||
// the gate is active, shorts never qualify; missing ranks do not qualify
|
// the gate is active, shorts never qualify; missing ranks do not qualify
|
||||||
@@ -77,6 +84,9 @@ export function disqualifyReason(setup: TradeSetup, config: ActivationConfig): s
|
|||||||
}
|
}
|
||||||
const targetProbability = primaryTargetProbability(setup);
|
const targetProbability = primaryTargetProbability(setup);
|
||||||
if (targetProbability == null || targetProbability <= 0) return 'no target probability';
|
if (targetProbability == null || targetProbability <= 0) return 'no target probability';
|
||||||
|
if (targetProbability < MIN_TARGET_PROBABILITY) {
|
||||||
|
return `target probability below ${MIN_TARGET_PROBABILITY}%`;
|
||||||
|
}
|
||||||
if ((setup.confidence_score ?? 0) < config.min_confidence) {
|
if ((setup.confidence_score ?? 0) < config.min_confidence) {
|
||||||
return `confidence below ${config.min_confidence.toFixed(0)}%`;
|
return `confidence below ${config.min_confidence.toFixed(0)}%`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,6 +82,31 @@ class TestFloors:
|
|||||||
DEFAULT_GATE,
|
DEFAULT_GATE,
|
||||||
) is True
|
) is True
|
||||||
|
|
||||||
|
def test_lottery_primary_target_fails(self):
|
||||||
|
# A far target pinned at the model's 3% clamp floor: its distance keeps
|
||||||
|
# both stored and live R:R above the gate, so only the probability floor
|
||||||
|
# can reject it (the stale pre-fix lottery-headline case).
|
||||||
|
s = _setup(rr_ratio=3.09, targets=[{"probability": 3.0, "is_primary": True}])
|
||||||
|
assert setup_qualifies(s, DEFAULT_GATE) is False
|
||||||
|
|
||||||
|
def test_probability_at_floor_passes(self):
|
||||||
|
assert setup_qualifies(
|
||||||
|
_setup(targets=[{"probability": 20.0, "is_primary": True}]),
|
||||||
|
DEFAULT_GATE,
|
||||||
|
) is True
|
||||||
|
|
||||||
|
def test_probability_just_below_floor_fails(self):
|
||||||
|
assert setup_qualifies(
|
||||||
|
_setup(targets=[{"probability": 19.9, "is_primary": True}]),
|
||||||
|
DEFAULT_GATE,
|
||||||
|
) is False
|
||||||
|
|
||||||
|
def test_best_target_fallback_below_floor_fails(self):
|
||||||
|
# No starred primary: the fallback takes the best target, which must
|
||||||
|
# still clear the probability floor.
|
||||||
|
s = _setup(targets=[{"probability": 12.0}, {"probability": 8.0}])
|
||||||
|
assert setup_qualifies(s, DEFAULT_GATE) is False
|
||||||
|
|
||||||
|
|
||||||
class TestMomentumGate:
|
class TestMomentumGate:
|
||||||
def test_top_momentum_passes(self):
|
def test_top_momentum_passes(self):
|
||||||
|
|||||||
Reference in New Issue
Block a user