Files
signal-platform/frontend/src/components/ticker/StandingMatrix.tsx
T
dennisthiessen 8db535b889
Deploy / lint (push) Successful in 9s
Deploy / test (push) Successful in 1m20s
Deploy / deploy (push) Successful in 39s
Fix manual refresh dropping qualified ranks and clarify trade UI.
Single-ticker fetch now attaches residual-momentum ranks so setups do not silently fail the activation gate. Exit plan is a timeline, chart labels move left of the price scale, and missing ranks surface explicitly.
2026-07-14 10:10:30 +02:00

216 lines
9.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 (residual 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="Residual 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">
{composite != null && momentum == null
? 'Setup is present, but residual momentum rank is missing — the activation gate treats unranked setups as not qualified. Refresh this ticker (or wait for the daily scan) so universe ranks are attached.'
: '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 residual momentum) above it qualifies for a top pick. Click any peer to open it.
</p>
</div>
);
}