- Add nasdaq SourceKind + nasdaqShortinterest KindHandler (kv_cache with nasdaq: prefix) - Create NasdaqAdapter: calls api.nasdaq.com/quote/SYM/short-interest, parses settlementDate/sharesShort/daysToCover/avgVolume, returns 24mo history; handles non-NASDAq gracefully - Register NasdaqAdapter in server index.ts adapters map - Update shortInterest procedure: reads both yfinance + nasdaq caches in parallel, reconciles (NASDAQ for daysToCover/sharesShort, Yahoo for %float/shortRatio and NYSE fallback) - Update ShortInterestPanel: display daysToCover, settlement date, source badges, 6-month history table, updated disclaimer - Update trpc.ts client type for merged response shape
137 lines
5.9 KiB
TypeScript
137 lines
5.9 KiB
TypeScript
'use client';
|
|
import { useState, useEffect, useMemo } from 'react';
|
|
import { api } from '@/lib/trpc';
|
|
|
|
interface HistoryEntry {
|
|
settlementDate: string;
|
|
sharesShort: number | null;
|
|
avgDailyVolume: number | null;
|
|
daysToCover: number;
|
|
}
|
|
|
|
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;
|
|
daysToCover: number | null;
|
|
settlementDate: string | null;
|
|
history: HistoryEntry[] | null;
|
|
sources: { yfinance: { available: boolean; stale: boolean }; nasdaq: { available: boolean; stale: boolean } };
|
|
stale: boolean;
|
|
}
|
|
|
|
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.settlementDate
|
|
? `Settlement: ${data.settlementDate}`
|
|
: data.dateShortInterest
|
|
? `Yahoo: ${data.dateShortInterest}`
|
|
: null;
|
|
|
|
const sourceTags = [] as string[];
|
|
if (data.sources.nasdaq.available) sourceTags.push('NASDAQ');
|
|
if (data.sources.yfinance.available) sourceTags.push('Yahoo');
|
|
const sourceLabel = sourceTags.length ? sourceTags.join(' + ') : '—';
|
|
|
|
const historySlice = data.history ? data.history.slice(0, 6) : [];
|
|
|
|
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' : ''} ({sourceLabel})</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>
|
|
{data.daysToCover !== null && (
|
|
<div className="flex justify-between"><dt className="text-fg-muted">Days to Cover</dt><dd className="text-fg font-mono">{data.daysToCover.toFixed(2)}d</dd></div>
|
|
)}
|
|
{data.sharesShortPriorMonth !== null && data.floatShares ? (
|
|
<div className="flex justify-between"><dt className="text-fg-muted">% Float (prior)</dt><dd className="text-fg font-mono">{formatPct(data.sharesShortPriorMonth / data.floatShares)}</dd></div>
|
|
) : null}
|
|
{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 Out</dt><dd className="text-fg font-mono">{formatNumber(data.sharesOutstanding)}</dd></div>
|
|
)}
|
|
</dl>
|
|
)}
|
|
{historySlice.length > 0 && (
|
|
<div className="mt-2">
|
|
<p className="text-[10px] text-fg-muted mb-1">6-month history (NASDAQ source)</p>
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-[10px] text-fg-muted">
|
|
<thead><tr><th className="text-left py-0.5">Date</th><th className="text-right py-0.5">Short</th><th className="text-right py-0.5">Days</th></tr></thead>
|
|
<tbody>
|
|
{historySlice.map((h, i) => (
|
|
<tr key={i} className="border-b border-line/40">
|
|
<td className="py-0.5">{h.settlementDate}</td>
|
|
<td className="text-right font-mono">{formatNumber(h.sharesShort)}</td>
|
|
<td className="text-right font-mono">{h.daysToCover.toFixed(2)}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
)}
|
|
<p className="text-[10px] text-fg-muted mt-1">
|
|
Reconciled from NASDAQ (days-to-cover, history) + Yahoo Finance (all tickers). Educational display only.
|
|
</p>
|
|
</div>
|
|
);
|
|
} |