From 4a96f85cd9e48ba79805a1874f01f459ffa8da59 Mon Sep 17 00:00:00 2001 From: Dennis Thiessen Date: Sat, 27 Jun 2026 17:00:47 +0200 Subject: [PATCH] feat: Standing matrix on the ticker page (quality x momentum verdict) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the scattered score readouts with one hero: a quality (composite) x momentum-percentile scatter that plots this ticker against the whole field and reads out a verdict by quadrant — Strong Buy / Momentum / Accumulate / Pass. The dashed divider is the activation gate's momentum percentile, so "above the line = qualifies" is visible at a glance; peers are clickable. Reuses the regime-quadrant visual language and is lazy-loaded so recharts stays out of the main ticker chunk. - New StandingMatrix component (composite x momentum, field cloud, verdict). - ScoreCard gains showComposite (default true); the ticker page now renders it without the composite ring (composite lives in the matrix) under "Dimensions". - Confidence + target probability stay in the recommendation panel (the trade). Co-Authored-By: Claude Opus 4.8 --- .../src/components/ticker/StandingMatrix.tsx | 213 ++++++++++++++++++ frontend/src/components/ui/ScoreCard.tsx | 41 ++-- frontend/src/pages/TickerDetailPage.tsx | 59 ++++- 3 files changed, 290 insertions(+), 23 deletions(-) create mode 100644 frontend/src/components/ticker/StandingMatrix.tsx diff --git a/frontend/src/components/ticker/StandingMatrix.tsx b/frontend/src/components/ticker/StandingMatrix.tsx new file mode 100644 index 0000000..edb655c --- /dev/null +++ b/frontend/src/components/ticker/StandingMatrix.tsx @@ -0,0 +1,213 @@ +import { useMemo } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { + ScatterChart, + Scatter, + XAxis, + YAxis, + ZAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, + ReferenceLine, + ReferenceArea, +} from 'recharts'; + +// Lazy-loaded by TickerDetailPage so recharts stays out of the main ticker chunk. + +export interface FieldPoint { + symbol: string; + composite: number; + momentum: number; +} + +interface StandingMatrixProps { + symbol: string; + composite: number | null; // X for the highlighted dot (authoritative, from the scores endpoint) + momentum: number | null; // Y for the highlighted dot (the ticker's 12-1 momentum percentile) + field: FieldPoint[]; // every tracked ticker, for the background cloud + gateMomentum: number; // Y divider = the activation gate's momentum percentile + status: 'top-pick' | 'qualified' | 'none'; + confidence?: number | null; // long confidence, for the verdict sidebar +} + +// X divider: composite midpoint between "amber" (40–70) and clearly good (>70). +const QUALITY_DIV = 60; + +type Tone = 'emerald' | 'amber' | 'sky' | 'slate'; + +const TONE: Record = { + emerald: { text: 'text-emerald-300', dot: '#10b981' }, + amber: { text: 'text-amber-300', dot: '#f59e0b' }, + sky: { text: 'text-sky-300', dot: '#38bdf8' }, + slate: { text: 'text-gray-300', dot: '#94a3b8' }, +}; + +function verdict(composite: number, momentum: number, gate: number): { label: string; tone: Tone; note: string } { + const q = composite >= QUALITY_DIV; + const m = momentum >= gate; + if (m && q) return { label: 'Strong Buy', tone: 'emerald', note: 'Solid quality and top-tier momentum — clears the gate.' }; + if (m && !q) return { label: 'Momentum', tone: 'amber', note: 'Trending hard, but quality is thin — speculative.' }; + if (!m && q) return { label: 'Accumulate', tone: 'sky', note: 'Good quality; momentum not yet in the top tier.' }; + return { label: 'Pass', tone: 'slate', note: 'Neither quality nor momentum stands out yet.' }; +} + +function MatrixTip({ active, payload }: { active?: boolean; payload?: { payload: FieldPoint }[] }) { + if (!active || !payload?.length) return null; + const p = payload[0].payload; + return ( +
+
{p.symbol}
+
+ quality {Math.round(p.composite)} · momentum{' '} + {Math.round(p.momentum)} +
+
+ ); +} + +function StatRow({ label, value }: { label: string; value: string }) { + return ( +
+ {label} + {value} +
+ ); +} + +export default function StandingMatrix({ + symbol, + composite, + momentum, + field, + gateMomentum, + status, + confidence, +}: StandingMatrixProps) { + const navigate = useNavigate(); + const gate = gateMomentum > 0 ? gateMomentum : 80; + const sym = symbol.toUpperCase(); + + const here = useMemo( + () => (composite != null && momentum != null ? { symbol: sym, composite, momentum } : null), + [sym, composite, momentum], + ); + // Background cloud excludes this ticker — it's drawn separately, highlighted. + const others = useMemo(() => field.filter((p) => p.symbol.toUpperCase() !== sym), [field, sym]); + + const v = here ? verdict(here.composite, here.momentum, gate) : null; + + return ( +
+
+
+ Standing — quality × momentum vs. the field +
+ {status === 'top-pick' && ( + + ★ Top Pick + + )} + {status === 'qualified' && ( + + ✓ Qualified + + )} +
+ +
+
+ + + {/* Quadrant shading (behind everything) */} + + + + + + + + + + + } /> + p?.symbol && navigate(`/ticker/${p.symbol}`)} + shape={(props: { cx?: number; cy?: number }) => ( + + )} + /> + {here && v && ( + ( + + )} + /> + )} + + +
+ +
+ {v && here ? ( + <> +
{v.label}
+

{v.note}

+
+ + + {confidence != null && } +
+ + ) : ( +

+ No active setup, so this ticker isn’t ranked on the momentum axis yet. Run the scanner to place it. +

+ )} +
+
+ +
+ Strong Buy — quality + momentum (top-right) + Momentum — trend without the quality + Accumulate — quality, awaiting momentum + Pass — neither stands out +
+

