From 76f60dc7660ebc1644ffac1e8fb6a87aebf1cfaf Mon Sep 17 00:00:00 2001 From: Investor Flow Build Date: Sat, 25 Jul 2026 12:59:43 -0400 Subject: [PATCH] feat: Phase 2 NASDAQ adapter for days-to-cover + 24mo history - 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 --- app/server/src/adapters/NasdaqAdapter.ts | 91 +++++++++++++++++++++++ app/server/src/cache/CacheRepository.ts | 19 ++++- app/server/src/index.ts | 2 + app/server/src/trpc/router.ts | 63 ++++++++++------ app/src/components/ShortInterestPanel.tsx | 61 ++++++++++++--- app/src/lib/trpc.ts | 2 +- 6 files changed, 205 insertions(+), 33 deletions(-) create mode 100644 app/server/src/adapters/NasdaqAdapter.ts diff --git a/app/server/src/adapters/NasdaqAdapter.ts b/app/server/src/adapters/NasdaqAdapter.ts new file mode 100644 index 0000000..5bc19da --- /dev/null +++ b/app/server/src/adapters/NasdaqAdapter.ts @@ -0,0 +1,91 @@ +// Investor Flow — NasdaqAdapter (DESIGN.md §3a Module 2 + §5 yfinance policy). +// SourceFetch for NASDAQ short-interest data via the Nasdaq Data Link API. +// Per-symbol JSON endpoint, no API key required (public, rate-limited). +// Covers NASDAQ-listed stocks only; NYSE/other tickers return an error. +import type { CacheKey, TtlClass, Provenance, SourceKind } from '../cache/CacheRepository.ts'; +import { parseCacheKey } from '../cache/CacheRepository.ts'; +import type { SourceFetch, FetchResult } from './SourceAdapter.ts'; + +const UA = `InvestorFlow/${process.env.APP_VERSION ?? 'dev'} (+https://github.com/anomalyco/investor-flow)`; +const NASDAQ_SHORTINTEREST_TTL_MS = 12 * 60 * 60_000; // 12h (NASDAQ publishes bi-monthly) + +interface NasdaqShortInterestRow { + settlementDate: string; + interest: string; + avgDailyShareVolume: string; + daysToCover: number; +} + +interface NasdaqShortInterestResponse { + data?: { + symbol: string; + shortInterestTable?: { + headers: Array<{ key: string }>; + rows: NasdaqShortInterestRow[]; + }; + }; + status?: { rCode: number }; + message?: string | null; +} + +export class NasdaqAdapter implements SourceFetch { + readonly sourceKind = 'nasdaq' as SourceKind; + + async fetchOne(key: CacheKey): Promise { + const { kind, id } = parseCacheKey(key); + const fetchedAt = new Date().toISOString(); + + if (kind !== 'nasdaqShortinterest') { + throw new Error(`NasdaqAdapter: unknown kind '${kind}'`); + } + + const res = await fetch( + `https://api.nasdaq.com/api/quote/${encodeURIComponent(id)}/short-interest?assetclass=stocks`, + { + headers: { + 'User-Agent': UA, + Accept: 'application/json', + }, + } + ); + + if (!res.ok) { + throw new Error(`NASDAQ API returned ${res.status}`); + } + + const body = (await res.json()) as NasdaqShortInterestResponse; + + if (!body.data?.shortInterestTable?.rows?.length) { + throw new Error(body.message ?? 'No short-interest data available'); + } + + const rows = body.data.shortInterestTable.rows as NasdaqShortInterestRow[]; + const latest = rows[0]; + const history = rows.map((r) => ({ + settlementDate: r.settlementDate, + sharesShort: parseShares(r.interest), + avgDailyVolume: parseShares(r.avgDailyShareVolume), + daysToCover: r.daysToCover, + })); + + return { + value: { + sharesShort: parseShares(latest.interest), + avgDailyShareVolume: parseShares(latest.avgDailyShareVolume), + daysToCover: latest.daysToCover, + settlementDate: latest.settlementDate, + history, + source: 'nasdaq', + }, + ttlClass: 'short_interest' as TtlClass, + provenance: { fetchedAt, sourceKind: 'nasdaq', rawSourceId: id }, + }; + } +} + +function parseShares(raw: string): number | null { + if (!raw) return null; + const cleaned = raw.replace(/,/g, ''); + const n = Number(cleaned); + return Number.isNaN(n) || n < 0 ? null : n; +} \ No newline at end of file diff --git a/app/server/src/cache/CacheRepository.ts b/app/server/src/cache/CacheRepository.ts index 426bb7f..84a0229 100644 --- a/app/server/src/cache/CacheRepository.ts +++ b/app/server/src/cache/CacheRepository.ts @@ -6,7 +6,7 @@ import { DatabaseSync } from 'node:sqlite'; import { db as defaultDb } from '../db/client.ts'; -export type SourceKind = 'yfinance' | 'sec' | 'sec-fetch' | 'reddit' | 'x' | 'macro' | 'llm' | 'sec-lint-holders' | 'sec-lint-insiders' | 'fred'; +export type SourceKind = 'yfinance' | 'nasdaq' | 'sec' | 'sec-fetch' | 'reddit' | 'x' | 'macro' | 'llm' | 'sec-lint-holders' | 'sec-lint-insiders' | 'fred'; export type TickerKind = 'equity' | 'crypto' | 'etf' | 'index'; export type CacheKey = string; // `${SourceKind}:${kind}:${id}` e.g. 'yfinance:quote:NVDA', 'yfinance:candles:NVDA:1d' export type TtlClass = @@ -302,6 +302,19 @@ const shortInterestHandler: KindHandler = { isStale(ts, now) { return tsAgeMs(ts, now) > TTL_MS.short_interest; }, }; +const nasdaqShortinterestHandler: KindHandler = { + ttlClass: 'short_interest', + read(d, id) { + const r = d.prepare('SELECT value, observed_at FROM kv_cache WHERE key=?').get(`nasdaq:nasdaqShortinterest:${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(`nasdaq:nasdaqShortinterest:${id}`, JSON.stringify(value), provenance.fetchedAt); + }, + isStale(ts, now) { return tsAgeMs(ts, now) > TTL_MS.short_interest; }, +}; + const HANDLERS = new Map([ ['quote', quoteHandler], ['candles', candlesHandler], @@ -314,6 +327,7 @@ const HANDLERS = new Map([ ['holders', lintHoldersHandler], ['insiders', lintInsidersHandler], ['shortinterest', shortInterestHandler], + ['nasdaqShortinterest', nasdaqShortinterestHandler], ]); export interface CacheRepository { @@ -424,6 +438,9 @@ export class CacheRepositoryImpl implements CacheRepository { case 'shortinterest': d.prepare('DELETE FROM kv_cache WHERE key=?').run(`yfinance:shortinterest:${id}`); break; + case 'nasdaqShortinterest': + d.prepare('DELETE FROM kv_cache WHERE key=?').run(`nasdaq:nasdaqShortinterest:${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/index.ts b/app/server/src/index.ts index 66af502..89ca62a 100644 --- a/app/server/src/index.ts +++ b/app/server/src/index.ts @@ -5,6 +5,7 @@ import { fetchRequestHandler } from '@trpc/server/adapters/fetch'; import { db } from './db/client.ts'; import { createCacheRepository, type SourceKind } from './cache/CacheRepository.ts'; import { YFinanceAdapter } from './adapters/YFinanceAdapter.ts'; +import { NasdaqAdapter } from './adapters/NasdaqAdapter.ts'; import { SecFetchAdapter } from './adapters/SecFetchAdapter.ts'; import { SecLintAdapter } from './adapters/SecLintAdapter.ts'; import { XCookieAdapter } from './adapters/XCookieAdapter.ts'; @@ -18,6 +19,7 @@ const PORT = Number(process.env.PORT ?? 3001); const database = db(); const adapters = new Map([ ['yfinance' as const, new YFinanceAdapter() as unknown as SourceFetch], + ['nasdaq' as const, new NasdaqAdapter() as unknown as SourceFetch], ['sec-fetch' as const, new SecFetchAdapter(database) as unknown as SourceFetch], ['sec-lint-holders' as const, new SecLintAdapter(() => database, 'sec-lint-holders') as unknown as SourceFetch], ['sec-lint-insiders' as const, new SecLintAdapter(() => database, 'sec-lint-insiders') as unknown as SourceFetch], diff --git a/app/server/src/trpc/router.ts b/app/server/src/trpc/router.ts index a9c0618..9b274b4 100644 --- a/app/server/src/trpc/router.ts +++ b/app/server/src/trpc/router.ts @@ -1900,27 +1900,48 @@ const institutionalRouter = router({ 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, - }; + /** Short interest reconciled across Yahoo Finance + NASDAQ API. + * Yahoo covers all US-listed tickers (basic: shares short, % float, short ratio). + * NASDAQ covers NASDAQ-listed only but adds days-to-cover + 24mo history. + * Reconciliation: prefer NASDAQ's sharesShort/daysToCover when available (richer), + * fall back to Yahoo for NYSE/other tickers. */ + shortInterest: publicProcedure + .input(z.object({ symbol: z.string().min(1) })) + .query(async ({ ctx, input }) => { + const symbol = input.symbol.toUpperCase(); + const [yfEntry, ndqEntry] = await Promise.all([ + ctx.cache.get>(`yfinance:shortinterest:${symbol}`), + ctx.cache.get>(`nasdaq:nasdaqShortinterest:${symbol}`), + ]); + const yf = yfEntry.value; + const ndq = ndqEntry.value; + + // Prefer NASDAQ's sharesShort + daysToCover when available (NASDAQ-listed); + // otherwise fall back to Yahoo (covers NYSE/other tickers). + const sharesShort = ndq?.sharesShort ?? yf?.sharesShort ?? null; + const daysToCover = ndq?.daysToCover ?? null; + const settlementDate = ndq?.settlementDate ?? null; + const history = ndq?.history ?? null; + + return { + symbol, + sharesShort, + sharesShortPriorMonth: yf?.sharesShortPriorMonth ?? null, + sharesPercentSharesOut: yf?.sharesPercentSharesOut ?? null, + shortRatio: yf?.shortRatio ?? null, + shortPercentOfFloat: yf?.shortPercentOfFloat ?? null, + dateShortInterest: yf?.dateShortInterest ?? null, + floatShares: yf?.floatShares ?? null, + sharesOutstanding: yf?.sharesOutstanding ?? null, + daysToCover, + settlementDate, + history, + sources: { + yfinance: { available: !!yf, stale: yfEntry.isStale }, + nasdaq: { available: !!ndq, stale: ndqEntry.isStale }, + }, + stale: yfEntry.isStale && ndqEntry.isStale, + }; }), }); diff --git a/app/src/components/ShortInterestPanel.tsx b/app/src/components/ShortInterestPanel.tsx index d721484..858e201 100644 --- a/app/src/components/ShortInterestPanel.tsx +++ b/app/src/components/ShortInterestPanel.tsx @@ -2,6 +2,13 @@ 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; @@ -12,8 +19,11 @@ interface ShortInterestData { 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; - provenance: { fetchedAt: string; sourceKind: string; rawSourceId?: string } | null; } function formatNumber(n: number | null): string { @@ -62,34 +72,65 @@ export function ShortInterestPanel({ symbol }: { symbol: string | null }) { ); 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)}` + 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 (

