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:
2026-07-08 19:30:28 +02:00
co-authored by Claude Opus 4.8
parent 61156684ff
commit 294d935030
8 changed files with 475 additions and 27 deletions
+60 -5
View File
@@ -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