From 605dc7f3d15e63c36a9b4b557660bf5d6b153bf7 Mon Sep 17 00:00:00 2001 From: Investor Flow Build Date: Sat, 25 Jul 2026 12:09:21 -0400 Subject: [PATCH] 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 --- app/server/src/adapters/YFinanceAdapter.ts | 28 +++++++ app/server/src/cache/CacheRepository.ts | 25 +++++- app/server/src/trpc/router.ts | 29 ++++++- app/src/app/page.tsx | 2 + app/src/components/ShortInterestPanel.tsx | 96 ++++++++++++++++++++++ app/src/lib/trpc.ts | 2 + 6 files changed, 176 insertions(+), 6 deletions(-) create mode 100644 app/src/components/ShortInterestPanel.tsx diff --git a/app/server/src/adapters/YFinanceAdapter.ts b/app/server/src/adapters/YFinanceAdapter.ts index 1b90781..e035a74 100644 --- a/app/server/src/adapters/YFinanceAdapter.ts +++ b/app/server/src/adapters/YFinanceAdapter.ts @@ -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)?.defaultKeyStatistics as Record | 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).optionMethod?.toString() ?? null, + }, + ttlClass: 'short_interest', + provenance: { fetchedAt, sourceKind: 'yfinance', rawSourceId: id }, + }; + } default: throw new Error(`YFinanceAdapter: unknown kind '${kind}'`); } } diff --git a/app/server/src/cache/CacheRepository.ts b/app/server/src/cache/CacheRepository.ts index 2900e22..426bb7f 100644 --- a/app/server/src/cache/CacheRepository.ts +++ b/app/server/src/cache/CacheRepository.ts @@ -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 = { 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([ @@ -298,6 +313,7 @@ const HANDLERS = new Map([ ['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; diff --git a/app/server/src/trpc/router.ts b/app/server/src/trpc/router.ts index a8c2d42..a043e14 100644 --- a/app/server/src/trpc/router.ts +++ b/app/server/src/trpc/router.ts @@ -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(k); + const value = entry.value as Record | 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) ──────────────────────────── diff --git a/app/src/app/page.tsx b/app/src/app/page.tsx index f69980e..51be355 100644 --- a/app/src/app/page.tsx +++ b/app/src/app/page.tsx @@ -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() {
+ diff --git a/app/src/components/ShortInterestPanel.tsx b/app/src/components/ShortInterestPanel.tsx new file mode 100644 index 0000000..d721484 --- /dev/null +++ b/app/src/components/ShortInterestPanel.tsx @@ -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(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(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
Loading short interest...
; + if (error) return ( +
+ Short interest: {error} + +
+ ); + if (!data) return
No short interest data available for {symbol}.
; + + const dateLabel = data.dateShortInterest + ? `as of ${data.dateShortInterest}` + : data.provenance?.fetchedAt + ? `fetched ${data.provenance.fetchedAt.slice(0, 10)}` + : null; + + return ( +
+

Short Interest

+ {dateLabel && ( +

{dateLabel}{data.stale ? ' — stale' : ''}

+ )} + {data.sharesShort !== null && ( +
+
Shares Short
{formatNumber(data.sharesShort)}
+
% of Float
{formatPct(data.sharesPercentSharesOut)}
+
Short Ratio
{data.shortRatio !== null ? `${data.shortRatio.toFixed(1)}d` : '—'}
+
% of Float (prior mo)
{formatPct(data.sharesShortPriorMonth !== null && data.floatShares ? data.sharesShortPriorMonth / data.floatShares : null)}
+ {data.floatShares !== null && ( +
Float Shares
{formatNumber(data.floatShares)}
+ )} + {data.sharesOutstanding !== null && ( +
Shares Outstanding
{formatNumber(data.sharesOutstanding)}
+ )} +
+ )} +

+ Short interest from Yahoo Finance (derived from exchange filings). Educational display only — not investment advice. +

+
+ ); +} \ No newline at end of file diff --git a/app/src/lib/trpc.ts b/app/src/lib/trpc.ts index 3deb593..253c00c 100644 --- a/app/src/lib/trpc.ts +++ b/app/src/lib/trpc.ts @@ -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 }) =>