Files
investor-flow/app/server/src/services/FinraIngestService.ts
T
Investor Flow Build 48872b7805 fix: correct FINRA URL, filename format, parser column order, and default base URL
- Fix default base URL: https://cdn.finra.org/equity/regsho/daily (was www.finra.org/sites/default/files)
- Fix filename pattern: CNMSshvol{YYYYMMDD}.txt (was CAshvol with year/month subdir)
- Fix parser: actual file columns are Date|Symbol|ShortVolume|ShortExemptVolume|TotalVolume|Market
  (was incorrectly expecting Market|Symbol|... — first col is Date, last col is Market)
- Dedupe by symbol (volume is total across all listed exchanges, not per-exchange)
- Store combined market codes (e.g. 'B,Q,N') as exchange string
- Use BEGIN/COMMIT instead of db.transaction() (node:sqlite DatabaseSync doesn't have transaction())
- Update finra_config defaults in schema.sql + migration + admin fallback
2026-07-25 13:14:05 -04:00

156 lines
5.3 KiB
TypeScript

import type { DatabaseSync } from 'node:sqlite';
import { getFinraBaseUrl } from '../admin/admin.ts';
/** Format a FINRA consolidated-short-interest filename: CNMSshvol{YYYYMMDD}.txt */
function finraFilename(settlementDate: string): string {
const d = settlementDate.replace(/-/g, '');
return `CNMSshvol${d}.txt`;
}
/** Parse a FINRA CNMS short-vol file body (pipe-delimited).
* Actual columns (verified against live file):
* Date|Symbol|ShortVolume|ShortExemptVolume|TotalVolume|Market
* Where Market is comma-separated exchange codes (B=NYSE, Q=NASDAQ, N=NYSE Amer/ARCA).
* Volumes are TOTAL across all listed exchanges — one row per symbol per settlement date. */
function parseFinraFile(
body: string,
settlementDate: string,
ingestedAt: string,
sourceFile: string,
): Array<{
symbol: string;
exchange: string;
shortVolume: number;
shortExempt: number;
totalVolume: number;
}> {
const lines = body.split(/\r?\n/);
const rows: Array<{
symbol: string;
exchange: string;
shortVolume: number;
shortExempt: number;
totalVolume: number;
}> = [];
let headerFound = false;
for (const raw of lines) {
const line = raw.trim();
if (!line || line.startsWith('#')) continue;
if (line.includes('Date|Symbol|Short') || line.includes('Date|Symbol|')) {
headerFound = true;
continue;
}
if (!headerFound) continue;
const cols = line.split('|').map((c) => c.trim());
if (cols.length < 5) continue;
// cols[0] = Date (YYYYMMDD), cols[1] = Symbol, cols[4] = TotalVolume, cols[5] = Market
const symbol = cols[1]?.replace(/\/.*$/, ''); // strip warrant suffix like /WS
const rawShort = cols[2]?.replace(/,/g, '') ?? '0';
const rawExempt = cols[3]?.replace(/,/g, '') ?? '0';
const rawTotal = cols[4]?.replace(/,/g, '') ?? rawShort;
const markets = cols[5] ?? '';
const shortVolume = parseFloat(rawShort);
const shortExempt = parseFloat(rawExempt);
const totalVolume = parseFloat(rawTotal);
if (!symbol || Number.isNaN(shortVolume)) continue;
// Store market codes as combined string (e.g. "B,Q,N")
const normSymbol = symbol.toUpperCase();
const exchangeCode = markets.replace(/\s/g, '').replace(/^,+|,+$/g, '') || 'ALL';
// Dedupe by symbol — the volume is total across all listed exchanges
if (!rows.some((r) => r.symbol === normSymbol)) {
rows.push({
symbol: normSymbol,
exchange: exchangeCode,
shortVolume,
shortExempt,
totalVolume,
});
}
}
return rows;
}
/** Download and ingest a FINRA consolidated-short-interest file (CNMSshvol{YYYYMMDD}.txt).
* baseUrl resolves from DB config > env FINRA_BASE_URL > built-in default when omitted. */
export async function downloadAndIngestFinra(
db: DatabaseSync,
settlementDate: string,
baseUrl?: string,
): Promise<{ symbolsStored: number; sourceFile: string; exchanges: string[] }> {
const base = (baseUrl ?? getFinraBaseUrl(db)).replace(/\/+$/, '');
const filename = finraFilename(settlementDate);
const url = `${base}/${filename}`;
const ingestedAt = new Date().toISOString();
console.log(`[finra] downloading ${url}`);
const resp = await fetch(url, {
headers: { 'User-Agent': 'InvestorFlow/1.0 (research) node' },
signal: AbortSignal.timeout(30_000),
});
if (!resp.ok) throw new Error(`FINRA download failed: ${resp.status} ${resp.statusText}`);
const body = await resp.text();
if (!body.trim()) throw new Error('FINRA file is empty');
const rows = parseFinraFile(body, settlementDate, ingestedAt, filename);
if (!rows.length) throw new Error('No FINRA short interest rows parsed');
const exchanges = [...new Set(rows.map((r) => r.exchange))];
const upsert = db.prepare(
`INSERT OR REPLACE INTO finra_short_interest
(symbol, settlement_date, exchange, short_volume, short_exempt, total_volume, avg_daily_vol, days_to_cover, source_file, ingested_at)
VALUES (?, ?, ?, ?, ?, ?, NULL, NULL, ?, ?)`
);
db.exec('BEGIN TRANSACTION');
try {
for (const r of rows) {
upsert.run(
r.symbol,
settlementDate,
r.exchange,
r.shortVolume,
r.shortExempt,
r.totalVolume,
filename,
ingestedAt,
);
}
db.exec('COMMIT');
} catch (e) {
db.exec('ROLLBACK');
throw e;
}
console.log(`[finra] ingested ${rows.length} rows from ${filename} (${exchanges.length} exchanges)`);
return { symbolsStored: rows.length, sourceFile: filename, exchanges };
}
/** Compute days-to-cover for finra_short_interest rows that have avg_daily_vol set.
* Called after avg_daily_vol is populated from external volume data. */
export function computeDaysToCover(db: DatabaseSync): number {
const r = db.exec(
`UPDATE finra_short_interest
SET days_to_cover = CASE
WHEN avg_daily_vol IS NOT NULL AND avg_daily_vol > 0 THEN short_volume / avg_daily_vol
ELSE NULL
END
WHERE days_to_cover IS NULL AND avg_daily_vol IS NOT NULL`
);
return r.changes;
}
/** Get the latest settlement date available in the finra_short_interest table. */
export function latestFinraSettlementDate(db: DatabaseSync): string | null {
const r = db.prepare(
'SELECT settlement_date FROM finra_short_interest ORDER BY settlement_date DESC LIMIT 1'
).get() as { settlement_date: string } | undefined;
return r?.settlement_date ?? null;
}