feat: Standing matrix on the ticker page (quality x momentum verdict)
Deploy / lint (push) Successful in 6s
Deploy / test (push) Successful in 42s
Deploy / deploy (push) Successful in 26s

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 <noreply@anthropic.com>
This commit is contained in:
2026-06-27 17:00:47 +02:00
parent 146dadf06f
commit 4a96f85cd9
3 changed files with 290 additions and 23 deletions
@@ -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" (4070) and clearly good (>70).
const QUALITY_DIV = 60;
type Tone = 'emerald' | 'amber' | 'sky' | 'slate';
const TONE: Record<Tone, { text: string; dot: string }> = {
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 (
<div className="glass px-2.5 py-1.5 text-[11px]">
<div className="text-gray-200">{p.symbol}</div>
<div className="text-gray-400">
quality <span className="text-gray-200">{Math.round(p.composite)}</span> · momentum{' '}
<span className="text-gray-200">{Math.round(p.momentum)}</span>
</div>
</div>
);
}
function StatRow({ label, value }: { label: string; value: string }) {
return (
<div className="flex items-baseline justify-between">
<span>{label}</span>
<span className="num text-gray-300">{value}</span>
</div>
);
}
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<FieldPoint | null>(
() => (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 (
<div className="glass p-5">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="text-[11px] uppercase tracking-wider text-gray-500">
Standing quality × momentum vs. the field
</div>
{status === 'top-pick' && (
<span className="rounded-full border border-blue-500/30 bg-blue-500/15 px-2.5 py-0.5 text-[11px] font-medium text-blue-300">
Top Pick
</span>
)}
{status === 'qualified' && (
<span className="rounded-full border border-emerald-500/30 bg-emerald-500/15 px-2.5 py-0.5 text-[11px] font-medium text-emerald-300">
Qualified
</span>
)}
</div>
<div className="mt-3 grid gap-4 lg:grid-cols-5">
<div className="h-72 lg:col-span-3">
<ResponsiveContainer width="100%" height="100%">
<ScatterChart margin={{ top: 10, right: 16, bottom: 22, left: 0 }}>
{/* Quadrant shading (behind everything) */}
<ReferenceArea x1={QUALITY_DIV} x2={100} y1={gate} y2={100} fill="#10b981" fillOpacity={0.07} stroke="none" />
<ReferenceArea x1={0} x2={QUALITY_DIV} y1={gate} y2={100} fill="#f59e0b" fillOpacity={0.06} stroke="none" />
<ReferenceArea x1={QUALITY_DIV} x2={100} y1={0} y2={gate} fill="#38bdf8" fillOpacity={0.06} stroke="none" />
<ReferenceArea x1={0} x2={QUALITY_DIV} y1={0} y2={gate} fill="#94a3b8" fillOpacity={0.05} stroke="none" />
<CartesianGrid stroke="rgba(255,255,255,0.04)" />
<ReferenceLine x={QUALITY_DIV} stroke="rgba(255,255,255,0.12)" />
<ReferenceLine y={gate} stroke="rgba(255,255,255,0.12)" strokeDasharray="4 4" />
<XAxis
type="number"
dataKey="composite"
domain={[0, 100]}
ticks={[0, 20, 40, 60, 80, 100]}
tick={{ fill: '#6b7280', fontSize: 10 }}
tickLine={false}
axisLine={{ stroke: 'rgba(255,255,255,0.08)' }}
label={{ value: 'Quality (composite) →', position: 'insideBottom', offset: -12, fill: '#6b7280', fontSize: 10 }}
/>
<YAxis
type="number"
dataKey="momentum"
domain={[0, 100]}
ticks={[0, 20, 40, 60, 80, 100]}
tick={{ fill: '#6b7280', fontSize: 10 }}
width={30}
tickLine={false}
axisLine={false}
label={{ value: 'Momentum pct', angle: -90, position: 'insideLeft', fill: '#6b7280', fontSize: 10 }}
/>
<ZAxis range={[20, 20]} />
<Tooltip cursor={{ strokeDasharray: '3 3', stroke: 'rgba(255,255,255,0.2)' }} content={<MatrixTip />} />
<Scatter
data={others}
isAnimationActive={false}
onClick={(p: any) => p?.symbol && navigate(`/ticker/${p.symbol}`)}
shape={(props: { cx?: number; cy?: number }) => (
<circle cx={props.cx} cy={props.cy} r={3} fill="rgba(148,163,184,0.35)" className="cursor-pointer" />
)}
/>
{here && v && (
<Scatter
data={[here]}
isAnimationActive={false}
shape={(props: { cx?: number; cy?: number }) => (
<circle
cx={props.cx}
cy={props.cy}
r={7}
fill="#ffffff"
stroke={TONE[v.tone].dot}
strokeWidth={3}
style={{ filter: `drop-shadow(0 0 6px ${TONE[v.tone].dot}66)` }}
/>
)}
/>
)}
</ScatterChart>
</ResponsiveContainer>
</div>
<div className="flex flex-col justify-center lg:col-span-2">
{v && here ? (
<>
<div className={`text-2xl font-semibold ${TONE[v.tone].text}`}>{v.label}</div>
<p className="mt-1 text-sm leading-snug text-gray-400">{v.note}</p>
<div className="mt-3 space-y-1 text-xs text-gray-500">
<StatRow label="Quality (composite)" value={`${Math.round(here.composite)}`} />
<StatRow label="Momentum percentile" value={`${Math.round(here.momentum)}`} />
{confidence != null && <StatRow label="Long confidence" value={`${Math.round(confidence)}%`} />}
</div>
</>
) : (
<p className="text-sm leading-relaxed text-gray-500">
No active setup, so this ticker isnt ranked on the momentum axis yet. Run the scanner to place it.
</p>
)}
</div>
</div>
<div className="mt-2 grid grid-cols-1 gap-x-4 gap-y-1 text-[11px] text-gray-500 sm:grid-cols-2">
<span><span className="text-emerald-400">Strong Buy</span> quality + momentum (top-right)</span>
<span><span className="text-amber-400">Momentum</span> trend without the quality</span>
<span><span className="text-sky-400">Accumulate</span> quality, awaiting momentum</span>
<span><span className="text-gray-400">Pass</span> neither stands out</span>
</div>
<p className="mt-2 text-[11px] leading-relaxed text-gray-600">
Each dot is a tracked ticker; <span className="text-gray-300">this one is highlighted</span>. 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.
</p>
</div>
);
}
+23 -18
View File
@@ -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<Record<string, boolean>>({});
const toggleExpand = (dimension: string) => {
@@ -60,27 +63,29 @@ export function ScoreCard({ compositeScore, dimensions, compositeBreakdown }: Sc
return (
<div className="glass p-5">
<div className="flex items-center gap-4">
{compositeScore !== null ? (
<ScoreRing score={compositeScore} />
) : (
<div className="flex h-[88px] w-[88px] items-center justify-center text-sm text-gray-500">N/A</div>
)}
<div>
<p className="text-xs text-gray-500 uppercase tracking-wider">Composite Score</p>
<p className={`text-2xl font-bold ${compositeScore !== null ? scoreColor(compositeScore) : 'text-gray-500'}`}>
{compositeScore !== null ? Math.round(compositeScore) : '—'}
</p>
{compositeBreakdown && (
<p className="mt-1 text-[10px] text-gray-500 leading-snug max-w-[200px]" data-testid="renorm-explanation">
Weighted average of available dimensions with re-normalized weights.
</p>
{showComposite && (
<div className="flex items-center gap-4">
{compositeScore !== null ? (
<ScoreRing score={compositeScore} />
) : (
<div className="flex h-[88px] w-[88px] items-center justify-center text-sm text-gray-500">N/A</div>
)}
<div>
<p className="text-xs text-gray-500 uppercase tracking-wider">Composite Score</p>
<p className={`text-2xl font-bold ${compositeScore !== null ? scoreColor(compositeScore) : 'text-gray-500'}`}>
{compositeScore !== null ? Math.round(compositeScore) : '—'}
</p>
{compositeBreakdown && (
<p className="mt-1 text-[10px] text-gray-500 leading-snug max-w-[200px]" data-testid="renorm-explanation">
Weighted average of available dimensions with re-normalized weights.
</p>
)}
</div>
</div>
</div>
)}
{dimensions.length > 0 && (
<div className="mt-5 space-y-1">
<div className={`${showComposite ? 'mt-5' : ''} space-y-1`}>
<p className="text-[10px] font-medium uppercase tracking-widest text-gray-500">Dimensions</p>
{dimensions.map((d) => {
const isExpanded = expanded[d.dimension] ?? false;
+54 -5
View File
@@ -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<FieldPoint[]>(() => {
const seen = new Set<string>();
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() {
<Tabs tabs={detailTabs} active={activeTab} onChange={setActiveTab} />
{activeTab === 'Analysis' && (
<div className="grid gap-6 lg:grid-cols-3 animate-fade-in">
<Section title="Scores">
<div className="space-y-6 animate-fade-in">
<Section title="Standing" hint="how this ticker ranks vs. the field">
{scores.isLoading && <SkeletonCard className="h-80" />}
{scores.isError && (
<SectionError message={scores.error instanceof Error ? scores.error.message : 'Failed to load scores'} onRetry={() => scores.refetch()} />
)}
{scores.data && (
<Suspense fallback={<SkeletonCard className="h-80" />}>
<StandingMatrix
symbol={symbol}
composite={scores.data.composite_score}
momentum={myMomentum}
field={standingField}
gateMomentum={gateMomentum}
status={standingStatus}
confidence={myConfidence}
/>
</Suspense>
)}
</Section>
<div className="grid gap-6 lg:grid-cols-3">
<Section title="Dimensions">
{scores.isLoading && <SkeletonCard />}
{scores.isError && (
<SectionError message={scores.error instanceof Error ? scores.error.message : 'Failed to load scores'} onRetry={() => scores.refetch()} />
)}
{scores.data && (
<ScoreCard compositeScore={scores.data.composite_score} dimensions={scores.data.dimensions} compositeBreakdown={scores.data.composite_breakdown} />
<ScoreCard showComposite={false} compositeScore={scores.data.composite_score} dimensions={scores.data.dimensions} compositeBreakdown={scores.data.composite_breakdown} />
)}
</Section>
@@ -397,6 +445,7 @@ export default function TickerDetailPage() {
)}
{fundamentals.data && <FundamentalsPanel data={fundamentals.data} />}
</Section>
</div>
</div>
)}