feat: short interest panel Phase 1 Yahoo Finance
- Add short_interest TtlClass (24h) to CacheRepository.ts with shortInterestHandler KindHandler (kv_cache JSON pattern), register in HANDLERS and del switch case - Add case 'shortinterest' to YFinanceAdapter.fetchOne using quoteSummary(defaultKeyStatistics): parses sharesShort, shortPercentOfFloat, shortRatio, dateShortInterest, sharesShortPriorMonth, floatShares, sharesOutstanding - Add institutional.shortInterest tRPC procedure (cache-read pattern, returns stale flag + provenance) - Add api.institutional.shortInterest to frontend trpc client - Create ShortInterestPanel.tsx: mirrors AnalystRatings pattern with loading/error/retry states, formatted shares/ratio/percentage display with source date - Mount ShortInterestPanel on overview page after AnalystRatings
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
'use client';
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { api } from '@/lib/trpc';
|
||||
|
||||
interface ShortInterestData {
|
||||
symbol: string;
|
||||
sharesShort: number | null;
|
||||
sharesShortPriorMonth: number | null;
|
||||
sharesPercentSharesOut: number | null;
|
||||
shortRatio: number | null;
|
||||
shortPercentOfFloat: number | null;
|
||||
dateShortInterest: string | null;
|
||||
floatShares: number | null;
|
||||
sharesOutstanding: number | null;
|
||||
stale: boolean;
|
||||
provenance: { fetchedAt: string; sourceKind: string; rawSourceId?: string } | null;
|
||||
}
|
||||
|
||||
function formatNumber(n: number | null): string {
|
||||
if (n === null || n === undefined) return '—';
|
||||
if (n >= 1e9) return `${(n / 1e9).toFixed(2)}B`;
|
||||
if (n >= 1e6) return `${(n / 1e6).toFixed(1)}M`;
|
||||
if (n >= 1e3) return `${(n / 1e3).toFixed(1)}K`;
|
||||
return String(n);
|
||||
}
|
||||
|
||||
function formatPct(n: number | null): string {
|
||||
if (n === null || n === undefined) return '—';
|
||||
return `${(n * 100).toFixed(2)}%`;
|
||||
}
|
||||
|
||||
export function ShortInterestPanel({ symbol }: { symbol: string | null }) {
|
||||
const [data, setData] = useState<ShortInterestData | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [retryKey, setRetryKey] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!symbol) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
let cancelled = false;
|
||||
api.institutional.shortInterest(symbol).then((res) => {
|
||||
if (cancelled) return;
|
||||
setData(res);
|
||||
}).catch((e: Error) => {
|
||||
if (cancelled) return;
|
||||
setError(e.message);
|
||||
}).finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [symbol, retryKey]);
|
||||
|
||||
if (!symbol) return null;
|
||||
if (loading) return <div className="text-xs text-fg-muted py-2">Loading short interest...</div>;
|
||||
if (error) return (
|
||||
<div className="text-xs text-danger py-2 flex items-center gap-2">
|
||||
<span>Short interest: {error}</span>
|
||||
<button className="text-up hover:underline" onClick={() => setRetryKey((k) => k + 1)}>Retry</button>
|
||||
</div>
|
||||
);
|
||||
if (!data) return <div className="text-xs text-fg-muted py-2">No short interest data available for {symbol}.</div>;
|
||||
|
||||
const dateLabel = data.dateShortInterest
|
||||
? `as of ${data.dateShortInterest}`
|
||||
: data.provenance?.fetchedAt
|
||||
? `fetched ${data.provenance.fetchedAt.slice(0, 10)}`
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="rounded-lg bg-surface-sunken border border-line p-4 space-y-3">
|
||||
<h4 className="text-sm font-semibold text-fg">Short Interest</h4>
|
||||
{dateLabel && (
|
||||
<p className="text-[10px] text-fg-muted">{dateLabel}{data.stale ? ' — stale' : ''}</p>
|
||||
)}
|
||||
{data.sharesShort !== null && (
|
||||
<dl className="grid grid-cols-2 gap-x-6 gap-y-1 text-sm">
|
||||
<div className="flex justify-between"><dt className="text-fg-muted">Shares Short</dt><dd className="text-fg font-mono">{formatNumber(data.sharesShort)}</dd></div>
|
||||
<div className="flex justify-between"><dt className="text-fg-muted">% of Float</dt><dd className="text-fg font-mono">{formatPct(data.sharesPercentSharesOut)}</dd></div>
|
||||
<div className="flex justify-between"><dt className="text-fg-muted">Short Ratio</dt><dd className="text-fg font-mono">{data.shortRatio !== null ? `${data.shortRatio.toFixed(1)}d` : '—'}</dd></div>
|
||||
<div className="flex justify-between"><dt className="text-fg-muted">% of Float (prior mo)</dt><dd className="text-fg font-mono">{formatPct(data.sharesShortPriorMonth !== null && data.floatShares ? data.sharesShortPriorMonth / data.floatShares : null)}</dd></div>
|
||||
{data.floatShares !== null && (
|
||||
<div className="flex justify-between"><dt className="text-fg-muted">Float Shares</dt><dd className="text-fg font-mono">{formatNumber(data.floatShares)}</dd></div>
|
||||
)}
|
||||
{data.sharesOutstanding !== null && (
|
||||
<div className="flex justify-between"><dt className="text-fg-muted">Shares Outstanding</dt><dd className="text-fg font-mono">{formatNumber(data.sharesOutstanding)}</dd></div>
|
||||
)}
|
||||
</dl>
|
||||
)}
|
||||
<p className="text-[10px] text-fg-muted">
|
||||
Short interest from Yahoo Finance (derived from exchange filings). Educational display only — not investment advice.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user