From d892c46fbb16179c068e57cd5f9e73b9b6a1cc53 Mon Sep 17 00:00:00 2001 From: Dennis Thiessen Date: Sun, 14 Jun 2026 14:09:33 +0200 Subject: [PATCH] rank Top Setups by expected value, badge the top pick MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dashboard Top Setups list showed raw fields in arbitrary order with no indication of why a ticker was listed or which was best. Now sort by expected value (R) — probability-weighted payoff per unit risk — so the strongest opportunity is row 1, badged "Top pick", with a new Exp. Value column that folds R:R and target probability into one "is this worth taking" number. Co-Authored-By: Claude Opus 4.8 --- frontend/src/lib/qualification.ts | 15 +++++ frontend/src/pages/DashboardPage.tsx | 87 ++++++++++++++++++---------- 2 files changed, 73 insertions(+), 29 deletions(-) diff --git a/frontend/src/lib/qualification.ts b/frontend/src/lib/qualification.ts index 5944a3e..4f0c1df 100644 --- a/frontend/src/lib/qualification.ts +++ b/frontend/src/lib/qualification.ts @@ -13,6 +13,21 @@ export function primaryTargetProbability(setup: TradeSetup): number | null { return setup.targets?.length ? bestTargetProbability(setup) : null; } +/** + * Expected value per unit of risk, in R. Probability-weighted payoff: + * EV = p·(R:R) − (1 − p) + * where p is the primary target's hit probability. This is the single "is this + * worth taking" number — it rewards both a good payoff ratio and a likely + * target, so a fat-but-improbable target can't outrank a solid, probable one. + * Returns null when no target probability is known. + */ +export function expectedValueR(setup: TradeSetup): number | null { + const prob = primaryTargetProbability(setup); + if (prob == null) return null; + const p = prob / 100; + return p * setup.rr_ratio - (1 - p); +} + /** R:R recomputed from the current price (0 if no reward/risk left). */ export function liveRiskReward(setup: TradeSetup, currentPrice: number): number { const reward = setup.direction === 'long' ? setup.target - currentPrice : currentPrice - setup.target; diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx index a4578fc..458edf4 100644 --- a/frontend/src/pages/DashboardPage.tsx +++ b/frontend/src/pages/DashboardPage.tsx @@ -9,7 +9,7 @@ import { Section } from '../components/ui/Section'; import { SkeletonCard, SkeletonTable } from '../components/ui/Skeleton'; import { formatPrice } from '../lib/format'; import { recommendationActionLabel } from '../lib/recommendation'; -import { qualifiesSetup, activationSummary, primaryTargetProbability } from '../lib/qualification'; +import { qualifiesSetup, activationSummary, primaryTargetProbability, expectedValueR } from '../lib/qualification'; import type { TradeSetup } from '../lib/types'; function fmtR(value: number | null): string { @@ -64,12 +64,15 @@ export default function DashboardPage() { [trades.data, activation.data], ); - // Show qualified setups first; fall back to the full list when none qualify + // Show qualified setups first; fall back to the full list when none qualify. + // Rank by expected value (R) so the best opportunity sits at the top. const showingQualified = qualifiedSetups.length > 0; - const topSetups: TradeSetup[] = useMemo( - () => (showingQualified ? qualifiedSetups : trades.data ?? []).slice(0, 5), - [showingQualified, qualifiedSetups, trades.data], - ); + const topSetups: TradeSetup[] = useMemo(() => { + const pool = showingQualified ? qualifiedSetups : trades.data ?? []; + return [...pool] + .sort((a, b) => (expectedValueR(b) ?? -Infinity) - (expectedValueR(a) ?? -Infinity)) + .slice(0, 5); + }, [showingQualified, qualifiedSetups, trades.data]); const topWatchlist = useMemo( () => @@ -130,7 +133,10 @@ export default function DashboardPage() {
{/* Top setups */}
-
+
{trades.isLoading && } {trades.isError && Failed to load setups} {trades.data && topSetups.length === 0 && ( @@ -146,34 +152,57 @@ export default function DashboardPage() { Entry R:R Target Prob + Exp. Value Action - {topSetups.map((setup) => ( - - - - {setup.symbol} - - - - {formatPrice(setup.entry_price)} - {setup.rr_ratio.toFixed(1)}:1 - - {(() => { - const p = primaryTargetProbability(setup); - return p != null ? `${Math.round(p)}%` : '—'; - })()} - - - {recommendationActionLabel(setup.recommended_action)} - - - ))} + {topSetups.map((setup, i) => { + const ev = expectedValueR(setup); + const isTopPick = i === 0; + return ( + + +
+ + {setup.symbol} + + {isTopPick && ( + + Top pick + + )} +
+ + + {formatPrice(setup.entry_price)} + {setup.rr_ratio.toFixed(1)}:1 + + {(() => { + const p = primaryTargetProbability(setup); + return p != null ? `${Math.round(p)}%` : '—'; + })()} + + + {fmtR(ev)} + + + {recommendationActionLabel(setup.recommended_action)} + + + ); + })} -
+
+ + Exp. Value = probability-weighted payoff per unit of risk + All setups →