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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user