Require aligned action for qualified setups
This commit is contained in:
@@ -54,6 +54,7 @@ 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 (
|
||||||
HIGH_CONVICTION_ACTIONS,
|
HIGH_CONVICTION_ACTIONS,
|
||||||
|
_action_direction,
|
||||||
best_target_probability,
|
best_target_probability,
|
||||||
setup_qualifies,
|
setup_qualifies,
|
||||||
)
|
)
|
||||||
@@ -917,7 +918,10 @@ def _gate_ablation(candidates: list[dict], activation: dict, threshold: float) -
|
|||||||
return (c["confidence"] or 0.0) >= min_conf
|
return (c["confidence"] or 0.0) >= min_conf
|
||||||
|
|
||||||
def neutral_ok(c: dict) -> bool:
|
def neutral_ok(c: dict) -> bool:
|
||||||
return not exclude_neutral or (c.get("action") or "NEUTRAL") != "NEUTRAL"
|
if not exclude_neutral:
|
||||||
|
return True
|
||||||
|
action_direction = _action_direction(c.get("action"))
|
||||||
|
return action_direction != "neutral" and action_direction == c["direction"]
|
||||||
|
|
||||||
def tighteners_ok(c: dict) -> bool:
|
def tighteners_ok(c: dict) -> bool:
|
||||||
if require_high and (c.get("action") or "") not in HIGH_CONVICTION_ACTIONS:
|
if require_high and (c.get("action") or "") not in HIGH_CONVICTION_ACTIONS:
|
||||||
|
|||||||
@@ -17,6 +17,16 @@ from typing import Any
|
|||||||
HIGH_CONVICTION_ACTIONS = {"LONG_HIGH", "SHORT_HIGH"}
|
HIGH_CONVICTION_ACTIONS = {"LONG_HIGH", "SHORT_HIGH"}
|
||||||
|
|
||||||
|
|
||||||
|
def _action_direction(action: str | None) -> str:
|
||||||
|
if not action or action == "NEUTRAL":
|
||||||
|
return "neutral"
|
||||||
|
if action.startswith("LONG"):
|
||||||
|
return "long"
|
||||||
|
if action.startswith("SHORT"):
|
||||||
|
return "short"
|
||||||
|
return "neutral"
|
||||||
|
|
||||||
|
|
||||||
def best_target_probability(setup: Any) -> float:
|
def best_target_probability(setup: Any) -> float:
|
||||||
"""Highest probability among a setup's targets, 0 if none."""
|
"""Highest probability among a setup's targets, 0 if none."""
|
||||||
targets = getattr(setup, "targets", None) or []
|
targets = getattr(setup, "targets", None) or []
|
||||||
@@ -78,12 +88,14 @@ def setup_qualifies(setup: Any, config: dict) -> bool:
|
|||||||
momentum_percentile = getattr(setup, "momentum_percentile", None)
|
momentum_percentile = getattr(setup, "momentum_percentile", None)
|
||||||
if momentum_percentile is not None and momentum_percentile < min_pct:
|
if momentum_percentile is not None and momentum_percentile < min_pct:
|
||||||
return False
|
return False
|
||||||
# A NEUTRAL recommendation means the engine found no clear directional setup —
|
# A setup is actionable only when the live ticker action points in the same
|
||||||
# not an actionable signal, so by default it doesn't qualify (and can't be a
|
# direction. NEUTRAL means no clear signal; an opposite action means the
|
||||||
# top pick). ``exclude_neutral`` defaults on; turn it off to also count
|
# setup is counter-bias. ``exclude_neutral`` defaults on; callers that omit
|
||||||
# no-clear-direction residual momentum leaders.
|
# it keep legacy floor-only behavior.
|
||||||
if config.get("exclude_neutral"):
|
if config.get("exclude_neutral"):
|
||||||
if (setup.recommended_action or "NEUTRAL") == "NEUTRAL":
|
action_direction = _action_direction(getattr(setup, "recommended_action", None))
|
||||||
|
setup_direction = (getattr(setup, "direction", "long") or "long").lower()
|
||||||
|
if action_direction == "neutral" or action_direction != setup_direction:
|
||||||
return False
|
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:
|
||||||
|
|||||||
@@ -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']);
|
||||||
|
|
||||||
|
function actionDirection(action: TradeSetup['recommended_action']): 'long' | 'short' | 'neutral' {
|
||||||
|
if (!action || action === 'NEUTRAL') return 'neutral';
|
||||||
|
if (action.startsWith('LONG')) return 'long';
|
||||||
|
if (action.startsWith('SHORT')) return 'short';
|
||||||
|
return 'neutral';
|
||||||
|
}
|
||||||
|
|
||||||
export function bestTargetProbability(setup: TradeSetup): number {
|
export function bestTargetProbability(setup: TradeSetup): number {
|
||||||
return setup.targets?.length ? Math.max(...setup.targets.map((t) => t.probability)) : 0;
|
return setup.targets?.length ? Math.max(...setup.targets.map((t) => t.probability)) : 0;
|
||||||
}
|
}
|
||||||
@@ -42,8 +49,11 @@ export function qualifiesSetup(setup: TradeSetup, config: ActivationConfig): boo
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// NEUTRAL = "no clear setup" — not actionable, so by default it doesn't qualify.
|
// NEUTRAL = "no clear setup"; an opposite action means this setup is counter-bias.
|
||||||
if (config.exclude_neutral && (setup.recommended_action ?? 'NEUTRAL') === 'NEUTRAL') return false;
|
if (config.exclude_neutral) {
|
||||||
|
const actionDir = actionDirection(setup.recommended_action);
|
||||||
|
if (actionDir === 'neutral' || actionDir !== setup.direction) 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;
|
||||||
}
|
}
|
||||||
@@ -53,9 +63,9 @@ 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 residual 12-1 momentum percentile among qualified setups (or among all
|
* the highest residual 12-1 momentum percentile among qualified setups. Returns
|
||||||
* setups when none qualify). Returns null when there are no setups. Keep in step
|
* null when there are no actionable setups. Keep in step with the Top Setups
|
||||||
* with the Top Setups ranking in DashboardPage.
|
* ranking in DashboardPage.
|
||||||
*/
|
*/
|
||||||
export function topPickSymbol(
|
export function topPickSymbol(
|
||||||
trades: TradeSetup[] | undefined,
|
trades: TradeSetup[] | undefined,
|
||||||
@@ -64,8 +74,7 @@ export function topPickSymbol(
|
|||||||
const all = trades ?? [];
|
const all = trades ?? [];
|
||||||
if (all.length === 0) return null;
|
if (all.length === 0) return null;
|
||||||
const qualified = activation ? all.filter((t) => qualifiesSetup(t, activation)) : [];
|
const qualified = activation ? all.filter((t) => qualifiesSetup(t, activation)) : [];
|
||||||
const pool = qualified.length > 0 ? qualified : all;
|
const top = [...qualified].sort(
|
||||||
const top = [...pool].sort(
|
|
||||||
(a, b) => (b.momentum_percentile ?? -Infinity) - (a.momentum_percentile ?? -Infinity),
|
(a, b) => (b.momentum_percentile ?? -Infinity) - (a.momentum_percentile ?? -Infinity),
|
||||||
)[0];
|
)[0];
|
||||||
return top?.symbol ?? null;
|
return top?.symbol ?? null;
|
||||||
|
|||||||
@@ -76,15 +76,12 @@ export default function DashboardPage() {
|
|||||||
[trades.data, activation.data],
|
[trades.data, activation.data],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Show qualified setups first; fall back to the full list when none qualify.
|
// Rank only actionable/qualified setups by residual 12-1 momentum percentile.
|
||||||
// Rank by residual 12-1 momentum percentile so the strongest names sit at the top.
|
|
||||||
const showingQualified = qualifiedSetups.length > 0;
|
|
||||||
const topSetups: TradeSetup[] = useMemo(() => {
|
const topSetups: TradeSetup[] = useMemo(() => {
|
||||||
const pool = showingQualified ? qualifiedSetups : trades.data ?? [];
|
return [...qualifiedSetups]
|
||||||
return [...pool]
|
|
||||||
.sort((a, b) => (b.momentum_percentile ?? -Infinity) - (a.momentum_percentile ?? -Infinity))
|
.sort((a, b) => (b.momentum_percentile ?? -Infinity) - (a.momentum_percentile ?? -Infinity))
|
||||||
.slice(0, 5);
|
.slice(0, 5);
|
||||||
}, [showingQualified, qualifiedSetups, trades.data]);
|
}, [qualifiedSetups]);
|
||||||
|
|
||||||
const topWatchlist = useMemo(
|
const topWatchlist = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -197,12 +194,12 @@ export default function DashboardPage() {
|
|||||||
<div className="xl:col-span-3">
|
<div className="xl:col-span-3">
|
||||||
<Section
|
<Section
|
||||||
title="Top Setups"
|
title="Top Setups"
|
||||||
hint={showingQualified ? 'ranked by expected value' : 'none qualified — showing all'}
|
hint="qualified and ranked by residual momentum"
|
||||||
>
|
>
|
||||||
{trades.isLoading && <SkeletonTable rows={5} cols={5} />}
|
{trades.isLoading && <SkeletonTable rows={5} cols={5} />}
|
||||||
{trades.isError && <Callout variant="error">Failed to load setups</Callout>}
|
{trades.isError && <Callout variant="error">Failed to load setups</Callout>}
|
||||||
{trades.data && topSetups.length === 0 && (
|
{trades.data && topSetups.length === 0 && (
|
||||||
<Callout variant="empty">No active setups. Run the scanner from the Signals page.</Callout>
|
<Callout variant="empty">No qualified actionable setups right now.</Callout>
|
||||||
)}
|
)}
|
||||||
{topSetups.length > 0 && (
|
{topSetups.length > 0 && (
|
||||||
<div className="glass overflow-x-auto">
|
<div className="glass overflow-x-auto">
|
||||||
|
|||||||
@@ -313,7 +313,8 @@ def _acand(
|
|||||||
) -> dict:
|
) -> dict:
|
||||||
"""Ablation candidate: meets_core mirrors the default floors (min_rr 1.2,
|
"""Ablation candidate: meets_core mirrors the default floors (min_rr 1.2,
|
||||||
min_confidence 55, exclude_neutral on)."""
|
min_confidence 55, exclude_neutral on)."""
|
||||||
meets = rr >= 1.2 and conf >= 55.0 and action != "NEUTRAL"
|
action_dir = "long" if action.startswith("LONG") else "short" if action.startswith("SHORT") else "neutral"
|
||||||
|
meets = rr >= 1.2 and conf >= 55.0 and action_dir != "neutral" and action_dir == direction
|
||||||
return {
|
return {
|
||||||
"rr": rr,
|
"rr": rr,
|
||||||
"confidence": conf,
|
"confidence": conf,
|
||||||
@@ -347,7 +348,7 @@ class TestGateAblation:
|
|||||||
_acand(rr=1.0), # fails R:R floor
|
_acand(rr=1.0), # fails R:R floor
|
||||||
_acand(action="NEUTRAL"), # fails NEUTRAL exclusion
|
_acand(action="NEUTRAL"), # fails NEUTRAL exclusion
|
||||||
_acand(mp=50.0), # fails the momentum cutoff
|
_acand(mp=50.0), # fails the momentum cutoff
|
||||||
_acand(direction="short", mp=95.0), # short — gated out
|
_acand(direction="short", action="SHORT_MODERATE", mp=95.0), # short — gated out
|
||||||
]
|
]
|
||||||
rows = {r["variant"]: r for r in bt._gate_ablation(cands, self.ACTIVATION, 80.0)}
|
rows = {r["variant"]: r for r in bt._gate_ablation(cands, self.ACTIVATION, 80.0)}
|
||||||
assert rows["all_floors"]["total"] == 1
|
assert rows["all_floors"]["total"] == 1
|
||||||
@@ -364,7 +365,7 @@ class TestGateAblation:
|
|||||||
|
|
||||||
def test_threshold_zero_disables_momentum_gate(self):
|
def test_threshold_zero_disables_momentum_gate(self):
|
||||||
# Floors only: the short and the low-momentum long both pass all_floors.
|
# Floors only: the short and the low-momentum long both pass all_floors.
|
||||||
cands = [_acand(mp=50.0), _acand(direction="short", mp=None)]
|
cands = [_acand(mp=50.0), _acand(direction="short", action="SHORT_MODERATE", mp=None)]
|
||||||
rows = {r["variant"]: r for r in bt._gate_ablation(cands, self.ACTIVATION, 0.0)}
|
rows = {r["variant"]: r for r in bt._gate_ablation(cands, self.ACTIVATION, 0.0)}
|
||||||
assert rows["all_floors"]["total"] == 2
|
assert rows["all_floors"]["total"] == 2
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ STRICT_GATE = {
|
|||||||
|
|
||||||
def _setup(**kwargs):
|
def _setup(**kwargs):
|
||||||
base = dict(
|
base = dict(
|
||||||
|
direction="long",
|
||||||
rr_ratio=3.0,
|
rr_ratio=3.0,
|
||||||
confidence_score=80.0,
|
confidence_score=80.0,
|
||||||
recommended_action="LONG_HIGH",
|
recommended_action="LONG_HIGH",
|
||||||
@@ -124,6 +125,15 @@ class TestExcludeNeutral:
|
|||||||
def test_directional_passes_when_on(self):
|
def test_directional_passes_when_on(self):
|
||||||
assert setup_qualifies(_setup(recommended_action="LONG_MODERATE"), NEUTRAL_GATE) is True
|
assert setup_qualifies(_setup(recommended_action="LONG_MODERATE"), NEUTRAL_GATE) is True
|
||||||
|
|
||||||
|
def test_opposing_short_action_fails_for_long_setup(self):
|
||||||
|
assert setup_qualifies(_setup(direction="long", recommended_action="SHORT_MODERATE"), NEUTRAL_GATE) is False
|
||||||
|
|
||||||
|
def test_matching_short_action_still_fails_long_only_momentum_gate(self):
|
||||||
|
assert setup_qualifies(
|
||||||
|
_setup(direction="short", recommended_action="SHORT_MODERATE", momentum_percentile=95.0),
|
||||||
|
{**NEUTRAL_GATE, "min_momentum_percentile": 80.0},
|
||||||
|
) is False
|
||||||
|
|
||||||
def test_neutral_allowed_when_off(self):
|
def test_neutral_allowed_when_off(self):
|
||||||
# Flag absent from the config → NEUTRAL still qualifies (backward compatible).
|
# Flag absent from the config → NEUTRAL still qualifies (backward compatible).
|
||||||
assert setup_qualifies(_setup(recommended_action="NEUTRAL"), DEFAULT_GATE) is True
|
assert setup_qualifies(_setup(recommended_action="NEUTRAL"), DEFAULT_GATE) is True
|
||||||
|
|||||||
Reference in New Issue
Block a user