UI: frame the setup as what it is — a momentum signal with a trailing exit
The UI told a swing-trade story (entry -> target -> stop) while the engine runs a momentum portfolio (buy strength, trail out, re-rank). The selection was honest; everything around it was borrowed from a strategy we don't run. The target is never an exit under `atr_trailing`: `_atr_trailing_close()` does not even take it as a parameter. It exists only to compute the R:R and touch odds that admit a setup through the activation gate. Backtested exit reasons for the production strategy: 144 initial stop, 98 trailing stop, 78 max hold — target 0. See docs/research/sr-levels-and-exits.md. What changed: - New ExitPlanPanel on every setup card states the rules that actually close the trade: initial stop (1R), the price at which the 3x ATR trail takes over from it, the trail width in R, and the max hold. Derived in lib/exitPlan.ts from the live exit policy, so it follows Admin rather than hardcoding the default. - New BaseRatesPanel replaces per-target "probability" as the answer to "what usually happens": win rate, average hold, best/worst R, and how trades actually ended — measured under the real exit, from the backtest report. - "Target"/"target probability" relabelled to "level"/"touch odds" and grouped as gate metrics, with the R:R. On the dashboard focus card, residual momentum (the actual signal) takes the headline stat those two used to occupy. - The take-trade dialog no longer offers a target dropdown whose value the exit ignores; it states the trailing plan instead. The picker returns only when the live policy is mode='target', where the choice is real. The stored target is now the setup's own, not whichever row was last clicked while exploring. - "Played out" is gone. A setup was declared dead once price reached the target — backwards under a trailing exit, where reaching a level is the good case and the trade keeps running. Only the stop invalidates a setup now; running past the entry is an "extended" warning, measured in R (you'd be chasing). The levels ladder, the price rail and the chart overlay all stay fully explorable — clicking a level still drives them. It is framed as overhead structure, which is what it is, rather than a menu of exits. Adds a parity guard: the UI recovers ATR as |entry - stop| / 1.5, so the test fails if the scanner's stop width ever moves. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,73 @@
|
|||||||
|
import type { BaseRates } from '../../lib/baseRates';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What actually happens to trades like this one, measured under the real exit
|
||||||
|
* policy in the backtest. This is the honest replacement for the per-target
|
||||||
|
* "probability", which estimates the odds of touching a level the trade never
|
||||||
|
* exits at. Note `target` is absent from the exit mix — by construction.
|
||||||
|
*/
|
||||||
|
export function BaseRatesPanel({ rates }: { rates: BaseRates }) {
|
||||||
|
return (
|
||||||
|
<details className="rounded-xl border border-white/[0.07] bg-white/[0.02] px-3 py-2">
|
||||||
|
<summary className="cursor-pointer text-[11px] font-medium text-gray-500 transition-colors hover:text-gray-300">
|
||||||
|
What usually happens · {rates.trades} backtested trades ({rates.lookbackLabel})
|
||||||
|
</summary>
|
||||||
|
|
||||||
|
<div className="mt-2.5 flex flex-wrap gap-x-5 gap-y-1.5 text-[11.5px]">
|
||||||
|
<span className="text-gray-500">
|
||||||
|
Win rate <span className="num text-gray-200">{rates.winRate.toFixed(0)}%</span>
|
||||||
|
</span>
|
||||||
|
{rates.avgHoldDays != null && (
|
||||||
|
<span className="text-gray-500">
|
||||||
|
Avg hold <span className="num text-gray-200">{rates.avgHoldDays.toFixed(0)}d</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{rates.bestR != null && (
|
||||||
|
<span className="text-gray-500">
|
||||||
|
Best <span className="num text-emerald-300">+{rates.bestR.toFixed(1)}R</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{rates.worstR != null && (
|
||||||
|
<span className="text-gray-500">
|
||||||
|
Worst <span className="num text-red-300">{rates.worstR.toFixed(1)}R</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="mt-2 text-[11px] leading-relaxed text-gray-500">
|
||||||
|
Most trades lose a little; a few win big. That asymmetry <em>is</em> the edge — which is why
|
||||||
|
there is no take-profit.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{rates.exits.length > 0 && (
|
||||||
|
<div className="mt-2.5 border-t border-white/[0.05] pt-2">
|
||||||
|
<p className="num mb-1.5 text-[10px] uppercase tracking-[0.16em] text-gray-500">how they ended</p>
|
||||||
|
<div className="flex h-1.5 overflow-hidden rounded-full bg-white/[0.05]">
|
||||||
|
{rates.exits.map((e) => (
|
||||||
|
<div
|
||||||
|
key={e.reason}
|
||||||
|
style={{ width: `${e.share * 100}%` }}
|
||||||
|
className={
|
||||||
|
e.reason === 'stop'
|
||||||
|
? 'bg-red-400/60'
|
||||||
|
: e.reason === 'trailing_stop'
|
||||||
|
? 'bg-emerald-400/60'
|
||||||
|
: 'bg-gray-500/60'
|
||||||
|
}
|
||||||
|
title={`${e.label}: ${e.count} trades`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1.5 flex flex-wrap gap-x-3 gap-y-1 text-[11px] text-gray-500">
|
||||||
|
{rates.exits.map((e) => (
|
||||||
|
<span key={e.reason}>
|
||||||
|
{e.label} <span className="num text-gray-300">{(e.share * 100).toFixed(0)}%</span>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
<span className="text-gray-600">target 0%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</details>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import type { ExitPlan } from '../../lib/exitPlan';
|
||||||
|
import { formatPrice } from '../../lib/format';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The exit rules that will actually close this trade.
|
||||||
|
*
|
||||||
|
* Deliberately sits *above* the levels ladder: the levels are context, this is
|
||||||
|
* the plan. Before this existed the card showed a "Target" with the same visual
|
||||||
|
* weight as the entry, implying a take-profit that the live exit never fires.
|
||||||
|
*/
|
||||||
|
export function ExitPlanPanel({ plan, direction }: { plan: ExitPlan; direction: string }) {
|
||||||
|
const isLong = direction === 'long';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-3 rounded-xl border border-white/[0.07] bg-white/[0.02] p-3">
|
||||||
|
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-1">
|
||||||
|
<span className="num text-[10px] uppercase tracking-[0.16em] text-gray-500">how this exits</span>
|
||||||
|
<span className="text-[11.5px] font-medium text-gray-300">{plan.headline}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dl className="mt-2.5 grid gap-x-4 gap-y-1.5 text-[11.5px] sm:grid-cols-2">
|
||||||
|
<div className="flex items-baseline justify-between gap-2">
|
||||||
|
<dt className="text-gray-500">Initial stop</dt>
|
||||||
|
<dd className="num text-gray-200">
|
||||||
|
{formatPrice(plan.initialStop)}{' '}
|
||||||
|
<span className="text-gray-600">(1R = {formatPrice(plan.riskPerShare)}/sh)</span>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{plan.mode === 'atr_trailing' && plan.trailTakesOverAt != null && plan.trailWidthR != null && (
|
||||||
|
<>
|
||||||
|
<div className="flex items-baseline justify-between gap-2">
|
||||||
|
<dt className="text-gray-500">Trail takes over</dt>
|
||||||
|
<dd className="num text-gray-200">
|
||||||
|
{isLong ? 'above' : 'below'} {formatPrice(plan.trailTakesOverAt)}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-baseline justify-between gap-2 sm:col-span-2">
|
||||||
|
<dt className="text-gray-500">Then it trails</dt>
|
||||||
|
<dd className="num text-gray-200">
|
||||||
|
{formatPrice(plan.trailWidth ?? 0)} ({plan.trailWidthR.toFixed(1)}R) below the highest close
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-baseline justify-between gap-2">
|
||||||
|
<dt className="text-gray-500">Max hold</dt>
|
||||||
|
<dd className="num text-gray-200">{plan.maxHoldDays} trading days</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
{!plan.honorsTarget && (
|
||||||
|
<p className="mt-2.5 border-t border-white/[0.05] pt-2 text-[11px] leading-relaxed text-gray-500">
|
||||||
|
There is <span className="text-gray-400">no take-profit</span>. Winners are ridden until the
|
||||||
|
trailing stop is hit — that’s where the strategy’s edge comes from, so hitting a level
|
||||||
|
below is not a reason to sell. The levels shown below are screening context, not exits.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,7 +10,13 @@ import { useMarketRegime } from '../../hooks/useMarketRegime';
|
|||||||
import { isCounterTrend } from '../../lib/regime';
|
import { isCounterTrend } from '../../lib/regime';
|
||||||
import { primaryTargetProbability } from '../../lib/qualification';
|
import { primaryTargetProbability } from '../../lib/qualification';
|
||||||
import { PriceRail } from '../charts/horizon';
|
import { PriceRail } from '../charts/horizon';
|
||||||
import type { MarketRegime } from '../../lib/types';
|
import type { ExitPolicy, MarketRegime } from '../../lib/types';
|
||||||
|
import { deriveExitPlan, driftInR } from '../../lib/exitPlan';
|
||||||
|
import { productionBaseRates } from '../../lib/baseRates';
|
||||||
|
import { useExitPolicy } from '../../hooks/usePaperTrades';
|
||||||
|
import { useBacktestReport } from '../../hooks/useMarketRegime';
|
||||||
|
import { ExitPlanPanel } from './ExitPlanPanel';
|
||||||
|
import { BaseRatesPanel } from './BaseRatesPanel';
|
||||||
|
|
||||||
interface RecommendationPanelProps {
|
interface RecommendationPanelProps {
|
||||||
symbol: string;
|
symbol: string;
|
||||||
@@ -32,44 +38,42 @@ function daysUntil(iso: string): number | null {
|
|||||||
return Math.ceil((t - Date.now()) / 86_400_000);
|
return Math.ceil((t - Date.now()) / 86_400_000);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Earnings within the ~30-day target horizon can gap price through stop/target. */
|
/** Earnings within the ~30-day hold horizon can gap price through the stop. */
|
||||||
const EARNINGS_HORIZON_DAYS = 30;
|
const EARNINGS_HORIZON_DAYS = 30;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* How far current price has drifted from the setup's entry. A setup whose
|
* How far price has drifted from the scan entry, measured in R (the initial risk
|
||||||
* entry is far from the live price (price already ran toward target, or fell
|
* distance). R is the right unit: the stop sits 1R away and the trailing exit is
|
||||||
* through the stop) is stale — entering now changes the risk/reward.
|
* denominated in R too.
|
||||||
|
*
|
||||||
|
* This used to judge staleness by progress toward the *target*, and declared a
|
||||||
|
* setup "played out" once price reached it. Under the live trailing exit that is
|
||||||
|
* backwards — reaching a level is the good case and the trade keeps running. The
|
||||||
|
* only thing that invalidates a setup is price through the stop; running past the
|
||||||
|
* entry just means you'd be chasing (a wider effective stop), which is a warning,
|
||||||
|
* not a death sentence.
|
||||||
*/
|
*/
|
||||||
function entryDrift(setup: TradeSetup, currentPrice?: number) {
|
function entryDrift(setup: TradeSetup, currentPrice?: number) {
|
||||||
if (currentPrice == null || !setup.entry_price) return null;
|
if (currentPrice == null || !setup.entry_price) return null;
|
||||||
const pct = ((currentPrice - setup.entry_price) / setup.entry_price) * 100;
|
const pct = ((currentPrice - setup.entry_price) / setup.entry_price) * 100;
|
||||||
const towardTarget = setup.direction === 'long' ? currentPrice >= setup.entry_price : currentPrice <= setup.entry_price;
|
const r = driftInR(setup, currentPrice);
|
||||||
// Judge staleness by how much of the entry→target distance is already gone,
|
const beyondStop =
|
||||||
// not the raw % move — an 8%-wide setup is "used up" far faster than a 40% one.
|
setup.direction === 'long' ? currentPrice <= setup.stop_loss : currentPrice >= setup.stop_loss;
|
||||||
const span = Math.abs(setup.target - setup.entry_price);
|
let status: 'fresh' | 'extended' | 'invalidated' = 'fresh';
|
||||||
const moved = Math.abs(currentPrice - setup.entry_price);
|
|
||||||
const progressPct = span > 0 ? (moved / span) * 100 : 0;
|
|
||||||
const beyondStop = setup.direction === 'long' ? currentPrice <= setup.stop_loss : currentPrice >= setup.stop_loss;
|
|
||||||
let status: 'fresh' | 'stale' | 'invalidated' = 'fresh';
|
|
||||||
if (beyondStop) status = 'invalidated';
|
if (beyondStop) status = 'invalidated';
|
||||||
else if (towardTarget && progressPct > 33) status = 'stale';
|
else if (r != null && r >= 1) status = 'extended';
|
||||||
else if (!towardTarget && progressPct > 33) status = 'stale';
|
else if (r != null && r <= -0.5) status = 'extended';
|
||||||
return { pct, progressPct, towardTarget, status };
|
return { pct, r, status };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A stored setup is the latest for its direction. When price has run to/past the
|
* The only state with no tradeable setup left: price has gone through the stop.
|
||||||
* target (played out) or through the stop (invalidated), there is no fresh setup
|
* Returns null when there's no live price.
|
||||||
* — 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) {
|
function notActionableState(setup: TradeSetup, currentPrice?: number) {
|
||||||
if (currentPrice == null) return null;
|
if (currentPrice == null) return null;
|
||||||
const drift = entryDrift(setup, currentPrice);
|
if (entryDrift(setup, currentPrice)?.status !== 'invalidated') return null;
|
||||||
const playedOut = setup.direction === 'long' ? currentPrice >= setup.target : currentPrice <= setup.target;
|
return { invalidated: true };
|
||||||
const invalidated = drift?.status === 'invalidated';
|
|
||||||
if (!playedOut && !invalidated) return null;
|
|
||||||
return { playedOut, invalidated };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function riskClass(risk: TradeSetup['risk_level']) {
|
function riskClass(risk: TradeSetup['risk_level']) {
|
||||||
@@ -106,25 +110,39 @@ function Chip({ children }: { children: React.ReactNode }) {
|
|||||||
|
|
||||||
type Target = NonNullable<TradeSetup['targets']>[number];
|
type Target = NonNullable<TradeSetup['targets']>[number];
|
||||||
|
|
||||||
function TargetTable({ setup, selectedPrice, onSelect }: {
|
function TargetTable({ setup, selectedPrice, onSelect, honorsTarget }: {
|
||||||
setup: TradeSetup;
|
setup: TradeSetup;
|
||||||
selectedPrice: number;
|
selectedPrice: number;
|
||||||
onSelect: (target: Target) => void;
|
onSelect: (target: Target) => void;
|
||||||
|
/** True only when the live exit policy actually takes profit at a level. */
|
||||||
|
honorsTarget: boolean;
|
||||||
}) {
|
}) {
|
||||||
if (!setup.targets || setup.targets.length === 0) {
|
if (!setup.targets || setup.targets.length === 0) {
|
||||||
return <p className="text-xs text-gray-500">No target probabilities available.</p>;
|
return <p className="text-xs text-gray-500">No overhead levels detected.</p>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-xs" role="radiogroup" aria-label="Choose the target for the rail and paper trade">
|
<table
|
||||||
|
className="w-full text-xs"
|
||||||
|
role="radiogroup"
|
||||||
|
aria-label={
|
||||||
|
honorsTarget
|
||||||
|
? 'Choose the take-profit level for the rail and paper trade'
|
||||||
|
: 'Choose a level to preview on the rail (does not affect the exit)'
|
||||||
|
}
|
||||||
|
>
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="text-left text-gray-500 border-b border-white/[0.06]">
|
<tr className="text-left text-gray-500 border-b border-white/[0.06]">
|
||||||
<th className="py-2 pr-3">Classification</th>
|
<th className="py-2 pr-3">Band</th>
|
||||||
<th className="py-2 pr-3">Price</th>
|
<th className="py-2 pr-3">Level</th>
|
||||||
<th className="py-2 pr-3">Distance</th>
|
<th className="py-2 pr-3">Distance</th>
|
||||||
<th className="py-2 pr-3">R:R</th>
|
<th className="py-2 pr-3" title="Reward-to-risk if the trade were exited at this level. Used by the activation gate — not an exit.">
|
||||||
<th className="py-2">Probability</th>
|
Gate R:R
|
||||||
|
</th>
|
||||||
|
<th className="py-2" title="Modelled odds of price TOUCHING this level within ~30 days. Not the odds of the trade winning — the trade does not exit here.">
|
||||||
|
Touch odds
|
||||||
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -170,13 +188,14 @@ function TargetTable({ setup, selectedPrice, onSelect }: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SetupCard({ setup, action, currentPrice, risk, regime, selectedPrice, onSelectPrice }: {
|
function SetupCard({ setup, action, currentPrice, risk, regime, exitPolicy, selectedPrice, onSelectPrice }: {
|
||||||
setup?: TradeSetup;
|
setup?: TradeSetup;
|
||||||
action?: TradeSetup['recommended_action'];
|
action?: TradeSetup['recommended_action'];
|
||||||
currentPrice?: number;
|
currentPrice?: number;
|
||||||
risk: RiskSettings;
|
risk: RiskSettings;
|
||||||
regime?: MarketRegime;
|
regime?: MarketRegime;
|
||||||
/** Controlled target selection (lifted so the candlestick chart can follow). */
|
exitPolicy?: ExitPolicy;
|
||||||
|
/** Controlled level selection (lifted so the candlestick chart can follow). */
|
||||||
selectedPrice?: number | null;
|
selectedPrice?: number | null;
|
||||||
onSelectPrice?: (price: number) => void;
|
onSelectPrice?: (price: number) => void;
|
||||||
}) {
|
}) {
|
||||||
@@ -194,12 +213,13 @@ function SetupCard({ setup, action, currentPrice, risk, regime, selectedPrice, o
|
|||||||
const counterTrend = regime ? isCounterTrend(setup.direction, regime.label) : false;
|
const counterTrend = regime ? isCounterTrend(setup.direction, regime.label) : false;
|
||||||
const prob = primaryTargetProbability(setup);
|
const prob = primaryTargetProbability(setup);
|
||||||
|
|
||||||
// When price has run to/past the target (played out) or through the stop
|
// The real exit rules. `honorsTarget` is false under the production policy —
|
||||||
// (invalidated), there is no fresh setup — show a plain "no current setup"
|
// the level ladder below is context, not a menu of exits.
|
||||||
// state instead of an actionable card with no reward left.
|
const exitPlan = deriveExitPlan(setup, exitPolicy);
|
||||||
const inactive = notActionableState(setup, currentPrice);
|
const honorsTarget = exitPlan?.honorsTarget ?? false;
|
||||||
const invalidated = inactive?.invalidated ?? false;
|
|
||||||
const notActionable = inactive != null;
|
// Only price through the stop leaves no tradeable setup.
|
||||||
|
const notActionable = notActionableState(setup, currentPrice) != null;
|
||||||
|
|
||||||
const createTrade = useCreatePaperTrade();
|
const createTrade = useCreatePaperTrade();
|
||||||
const [taking, setTaking] = useState(false);
|
const [taking, setTaking] = useState(false);
|
||||||
@@ -239,7 +259,10 @@ function SetupCard({ setup, action, currentPrice, risk, regime, selectedPrice, o
|
|||||||
entry_price: takeEntry,
|
entry_price: takeEntry,
|
||||||
shares: takeShares,
|
shares: takeShares,
|
||||||
stop_loss: setup.stop_loss,
|
stop_loss: setup.stop_loss,
|
||||||
target: takeTarget,
|
// Only a real choice when the exit honors it. Otherwise record the
|
||||||
|
// setup's own primary level, so the stored value doesn't silently depend
|
||||||
|
// on which row the user happened to click while exploring the chart.
|
||||||
|
target: honorsTarget ? takeTarget : setup.target,
|
||||||
},
|
},
|
||||||
{ onSuccess: () => setTaking(false) },
|
{ onSuccess: () => setTaking(false) },
|
||||||
);
|
);
|
||||||
@@ -254,13 +277,13 @@ function SetupCard({ setup, action, currentPrice, risk, regime, selectedPrice, o
|
|||||||
<span className="num text-[10px] uppercase tracking-[0.16em] text-gray-500">no current setup</span>
|
<span className="num text-[10px] uppercase tracking-[0.16em] text-gray-500">no current setup</span>
|
||||||
<span className="num ml-auto text-xs text-gray-500">
|
<span className="num ml-auto text-xs text-gray-500">
|
||||||
now {currentPrice != null ? formatPrice(currentPrice) : '—'} · last entry {formatPrice(setup.entry_price)}
|
now {currentPrice != null ? formatPrice(currentPrice) : '—'} · last entry {formatPrice(setup.entry_price)}
|
||||||
{drift ? ` (${drift.pct >= 0 ? '+' : ''}${drift.pct.toFixed(1)}%)` : ''} · last target {formatPrice(setup.target)}
|
{drift ? ` (${drift.pct >= 0 ? '+' : ''}${drift.pct.toFixed(1)}%)` : ''}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-2 text-[11.5px] leading-relaxed text-gray-400">
|
<p className="mt-2 text-[11.5px] leading-relaxed text-gray-400">
|
||||||
{invalidated
|
The last {dir} setup is invalidated — price ({formatPrice(currentPrice!)}) has passed the stop
|
||||||
? `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.`
|
({formatPrice(setup.stop_loss)}). No fresh {dir} setup right now; the scanner surfaces a new one
|
||||||
: `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.`}
|
when it forms.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -279,10 +302,23 @@ function SetupCard({ setup, action, currentPrice, risk, regime, selectedPrice, o
|
|||||||
preferred
|
preferred
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{setup.momentum_percentile != null && (
|
||||||
|
<span
|
||||||
|
className="num rounded-full border border-blue-400/25 bg-blue-400/10 px-2.5 py-0.5 text-[11px] text-blue-200"
|
||||||
|
title="Residual 12-1 month momentum percentile across the universe. This is the actual signal — the reason the ticker was selected at all."
|
||||||
|
>
|
||||||
|
momentum top {Math.max(1, Math.round(100 - setup.momentum_percentile))}%
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<Chip>confidence {setup.confidence_score?.toFixed(0) ?? '—'}%</Chip>
|
<Chip>confidence {setup.confidence_score?.toFixed(0) ?? '—'}%</Chip>
|
||||||
<Chip>R:R {activeRR.toFixed(1)}:1</Chip>
|
<span
|
||||||
{activeProb != null && <Chip>target prob {Math.round(activeProb)}%</Chip>}
|
className="rounded-full border border-white/[0.09] px-2.5 py-0.5 text-[11px] text-gray-500"
|
||||||
{selected && !selected.is_primary && <Chip>custom target</Chip>}
|
title="Gate metrics: the R:R and touch-odds of the selected level are what admitted this setup through the activation gate. They are NOT forecasts of this trade — it does not exit at that level."
|
||||||
|
>
|
||||||
|
gate · R:R {activeRR.toFixed(1)}:1
|
||||||
|
{activeProb != null && ` · touch ${Math.round(activeProb)}%`}
|
||||||
|
</span>
|
||||||
|
{selected && !selected.is_primary && <Chip>custom level</Chip>}
|
||||||
<span className="ml-auto flex flex-wrap items-center gap-3">
|
<span className="ml-auto flex flex-wrap items-center gap-3">
|
||||||
{sizing ? (
|
{sizing ? (
|
||||||
<span
|
<span
|
||||||
@@ -323,28 +359,20 @@ function SetupCard({ setup, action, currentPrice, risk, regime, selectedPrice, o
|
|||||||
({regime.benchmark ?? 'SPY'}). Lower odds — size down or wait for confirmation.
|
({regime.benchmark ?? 'SPY'}). Lower odds — size down or wait for confirmation.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{drift && drift.status === 'invalidated' && (
|
{/* No 'invalidated' branch here: that state returns the "no current
|
||||||
<p className="text-[11px] text-red-300">
|
setup" card above, so it can never reach this block. */}
|
||||||
⚠ Price ({formatPrice(currentPrice!)}) is past the stop — this setup is invalidated.
|
{drift && drift.status === 'extended' && drift.r != null && (
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{drift && drift.status === 'stale' && (
|
|
||||||
<p className="text-[11px] text-amber-400">
|
<p className="text-[11px] text-amber-400">
|
||||||
{drift.towardTarget
|
{drift.r >= 1
|
||||||
? `⚠ ${drift.progressPct.toFixed(0)}% of the entry→target move is already gone (${drift.pct >= 0 ? '+' : ''}${drift.pct.toFixed(1)}% from entry) — little reward left.`
|
? `⚠ Price has run ${drift.r.toFixed(1)}R past the scan entry (${drift.pct >= 0 ? '+' : ''}${drift.pct.toFixed(1)}%). Entering now means chasing — your stop sits further away, so the same dollar risk buys fewer shares.`
|
||||||
: `⚠ Price has moved ${Math.abs(drift.pct).toFixed(1)}% against the setup (toward the stop) — entry may be stale.`}
|
: `⚠ Price has drifted ${Math.abs(drift.r).toFixed(1)}R toward the stop (${drift.pct.toFixed(1)}%) — the entry is stale.`}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{activeProb != null && activeProb < 15 && (
|
|
||||||
<p className="mt-2.5 text-[11px] text-amber-400">
|
|
||||||
⚠ This target has only a {Math.round(activeProb)}% probability — pick a nearer one from the target list below.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* The setup, spatially — stop/entry/now hold still, the *selected*
|
{/* The setup, spatially — stop/entry/now hold still, the *selected*
|
||||||
target moves along a scale that always spans the whole ladder */}
|
level moves along a scale that always spans the whole ladder */}
|
||||||
<PriceRail
|
<PriceRail
|
||||||
direction={setup.direction}
|
direction={setup.direction}
|
||||||
entry={setup.entry_price}
|
entry={setup.entry_price}
|
||||||
@@ -354,6 +382,10 @@ function SetupCard({ setup, action, currentPrice, risk, regime, selectedPrice, o
|
|||||||
scaleTo={(setup.targets ?? []).map((t) => t.price)}
|
scaleTo={(setup.targets ?? []).map((t) => t.price)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* The rules that actually close the trade. Above the ladder on purpose:
|
||||||
|
this is the plan, the levels below are only context. */}
|
||||||
|
{exitPlan && <ExitPlanPanel plan={exitPlan} direction={setup.direction} />}
|
||||||
|
|
||||||
{/* Take dialog — portaled overlay so the panel structure stays put */}
|
{/* Take dialog — portaled overlay so the panel structure stays put */}
|
||||||
{taking && createPortal(
|
{taking && createPortal(
|
||||||
<div
|
<div
|
||||||
@@ -370,10 +402,22 @@ function SetupCard({ setup, action, currentPrice, risk, regime, selectedPrice, o
|
|||||||
<span className="num ml-auto text-[10px] uppercase tracking-[0.16em] text-gray-500">paper trade</span>
|
<span className="num ml-auto text-[10px] uppercase tracking-[0.16em] text-gray-500">paper trade</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="num mt-1.5 text-[11px] text-gray-500">
|
<p className="num mt-1.5 text-[11px] text-gray-500">
|
||||||
stop {formatPrice(setup.stop_loss)} · target {formatPrice(takeTarget)}
|
stop {formatPrice(setup.stop_loss)}
|
||||||
|
{honorsTarget && <> · take profit {formatPrice(takeTarget)}</>}
|
||||||
{sizing && <> · suggested {sizing.shares} sh, max loss {formatPrice(sizing.dollarRisk)}</>}
|
{sizing && <> · suggested {sizing.shares} sh, max loss {formatPrice(sizing.dollarRisk)}</>}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
{/* The exit is the trailing stop, not a target. Say so here, where the
|
||||||
|
user is actually committing — this dialog used to offer a target
|
||||||
|
dropdown whose value the exit never reads. */}
|
||||||
|
{exitPlan && !honorsTarget && (
|
||||||
|
<p className="mt-2 rounded-lg border border-white/[0.07] bg-white/[0.02] px-2.5 py-2 text-[11px] leading-relaxed text-gray-400">
|
||||||
|
<span className="num text-[10px] uppercase tracking-[0.16em] text-gray-500">exits on</span>{' '}
|
||||||
|
{exitPlan.headline}. No take-profit — the {formatPrice(setup.target)} level is recorded for
|
||||||
|
reference only and will not close this trade.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="mt-4 grid grid-cols-2 gap-3">
|
<div className="mt-4 grid grid-cols-2 gap-3">
|
||||||
<label className="block space-y-1">
|
<label className="block space-y-1">
|
||||||
<span className="num text-[10px] uppercase tracking-wider text-gray-500">Shares</span>
|
<span className="num text-[10px] uppercase tracking-wider text-gray-500">Shares</span>
|
||||||
@@ -398,9 +442,12 @@ function SetupCard({ setup, action, currentPrice, risk, regime, selectedPrice, o
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
{setup.targets && setup.targets.length > 1 ? (
|
{/* Only a real choice when the live exit policy takes profit at a
|
||||||
|
level. Under `atr_trailing` (production) the value is inert, so
|
||||||
|
offering it would imply control the user does not have. */}
|
||||||
|
{honorsTarget && setup.targets && setup.targets.length > 1 ? (
|
||||||
<label className="mt-3 block space-y-1">
|
<label className="mt-3 block space-y-1">
|
||||||
<span className="num text-[10px] uppercase tracking-wider text-gray-500">Target</span>
|
<span className="num text-[10px] uppercase tracking-wider text-gray-500">Take profit at</span>
|
||||||
<select
|
<select
|
||||||
value={takeTarget}
|
value={takeTarget}
|
||||||
onChange={(e) => setTakeTarget(Number(e.target.value))}
|
onChange={(e) => setTakeTarget(Number(e.target.value))}
|
||||||
@@ -408,7 +455,7 @@ function SetupCard({ setup, action, currentPrice, risk, regime, selectedPrice, o
|
|||||||
>
|
>
|
||||||
{setup.targets.map((t) => (
|
{setup.targets.map((t) => (
|
||||||
<option key={`${t.sr_level_id}-${t.price}`} value={t.price} className="bg-[#14161f]">
|
<option key={`${t.sr_level_id}-${t.price}`} value={t.price} className="bg-[#14161f]">
|
||||||
{formatPrice(t.price)} · {t.probability.toFixed(0)}% · {t.classification}{t.is_primary ? ' · primary' : ''}
|
{formatPrice(t.price)} · {t.probability.toFixed(0)}% touch odds · {t.classification}{t.is_primary ? ' · primary' : ''}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
@@ -435,16 +482,28 @@ function SetupCard({ setup, action, currentPrice, risk, regime, selectedPrice, o
|
|||||||
document.body,
|
document.body,
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Target ladder — open by default; clicking a row previews it on the rail */}
|
{/* Levels ladder — still fully explorable (clicking a row drives the rail
|
||||||
|
and the candlestick overlay), but framed as what it is: overhead
|
||||||
|
structure used to screen the setup, not a menu of exits. */}
|
||||||
{setup.targets && setup.targets.length > 0 && (
|
{setup.targets && setup.targets.length > 0 && (
|
||||||
<details className="mt-3" open>
|
<details className="mt-3" open>
|
||||||
<summary className="cursor-pointer text-[11px] font-medium text-gray-500 transition-colors hover:text-gray-300">
|
<summary className="cursor-pointer text-[11px] font-medium text-gray-500 transition-colors hover:text-gray-300">
|
||||||
Targets ({setup.targets.length}) · select a row to preview it on the rail and use it when taking
|
{honorsTarget
|
||||||
|
? `Take-profit levels (${setup.targets.length}) · select one to preview it and use it when taking`
|
||||||
|
: `Overhead levels (${setup.targets.length}) · select one to preview it on the rail and chart`}
|
||||||
</summary>
|
</summary>
|
||||||
|
{!honorsTarget && (
|
||||||
|
<p className="mt-1.5 text-[11px] leading-relaxed text-gray-600">
|
||||||
|
Resistance levels the scanner found. Their R:R and touch odds are what got this setup
|
||||||
|
through the gate — but the trade exits on the trailing stop, so price reaching one of
|
||||||
|
these is not a sell signal. Clicking only moves the marker.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<TargetTable
|
<TargetTable
|
||||||
setup={setup}
|
setup={setup}
|
||||||
selectedPrice={activePrice}
|
selectedPrice={activePrice}
|
||||||
|
honorsTarget={honorsTarget}
|
||||||
onSelect={(t) => {
|
onSelect={(t) => {
|
||||||
selectTargetPrice(t.price);
|
selectTargetPrice(t.price);
|
||||||
setTakeTarget(t.price);
|
setTakeTarget(t.price);
|
||||||
@@ -514,6 +573,8 @@ export function RecommendationPanel({ symbol, longSetup, shortSetup, currentPric
|
|||||||
: undefined;
|
: undefined;
|
||||||
const { settings: risk, update: updateRisk } = useRiskSettings();
|
const { settings: risk, update: updateRisk } = useRiskSettings();
|
||||||
const regime = useMarketRegime().data;
|
const regime = useMarketRegime().data;
|
||||||
|
const exitPolicy = useExitPolicy().data;
|
||||||
|
const baseRates = productionBaseRates(useBacktestReport().data);
|
||||||
const summary = longSetup?.recommendation_summary ?? shortSetup?.recommendation_summary;
|
const summary = longSetup?.recommendation_summary ?? shortSetup?.recommendation_summary;
|
||||||
const earningsDays = nextEarningsDate ? daysUntil(nextEarningsDate) : null;
|
const earningsDays = nextEarningsDate ? daysUntil(nextEarningsDate) : null;
|
||||||
const action = (summary?.action ?? 'NEUTRAL') as TradeSetup['recommended_action'];
|
const action = (summary?.action ?? 'NEUTRAL') as TradeSetup['recommended_action'];
|
||||||
@@ -537,9 +598,9 @@ export function RecommendationPanel({ symbol, longSetup, shortSetup, currentPric
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If the preferred setup has played out / been invalidated, the stored
|
// If the preferred setup has been invalidated (price through the stop), the
|
||||||
// ticker-level bias and reasoning are stale — don't headline "Strong Long"
|
// stored ticker-level bias and reasoning are stale — don't headline "Strong
|
||||||
// above a "no current setup" card.
|
// Long" above a "no current setup" card.
|
||||||
const preferredInactive = preferredSetup ? notActionableState(preferredSetup, currentPrice) : null;
|
const preferredInactive = preferredSetup ? notActionableState(preferredSetup, currentPrice) : null;
|
||||||
|
|
||||||
const body = (
|
const body = (
|
||||||
@@ -549,7 +610,7 @@ export function RecommendationPanel({ symbol, longSetup, shortSetup, currentPric
|
|||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
{preferredInactive ? (
|
{preferredInactive ? (
|
||||||
<span className="text-sm font-semibold text-gray-400">
|
<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>
|
No current setup <span className="font-normal text-gray-500">(last {preferredDirection} bias {recommendationActionLabel(action).toLowerCase()} — invalidated at the stop)</span>
|
||||||
</span>
|
</span>
|
||||||
) : (() => {
|
) : (() => {
|
||||||
const reasoning = summary?.reasoning ?? '';
|
const reasoning = summary?.reasoning ?? '';
|
||||||
@@ -578,7 +639,7 @@ export function RecommendationPanel({ symbol, longSetup, shortSetup, currentPric
|
|||||||
earningsDays <= EARNINGS_HORIZON_DAYS ? (
|
earningsDays <= EARNINGS_HORIZON_DAYS ? (
|
||||||
<p className="rounded-lg border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-[11px] text-amber-300">
|
<p className="rounded-lg border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-[11px] text-amber-300">
|
||||||
⚠ Earnings in {earningsDays} day{earningsDays === 1 ? '' : 's'} ({nextEarningsDate}) — inside the ~30-day
|
⚠ Earnings in {earningsDays} day{earningsDays === 1 ? '' : 's'} ({nextEarningsDate}) — inside the ~30-day
|
||||||
target horizon. A report can gap price through your stop or target; consider waiting or sizing down.
|
max hold. A report can gap price straight through your stop; consider waiting or sizing down.
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-[11px] text-gray-500">Next earnings: {nextEarningsDate} ({earningsDays} days).</p>
|
<p className="text-[11px] text-gray-500">Next earnings: {nextEarningsDate} ({earningsDays} days).</p>
|
||||||
@@ -587,7 +648,7 @@ export function RecommendationPanel({ symbol, longSetup, shortSetup, currentPric
|
|||||||
|
|
||||||
{preferredDirection !== 'neutral' && preferredSetup ? (
|
{preferredDirection !== 'neutral' && preferredSetup ? (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<SetupCard setup={preferredSetup} action={action} currentPrice={currentPrice} risk={risk} regime={regime} selectedPrice={selFor(preferredSetup)} onSelectPrice={onSelFor(preferredSetup)} />
|
<SetupCard setup={preferredSetup} action={action} currentPrice={currentPrice} risk={risk} regime={regime} exitPolicy={exitPolicy} selectedPrice={selFor(preferredSetup)} onSelectPrice={onSelFor(preferredSetup)} />
|
||||||
|
|
||||||
{alternativeSetup && (
|
{alternativeSetup && (
|
||||||
<details>
|
<details>
|
||||||
@@ -595,17 +656,21 @@ export function RecommendationPanel({ symbol, longSetup, shortSetup, currentPric
|
|||||||
Alternative scenario ({alternativeSetup.direction.toUpperCase()})
|
Alternative scenario ({alternativeSetup.direction.toUpperCase()})
|
||||||
</summary>
|
</summary>
|
||||||
<div className="mt-3">
|
<div className="mt-3">
|
||||||
<SetupCard setup={alternativeSetup} action={action} currentPrice={currentPrice} risk={risk} regime={regime} selectedPrice={selFor(alternativeSetup)} onSelectPrice={onSelFor(alternativeSetup)} />
|
<SetupCard setup={alternativeSetup} action={action} currentPrice={currentPrice} risk={risk} regime={regime} exitPolicy={exitPolicy} selectedPrice={selFor(alternativeSetup)} onSelectPrice={onSelFor(alternativeSetup)} />
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid gap-4 lg:grid-cols-2">
|
<div className="grid gap-4 lg:grid-cols-2">
|
||||||
<SetupCard setup={longSetup} action={action} currentPrice={currentPrice} risk={risk} regime={regime} selectedPrice={selFor(longSetup)} onSelectPrice={onSelFor(longSetup)} />
|
<SetupCard setup={longSetup} action={action} currentPrice={currentPrice} risk={risk} regime={regime} exitPolicy={exitPolicy} selectedPrice={selFor(longSetup)} onSelectPrice={onSelFor(longSetup)} />
|
||||||
<SetupCard setup={shortSetup} action={action} currentPrice={currentPrice} risk={risk} regime={regime} selectedPrice={selFor(shortSetup)} onSelectPrice={onSelFor(shortSetup)} />
|
<SetupCard setup={shortSetup} action={action} currentPrice={currentPrice} risk={risk} regime={regime} exitPolicy={exitPolicy} selectedPrice={selFor(shortSetup)} onSelectPrice={onSelFor(shortSetup)} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* System-level base rates: what actually happens to trades like these,
|
||||||
|
under the real exit. The honest counterpart to per-target "probability". */}
|
||||||
|
{baseRates && <BaseRatesPanel rates={baseRates} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
/**
|
||||||
|
* Base rates for the strategy as it actually runs.
|
||||||
|
*
|
||||||
|
* The per-target `probability` on a setup answers "will price touch this S/R
|
||||||
|
* level?" — a question about a level we never exit at. These numbers answer the
|
||||||
|
* question the user is really asking ("what tends to happen when I take one of
|
||||||
|
* these?") and they are measured under the *real* exit policy, from the same
|
||||||
|
* backtest report the Track Record page already consumes.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface MonitorRun {
|
||||||
|
strategy?: string;
|
||||||
|
lookback?: string;
|
||||||
|
is_production?: boolean;
|
||||||
|
sharpe?: number | null;
|
||||||
|
cagr_pct?: number | null;
|
||||||
|
max_drawdown_pct?: number | null;
|
||||||
|
trades?: number | null;
|
||||||
|
win_rate?: number | null;
|
||||||
|
avg_hold_days?: number | null;
|
||||||
|
best_trade_r?: number | null;
|
||||||
|
worst_trade_r?: number | null;
|
||||||
|
exit_reasons?: Record<string, number> | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BaseRates {
|
||||||
|
lookbackLabel: string;
|
||||||
|
trades: number;
|
||||||
|
winRate: number;
|
||||||
|
avgHoldDays: number | null;
|
||||||
|
bestR: number | null;
|
||||||
|
worstR: number | null;
|
||||||
|
sharpe: number | null;
|
||||||
|
/** How trades actually ended, as shares of the total (0-1). */
|
||||||
|
exits: { reason: string; label: string; count: number; share: number }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const EXIT_LABELS: Record<string, string> = {
|
||||||
|
stop: 'initial stop',
|
||||||
|
trailing_stop: 'trailing stop',
|
||||||
|
time: 'max hold',
|
||||||
|
target: 'target',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pull the production strategy's full-history row out of a backtest report.
|
||||||
|
* Returns null when the report hasn't run or has no production row.
|
||||||
|
*/
|
||||||
|
export function productionBaseRates(report: unknown): BaseRates | null {
|
||||||
|
const monitor = (report as { portfolio_monitor?: { production_strategy?: string; runs?: MonitorRun[] } })
|
||||||
|
?.portfolio_monitor;
|
||||||
|
if (!monitor?.runs?.length) return null;
|
||||||
|
|
||||||
|
const strategy = monitor.production_strategy;
|
||||||
|
const row =
|
||||||
|
monitor.runs.find((r) => r.strategy === strategy && r.lookback === 'all') ??
|
||||||
|
monitor.runs.find((r) => r.is_production && r.lookback === 'all');
|
||||||
|
if (!row || !row.trades) return null;
|
||||||
|
|
||||||
|
const reasons = row.exit_reasons ?? {};
|
||||||
|
const total = Object.values(reasons).reduce((a, b) => a + b, 0);
|
||||||
|
const exits = Object.entries(reasons)
|
||||||
|
.map(([reason, count]) => ({
|
||||||
|
reason,
|
||||||
|
label: EXIT_LABELS[reason] ?? reason,
|
||||||
|
count,
|
||||||
|
share: total > 0 ? count / total : 0,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => b.count - a.count);
|
||||||
|
|
||||||
|
return {
|
||||||
|
lookbackLabel: 'all history',
|
||||||
|
trades: row.trades,
|
||||||
|
winRate: row.win_rate ?? 0,
|
||||||
|
avgHoldDays: row.avg_hold_days ?? null,
|
||||||
|
bestR: row.best_trade_r ?? null,
|
||||||
|
worstR: row.worst_trade_r ?? null,
|
||||||
|
sharpe: row.sharpe ?? null,
|
||||||
|
exits,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
/**
|
||||||
|
* What actually closes a trade.
|
||||||
|
*
|
||||||
|
* The setup's `target` is NOT an exit under the production policy: it is a
|
||||||
|
* screening artifact — the nearest S/R level, used to compute the R:R and
|
||||||
|
* probability that admit the setup through the activation gate. The live exit
|
||||||
|
* (`paper_trade_service.resolve_open_trades`) never reads it; `atr_trailing`
|
||||||
|
* closes on the initial stop, a trailing stop, or the max hold.
|
||||||
|
*
|
||||||
|
* This module derives the real plan so the UI can show it instead of implying
|
||||||
|
* a take-profit that will never fire. See docs/research/sr-levels-and-exits.md.
|
||||||
|
*/
|
||||||
|
import type { ExitPolicy, TradeSetup } from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop width used when the scanner builds a setup: stop = entry ∓ 1.5 × ATR
|
||||||
|
* (`rr_scanner_service.scan_symbol`, and `backtest_service.ATR_MULTIPLIER`).
|
||||||
|
* Lets us recover ATR from a setup without another round trip:
|
||||||
|
* ATR = |entry − stop| / 1.5
|
||||||
|
* Guarded by test_prod_strategy_parity.py so a backend change can't silently
|
||||||
|
* desync this.
|
||||||
|
*/
|
||||||
|
export const SETUP_STOP_ATR_MULTIPLIER = 1.5;
|
||||||
|
|
||||||
|
export interface ExitPlan {
|
||||||
|
mode: ExitPolicy['mode'];
|
||||||
|
/** Does the setup's target actually close the trade? Only when mode === 'target'. */
|
||||||
|
honorsTarget: boolean;
|
||||||
|
/** Distance from entry to the initial stop, i.e. 1R per share. */
|
||||||
|
riskPerShare: number;
|
||||||
|
initialStop: number;
|
||||||
|
/** Trailing-stop width in price, once the trail is active (atr_trailing only). */
|
||||||
|
trailWidth: number | null;
|
||||||
|
/**
|
||||||
|
* Price the trade must reach before the trailing stop rises above the initial
|
||||||
|
* stop and takes over. Below this, the initial stop is what's protecting you.
|
||||||
|
*/
|
||||||
|
trailTakesOverAt: number | null;
|
||||||
|
/** Trail width expressed in R — the intuitive "how much give-back". */
|
||||||
|
trailWidthR: number | null;
|
||||||
|
maxHoldDays: number;
|
||||||
|
headline: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive the real exit plan for a setup under the live policy.
|
||||||
|
* Returns null when the setup has no usable risk distance.
|
||||||
|
*/
|
||||||
|
export function deriveExitPlan(setup: TradeSetup, policy?: ExitPolicy): ExitPlan | null {
|
||||||
|
const isLong = setup.direction === 'long';
|
||||||
|
const riskPerShare = Math.abs(setup.entry_price - setup.stop_loss);
|
||||||
|
if (!(riskPerShare > 0)) return null;
|
||||||
|
|
||||||
|
// Fall back to the shipped defaults when the policy hasn't loaded yet, so the
|
||||||
|
// card never renders a blank or (worse) a target-based plan.
|
||||||
|
const mode = policy?.mode ?? 'atr_trailing';
|
||||||
|
const maxHoldDays = policy?.hold_days ?? 30;
|
||||||
|
const atrMultiplier = policy?.atr_multiplier ?? 3;
|
||||||
|
|
||||||
|
const atr = riskPerShare / SETUP_STOP_ATR_MULTIPLIER;
|
||||||
|
|
||||||
|
if (mode === 'atr_trailing') {
|
||||||
|
const trailWidth = atrMultiplier * atr;
|
||||||
|
// The trail only bites once it clears the initial stop:
|
||||||
|
// highestClose − trailWidth > stop ⇔ highestClose > entry ± (trailWidth − R)
|
||||||
|
const takeoverOffset = trailWidth - riskPerShare;
|
||||||
|
const trailTakesOverAt = isLong
|
||||||
|
? setup.entry_price + takeoverOffset
|
||||||
|
: setup.entry_price - takeoverOffset;
|
||||||
|
return {
|
||||||
|
mode,
|
||||||
|
honorsTarget: false,
|
||||||
|
riskPerShare,
|
||||||
|
initialStop: setup.stop_loss,
|
||||||
|
trailWidth,
|
||||||
|
trailTakesOverAt,
|
||||||
|
trailWidthR: trailWidth / riskPerShare,
|
||||||
|
maxHoldDays,
|
||||||
|
headline: `${atrMultiplier}× ATR trailing stop · max ${maxHoldDays} trading days`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === 'trailing') {
|
||||||
|
const trailWidth = (setup.entry_price * (policy?.trailing_pct ?? 12)) / 100;
|
||||||
|
return {
|
||||||
|
mode,
|
||||||
|
honorsTarget: false,
|
||||||
|
riskPerShare,
|
||||||
|
initialStop: setup.stop_loss,
|
||||||
|
trailWidth,
|
||||||
|
trailTakesOverAt: null,
|
||||||
|
trailWidthR: trailWidth / riskPerShare,
|
||||||
|
maxHoldDays,
|
||||||
|
headline: `${policy?.trailing_pct ?? 12}% trailing stop · max ${maxHoldDays} trading days`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === 'target') {
|
||||||
|
return {
|
||||||
|
mode,
|
||||||
|
honorsTarget: true,
|
||||||
|
riskPerShare,
|
||||||
|
initialStop: setup.stop_loss,
|
||||||
|
trailWidth: null,
|
||||||
|
trailTakesOverAt: null,
|
||||||
|
trailWidthR: null,
|
||||||
|
maxHoldDays,
|
||||||
|
headline: 'Take profit at the selected level, or exit at the stop',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 'time'
|
||||||
|
return {
|
||||||
|
mode,
|
||||||
|
honorsTarget: false,
|
||||||
|
riskPerShare,
|
||||||
|
initialStop: setup.stop_loss,
|
||||||
|
trailWidth: null,
|
||||||
|
trailTakesOverAt: null,
|
||||||
|
trailWidthR: null,
|
||||||
|
maxHoldDays,
|
||||||
|
headline: `Hold to the stop or ${maxHoldDays} trading days — no target, no trail`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How far price has run from the scan entry, in R. Sign is direction-aware. */
|
||||||
|
export function driftInR(setup: TradeSetup, currentPrice: number): number | null {
|
||||||
|
const risk = Math.abs(setup.entry_price - setup.stop_loss);
|
||||||
|
if (!(risk > 0)) return null;
|
||||||
|
const moved = setup.direction === 'long'
|
||||||
|
? currentPrice - setup.entry_price
|
||||||
|
: setup.entry_price - currentPrice;
|
||||||
|
return moved / risk;
|
||||||
|
}
|
||||||
@@ -92,7 +92,7 @@ function RadarSetupRow({ setup, rank, reason, name, selected, onSelect }: RadarR
|
|||||||
className={`grid cursor-pointer grid-cols-[20px_minmax(92px,116px)_44px_1fr_auto] items-center gap-2.5 rounded-lg px-2 py-2.5 transition-colors ${
|
className={`grid cursor-pointer grid-cols-[20px_minmax(92px,116px)_44px_1fr_auto] items-center gap-2.5 rounded-lg px-2 py-2.5 transition-colors ${
|
||||||
selected ? 'bg-blue-400/[0.08]' : 'hover:bg-white/[0.03]'
|
selected ? 'bg-blue-400/[0.08]' : 'hover:bg-white/[0.03]'
|
||||||
} ${qualified ? '' : 'opacity-60'}`}
|
} ${qualified ? '' : 'opacity-60'}`}
|
||||||
title={`R:R ${setup.rr_ratio.toFixed(1)}:1${prob != null ? ` · target prob ${Math.round(prob)}%` : ''} · click to focus`}
|
title={`gate: R:R ${setup.rr_ratio.toFixed(1)}:1${prob != null ? ` · touch odds ${Math.round(prob)}%` : ''} (screening, not an exit) · click to focus`}
|
||||||
>
|
>
|
||||||
<span className="num text-[11px] text-gray-500">{rank}</span>
|
<span className="num text-[11px] text-gray-500">{rank}</span>
|
||||||
<span className="min-w-0">
|
<span className="min-w-0">
|
||||||
@@ -164,34 +164,41 @@ function FocusCard({ setup, name, badge, badgeTone, footNote, onReset }: {
|
|||||||
<span className="rounded-full border border-white/[0.09] px-2.5 py-0.5 text-[11.5px] text-gray-400">
|
<span className="rounded-full border border-white/[0.09] px-2.5 py-0.5 text-[11.5px] text-gray-400">
|
||||||
conviction {convictionLabel(setup.recommended_action)}
|
conviction {convictionLabel(setup.recommended_action)}
|
||||||
</span>
|
</span>
|
||||||
{setup.momentum_percentile != null && (
|
|
||||||
<span className="rounded-full border border-white/[0.09] px-2.5 py-0.5 text-[11.5px] text-gray-400">
|
|
||||||
residual momentum {Math.round(setup.momentum_percentile)}th %ile
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-10 text-right">
|
{/* The headline stat is the signal that actually selected this ticker.
|
||||||
<div>
|
R:R and touch odds are gate inputs computed from an S/R level the
|
||||||
<p className="section-index">reward / risk</p>
|
trade never exits at — they get quiet, labelled treatment. */}
|
||||||
<p className="font-display mt-1 text-3xl font-semibold text-gray-100">
|
<div className="flex items-start gap-10 text-right">
|
||||||
{setup.rr_ratio.toFixed(1)}
|
{setup.momentum_percentile != null && (
|
||||||
<span className="text-lg text-gray-400"> : 1</span>
|
<div title="Residual 12-1 month momentum percentile across the universe. This is why the ticker was selected.">
|
||||||
</p>
|
<p className="section-index">residual momentum</p>
|
||||||
</div>
|
|
||||||
{prob != null && (
|
|
||||||
<div>
|
|
||||||
<p className="section-index">target probability</p>
|
|
||||||
<p className="font-display mt-1 text-3xl font-semibold text-gray-100">
|
<p className="font-display mt-1 text-3xl font-semibold text-gray-100">
|
||||||
{Math.round(prob)}
|
top {Math.max(1, Math.round(100 - setup.momentum_percentile))}
|
||||||
<span className="text-lg text-gray-400">%</span>
|
<span className="text-lg text-gray-400">%</span>
|
||||||
</p>
|
</p>
|
||||||
<div className="ml-auto mt-2 h-1 w-28 rounded-full bg-blue-500/20">
|
<div className="ml-auto mt-2 h-1 w-28 rounded-full bg-blue-500/20">
|
||||||
<span className="block h-full rounded-full bg-blue-500" style={{ width: `${Math.round(prob)}%` }} />
|
<span
|
||||||
|
className="block h-full rounded-full bg-blue-500"
|
||||||
|
style={{ width: `${Math.min(100, Math.round(setup.momentum_percentile))}%` }}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<div
|
||||||
|
className="max-w-[13rem]"
|
||||||
|
title="Gate metrics. The reward/risk and touch odds of the nearest S/R level are what admitted this setup through the activation gate. The trade does NOT exit at that level — it exits on the trailing stop."
|
||||||
|
>
|
||||||
|
<p className="section-index">gate metrics</p>
|
||||||
|
<p className="num mt-1.5 text-sm text-gray-300">
|
||||||
|
R:R {setup.rr_ratio.toFixed(1)}:1
|
||||||
|
{prob != null && <> · touch {Math.round(prob)}%</>}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-[10.5px] leading-relaxed text-gray-500">
|
||||||
|
screening only — exits on the trailing stop, not at the level
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import pytest
|
|||||||
from app.services import paper_trade_service
|
from app.services import paper_trade_service
|
||||||
from app.services.admin_service import ACTIVATION_DEFAULTS
|
from app.services.admin_service import ACTIVATION_DEFAULTS
|
||||||
from app.services.backtest_service import (
|
from app.services.backtest_service import (
|
||||||
|
ATR_MULTIPLIER,
|
||||||
ATR_TRAIL_MULTIPLIER,
|
ATR_TRAIL_MULTIPLIER,
|
||||||
LIVE_EXIT_MODE_TO_SIM,
|
LIVE_EXIT_MODE_TO_SIM,
|
||||||
PORTFOLIO_MONITOR_STRATEGIES,
|
PORTFOLIO_MONITOR_STRATEGIES,
|
||||||
@@ -43,6 +44,22 @@ def test_every_live_exit_mode_has_a_sim_mapping() -> None:
|
|||||||
assert set(paper_trade_service._VALID_EXIT_MODES) == set(LIVE_EXIT_MODE_TO_SIM)
|
assert set(paper_trade_service._VALID_EXIT_MODES) == set(LIVE_EXIT_MODE_TO_SIM)
|
||||||
|
|
||||||
|
|
||||||
|
def test_setup_stop_width_matches_the_frontend_constant() -> None:
|
||||||
|
"""The UI recovers ATR from a setup as |entry - stop| / 1.5 to render the real
|
||||||
|
exit plan (frontend/src/lib/exitPlan.ts: SETUP_STOP_ATR_MULTIPLIER). Nothing
|
||||||
|
else transmits ATR, so if the scanner's stop width changes here the UI would
|
||||||
|
silently draw the trailing stop in the wrong place."""
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
from app.services import rr_scanner_service
|
||||||
|
|
||||||
|
frontend_constant = 1.5
|
||||||
|
assert ATR_MULTIPLIER == frontend_constant
|
||||||
|
for fn in (rr_scanner_service.scan_ticker, rr_scanner_service.scan_all_tickers):
|
||||||
|
signature = inspect.signature(fn)
|
||||||
|
assert signature.parameters["atr_multiplier"].default == frontend_constant
|
||||||
|
|
||||||
|
|
||||||
def test_gate_default_matches_the_promoted_cutoff() -> None:
|
def test_gate_default_matches_the_promoted_cutoff() -> None:
|
||||||
prod = _production_monitor_row()
|
prod = _production_monitor_row()
|
||||||
entry_cfg = _entry_variant_config(str(prod["entry_variant"]))
|
entry_cfg = _entry_variant_config(str(prod["entry_variant"]))
|
||||||
|
|||||||
Reference in New Issue
Block a user