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.
216 lines
9.4 KiB
TypeScript
216 lines
9.4 KiB
TypeScript
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" (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="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 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 residual momentum) — above it qualifies for a top pick. Click any peer to open it.
|
||
</p>
|
||
</div>
|
||
);
|
||
}
|