Three follow-ups to the gate probability floor (8f41143):
- Signals table shows the starred primary target (shared primaryTarget
helper) instead of an independently computed max-probability best,
so Overview, Signals and ticker details agree by construction.
- Targets pinned at the 3% probability clamp floor collapse to the
nearest one (enhance_trade_setup + backtest candidates in parity):
floor-pinned levels are indistinguishable to the model, so farther
ones were duplicate 3% rows inviting lottery headlines.
- get_trade_setups only returns setups re-emitted within
LIVE_SETUP_MAX_AGE_DAYS (3): an older latest row means the daily
scan no longer confirms the setup, and such rows otherwise surface
forever on Overview/Signals/ticker/alerts. History endpoints keep
full history.
Backtest on the Jul-3 snapshot is metric-identical to the gate-floor
run on all qualified stats (1089 qualified, Sharpe 2.02, CAGR +49.6%,
DD -15.8%): the prune only removes noise the gate already rejected.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
321 lines
12 KiB
Python
321 lines
12 KiB
Python
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
|
|
from app.services.recommendation_service import (
|
|
_build_reasoning,
|
|
_choose_recommended_action,
|
|
_prune_floor_pinned_targets,
|
|
_select_primary_target,
|
|
direction_analyzer,
|
|
probability_estimator,
|
|
signal_conflict_detector,
|
|
target_generator,
|
|
)
|
|
|
|
_DEFAULT_CFG = {
|
|
"recommendation_high_confidence_threshold": 70.0,
|
|
"recommendation_moderate_confidence_threshold": 50.0,
|
|
"recommendation_confidence_diff_threshold": 20.0,
|
|
}
|
|
|
|
|
|
@dataclass
|
|
class _SRLevelStub:
|
|
id: int
|
|
price_level: float
|
|
type: str
|
|
strength: int
|
|
|
|
|
|
def test_high_confidence_long_example():
|
|
dimension_scores = {
|
|
"technical": 75.0,
|
|
"momentum": 68.0,
|
|
"fundamental": 55.0,
|
|
}
|
|
|
|
confidence = direction_analyzer.calculate_confidence(
|
|
direction="long",
|
|
dimension_scores=dimension_scores,
|
|
sentiment_classification="bullish",
|
|
conflicts=[],
|
|
)
|
|
|
|
assert confidence > 70.0
|
|
|
|
|
|
def test_high_confidence_short_example():
|
|
dimension_scores = {
|
|
"technical": 30.0,
|
|
"momentum": 35.0,
|
|
"fundamental": 45.0,
|
|
}
|
|
|
|
confidence = direction_analyzer.calculate_confidence(
|
|
direction="short",
|
|
dimension_scores=dimension_scores,
|
|
sentiment_classification="bearish",
|
|
conflicts=[],
|
|
)
|
|
|
|
assert confidence > 70.0
|
|
|
|
|
|
def test_short_confidence_low_on_strongly_bullish_stock():
|
|
"""The CNC case: technical 92 / momentum 99 must make SHORT confidence low."""
|
|
dims = {"technical": 92.0, "momentum": 99.0, "fundamental": 96.0}
|
|
|
|
short_conf = direction_analyzer.calculate_confidence(
|
|
direction="short", dimension_scores=dims, sentiment_classification="neutral", conflicts=[],
|
|
)
|
|
long_conf = direction_analyzer.calculate_confidence(
|
|
direction="long", dimension_scores=dims, sentiment_classification="neutral", conflicts=[],
|
|
)
|
|
|
|
assert short_conf < 20.0 # not 55
|
|
assert long_conf > 90.0
|
|
|
|
|
|
def test_action_neutral_when_bias_direction_has_no_setup():
|
|
"""Strong LONG bias but only a SHORT setup is tradeable → NEUTRAL, not LONG_HIGH."""
|
|
action = _choose_recommended_action(
|
|
long_confidence=100.0, short_confidence=5.0, config=_DEFAULT_CFG,
|
|
available_directions={"short"},
|
|
)
|
|
assert action == "NEUTRAL"
|
|
|
|
# With the long setup available, the same numbers give LONG_HIGH
|
|
action_ok = _choose_recommended_action(
|
|
long_confidence=100.0, short_confidence=5.0, config=_DEFAULT_CFG,
|
|
available_directions={"long", "short"},
|
|
)
|
|
assert action_ok == "LONG_HIGH"
|
|
|
|
|
|
def test_reasoning_explains_missing_setup():
|
|
reasoning = _build_reasoning(
|
|
action="NEUTRAL", long_confidence=100.0, short_confidence=5.0, conflicts=[],
|
|
dimension_scores={"technical": 92.0, "momentum": 99.0},
|
|
sentiment_classification="neutral", config=_DEFAULT_CFG,
|
|
available_directions={"short"},
|
|
)
|
|
assert "bias is LONG" in reasoning
|
|
assert "no high-conviction long setup" in reasoning.lower()
|
|
|
|
|
|
def test_primary_target_is_most_likely_worthwhile_not_lottery():
|
|
targets = [
|
|
{"price": 110.0, "rr_ratio": 2.0, "probability": 65.0}, # worthwhile, most likely ← primary
|
|
{"price": 120.0, "rr_ratio": 3.5, "probability": 50.0},
|
|
{"price": 140.0, "rr_ratio": 6.0, "probability": 15.0}, # far lottery — not chosen
|
|
]
|
|
primary = _select_primary_target(targets)
|
|
assert primary is not None
|
|
assert primary["price"] == 110.0
|
|
|
|
|
|
def test_primary_target_skips_sub_threshold_rr():
|
|
targets = [
|
|
{"price": 102.0, "rr_ratio": 1.0, "probability": 95.0}, # high prob but trivial R:R — skipped
|
|
{"price": 115.0, "rr_ratio": 2.5, "probability": 60.0}, # most likely above the R:R floor ← primary
|
|
]
|
|
primary = _select_primary_target(targets)
|
|
assert primary is not None
|
|
assert primary["price"] == 115.0
|
|
|
|
|
|
def test_primary_target_none_when_empty():
|
|
assert _select_primary_target([]) is None
|
|
|
|
|
|
def test_primary_target_never_headlines_a_lottery():
|
|
# After a run-up: the only target clearing the R:R floor is a near-impossible
|
|
# far level. The primary must NOT be that lottery — fall back to the most
|
|
# likely target overall, so the headline R:R is honest (and the activation
|
|
# gate rejects the setup on min_rr instead of being gamed).
|
|
targets = [
|
|
{"price": 101.0, "rr_ratio": 0.9, "probability": 55.0}, # likely, no asymmetry
|
|
{"price": 140.0, "rr_ratio": 5.0, "probability": 3.0}, # asymmetric lottery
|
|
]
|
|
primary = _select_primary_target(targets)
|
|
assert primary is not None
|
|
assert primary["price"] == 101.0
|
|
|
|
|
|
def test_primary_target_requires_probability_floor():
|
|
# A worthwhile-R:R target below the probability floor loses to one that
|
|
# clears both floors, even at lower R:R.
|
|
targets = [
|
|
{"price": 130.0, "rr_ratio": 4.0, "probability": 12.0}, # asymmetric but unlikely
|
|
{"price": 112.0, "rr_ratio": 1.8, "probability": 38.0}, # clears both floors ← primary
|
|
]
|
|
primary = _select_primary_target(targets)
|
|
assert primary is not None
|
|
assert primary["price"] == 112.0
|
|
|
|
|
|
def test_prune_keeps_only_nearest_floor_pinned_target():
|
|
# Two targets pinned at the 3% clamp floor are indistinguishable to the
|
|
# model — only the nearest survives; farther ones are duplicate noise.
|
|
targets = [
|
|
{"price": 204.0, "rr_ratio": 0.7, "probability": 25.6},
|
|
{"price": 241.0, "rr_ratio": 2.0, "probability": 3.0},
|
|
{"price": 272.0, "rr_ratio": 3.1, "probability": 3.0},
|
|
]
|
|
pruned = _prune_floor_pinned_targets(targets)
|
|
assert [t["price"] for t in pruned] == [204.0, 241.0]
|
|
|
|
|
|
def test_prune_leaves_targets_above_floor_untouched():
|
|
targets = [
|
|
{"price": 110.0, "rr_ratio": 2.0, "probability": 65.0},
|
|
{"price": 120.0, "rr_ratio": 3.5, "probability": 20.0},
|
|
]
|
|
assert _prune_floor_pinned_targets(targets) == targets
|
|
|
|
|
|
def test_prune_all_floor_pinned_keeps_nearest_only():
|
|
targets = [
|
|
{"price": 241.0, "rr_ratio": 2.0, "probability": 3.0},
|
|
{"price": 272.0, "rr_ratio": 3.1, "probability": 3.0},
|
|
]
|
|
pruned = _prune_floor_pinned_targets(targets)
|
|
assert [t["price"] for t in pruned] == [241.0]
|
|
|
|
|
|
def test_detects_sentiment_technical_conflict():
|
|
conflicts = signal_conflict_detector.detect_conflicts(
|
|
dimension_scores={"technical": 72.0, "momentum": 55.0, "fundamental": 50.0},
|
|
sentiment_classification="bearish",
|
|
)
|
|
|
|
assert any("sentiment-technical" in conflict for conflict in conflicts)
|
|
|
|
|
|
def test_generate_targets_respects_direction_and_order():
|
|
sr_levels = [
|
|
_SRLevelStub(id=1, price_level=110.0, type="resistance", strength=80),
|
|
_SRLevelStub(id=2, price_level=115.0, type="resistance", strength=70),
|
|
_SRLevelStub(id=3, price_level=120.0, type="resistance", strength=60),
|
|
_SRLevelStub(id=4, price_level=95.0, type="support", strength=75),
|
|
]
|
|
|
|
targets = target_generator.generate_targets(
|
|
direction="long",
|
|
entry_price=100.0,
|
|
stop_loss=96.0,
|
|
sr_levels=sr_levels, # type: ignore[arg-type]
|
|
atr_value=2.0,
|
|
)
|
|
|
|
assert len(targets) >= 1
|
|
assert all(target["price"] > 100.0 for target in targets)
|
|
distances = [target["distance_from_entry"] for target in targets]
|
|
assert distances == sorted(distances)
|
|
|
|
|
|
def test_generate_targets_spreads_across_distance_bands():
|
|
"""Near levels must not be crowded out by far high-R:R ones — expect a mix
|
|
of distance bands, including the nearest, not just the 5 farthest."""
|
|
# entry 100, atr 2 → ATR multiples: 5→2.5, 8→4.0, 14→7.0, 24→12, 34→17, 44→22
|
|
sr_levels = [
|
|
_SRLevelStub(id=1, price_level=105.0, type="resistance", strength=60), # 2.5 ATR (conservative)
|
|
_SRLevelStub(id=2, price_level=108.0, type="resistance", strength=55), # 4.0 ATR (moderate)
|
|
_SRLevelStub(id=3, price_level=114.0, type="resistance", strength=90), # 7.0 ATR (aggressive, strong)
|
|
_SRLevelStub(id=4, price_level=124.0, type="resistance", strength=95), # 12 ATR (aggressive, strong)
|
|
_SRLevelStub(id=5, price_level=134.0, type="resistance", strength=92), # 17 ATR (aggressive, strong)
|
|
_SRLevelStub(id=6, price_level=144.0, type="resistance", strength=88), # 22 ATR (aggressive, strong)
|
|
]
|
|
|
|
targets = target_generator.generate_targets(
|
|
direction="long", entry_price=100.0, stop_loss=96.0,
|
|
sr_levels=sr_levels, atr_value=2.0, # type: ignore[arg-type]
|
|
)
|
|
|
|
multiples = [t["distance_atr_multiple"] for t in targets]
|
|
# The nearest (conservative) and a moderate target survive despite the
|
|
# strong far levels that would dominate a pure top-5-by-quality pick.
|
|
assert any(m <= 2.9 for m in multiples), "expected a conservative (near) target"
|
|
assert any(2.9 < m <= 4.6 for m in multiples), "expected a moderate target"
|
|
assert any(m > 4.6 for m in multiples), "expected an aggressive (far) target"
|
|
|
|
|
|
def test_probability_decreases_with_distance():
|
|
"""A far target must be far less likely than a near one — no 90% at +39%."""
|
|
config = {
|
|
"recommendation_signal_alignment_weight": 0.15,
|
|
"recommendation_sr_strength_weight": 0.20,
|
|
}
|
|
dims = {"technical": 50.0, "momentum": 50.0} # neutral, isolate the distance term
|
|
|
|
def prob(atr_multiple: float, strength: float = 50.0) -> float:
|
|
return probability_estimator.estimate_probability(
|
|
{"sr_strength": strength, "distance_atr_multiple": atr_multiple},
|
|
dims, None, "long", config,
|
|
)
|
|
|
|
near = prob(1.5)
|
|
mid = prob(4.0)
|
|
far = prob(10.0)
|
|
|
|
# Monotonic decay with distance
|
|
assert near > mid > far
|
|
# Backtest-calibrated: even a near target with no R:R context (even race) is
|
|
# only a moderate probability, and a 10-ATR target is a long shot.
|
|
assert near > 30
|
|
assert far < 15
|
|
|
|
|
|
def test_far_target_not_high_probability_even_with_strong_level():
|
|
"""The AJG case: a far target stays low-probability even at max strength."""
|
|
config = {"recommendation_sr_strength_weight": 0.20, "recommendation_signal_alignment_weight": 0.15}
|
|
# ~10 ATR away, strongest possible level, fully aligned bullish
|
|
p = probability_estimator.estimate_probability(
|
|
{"sr_strength": 100, "distance_atr_multiple": 10.0},
|
|
{"technical": 80.0, "momentum": 80.0}, "bullish", "long", config,
|
|
)
|
|
assert p < 40 # nowhere near 90
|
|
|
|
|
|
def test_classify_by_probability_thresholds():
|
|
from app.services.recommendation_service import _classify_by_probability
|
|
assert _classify_by_probability(75) == "Conservative"
|
|
assert _classify_by_probability(50) == "Moderate"
|
|
assert _classify_by_probability(20) == "Aggressive"
|
|
|
|
|
|
def test_zone_representative_levels_merges_wall():
|
|
"""Near-duplicate resistances collapse to one zone with combined strength."""
|
|
from types import SimpleNamespace
|
|
from app.services.recommendation_service import _zone_representative_levels
|
|
|
|
levels = [
|
|
SimpleNamespace(id=1, price_level=183.0, type="resistance", strength=60),
|
|
SimpleNamespace(id=2, price_level=185.0, type="resistance", strength=60),
|
|
SimpleNamespace(id=3, price_level=200.0, type="resistance", strength=50),
|
|
]
|
|
reps = _zone_representative_levels(levels, entry_price=180.0)
|
|
|
|
# 183/185 merge into one zone; 200 stays separate → 2 representatives
|
|
assert len(reps) == 2
|
|
wall = min(reps, key=lambda r: r.price_level)
|
|
assert wall.price_level == 183.0 # near edge of the wall
|
|
assert wall.strength == 100 # 60 + 60, capped at 100
|
|
far = max(reps, key=lambda r: r.price_level)
|
|
assert far.price_level == 200.0
|
|
assert far.strength == 50
|
|
|
|
|
|
def test_zone_representative_levels_singletons_unchanged():
|
|
from types import SimpleNamespace
|
|
from app.services.recommendation_service import _zone_representative_levels
|
|
|
|
levels = [
|
|
SimpleNamespace(id=1, price_level=120.0, type="resistance", strength=70),
|
|
SimpleNamespace(id=2, price_level=150.0, type="resistance", strength=40),
|
|
]
|
|
reps = _zone_representative_levels(levels, entry_price=100.0)
|
|
assert len(reps) == 2
|
|
assert {round(r.price_level) for r in reps} == {120, 150}
|