Revert blue-sky projection; keep played-out setup UX

A local backtest (offline prod snapshot, 506 tickers) evaluated blue-sky
projected targets under the PRODUCTION exit (3x ATR trailing + 30d max hold,
paper_trade_service DEFAULT_EXIT_MODE="atr_trailing"). Blue-sky setups are
dilutive: the qualified book scored 328% return / Sharpe 1.84 / DD -21.0%
WITHOUT them vs 300% / 1.58 / -18.7% WITH them. They rank high on momentum by
construction, so they grab slots from S/R setups that catch bigger runs under
a trailing-stop exit (only ~2pp worse drawdown doesn't justify the lost return
and Sharpe).

Reverts the scanner/TargetGenerator measured-move projection, the stricter
projected activation gate, the frontend qualification mirror, the `projected`
type field, and the projected tests -- all backend files are now byte-identical
to the pre-blue-sky commit.

Keeps the played-out "No current setup" UX (RecommendationPanel): when price
has run past the target (played out) or through the stop (invalidated), the
panel shows a plain no-setup state instead of a stale actionable card. This is
frontend-only (reads last close + existing setup fields) and is what actually
fixes the reported stale-below-price bug -- no backend change or rescan needed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-08 22:03:25 +02:00
co-authored by Claude Opus 4.8
parent 294d935030
commit 65d2dae62a
8 changed files with 25 additions and 416 deletions
-34
View File
@@ -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
+5 -60
View File
@@ -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
+20 -39
View File
@@ -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)