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
+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>
)}