Consolidate setup numbers; clearer staleness message
Deploy / lint (push) Successful in 5s
Deploy / test (push) Successful in 33s
Deploy / deploy (push) Successful in 24s

- Overview Top Setups shows the primary target's probability (concrete,
  distance-calibrated) instead of the overlapping confidence number. The
  stale 100% confidences were leftovers from the old model and self-heal
  on rescan; confidence stays in the detail view + gate.
- Each metric now has one home: composite = ranking, target probability =
  actionability, confidence = direction conviction.
- Staleness message states the real basis (% of entry->target distance
  already covered), not the raw % from entry, so narrow setups read
  correctly ("67% of the move is gone").

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-14 13:43:17 +02:00
parent 316226096b
commit a32f09c8ba
3 changed files with 23 additions and 8 deletions
@@ -18,14 +18,17 @@ 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 towardTarget = setup.direction === 'long' ? currentPrice >= setup.entry_price : currentPrice <= setup.entry_price;
// Past the stop entirely = invalidated; moved >1/3 of the way to target = stale // 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 span = Math.abs(setup.target - setup.entry_price);
const moved = Math.abs(currentPrice - 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; const beyondStop = setup.direction === 'long' ? currentPrice <= setup.stop_loss : currentPrice >= setup.stop_loss;
let status: 'fresh' | 'stale' | 'invalidated' = 'fresh'; let status: 'fresh' | 'stale' | 'invalidated' = 'fresh';
if (beyondStop) status = 'invalidated'; if (beyondStop) status = 'invalidated';
else if (span > 0 && moved / span > 0.33) status = 'stale'; else if (towardTarget && progressPct > 33) status = 'stale';
return { pct, towardTarget, status }; else if (!towardTarget && progressPct > 33) status = 'stale';
return { pct, progressPct, towardTarget, status };
} }
function riskClass(risk: TradeSetup['risk_level']) { function riskClass(risk: TradeSetup['risk_level']) {
@@ -115,7 +118,9 @@ function SetupCard({ setup, action, currentPrice }: { setup?: TradeSetup; action
)} )}
{drift && drift.status === 'stale' && ( {drift && drift.status === 'stale' && (
<p className="text-[11px] text-amber-400"> <p className="text-[11px] text-amber-400">
Price has moved {drift.pct >= 0 ? '+' : ''}{drift.pct.toFixed(1)}% from entry{drift.towardTarget ? ' toward target' : ' against the setup'} entry may be stale. {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.`}
</p> </p>
)} )}
+7
View File
@@ -6,6 +6,13 @@ export function bestTargetProbability(setup: TradeSetup): number {
return setup.targets?.length ? Math.max(...setup.targets.map((t) => t.probability)) : 0; return setup.targets?.length ? Math.max(...setup.targets.map((t) => t.probability)) : 0;
} }
/** Probability of the starred primary target (the one the headline R:R refers to). */
export function primaryTargetProbability(setup: TradeSetup): number | null {
const primary = setup.targets?.find((t) => t.is_primary);
if (primary) return primary.probability;
return setup.targets?.length ? bestTargetProbability(setup) : null;
}
/** /**
* Whether a setup clears the activation gate. Mirrors the backend predicate in * Whether a setup clears the activation gate. Mirrors the backend predicate in
* app/services/qualification.py — keep the two in sync. * app/services/qualification.py — keep the two in sync.
+7 -4
View File
@@ -9,7 +9,7 @@ import { Section } from '../components/ui/Section';
import { SkeletonCard, SkeletonTable } from '../components/ui/Skeleton'; import { SkeletonCard, SkeletonTable } from '../components/ui/Skeleton';
import { formatPrice } from '../lib/format'; import { formatPrice } from '../lib/format';
import { recommendationActionLabel } from '../lib/recommendation'; import { recommendationActionLabel } from '../lib/recommendation';
import { qualifiesSetup, activationSummary } from '../lib/qualification'; import { qualifiesSetup, activationSummary, primaryTargetProbability } from '../lib/qualification';
import type { TradeSetup } from '../lib/types'; import type { TradeSetup } from '../lib/types';
function fmtR(value: number | null): string { function fmtR(value: number | null): string {
@@ -130,7 +130,7 @@ export default function DashboardPage() {
<div className="grid gap-8 xl:grid-cols-5"> <div className="grid gap-8 xl:grid-cols-5">
{/* Top setups */} {/* Top setups */}
<div className="xl:col-span-3"> <div className="xl:col-span-3">
<Section title="Top Setups" hint={showingQualified ? 'qualified, by confidence' : 'none qualified — showing all'}> <Section title="Top Setups" hint={showingQualified ? 'qualified' : 'none qualified — showing all'}>
{trades.isLoading && <SkeletonTable rows={5} cols={5} />} {trades.isLoading && <SkeletonTable rows={5} cols={5} />}
{trades.isError && <Callout variant="error">Failed to load setups</Callout>} {trades.isError && <Callout variant="error">Failed to load setups</Callout>}
{trades.data && topSetups.length === 0 && ( {trades.data && topSetups.length === 0 && (
@@ -145,7 +145,7 @@ export default function DashboardPage() {
<th className="px-4 py-3">Dir</th> <th className="px-4 py-3">Dir</th>
<th className="px-4 py-3 text-right">Entry</th> <th className="px-4 py-3 text-right">Entry</th>
<th className="px-4 py-3 text-right">R:R</th> <th className="px-4 py-3 text-right">R:R</th>
<th className="px-4 py-3 text-right">Conf.</th> <th className="px-4 py-3 text-right">Target&nbsp;Prob</th>
<th className="hidden px-4 py-3 md:table-cell">Action</th> <th className="hidden px-4 py-3 md:table-cell">Action</th>
</tr> </tr>
</thead> </thead>
@@ -161,7 +161,10 @@ export default function DashboardPage() {
<td className="num px-4 py-3 text-right text-gray-200">{formatPrice(setup.entry_price)}</td> <td className="num px-4 py-3 text-right text-gray-200">{formatPrice(setup.entry_price)}</td>
<td className="num px-4 py-3 text-right text-gray-200">{setup.rr_ratio.toFixed(1)}:1</td> <td className="num px-4 py-3 text-right text-gray-200">{setup.rr_ratio.toFixed(1)}:1</td>
<td className="num px-4 py-3 text-right text-gray-200"> <td className="num px-4 py-3 text-right text-gray-200">
{setup.confidence_score != null ? `${Math.round(setup.confidence_score)}%` : '—'} {(() => {
const p = primaryTargetProbability(setup);
return p != null ? `${Math.round(p)}%` : '—';
})()}
</td> </td>
<td className="hidden px-4 py-3 text-xs text-gray-400 md:table-cell"> <td className="hidden px-4 py-3 text-xs text-gray-400 md:table-cell">
{recommendationActionLabel(setup.recommended_action)} {recommendationActionLabel(setup.recommended_action)}