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:
Investor Flow Build
2026-07-25 12:09:21 -04:00
parent 351c104eba
commit 605dc7f3d1
6 changed files with 176 additions and 6 deletions
@@ -45,6 +45,34 @@ export class YFinanceAdapter implements SourceFetch {
const raw = await yf.quoteSummary(id, { modules: ['summaryProfile'] });
return { value: parseSymbol(raw, id), ttlClass: 'symbol_meta', provenance: { fetchedAt, sourceKind: 'yfinance', rawSourceId: id } };
}
case 'shortinterest': {
const raw = await yf.quoteSummary(id, { modules: ['defaultKeyStatistics'] });
const stats = (raw as Record<string, unknown>)?.defaultKeyStatistics as Record<string, unknown> | undefined;
if (!stats) throw new Error('No defaultKeyStatistics module available');
const parseNum = (v: unknown): number | null => {
if (v === null || v === undefined) return null;
if (typeof v === 'number') return v;
const s = String(v).replace(/[,]/g, '');
const n = parseFloat(s);
return Number.isNaN(n) ? null : n;
};
return {
value: {
sharesShort: parseNum(stats.sharesShort),
sharesShortPriorMonth: parseNum(stats.sharesShortPriorMonth),
sharesShortPreviousMonthDate: stats.sharesShortPreviousMonthDate ? String(stats.sharesShortPreviousMonthDate).slice(0, 10) : null,
dateShortInterest: stats.dateShortInterest ? String(stats.dateShortInterest).slice(0, 10) : null,
sharesPercentSharesOut: parseNum(stats.sharesPercentSharesOut),
shortRatio: parseNum(stats.shortRatio),
shortPercentOfFloat: parseNum(stats.shortPercentOfFloat),
floatShares: parseNum(stats.floatShares),
sharesOutstanding: parseNum(stats.sharesOutstanding),
sourceTickerKind: (stats as Record<string, unknown>).optionMethod?.toString() ?? null,
},
ttlClass: 'short_interest',
provenance: { fetchedAt, sourceKind: 'yfinance', rawSourceId: id },
};
}
default: throw new Error(`YFinanceAdapter: unknown kind '${kind}'`);
}
}
+22 -3
View File
@@ -12,7 +12,8 @@ export type CacheKey = string; // `${SourceKind}:${kind}:${id}` e.g. 'yfinance:q
export type TtlClass =
| 'live_quote' | 'intraday' | 'daily_permanent' | 'options_snapshot'
| 'filing_immutable' | 'quarterly_immutable' | 'thread_7d'
| 'macro_event' | 'regime_classification' | 'llm_summary' | 'symbol_meta';
| 'macro_event' | 'regime_classification' | 'llm_summary' | 'symbol_meta'
| 'short_interest';
export interface Provenance { fetchedAt: string; sourceKind: SourceKind; rawSourceId?: string; }
@@ -38,6 +39,7 @@ export const TTL_MS: Record<TtlClass, number> = {
regime_classification: 24 * 60 * 60_000,
llm_summary: Number.POSITIVE_INFINITY, // cached forever by prompt-hash
symbol_meta: 7 * 24 * 60 * 60_000, // weekly (slow-changing sector/industry)
short_interest: 24 * 60 * 60_000, // refreshed twice/month per source
};
/** Parse 'source:kind:id...' into { source, kind, id } (id may contain colons). */
@@ -280,11 +282,24 @@ const lintHoldersHandler: KindHandler = {
const lintInsidersHandler: KindHandler = {
ttlClass: 'daily_permanent',
read() { return null; }, // never read — work happens in DB tables directly
read() { return null; },
write(d, key, _value, provenance) {
d.prepare('INSERT OR REPLACE INTO kv_cache (key,value,observed_at) VALUES (?,?,?)').run(`sec-lint-insiders:${key}`, JSON.stringify({ ok: true }), provenance.fetchedAt);
},
isStale() { return false; }, // never stale once written
isStale() { return false; },
};
const shortInterestHandler: KindHandler = {
ttlClass: 'short_interest',
read(d, id) {
const r = d.prepare('SELECT value, observed_at FROM kv_cache WHERE key=?').get(`yfinance:shortinterest:${id}`) as { value: string; observed_at: string } | undefined;
if (!r) return null;
try { return { value: JSON.parse(r.value), stalenessTs: r.observed_at }; } catch { return null; }
},
write(d, id, value, provenance) {
d.prepare('INSERT OR REPLACE INTO kv_cache (key, value, observed_at) VALUES (?,?,?)').run(`yfinance:shortinterest:${id}`, JSON.stringify(value), provenance.fetchedAt);
},
isStale(ts, now) { return tsAgeMs(ts, now) > TTL_MS.short_interest; },
};
const HANDLERS = new Map<string, KindHandler>([
@@ -298,6 +313,7 @@ const HANDLERS = new Map<string, KindHandler>([
['fetch', fetchHandler],
['holders', lintHoldersHandler],
['insiders', lintInsidersHandler],
['shortinterest', shortInterestHandler],
]);
export interface CacheRepository {
@@ -405,6 +421,9 @@ export class CacheRepositoryImpl implements CacheRepository {
case 'expiry_dates':
d.prepare('DELETE FROM kv_cache WHERE key=?').run(`options_expiry:${id}`);
break;
case 'shortinterest':
d.prepare('DELETE FROM kv_cache WHERE key=?').run(`yfinance:shortinterest:${id}`);
break;
default: {
// Unknown/wildcard kind (e.g. `x:cashtag:*`): best-effort delete from kv_cache via LIKE.
const like = key.endsWith(':*') ? `${key.slice(0, -1)}%` : key;
+26 -3
View File
@@ -1897,9 +1897,32 @@ const institutionalRouter = router({
if (cached) return { symbol, ratings: cached.ratings, consensus: cached.consensus, stale: true };
return { symbol, ratings: [], consensus: null, error: result.error };
}
return { symbol, ratings: result.ratings, consensus: result.consensus };
}),
});
return { symbol, ratings: result.ratings, consensus: result.consensus };
}),
/** Short interest (shares short, % float, short ratio) from yahoo-finance2 defaultKeyStatistics. */
shortInterest: publicProcedure
.input(z.object({ symbol: z.string().min(1) }))
.query(async ({ ctx, input }) => {
const symbol = input.symbol.toUpperCase();
const k = `yfinance:shortinterest:${symbol}`;
const entry = await ctx.cache.get<unknown>(k);
const value = entry.value as Record<string, unknown> | null;
return {
symbol,
sharesShort: value ? (value.sharesShort as number | null) : null,
sharesShortPriorMonth: value ? (value.sharesShortPriorMonth as number | null) : null,
sharesPercentSharesOut: value ? (value.sharesPercentSharesOut as number | null) : null,
shortRatio: value ? (value.shortRatio as number | null) : null,
shortPercentOfFloat: value ? (value.shortPercentOfFloat as number | null) : null,
dateShortInterest: value ? (value.dateShortInterest as string | null) : null,
floatShares: value ? (value.floatShares as number | null) : null,
sharesOutstanding: value ? (value.sharesOutstanding as number | null) : null,
stale: entry.isStale,
provenance: entry.provenance,
};
}),
};
// ─── EDGAR / SEC Filings Router (Slice 6 / M6) ────────────────────────────
+2
View File
@@ -2,6 +2,7 @@
import { LayoutShell } from "@/components/LayoutShell";
import { OverviewPanel } from "@/components/OverviewPanel";
import { AnalystRatings } from "@/components/AnalystRatings";
import { ShortInterestPanel } from "@/components/ShortInterestPanel";
import { useActiveSymbolGuarded } from "@/stores/active-symbol-store";
import { TickerContextPanel } from "@/components/TickerContextPanel";
import { CompanyPanel } from "@/components/CompanyPanel";
@@ -21,6 +22,7 @@ export default function Page() {
<div className="space-y-6">
<OverviewPanel />
<AnalystRatings symbol={activeSymbol} />
<ShortInterestPanel symbol={activeSymbol} />
<TickerContextPanel />
<CompanyPanel />
<SocialFeedPanel />
+96
View File
@@ -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>
);
}
+2
View File
@@ -622,6 +622,8 @@ export const api = {
trpcQuery<{ symbol: string; events: Array<{ filerName: string | null; sharesAdded: number; estimatedPrice: number; date: string }> }>("institutional.buyEvents", { symbol }),
analystRatings: (symbol: string) =>
trpcQuery<{ symbol: string; ratings: Array<{ firm: string; action: string | null; gradeFrom: string | null; gradeTo: string | null; ratingDate: string; targetFrom: number | null; targetTo: number | null }>; consensus: { strongBuy: number; buy: number; hold: number; sell: number; strongSell: number } | null; error?: string }>("institutional.analystRatings", { symbol }),
shortInterest: (symbol: string) =>
trpcQuery<{ 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 }>("institutional.shortInterest", { symbol }),
},
emotionLogger: {
add: (input: { tradeExecutionId: string; priceAtEvent?: number; emotion: string; note?: string }) =>