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
This commit is contained in:
Investor Flow Build
2026-07-25 12:59:43 -04:00
parent d53c3a1cab
commit 76f60dc766
6 changed files with 205 additions and 33 deletions
+91
View File
@@ -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<FetchResult> {
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;
}
+18 -1
View File
@@ -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<string, KindHandler>([
['quote', quoteHandler],
['candles', candlesHandler],
@@ -314,6 +327,7 @@ const HANDLERS = new Map<string, KindHandler>([
['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;
+2
View File
@@ -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<SourceKind, SourceFetch>([
['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],
+42 -21
View File
@@ -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<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,
};
/** 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<Record<string, unknown>>(`yfinance:shortinterest:${symbol}`),
ctx.cache.get<Record<string, unknown>>(`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,
};
}),
});