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;
|
compositeScore: number | null;
|
||||||
dimensions: DimensionScoreDetail[];
|
dimensions: DimensionScoreDetail[];
|
||||||
compositeBreakdown?: CompositeBreakdown;
|
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 {
|
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 [expanded, setExpanded] = useState<Record<string, boolean>>({});
|
||||||
|
|
||||||
const toggleExpand = (dimension: string) => {
|
const toggleExpand = (dimension: string) => {
|
||||||
@@ -60,27 +63,29 @@ export function ScoreCard({ compositeScore, dimensions, compositeBreakdown }: Sc
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="glass p-5">
|
<div className="glass p-5">
|
||||||
<div className="flex items-center gap-4">
|
{showComposite && (
|
||||||
{compositeScore !== null ? (
|
<div className="flex items-center gap-4">
|
||||||
<ScoreRing score={compositeScore} />
|
{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 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>
|
||||||
|
<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>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
{dimensions.length > 0 && (
|
{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>
|
<p className="text-[10px] font-medium uppercase tracking-widest text-gray-500">Dimensions</p>
|
||||||
{dimensions.map((d) => {
|
{dimensions.map((d) => {
|
||||||
const isExpanded = expanded[d.dimension] ?? false;
|
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 { useParams } from 'react-router-dom';
|
||||||
import { useTickerDetail } from '../hooks/useTickerDetail';
|
import { useTickerDetail } from '../hooks/useTickerDetail';
|
||||||
import { useFetchSymbolData } from '../hooks/useFetchSymbolData';
|
import { useFetchSymbolData } from '../hooks/useFetchSymbolData';
|
||||||
@@ -6,7 +6,7 @@ import { useWatchlist, useAddToWatchlist, useRemoveFromWatchlist } from '../hook
|
|||||||
import { useTrades } from '../hooks/useTrades';
|
import { useTrades } from '../hooks/useTrades';
|
||||||
import { usePaperTrades } from '../hooks/usePaperTrades';
|
import { usePaperTrades } from '../hooks/usePaperTrades';
|
||||||
import { useActivation } from '../hooks/useActivation';
|
import { useActivation } from '../hooks/useActivation';
|
||||||
import { topPickSymbol } from '../lib/qualification';
|
import { topPickSymbol, qualifiesSetup } from '../lib/qualification';
|
||||||
import type { FetchSelector } from '../api/ingestion';
|
import type { FetchSelector } from '../api/ingestion';
|
||||||
import { CandlestickChart } from '../components/charts/CandlestickChart';
|
import { CandlestickChart } from '../components/charts/CandlestickChart';
|
||||||
import { ScoreCard } from '../components/ui/ScoreCard';
|
import { ScoreCard } from '../components/ui/ScoreCard';
|
||||||
@@ -21,6 +21,10 @@ import { Section } from '../components/ui/Section';
|
|||||||
import { Tabs } from '../components/ui/Tabs';
|
import { Tabs } from '../components/ui/Tabs';
|
||||||
import { formatPrice } from '../lib/format';
|
import { formatPrice } from '../lib/format';
|
||||||
import type { TradeSetup } from '../lib/types';
|
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;
|
const detailTabs = ['Analysis', 'Indicators', 'S/R Levels'] as const;
|
||||||
type DetailTab = (typeof detailTabs)[number];
|
type DetailTab = (typeof detailTabs)[number];
|
||||||
@@ -210,6 +214,29 @@ export default function TickerDetailPage() {
|
|||||||
[setupsForSymbol],
|
[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
|
// Current price = latest close, with day-over-day change
|
||||||
const priceInfo = useMemo(() => {
|
const priceInfo = useMemo(() => {
|
||||||
const bars = ohlcv.data;
|
const bars = ohlcv.data;
|
||||||
@@ -371,14 +398,35 @@ export default function TickerDetailPage() {
|
|||||||
<Tabs tabs={detailTabs} active={activeTab} onChange={setActiveTab} />
|
<Tabs tabs={detailTabs} active={activeTab} onChange={setActiveTab} />
|
||||||
|
|
||||||
{activeTab === 'Analysis' && (
|
{activeTab === 'Analysis' && (
|
||||||
<div className="grid gap-6 lg:grid-cols-3 animate-fade-in">
|
<div className="space-y-6 animate-fade-in">
|
||||||
<Section title="Scores">
|
<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.isLoading && <SkeletonCard />}
|
||||||
{scores.isError && (
|
{scores.isError && (
|
||||||
<SectionError message={scores.error instanceof Error ? scores.error.message : 'Failed to load scores'} onRetry={() => scores.refetch()} />
|
<SectionError message={scores.error instanceof Error ? scores.error.message : 'Failed to load scores'} onRetry={() => scores.refetch()} />
|
||||||
)}
|
)}
|
||||||
{scores.data && (
|
{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>
|
</Section>
|
||||||
|
|
||||||
@@ -397,6 +445,7 @@ export default function TickerDetailPage() {
|
|||||||
)}
|
)}
|
||||||
{fundamentals.data && <FundamentalsPanel data={fundamentals.data} />}
|
{fundamentals.data && <FundamentalsPanel data={fundamentals.data} />}
|
||||||
</Section>
|
</Section>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user