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:
@@ -45,6 +45,34 @@ export class YFinanceAdapter implements SourceFetch {
|
|||||||
const raw = await yf.quoteSummary(id, { modules: ['summaryProfile'] });
|
const raw = await yf.quoteSummary(id, { modules: ['summaryProfile'] });
|
||||||
return { value: parseSymbol(raw, id), ttlClass: 'symbol_meta', provenance: { fetchedAt, sourceKind: 'yfinance', rawSourceId: id } };
|
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}'`);
|
default: throw new Error(`YFinanceAdapter: unknown kind '${kind}'`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+22
-3
@@ -12,7 +12,8 @@ export type CacheKey = string; // `${SourceKind}:${kind}:${id}` e.g. 'yfinance:q
|
|||||||
export type TtlClass =
|
export type TtlClass =
|
||||||
| 'live_quote' | 'intraday' | 'daily_permanent' | 'options_snapshot'
|
| 'live_quote' | 'intraday' | 'daily_permanent' | 'options_snapshot'
|
||||||
| 'filing_immutable' | 'quarterly_immutable' | 'thread_7d'
|
| '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; }
|
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,
|
regime_classification: 24 * 60 * 60_000,
|
||||||
llm_summary: Number.POSITIVE_INFINITY, // cached forever by prompt-hash
|
llm_summary: Number.POSITIVE_INFINITY, // cached forever by prompt-hash
|
||||||
symbol_meta: 7 * 24 * 60 * 60_000, // weekly (slow-changing sector/industry)
|
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). */
|
/** Parse 'source:kind:id...' into { source, kind, id } (id may contain colons). */
|
||||||
@@ -280,11 +282,24 @@ const lintHoldersHandler: KindHandler = {
|
|||||||
|
|
||||||
const lintInsidersHandler: KindHandler = {
|
const lintInsidersHandler: KindHandler = {
|
||||||
ttlClass: 'daily_permanent',
|
ttlClass: 'daily_permanent',
|
||||||
read() { return null; }, // never read — work happens in DB tables directly
|
read() { return null; },
|
||||||
write(d, key, _value, provenance) {
|
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);
|
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>([
|
const HANDLERS = new Map<string, KindHandler>([
|
||||||
@@ -298,6 +313,7 @@ const HANDLERS = new Map<string, KindHandler>([
|
|||||||
['fetch', fetchHandler],
|
['fetch', fetchHandler],
|
||||||
['holders', lintHoldersHandler],
|
['holders', lintHoldersHandler],
|
||||||
['insiders', lintInsidersHandler],
|
['insiders', lintInsidersHandler],
|
||||||
|
['shortinterest', shortInterestHandler],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export interface CacheRepository {
|
export interface CacheRepository {
|
||||||
@@ -405,6 +421,9 @@ export class CacheRepositoryImpl implements CacheRepository {
|
|||||||
case 'expiry_dates':
|
case 'expiry_dates':
|
||||||
d.prepare('DELETE FROM kv_cache WHERE key=?').run(`options_expiry:${id}`);
|
d.prepare('DELETE FROM kv_cache WHERE key=?').run(`options_expiry:${id}`);
|
||||||
break;
|
break;
|
||||||
|
case 'shortinterest':
|
||||||
|
d.prepare('DELETE FROM kv_cache WHERE key=?').run(`yfinance:shortinterest:${id}`);
|
||||||
|
break;
|
||||||
default: {
|
default: {
|
||||||
// Unknown/wildcard kind (e.g. `x:cashtag:*`): best-effort delete from kv_cache via LIKE.
|
// Unknown/wildcard kind (e.g. `x:cashtag:*`): best-effort delete from kv_cache via LIKE.
|
||||||
const like = key.endsWith(':*') ? `${key.slice(0, -1)}%` : key;
|
const like = key.endsWith(':*') ? `${key.slice(0, -1)}%` : key;
|
||||||
|
|||||||
@@ -1897,9 +1897,32 @@ const institutionalRouter = router({
|
|||||||
if (cached) return { symbol, ratings: cached.ratings, consensus: cached.consensus, stale: true };
|
if (cached) return { symbol, ratings: cached.ratings, consensus: cached.consensus, stale: true };
|
||||||
return { symbol, ratings: [], consensus: null, error: result.error };
|
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) ────────────────────────────
|
// ─── EDGAR / SEC Filings Router (Slice 6 / M6) ────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { LayoutShell } from "@/components/LayoutShell";
|
import { LayoutShell } from "@/components/LayoutShell";
|
||||||
import { OverviewPanel } from "@/components/OverviewPanel";
|
import { OverviewPanel } from "@/components/OverviewPanel";
|
||||||
import { AnalystRatings } from "@/components/AnalystRatings";
|
import { AnalystRatings } from "@/components/AnalystRatings";
|
||||||
|
import { ShortInterestPanel } from "@/components/ShortInterestPanel";
|
||||||
import { useActiveSymbolGuarded } from "@/stores/active-symbol-store";
|
import { useActiveSymbolGuarded } from "@/stores/active-symbol-store";
|
||||||
import { TickerContextPanel } from "@/components/TickerContextPanel";
|
import { TickerContextPanel } from "@/components/TickerContextPanel";
|
||||||
import { CompanyPanel } from "@/components/CompanyPanel";
|
import { CompanyPanel } from "@/components/CompanyPanel";
|
||||||
@@ -21,6 +22,7 @@ export default function Page() {
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<OverviewPanel />
|
<OverviewPanel />
|
||||||
<AnalystRatings symbol={activeSymbol} />
|
<AnalystRatings symbol={activeSymbol} />
|
||||||
|
<ShortInterestPanel symbol={activeSymbol} />
|
||||||
<TickerContextPanel />
|
<TickerContextPanel />
|
||||||
<CompanyPanel />
|
<CompanyPanel />
|
||||||
<SocialFeedPanel />
|
<SocialFeedPanel />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -622,6 +622,8 @@ export const api = {
|
|||||||
trpcQuery<{ symbol: string; events: Array<{ filerName: string | null; sharesAdded: number; estimatedPrice: number; date: string }> }>("institutional.buyEvents", { symbol }),
|
trpcQuery<{ symbol: string; events: Array<{ filerName: string | null; sharesAdded: number; estimatedPrice: number; date: string }> }>("institutional.buyEvents", { symbol }),
|
||||||
analystRatings: (symbol: string) =>
|
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 }),
|
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: {
|
emotionLogger: {
|
||||||
add: (input: { tradeExecutionId: string; priceAtEvent?: number; emotion: string; note?: string }) =>
|
add: (input: { tradeExecutionId: string; priceAtEvent?: number; emotion: string; note?: string }) =>
|
||||||
|
|||||||
Reference in New Issue
Block a user