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:
2026-07-12 15:29:21 +02:00
co-authored by Claude Opus 4.8
parent 85b3ef618f
commit 9789e3d762
7 changed files with 536 additions and 97 deletions
@@ -10,7 +10,13 @@ import { useMarketRegime } from '../../hooks/useMarketRegime';
import { isCounterTrend } from '../../lib/regime';
import { primaryTargetProbability } from '../../lib/qualification';
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 {
symbol: string;
@@ -32,44 +38,42 @@ function daysUntil(iso: string): number | null {
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;
/**
* How far current price has drifted from the setup's entry. A setup whose
* entry is far from the live price (price already ran toward target, or fell
* through the stop) is stale — entering now changes the risk/reward.
* How far price has drifted from the scan entry, measured in R (the initial risk
* distance). R is the right unit: the stop sits 1R away and the trailing exit is
* 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) {
if (currentPrice == null || !setup.entry_price) return null;
const pct = ((currentPrice - setup.entry_price) / setup.entry_price) * 100;
const towardTarget = setup.direction === 'long' ? currentPrice >= setup.entry_price : currentPrice <= setup.entry_price;
// Judge staleness by how much of the entry→target distance is already gone,
// not the raw % move — an 8%-wide setup is "used up" far faster than a 40% one.
const span = Math.abs(setup.target - setup.entry_price);
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';
const r = driftInR(setup, currentPrice);
const beyondStop =
setup.direction === 'long' ? currentPrice <= setup.stop_loss : currentPrice >= setup.stop_loss;
let status: 'fresh' | 'extended' | 'invalidated' = 'fresh';
if (beyondStop) status = 'invalidated';
else if (towardTarget && progressPct > 33) status = 'stale';
else if (!towardTarget && progressPct > 33) status = 'stale';
return { pct, progressPct, towardTarget, status };
else if (r != null && r >= 1) status = 'extended';
else if (r != null && r <= -0.5) status = 'extended';
return { pct, r, 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.
* The only state with no tradeable setup left: price has gone through the stop.
* 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 };
if (entryDrift(setup, currentPrice)?.status !== 'invalidated') return null;
return { invalidated: true };
}
function riskClass(risk: TradeSetup['risk_level']) {
@@ -106,25 +110,39 @@ function Chip({ children }: { children: React.ReactNode }) {
type Target = NonNullable<TradeSetup['targets']>[number];
function TargetTable({ setup, selectedPrice, onSelect }: {
function TargetTable({ setup, selectedPrice, onSelect, honorsTarget }: {
setup: TradeSetup;
selectedPrice: number;
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) {
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 (
<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>
<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">Price</th>
<th className="py-2 pr-3">Band</th>
<th className="py-2 pr-3">Level</th>
<th className="py-2 pr-3">Distance</th>
<th className="py-2 pr-3">R:R</th>
<th className="py-2">Probability</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.">
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>
</thead>
<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;
action?: TradeSetup['recommended_action'];
currentPrice?: number;
risk: RiskSettings;
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;
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 prob = primaryTargetProbability(setup);
// 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;
// The real exit rules. `honorsTarget` is false under the production policy —
// the level ladder below is context, not a menu of exits.
const exitPlan = deriveExitPlan(setup, exitPolicy);
const honorsTarget = exitPlan?.honorsTarget ?? false;
// Only price through the stop leaves no tradeable setup.
const notActionable = notActionableState(setup, currentPrice) != null;
const createTrade = useCreatePaperTrade();
const [taking, setTaking] = useState(false);
@@ -239,7 +259,10 @@ function SetupCard({ setup, action, currentPrice, risk, regime, selectedPrice, o
entry_price: takeEntry,
shares: takeShares,
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) },
);
@@ -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 ml-auto text-xs text-gray-500">
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>
</div>
<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 (${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.`}
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.
</p>
</div>
);
@@ -279,10 +302,23 @@ function SetupCard({ setup, action, currentPrice, risk, regime, selectedPrice, o
preferred
</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>R:R {activeRR.toFixed(1)}:1</Chip>
{activeProb != null && <Chip>target prob {Math.round(activeProb)}%</Chip>}
{selected && !selected.is_primary && <Chip>custom target</Chip>}
<span
className="rounded-full border border-white/[0.09] px-2.5 py-0.5 text-[11px] text-gray-500"
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">
{sizing ? (
<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.
</p>
)}
{drift && drift.status === 'invalidated' && (
<p className="text-[11px] text-red-300">
Price ({formatPrice(currentPrice!)}) is past the stop this setup is invalidated.
</p>
)}
{drift && drift.status === 'stale' && (
{/* No 'invalidated' branch here: that state returns the "no current
setup" card above, so it can never reach this block. */}
{drift && drift.status === 'extended' && drift.r != null && (
<p className="text-[11px] text-amber-400">
{drift.towardTarget
? `${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 moved ${Math.abs(drift.pct).toFixed(1)}% against the setup (toward the stop) — entry may be stale.`}
{drift.r >= 1
? ` 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 drifted ${Math.abs(drift.r).toFixed(1)}R toward the stop (${drift.pct.toFixed(1)}%) — the entry is stale.`}
</p>
)}
</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*
target moves along a scale that always spans the whole ladder */}
level moves along a scale that always spans the whole ladder */}
<PriceRail
direction={setup.direction}
entry={setup.entry_price}
@@ -354,6 +382,10 @@ function SetupCard({ setup, action, currentPrice, risk, regime, selectedPrice, o
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 */}
{taking && createPortal(
<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>
</div>
<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)}</>}
</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">
<label className="block space-y-1">
<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>
</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">
<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
value={takeTarget}
onChange={(e) => setTakeTarget(Number(e.target.value))}
@@ -408,7 +455,7 @@ function SetupCard({ setup, action, currentPrice, risk, regime, selectedPrice, o
>
{setup.targets.map((t) => (
<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>
))}
</select>
@@ -435,16 +482,28 @@ function SetupCard({ setup, action, currentPrice, risk, regime, selectedPrice, o
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 && (
<details className="mt-3" open>
<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>
{!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">
<TargetTable
setup={setup}
selectedPrice={activePrice}
honorsTarget={honorsTarget}
onSelect={(t) => {
selectTargetPrice(t.price);
setTakeTarget(t.price);
@@ -514,6 +573,8 @@ export function RecommendationPanel({ symbol, longSetup, shortSetup, currentPric
: undefined;
const { settings: risk, update: updateRisk } = useRiskSettings();
const regime = useMarketRegime().data;
const exitPolicy = useExitPolicy().data;
const baseRates = productionBaseRates(useBacktestReport().data);
const summary = longSetup?.recommendation_summary ?? shortSetup?.recommendation_summary;
const earningsDays = nextEarningsDate ? daysUntil(nextEarningsDate) : null;
const action = (summary?.action ?? 'NEUTRAL') as TradeSetup['recommended_action'];
@@ -537,9 +598,9 @@ 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.
// If the preferred setup has been invalidated (price through the stop), 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;
const body = (
@@ -549,7 +610,7 @@ export function RecommendationPanel({ symbol, longSetup, shortSetup, currentPric
<div className="min-w-0">
{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>
No current setup <span className="font-normal text-gray-500">(last {preferredDirection} bias {recommendationActionLabel(action).toLowerCase()} invalidated at the stop)</span>
</span>
) : (() => {
const reasoning = summary?.reasoning ?? '';
@@ -578,7 +639,7 @@ export function RecommendationPanel({ symbol, longSetup, shortSetup, currentPric
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">
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 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 ? (
<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 && (
<details>
@@ -595,17 +656,21 @@ export function RecommendationPanel({ symbol, longSetup, shortSetup, currentPric
Alternative scenario ({alternativeSetup.direction.toUpperCase()})
</summary>
<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>
</details>
)}
</div>
) : (
<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={shortSetup} action={action} currentPrice={currentPrice} risk={risk} regime={regime} selectedPrice={selFor(shortSetup)} onSelectPrice={onSelFor(shortSetup)} />
<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} exitPolicy={exitPolicy} selectedPrice={selFor(shortSetup)} onSelectPrice={onSelFor(shortSetup)} />
</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>
);