Short Interest

{dateLabel && ( -

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

+

{dateLabel}{data.stale ? ' — stale' : ''} ({sourceLabel})

)} {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.daysToCover !== null && ( +
Days to Cover
{data.daysToCover.toFixed(2)}d
+ )} + {data.sharesShortPriorMonth !== null && data.floatShares ? ( +
% Float (prior)
{formatPct(data.sharesShortPriorMonth / data.floatShares)}
+ ) : null} {data.floatShares !== null && (
Float Shares
{formatNumber(data.floatShares)}
)} {data.sharesOutstanding !== null && ( -
Shares Outstanding
{formatNumber(data.sharesOutstanding)}
+
Shares Out
{formatNumber(data.sharesOutstanding)}
)}
)} -

- Short interest from Yahoo Finance (derived from exchange filings). Educational display only — not investment advice. + {historySlice.length > 0 && ( +

+

6-month history (NASDAQ source)

+
+ + + + {historySlice.map((h, i) => ( + + + + + + ))} + +
DateShortDays
{h.settlementDate}{formatNumber(h.sharesShort)}{h.daysToCover.toFixed(2)}
+
+
+ )} +

+ Reconciled from NASDAQ (days-to-cover, history) + Yahoo Finance (all tickers). Educational display only.

); diff --git a/app/src/lib/trpc.ts b/app/src/lib/trpc.ts index 253c00c..0ab2386 100644 --- a/app/src/lib/trpc.ts +++ b/app/src/lib/trpc.ts @@ -623,7 +623,7 @@ export const api = { 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 }), + 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; daysToCover: number | null; settlementDate: string | null; history: Array<{ settlementDate: string; sharesShort: number | null; avgDailyVolume: number | null; daysToCover: number }> | null; sources: { yfinance: { available: boolean; stale: boolean }; nasdaq: { available: boolean; stale: boolean } }; stale: boolean }>("institutional.shortInterest", { symbol }), }, emotionLogger: { add: (input: { tradeExecutionId: string; priceAtEvent?: number; emotion: string; note?: string }) =>