diff --git a/app/services/qualification.py b/app/services/qualification.py index e7ad678..a735213 100644 --- a/app/services/qualification.py +++ b/app/services/qualification.py @@ -16,6 +16,15 @@ 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": @@ -46,6 +55,19 @@ 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. @@ -102,6 +124,18 @@ 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 8f1f658..f31dce3 100644 --- a/app/services/recommendation_service.py +++ b/app/services/recommendation_service.py @@ -44,6 +44,16 @@ _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)) @@ -310,7 +320,43 @@ class TargetGenerator: ) if not candidates: - return [] + # 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, + } + ] # 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 @@ -450,9 +496,11 @@ 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``). 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. + (``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.) """ high = float(config.get("recommendation_high_confidence_threshold", 70.0)) moderate = float(config.get("recommendation_moderate_confidence_threshold", 50.0)) @@ -658,7 +706,14 @@ async def enhance_trade_setup( # Per-setup conflicts (target availability is specific to this setup) setup_conflicts = list(conflicts) - if len(targets) < 3: + 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: 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 5944801..43a10f5 100644 --- a/app/services/rr_scanner_service.py +++ b/app/services/rr_scanner_service.py @@ -29,6 +29,7 @@ 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, @@ -67,6 +68,16 @@ 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) @@ -428,13 +439,13 @@ async def scan_ticker( now = datetime.now(timezone.utc) setups: list[TradeSetup] = [] - if levels_above: - stop = entry_price - (atr_value * atr_multiplier) - risk = entry_price - stop - if risk > 0: + 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: 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: @@ -448,21 +459,29 @@ 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 0a39d9b..c5fb4ab 100644 --- a/frontend/src/components/ticker/RecommendationPanel.tsx +++ b/frontend/src/components/ticker/RecommendationPanel.tsx @@ -49,6 +49,21 @@ function entryDrift(setup: TradeSetup, currentPrice?: number) { return { pct, progressPct, towardTarget, status }; } +/** + * A stored setup is the latest for its direction. When price has run to/past the + * target (played out) or through the stop (invalidated), there is no fresh setup + * — the card and the ticker-level header should say so rather than present a + * stale actionable recommendation. Returns null when there's no live price. + */ +function notActionableState(setup: TradeSetup, currentPrice?: number) { + if (currentPrice == null) return null; + const drift = entryDrift(setup, currentPrice); + const playedOut = setup.direction === 'long' ? currentPrice >= setup.target : currentPrice <= setup.target; + const invalidated = drift?.status === 'invalidated'; + if (!playedOut && !invalidated) return null; + return { playedOut, invalidated }; +} + function riskClass(risk: TradeSetup['risk_level']) { if (risk === 'Low') return 'text-emerald-400'; if (risk === 'Medium') return 'text-amber-400'; @@ -88,6 +103,7 @@ function TargetTable({ setup }: { setup: TradeSetup }) { {target.is_primary && } {target.classification} + {target.projected && (projected)} {formatPrice(target.price)} {formatPercent((target.distance_from_entry / setup.entry_price) * 100)} @@ -114,6 +130,14 @@ function SetupCard({ setup, action, currentPrice, risk, regime }: { setup?: Trad const drift = entryDrift(setup, currentPrice); const sizing = positionSize(risk.accountSize, risk.riskPct, setup.entry_price, setup.stop_loss); const counterTrend = regime ? isCounterTrend(setup.direction, regime.label) : false; + const primaryProjected = setup.targets?.some((t) => t.is_primary && t.projected) ?? false; + + // When price has run to/past the target (played out) or through the stop + // (invalidated), there is no fresh setup — show a plain "no current setup" + // state instead of an actionable card with no reward left. + const inactive = notActionableState(setup, currentPrice); + const invalidated = inactive?.invalidated ?? false; + const notActionable = inactive != null; const createTrade = useCreatePaperTrade(); const [taking, setTaking] = useState(false); @@ -134,6 +158,30 @@ function SetupCard({ setup, action, currentPrice, risk, regime }: { setup?: Trad ); }; + if (notActionable) { + const dir = setup.direction.toUpperCase(); + return ( +
+
+

+ {dir} +

+ No current setup +
+

+ {invalidated + ? `The last ${dir} setup is invalidated — price (${formatPrice(currentPrice!)}) has passed the stop (${formatPrice(setup.stop_loss)}). No fresh ${dir} setup right now; the scanner surfaces a new one when it forms.` + : `The last ${dir} setup has played out — price (${formatPrice(currentPrice!)}) is at or past the target (${formatPrice(setup.target)}). No fresh ${dir} setup right now; the scanner surfaces a new one when it forms.`} +

+
+
Current
{currentPrice != null ? formatPrice(currentPrice) : '—'}
+
Last entry
{formatPrice(setup.entry_price)}{drift ? ` (${drift.pct >= 0 ? '+' : ''}${drift.pct.toFixed(1)}%)` : ''}
+
Last target
{formatPrice(setup.target)}
+
+
+ ); + } + return (
)} + {primaryProjected && ( +

+ ⚑ 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. @@ -341,12 +394,23 @@ export function RecommendationPanel({ symbol, longSetup, shortSetup, currentPric return null; } + // If the preferred setup has played out / been invalidated, the stored + // ticker-level bias and reasoning are stale — don't headline "Strong Long" + // above a "no current setup" card. + const preferredInactive = preferredSetup ? notActionableState(preferredSetup, currentPrice) : null; + return (

Recommendation

- {recommendationActionLabel(action)} + {preferredInactive ? ( + + No current setup (last {preferredDirection} bias {recommendationActionLabel(action).toLowerCase()} — {preferredInactive.invalidated ? 'invalidated' : 'played out'}) + + ) : ( + {recommendationActionLabel(action)} + )} Risk: {summary?.risk_level ?? '—'} @@ -359,7 +423,7 @@ export function RecommendationPanel({ symbol, longSetup, shortSetup, currentPric

Recommended Action is the ticker-level bias. The preferred setup is shown first; the opposite side is available under Alternative scenario.

- {summary?.reasoning && ( + {summary?.reasoning && !preferredInactive && (

{summary.reasoning}

)} diff --git a/frontend/src/lib/qualification.ts b/frontend/src/lib/qualification.ts index 195f3a6..f047b50 100644 --- a/frontend/src/lib/qualification.ts +++ b/frontend/src/lib/qualification.ts @@ -2,6 +2,20 @@ 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'; @@ -51,6 +65,16 @@ 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 7e61de0..1307ed1 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -577,6 +577,8 @@ 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 new file mode 100644 index 0000000..c8a021c --- /dev/null +++ b/tests/unit/test_projected_targets.py @@ -0,0 +1,201 @@ +"""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 f711243..9ca5fa3 100644 --- a/tests/unit/test_qualification.py +++ b/tests/unit/test_qualification.py @@ -6,6 +6,7 @@ from types import SimpleNamespace from app.services.qualification import ( best_target_probability, + primary_target_is_projected, primary_target_probability, setup_qualifies, ) @@ -155,6 +156,54 @@ 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}])