Add blue-sky projected targets and played-out setup UX
Fixes stale below-price setups showing as current recommendations. Three distinct causes share the symptom (get_trade_setups returns the latest stored setup per direction and never expires it): - Genuine blue-sky (no overhead S/R): scanner + TargetGenerator now project a measured-move target (entry +/- 3*ATR, ~2:1 R:R), flagged projected with a low sr_strength probability haircut. Overhead check keys on level tag OR price so it never projects through a straddling resistance cluster. - Projected targets clear a stricter activation bar (long-only, momentum >= 90, confidence >= min+10), independent of the general momentum gate. Mirrored in frontend qualification.ts. - Played-out UX (fixes the reported TTWO case, which is R:R-starved under a resistance cluster, not blue-sky): when price is at/past target or through the stop, RecommendationPanel shows a "No current setup" state and softens the stale ticker-level header/reasoning, instead of a stale actionable card. No migration: the projected flag rides in existing targets_json. 504 backend unit tests pass; frontend typechecks. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 }) {
|
||||
<td className="py-2 pr-3 text-gray-300">
|
||||
{target.is_primary && <span className="mr-1 text-blue-300">★</span>}
|
||||
{target.classification}
|
||||
{target.projected && <span className="ml-1 text-sky-400">(projected)</span>}
|
||||
</td>
|
||||
<td className="py-2 pr-3 font-mono text-gray-200">{formatPrice(target.price)}</td>
|
||||
<td className="py-2 pr-3 font-mono text-gray-200">{formatPercent((target.distance_from_entry / setup.entry_price) * 100)}</td>
|
||||
@@ -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 (
|
||||
<div data-direction={setup.direction} className="glass-sm p-4 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className={`text-sm font-semibold ${setup.direction === 'long' ? 'text-emerald-400' : 'text-red-400'}`}>
|
||||
{dir}
|
||||
</h4>
|
||||
<span className="text-[10px] uppercase tracking-wider text-gray-500">No current setup</span>
|
||||
</div>
|
||||
<p className="text-[11px] text-gray-400">
|
||||
{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.`}
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-x-2 gap-y-1 text-xs">
|
||||
<div className="text-gray-500">Current</div><div className="font-mono text-gray-300">{currentPrice != null ? formatPrice(currentPrice) : '—'}</div>
|
||||
<div className="text-gray-500">Last entry</div><div className="font-mono text-gray-400">{formatPrice(setup.entry_price)}{drift ? ` (${drift.pct >= 0 ? '+' : ''}${drift.pct.toFixed(1)}%)` : ''}</div>
|
||||
<div className="text-gray-500">Last target</div><div className="font-mono text-gray-400">{formatPrice(setup.target)}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
data-direction={setup.direction}
|
||||
@@ -157,6 +205,11 @@ function SetupCard({ setup, action, currentPrice, risk, regime }: { setup?: Trad
|
||||
</p>
|
||||
)}
|
||||
|
||||
{primaryProjected && (
|
||||
<p className="text-[11px] text-sky-400">
|
||||
⚑ Blue-sky: no resistance overhead — target is an ATR measured-move projection, not an S/R level.
|
||||
</p>
|
||||
)}
|
||||
{drift && drift.status === 'invalidated' && (
|
||||
<p className="text-[11px] text-red-400">
|
||||
⚠ 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 (
|
||||
<section>
|
||||
<h2 className="mb-3 text-xs font-medium uppercase tracking-widest text-gray-500">Recommendation</h2>
|
||||
<div className="glass p-5 space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<span className="text-sm font-semibold text-blue-300">{recommendationActionLabel(action)}</span>
|
||||
{preferredInactive ? (
|
||||
<span className="text-sm font-semibold text-gray-400">
|
||||
No current setup <span className="font-normal text-gray-500">(last {preferredDirection} bias {recommendationActionLabel(action).toLowerCase()} — {preferredInactive.invalidated ? 'invalidated' : 'played out'})</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-sm font-semibold text-blue-300">{recommendationActionLabel(action)}</span>
|
||||
)}
|
||||
<span className={`text-sm font-semibold ${riskClass(summary?.risk_level ?? null)}`}>
|
||||
Risk: {summary?.risk_level ?? '—'}
|
||||
</span>
|
||||
@@ -359,7 +423,7 @@ export function RecommendationPanel({ symbol, longSetup, shortSetup, currentPric
|
||||
|
||||
<p className="text-xs text-gray-500">Recommended Action is the ticker-level bias. The preferred setup is shown first; the opposite side is available under Alternative scenario.</p>
|
||||
|
||||
{summary?.reasoning && (
|
||||
{summary?.reasoning && !preferredInactive && (
|
||||
<p className="text-sm text-gray-300">{summary.reasoning}</p>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
@@ -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}])
|
||||
|
||||
Reference in New Issue
Block a user