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) ────────────────────────────