+ Each dot is a tracked ticker; this one is highlighted. The dashed line is the + activation gate ({Math.round(gate)}th-pct momentum) — above it qualifies for a top pick. Click any peer to open it. +

+
+ ); +} diff --git a/frontend/src/components/ui/ScoreCard.tsx b/frontend/src/components/ui/ScoreCard.tsx index 2701269..b3da6d0 100644 --- a/frontend/src/components/ui/ScoreCard.tsx +++ b/frontend/src/components/ui/ScoreCard.tsx @@ -6,6 +6,9 @@ interface ScoreCardProps { compositeScore: number | null; dimensions: DimensionScoreDetail[]; compositeBreakdown?: CompositeBreakdown; + /** Hide the composite ring/header when the composite is shown elsewhere + * (e.g. the Standing matrix) and this card only carries the dimension detail. */ + showComposite?: boolean; } function scoreColor(score: number): string { @@ -51,7 +54,7 @@ function ScoreRing({ score }: { score: number }) { ); } -export function ScoreCard({ compositeScore, dimensions, compositeBreakdown }: ScoreCardProps) { +export function ScoreCard({ compositeScore, dimensions, compositeBreakdown, showComposite = true }: ScoreCardProps) { const [expanded, setExpanded] = useState>({}); const toggleExpand = (dimension: string) => { @@ -60,27 +63,29 @@ export function ScoreCard({ compositeScore, dimensions, compositeBreakdown }: Sc return (
-
- {compositeScore !== null ? ( - - ) : ( -
N/A
- )} -
-

Composite Score

-

- {compositeScore !== null ? Math.round(compositeScore) : '—'} -

- {compositeBreakdown && ( -

- Weighted average of available dimensions with re-normalized weights. -

+ {showComposite && ( +
+ {compositeScore !== null ? ( + + ) : ( +
N/A
)} +
+

Composite Score

+

+ {compositeScore !== null ? Math.round(compositeScore) : '—'} +

+ {compositeBreakdown && ( +

+ Weighted average of available dimensions with re-normalized weights. +

+ )} +
-
+ )} {dimensions.length > 0 && ( -
+

Dimensions

{dimensions.map((d) => { const isExpanded = expanded[d.dimension] ?? false; diff --git a/frontend/src/pages/TickerDetailPage.tsx b/frontend/src/pages/TickerDetailPage.tsx index 4461fc5..5a2262a 100644 --- a/frontend/src/pages/TickerDetailPage.tsx +++ b/frontend/src/pages/TickerDetailPage.tsx @@ -1,4 +1,4 @@ -import { useMemo, useEffect, useState } from 'react'; +import { useMemo, useEffect, useState, lazy, Suspense } from 'react'; import { useParams } from 'react-router-dom'; import { useTickerDetail } from '../hooks/useTickerDetail'; import { useFetchSymbolData } from '../hooks/useFetchSymbolData'; @@ -6,7 +6,7 @@ import { useWatchlist, useAddToWatchlist, useRemoveFromWatchlist } from '../hook import { useTrades } from '../hooks/useTrades'; import { usePaperTrades } from '../hooks/usePaperTrades'; import { useActivation } from '../hooks/useActivation'; -import { topPickSymbol } from '../lib/qualification'; +import { topPickSymbol, qualifiesSetup } from '../lib/qualification'; import type { FetchSelector } from '../api/ingestion'; import { CandlestickChart } from '../components/charts/CandlestickChart'; import { ScoreCard } from '../components/ui/ScoreCard'; @@ -21,6 +21,10 @@ import { Section } from '../components/ui/Section'; import { Tabs } from '../components/ui/Tabs'; import { formatPrice } from '../lib/format'; import type { TradeSetup } from '../lib/types'; +import type { FieldPoint } from '../components/ticker/StandingMatrix'; + +// Lazy so recharts (heavy) ships in its own chunk, not the main ticker bundle. +const StandingMatrix = lazy(() => import('../components/ticker/StandingMatrix')); const detailTabs = ['Analysis', 'Indicators', 'S/R Levels'] as const; type DetailTab = (typeof detailTabs)[number]; @@ -210,6 +214,29 @@ export default function TickerDetailPage() { [setupsForSymbol], ); + // Standing matrix: this ticker's momentum percentile + long confidence (from its + // setup), the field (every ticker's composite × momentum) for the cloud, and + // whether it qualifies / is the top pick. + const myMomentum = longSetup?.momentum_percentile ?? shortSetup?.momentum_percentile ?? null; + const myConfidence = longSetup?.confidence_score ?? null; + const standingField = useMemo(() => { + const seen = new Set(); + const out: FieldPoint[] = []; + for (const t of allTrades.data ?? []) { + const s = t.symbol.toUpperCase(); + if (seen.has(s) || t.momentum_percentile == null) continue; + seen.add(s); + out.push({ symbol: s, composite: t.composite_score, momentum: t.momentum_percentile }); + } + return out; + }, [allTrades.data]); + const standingStatus: 'top-pick' | 'qualified' | 'none' = useMemo(() => { + if (isTopPick) return 'top-pick'; + if (longSetup && activation.data && qualifiesSetup(longSetup, activation.data)) return 'qualified'; + return 'none'; + }, [isTopPick, longSetup, activation.data]); + const gateMomentum = activation.data?.min_momentum_percentile ?? 80; + // Current price = latest close, with day-over-day change const priceInfo = useMemo(() => { const bars = ohlcv.data; @@ -371,14 +398,35 @@ export default function TickerDetailPage() { {activeTab === 'Analysis' && ( -
-
+
+
+ {scores.isLoading && } + {scores.isError && ( + scores.refetch()} /> + )} + {scores.data && ( + }> + + + )} +
+ +
+
{scores.isLoading && } {scores.isError && ( scores.refetch()} /> )} {scores.data && ( - + )}
@@ -397,6 +445,7 @@ export default function TickerDetailPage() { )} {fundamentals.data && }
+
)}