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:
@@ -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