feat: Standing matrix on the ticker page (quality x momentum verdict)
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:
@@ -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<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 isn’t 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user