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