diff --git a/app/services/qualification.py b/app/services/qualification.py index a735213..e7ad678 100644 --- a/app/services/qualification.py +++ b/app/services/qualification.py @@ -16,15 +16,6 @@ from typing import Any HIGH_CONVICTION_ACTIONS = {"LONG_HIGH", "SHORT_HIGH"} -# A projected (blue-sky) target has no S/R validation — it is a measured-move -# extension used when nothing sits overhead. Because that is exactly the kind of -# unvalidated target the gate exists to distrust, a projected setup clears a -# STRICTER bar than an S/R-anchored one, regardless of whether the general -# momentum gate is active: long-only (breakout continuation), strong residual -# momentum, and a higher confidence floor. Mirrored in frontend/src/lib/qualification.ts. -PROJECTED_MIN_MOMENTUM_PERCENTILE = 90.0 -PROJECTED_CONFIDENCE_MARGIN = 10.0 - def _action_direction(action: str | None) -> str: if not action or action == "NEUTRAL": @@ -55,19 +46,6 @@ def primary_target_probability(setup: Any) -> float | None: return best if best > 0 else None -def primary_target_is_projected(setup: Any) -> bool: - """Whether the setup's headline target is a blue-sky measured-move projection. - - Prefers the starred primary; falls back to any projected target when none is - explicitly flagged primary (matches primary_target_probability's fallback). - """ - targets = getattr(setup, "targets", None) or [] - for target in targets: - if isinstance(target, dict) and target.get("is_primary"): - return bool(target.get("projected")) - return any(isinstance(t, dict) and t.get("projected") for t in targets) - - def live_risk_reward(setup: Any, current_price: float) -> float | None: """R:R recomputed from the CURRENT price, not the (possibly stale) entry. @@ -124,18 +102,6 @@ def setup_qualifies(setup: Any, config: dict) -> bool: momentum_percentile = getattr(setup, "momentum_percentile", None) if momentum_percentile is None or momentum_percentile < min_pct: return False - # Projected (blue-sky) targets clear a stricter bar than S/R-anchored ones, - # independent of the general momentum gate above: long-only, strong residual - # momentum, and a higher confidence floor. The target has no S/R validation, - # so we only trust it for high-momentum breakout continuations. - if primary_target_is_projected(setup): - if (getattr(setup, "direction", "long") or "long").lower() != "long": - return False - momentum_percentile = getattr(setup, "momentum_percentile", None) - if momentum_percentile is None or momentum_percentile < PROJECTED_MIN_MOMENTUM_PERCENTILE: - return False - if (setup.confidence_score or 0.0) < config["min_confidence"] + PROJECTED_CONFIDENCE_MARGIN: - return False # A setup is actionable only when the live ticker action points in the same # direction. NEUTRAL means no clear signal; an opposite action means the # setup is counter-bias. ``exclude_neutral`` defaults on; callers that omit diff --git a/app/services/recommendation_service.py b/app/services/recommendation_service.py index f31dce3..8f1f658 100644 --- a/app/services/recommendation_service.py +++ b/app/services/recommendation_service.py @@ -44,16 +44,6 @@ _MODERATE_MAX_ATR = 4.6 # the same tolerance the chart and alerts use, so S/R is one model app-wide. _SR_ZONE_TOLERANCE = 0.02 -# Measured-move projection used when a ticker has NO S/R level overhead in the -# trade direction (genuine blue-sky, e.g. a stock at all-time highs). Without -# this the scanner produces no setup and the last (now stale) one lingers. The -# projected target sits this many ATRs from entry, so with the default 1.5-ATR -# stop it is a clean 2:1 R:R. Projected targets carry no touch history, so they -# take near-zero strength (small probability haircut via the strength magnet) and -# face a stricter activation bar — see app/services/qualification.py. -PROJECTED_TARGET_ATR_MULTIPLE = 3.0 -PROJECTED_TARGET_STRENGTH = 10.0 - def _clamp(value: float, low: float, high: float) -> float: return max(low, min(high, value)) @@ -320,43 +310,7 @@ class TargetGenerator: ) if not candidates: - # No S/R level in the trade direction cleared the ATR distance - # filter. If there is genuinely NO S/R overhead at all (blue-sky, - # e.g. all-time highs), project a measured-move target so a breakout - # name still yields a setup. When overhead S/R DOES exist but was - # merely too close/far to qualify, produce nothing as before — we - # never project a target through real, nearby resistance. - # - # Check both the level's tag AND its price. Zone representatives are - # typed relative to entry, so a resistance cluster straddling entry - # counts as overhead even if its near edge sits just below (which - # keeps this aligned with the scanner's raw ``levels_above`` gate); - # the price comparison covers raw levels for other callers. - has_overhead = any( - (direction == "long" and (lv.type == "resistance" or lv.price_level > entry_price)) - or (direction == "short" and (lv.type == "support" or lv.price_level < entry_price)) - for lv in sr_levels - ) - if has_overhead: - return [] - projected_price = ( - entry_price + PROJECTED_TARGET_ATR_MULTIPLE * atr_value - if direction == "long" - else entry_price - PROJECTED_TARGET_ATR_MULTIPLE * atr_value - ) - reward = abs(projected_price - entry_price) - return [ - { - "price": float(projected_price), - "distance_from_entry": float(reward), - "distance_atr_multiple": float(reward / atr_value), - "rr_ratio": float(reward / risk), - "classification": "Moderate", - "sr_level_id": -1, - "sr_strength": float(PROJECTED_TARGET_STRENGTH), - "projected": True, - } - ] + return [] # Select up to 5 targets that SPAN the distance range, instead of the # top-5 by quality (which biases toward far, high-R:R levels and buries @@ -496,11 +450,9 @@ def _choose_recommended_action( """Pick the ticker action — but only recommend a direction you can trade. A direction is recommendable only if a tradeable setup exists for it - (``available_directions``). A strong LONG bias on a stock with no tradeable - long setup does NOT yield LONG_HIGH; it falls through to NEUTRAL, and the - reasoning explains why. (At genuine all-time highs the scanner now projects a - measured-move long target, so blue-sky names can be recommendable; a name - capped just under resistance — with no ≥threshold R:R — still cannot.) + (``available_directions``). So a strong LONG bias on a stock at all-time + highs — where the scanner can build no long target — does NOT yield + LONG_HIGH; it falls through to NEUTRAL, and the reasoning explains why. """ high = float(config.get("recommendation_high_confidence_threshold", 70.0)) moderate = float(config.get("recommendation_moderate_confidence_threshold", 50.0)) @@ -706,14 +658,7 @@ async def enhance_trade_setup( # Per-setup conflicts (target availability is specific to this setup) setup_conflicts = list(conflicts) - primary_projected = bool(primary is not None and primary.get("projected")) - if primary_projected: - # Blue-sky: no overhead S/R to anchor to. Flag it so the target's basis - # is explicit rather than looking like a normal S/R level. - setup_conflicts.append( - "projected-target: No overhead resistance — target is an ATR measured-move projection" - ) - elif len(targets) < 3: + if len(targets) < 3: setup_conflicts.append("target-availability: Fewer than 3 valid S/R targets available") # Action and reasoning are ticker-level: they consider both directions and diff --git a/app/services/rr_scanner_service.py b/app/services/rr_scanner_service.py index 43a10f5..5944801 100644 --- a/app/services/rr_scanner_service.py +++ b/app/services/rr_scanner_service.py @@ -29,7 +29,6 @@ from app.models.trade_setup import TradeSetup from app.services.indicator_service import _extract_ohlcv, compute_atr from app.services.price_service import query_ohlcv from app.services.recommendation_service import ( - PROJECTED_TARGET_ATR_MULTIPLE, _risk_level_from_conflicts, build_recommendation_snapshot, enhance_trade_setup, @@ -68,16 +67,6 @@ def _compute_quality_score( return w_rr * norm_rr + w_strength * norm_strength + w_proximity * norm_proximity -def _projected_target(direction: str, entry_price: float, atr_value: float) -> float: - """Measured-move target for a blue-sky direction (no overhead S/R). - - Mirrors the projection in recommendation_service so the scanner's emission - decision and the enhanced target agree. - """ - move = PROJECTED_TARGET_ATR_MULTIPLE * atr_value - return entry_price + move if direction == "long" else entry_price - move - - async def _get_dimension_scores(db: AsyncSession, ticker_id: int) -> dict[str, float]: result = await db.execute( select(DimensionScore).where(DimensionScore.ticker_id == ticker_id) @@ -439,13 +428,13 @@ async def scan_ticker( now = datetime.now(timezone.utc) setups: list[TradeSetup] = [] - stop = entry_price - (atr_value * atr_multiplier) - risk = entry_price - stop - if risk > 0: - best_candidate_rr = 0.0 - best_candidate_target = 0.0 - if levels_above: + if levels_above: + stop = entry_price - (atr_value * atr_multiplier) + risk = entry_price - stop + if risk > 0: best_quality = 0.0 + best_candidate_rr = 0.0 + best_candidate_target = 0.0 for lv in levels_above: reward = lv.price_level - entry_price if reward <= 0: @@ -459,29 +448,21 @@ async def scan_ticker( best_quality = quality best_candidate_rr = rr best_candidate_target = lv.price_level - else: - # Blue-sky: no resistance overhead. Project a measured-move target so - # a breakout name still yields a setup (it faces a stricter gate). - projected = _projected_target("long", entry_price, atr_value) - projected_rr = (projected - entry_price) / risk - if projected_rr >= rr_threshold: - best_candidate_rr = projected_rr - best_candidate_target = projected - if best_candidate_rr > 0: - setups.append(TradeSetup( - ticker_id=ticker.id, - direction="long", - entry_price=round(entry_price, 4), - stop_loss=round(stop, 4), - target=round(best_candidate_target, 4), - rr_ratio=round(best_candidate_rr, 4), - composite_score=round(composite_score, 4), - detected_at=now, - momentum_percentile=momentum_percentile, - strategy_rank=strategy_rank, - volatility_percentile=volatility_percentile, - )) + if best_candidate_rr > 0: + setups.append(TradeSetup( + ticker_id=ticker.id, + direction="long", + entry_price=round(entry_price, 4), + stop_loss=round(stop, 4), + target=round(best_candidate_target, 4), + rr_ratio=round(best_candidate_rr, 4), + composite_score=round(composite_score, 4), + detected_at=now, + momentum_percentile=momentum_percentile, + strategy_rank=strategy_rank, + volatility_percentile=volatility_percentile, + )) if levels_below: stop = entry_price + (atr_value * atr_multiplier) diff --git a/frontend/src/components/ticker/RecommendationPanel.tsx b/frontend/src/components/ticker/RecommendationPanel.tsx index c5fb4ab..2297745 100644 --- a/frontend/src/components/ticker/RecommendationPanel.tsx +++ b/frontend/src/components/ticker/RecommendationPanel.tsx @@ -103,7 +103,6 @@ function TargetTable({ setup }: { setup: TradeSetup }) {
- ⚑ Blue-sky: no resistance overhead — target is an ATR measured-move projection, not an S/R level. -
- )} {drift && drift.status === 'invalidated' && (⚠ Price ({formatPrice(currentPrice!)}) is past the stop — this setup is invalidated. diff --git a/frontend/src/lib/qualification.ts b/frontend/src/lib/qualification.ts index f047b50..195f3a6 100644 --- a/frontend/src/lib/qualification.ts +++ b/frontend/src/lib/qualification.ts @@ -2,20 +2,6 @@ import type { ActivationConfig, TradeSetup } from './types'; const HIGH_CONVICTION_ACTIONS = new Set(['LONG_HIGH', 'SHORT_HIGH']); -// Projected (blue-sky) targets clear a stricter bar than S/R-anchored ones — -// long-only, strong momentum, higher confidence floor. Mirrors the constants in -// app/services/qualification.py; keep the two in sync. -const PROJECTED_MIN_MOMENTUM_PERCENTILE = 90; -const PROJECTED_CONFIDENCE_MARGIN = 10; - -/** Whether the setup's headline target is a blue-sky measured-move projection. */ -export function primaryTargetIsProjected(setup: TradeSetup): boolean { - const targets = setup.targets ?? []; - const primary = targets.find((t) => t.is_primary); - if (primary) return Boolean(primary.projected); - return targets.some((t) => t.projected); -} - function actionDirection(action: TradeSetup['recommended_action']): 'long' | 'short' | 'neutral' { if (!action || action === 'NEUTRAL') return 'neutral'; if (action.startsWith('LONG')) return 'long'; @@ -65,16 +51,6 @@ export function qualifiesSetup(setup: TradeSetup, config: ActivationConfig): boo return false; } } - // Projected (blue-sky) targets clear a stricter bar than S/R-anchored ones, - // independent of the general momentum gate: long-only, strong momentum, higher - // confidence floor. Mirrors app/services/qualification.py. - if (primaryTargetIsProjected(setup)) { - if (setup.direction !== 'long') return false; - if (setup.momentum_percentile == null || setup.momentum_percentile < PROJECTED_MIN_MOMENTUM_PERCENTILE) { - return false; - } - if ((setup.confidence_score ?? 0) < config.min_confidence + PROJECTED_CONFIDENCE_MARGIN) return false; - } // NEUTRAL = "no clear setup"; an opposite action means this setup is counter-bias. if (config.exclude_neutral) { const actionDir = actionDirection(setup.recommended_action); diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 1307ed1..7e61de0 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -577,8 +577,6 @@ export interface TradeTarget { sr_level_id: number; sr_strength: number; is_primary?: boolean; - /** Blue-sky measured-move target (no overhead S/R); sr_level_id is -1. */ - projected?: boolean; } export interface RecommendationSummary { diff --git a/tests/unit/test_projected_targets.py b/tests/unit/test_projected_targets.py deleted file mode 100644 index c8a021c..0000000 --- a/tests/unit/test_projected_targets.py +++ /dev/null @@ -1,201 +0,0 @@ -"""Tests for blue-sky projected targets. - -When a ticker has NO S/R level overhead in the trade direction (e.g. a stock at -all-time highs), the scanner would otherwise produce no setup and the last stale -one would linger. Instead we project a measured-move (ATR) target so a breakout -name still yields a setup — one that faces a stricter activation bar. These -tests cover the target generator's projection rule and the scanner emission. -""" - -from __future__ import annotations - -from datetime import date, datetime, timedelta, timezone -from types import SimpleNamespace - -import pytest -from sqlalchemy.ext.asyncio import AsyncSession - -from app.models.ohlcv import OHLCVRecord -from app.models.score import CompositeScore -from app.models.sr_level import SRLevel -from app.models.ticker import Ticker -from app.services.recommendation_service import ( - PROJECTED_TARGET_ATR_MULTIPLE, - PROJECTED_TARGET_STRENGTH, - target_generator, -) -from app.services.rr_scanner_service import scan_ticker - - -def _lvl(price: float, type_: str, strength: int = 50, id_: int = 1) -> SimpleNamespace: - return SimpleNamespace(id=id_, price_level=price, type=type_, strength=strength) - - -class TestGenerateTargetsProjection: - """target_generator.generate_targets(direction, entry, stop, sr_levels, atr).""" - - def test_blue_sky_long_projects_target(self): - # Entry 100, stop 97 (risk 3), ATR 2 — no resistance overhead at all. - targets = target_generator.generate_targets( - direction="long", - entry_price=100.0, - stop_loss=97.0, - sr_levels=[_lvl(90.0, "support"), _lvl(85.0, "support")], - atr_value=2.0, - ) - assert len(targets) == 1 - t = targets[0] - assert t["projected"] is True - assert t["sr_level_id"] == -1 - assert t["sr_strength"] == PROJECTED_TARGET_STRENGTH - # 100 + 3 ATR (=6) = 106; reward 6 / risk 3 = 2.0 R:R - assert t["price"] == pytest.approx(100.0 + PROJECTED_TARGET_ATR_MULTIPLE * 2.0) - assert t["rr_ratio"] == pytest.approx(2.0) - - def test_blue_sky_with_no_levels_at_all_projects(self): - targets = target_generator.generate_targets( - direction="long", entry_price=100.0, stop_loss=97.0, sr_levels=[], atr_value=2.0, - ) - assert len(targets) == 1 and targets[0]["projected"] is True - - def test_overhead_resistance_too_close_does_not_project(self): - # A resistance 0.5 ATR above (< 1.0 ATR min distance) is filtered out as a - # candidate — but it IS real overhead, so we must NOT project through it. - targets = target_generator.generate_targets( - direction="long", - entry_price=100.0, - stop_loss=97.0, - sr_levels=[_lvl(101.0, "resistance")], - atr_value=2.0, - ) - assert targets == [] - - def test_normal_overhead_resistance_is_not_projected(self): - targets = target_generator.generate_targets( - direction="long", - entry_price=100.0, - stop_loss=97.0, - sr_levels=[_lvl(106.0, "resistance", strength=80)], - atr_value=2.0, - ) - assert len(targets) == 1 - assert not targets[0].get("projected") - assert targets[0]["sr_level_id"] == 1 - - def test_resistance_tagged_straddle_does_not_project(self): - # A resistance-tagged zone rep whose near edge sits just BELOW entry still - # means real overhead — never project through it, even though its price is - # under entry and it isn't a valid candidate. - targets = target_generator.generate_targets( - direction="long", - entry_price=100.0, - stop_loss=97.0, - sr_levels=[_lvl(99.5, "resistance"), _lvl(90.0, "support")], - atr_value=2.0, - ) - assert targets == [] - - def test_blue_sky_short_projects_below(self): - targets = target_generator.generate_targets( - direction="short", - entry_price=100.0, - stop_loss=103.0, - sr_levels=[_lvl(110.0, "resistance")], - atr_value=2.0, - ) - assert len(targets) == 1 - assert targets[0]["projected"] is True - assert targets[0]["price"] == pytest.approx(100.0 - PROJECTED_TARGET_ATR_MULTIPLE * 2.0) - - -# --------------------------------------------------------------------------- -# Scanner emission -# --------------------------------------------------------------------------- - -@pytest.fixture -async def scan_session() -> AsyncSession: - from tests.conftest import _test_session_factory - - async with _test_session_factory() as session: - yield session - - -def _make_bars(ticker_id: int, num_bars: int = 20, base_close: float = 100.0): - bars = [] - start = date(2024, 1, 1) - for i in range(num_bars): - close = base_close + (i % 3 - 1) * 0.5 - bars.append(OHLCVRecord( - ticker_id=ticker_id, - date=start + timedelta(days=i), - open=close - 0.3, - high=close + 1.0, - low=close - 1.0, - close=close, - volume=100_000, - )) - return bars - - -@pytest.mark.asyncio -async def test_scan_emits_projected_long_when_blue_sky(scan_session: AsyncSession): - """A ticker with only support below entry (no overhead) yields a projected long.""" - ticker = Ticker(symbol="BLUESKY") - scan_session.add(ticker) - await scan_session.flush() - - scan_session.add_all(_make_bars(ticker.id, num_bars=20, base_close=100.0)) - # Only support levels below entry — nothing overhead. - scan_session.add_all([ - SRLevel(ticker_id=ticker.id, price_level=95.0, type="support", strength=80, - detection_method="pivot_point"), - SRLevel(ticker_id=ticker.id, price_level=90.0, type="support", strength=60, - detection_method="pivot_point"), - ]) - scan_session.add(CompositeScore( - ticker_id=ticker.id, score=70.0, is_stale=False, weights_json="{}", - computed_at=datetime.now(timezone.utc), - )) - await scan_session.commit() - - setups = await scan_ticker(scan_session, "BLUESKY", rr_threshold=1.5, atr_multiplier=1.5) - - long_setups = [s for s in setups if s.direction == "long"] - assert len(long_setups) == 1, "blue-sky ticker should still yield a long setup" - long_setup = long_setups[0] - - # Target is the measured-move projection, well above entry, R:R ~2.0. - assert long_setup.target > long_setup.entry_price - assert long_setup.rr_ratio == pytest.approx(2.0, abs=0.05) - - primary = [t for t in long_setup.targets if t.get("is_primary")] - assert primary and primary[0]["projected"] is True - assert primary[0]["sr_level_id"] == -1 - assert any("projected-target" in c for c in long_setup.conflict_flags) - - -@pytest.mark.asyncio -async def test_scan_does_not_project_when_resistance_overhead(scan_session: AsyncSession): - """With a normal resistance overhead, the long target is that S/R level, not a projection.""" - ticker = Ticker(symbol="CAPPED") - scan_session.add(ticker) - await scan_session.flush() - - scan_session.add_all(_make_bars(ticker.id, num_bars=20, base_close=100.0)) - scan_session.add_all([ - SRLevel(ticker_id=ticker.id, price_level=106.0, type="resistance", strength=80, - detection_method="pivot_point"), - SRLevel(ticker_id=ticker.id, price_level=95.0, type="support", strength=60, - detection_method="pivot_point"), - ]) - scan_session.add(CompositeScore( - ticker_id=ticker.id, score=70.0, is_stale=False, weights_json="{}", - computed_at=datetime.now(timezone.utc), - )) - await scan_session.commit() - - setups = await scan_ticker(scan_session, "CAPPED", rr_threshold=1.5, atr_multiplier=1.5) - long_setups = [s for s in setups if s.direction == "long"] - assert len(long_setups) == 1 - primary = [t for t in long_setups[0].targets if t.get("is_primary")] - assert primary and not primary[0].get("projected") diff --git a/tests/unit/test_qualification.py b/tests/unit/test_qualification.py index 9ca5fa3..f711243 100644 --- a/tests/unit/test_qualification.py +++ b/tests/unit/test_qualification.py @@ -6,7 +6,6 @@ from types import SimpleNamespace from app.services.qualification import ( best_target_probability, - primary_target_is_projected, primary_target_probability, setup_qualifies, ) @@ -156,54 +155,6 @@ class TestExcludeNeutral: assert setup_qualifies(_setup(recommended_action="NEUTRAL"), DEFAULT_GATE) is True -def _projected_setup(**kwargs): - """A setup whose primary (headline) target is a blue-sky projection.""" - base = dict( - direction="long", - momentum_percentile=92.0, - confidence_score=80.0, - targets=[{"probability": 30.0, "is_primary": True, "projected": True}], - ) - base.update(kwargs) - return _setup(**base) - - -class TestProjectedTargetGate: - """Projected targets clear a stricter bar, independent of the momentum gate.""" - - def test_projected_passes_stricter_bar(self): - # DEFAULT_GATE has the momentum selection OFF, yet the projected block - # still requires strong momentum + higher confidence — and this one clears. - assert setup_qualifies(_projected_setup(), DEFAULT_GATE) is True - - def test_projected_fails_below_momentum_floor(self): - assert setup_qualifies(_projected_setup(momentum_percentile=85.0), DEFAULT_GATE) is False - - def test_projected_fails_missing_momentum(self): - assert setup_qualifies(_projected_setup(momentum_percentile=None), DEFAULT_GATE) is False - - def test_projected_fails_below_raised_confidence_floor(self): - # min_confidence 55 + 10 margin = 65; a 60% confidence projected setup fails - # even though it would clear the plain 55 floor. - assert setup_qualifies(_projected_setup(confidence_score=60.0), DEFAULT_GATE) is False - - def test_projected_short_never_qualifies(self): - s = _projected_setup(direction="short", recommended_action="SHORT_HIGH") - assert setup_qualifies(s, DEFAULT_GATE) is False - - def test_sr_anchored_setup_unaffected_by_projected_bar(self): - # A normal (non-projected) setup with modest momentum still passes DEFAULT. - assert setup_qualifies(_setup(momentum_percentile=10.0), DEFAULT_GATE) is True - - def test_primary_target_is_projected_helper(self): - assert primary_target_is_projected(_projected_setup()) is True - assert primary_target_is_projected(_setup()) is False - - def test_projected_flag_falls_back_when_no_primary(self): - s = _setup(targets=[{"probability": 30.0, "projected": True}]) - assert primary_target_is_projected(s) is True - - class TestBestTargetProbability: def test_returns_max(self): s = _setup(targets=[{"probability": 40.0}, {"probability": 72.0}, {"probability": 55.0}])