feat: Phase 3 FINRA bulk adapter + Phase 4 three-way merge with discrepancy flagging

Phase 3 — FINRA bulk short-interest ingest:
- Add finra_short_interest table to schema (per-symbol, per-settlement-date, per-exchange, with short/exempt/total volume, avg daily vol, days to cover)
- Create FinraIngestService: downloads FINRA consolidated pipe-delimited file from configurable base URL, parses Market|Symbol|ShortVolume|ShortExemptVolume|TotalVolume, stores in finra_short_interest table
- Create FinraBulkAdapter: SourceFetch that calls downloadAndIngestFinra, registers as finra-bulk source kind with finraShortinterest cache handler
- finraShortinterest handler reads latest settlement row per symbol from finra_short_interest table (no per-symbol kv_cache write; data is bulk-ingested)
- Register in index.ts adapter map + HANDLERS + del case

Phase 4 — three-way merge with discrepancy detection:
- shortInterest tRPC procedure now reads all 3 caches (yfinance, nasdaq, finra-bulk) in parallel
- Reconciliation hierarchy: FINRA (shares short) > NASDAQ > Yahoo
- daysToCover: NASDAQ (specific) > FINRA (computed) > Yahoo (short ratio fallback)
- settlementDate: FINRA > NASDAQ > Yahoo
- Discrepancy detection: compares sharesShort across available sources, flags >10% difference with discrepancyPct + discrepancyBetween
- Updated ShortInterestPanel: FINRA source badge, discrepancy warning banner, three-source disclaimer
- Updated trpc.ts client type for new shape
This commit is contained in:
Investor Flow Build
2026-07-25 13:03:49 -04:00
parent 76f60dc766
commit 3fa6b32916
8 changed files with 308 additions and 15 deletions
+11 -3
View File
@@ -1,5 +1,5 @@
'use client';
import { useState, useEffect, useMemo } from 'react';
import { useState, useEffect } from 'react';
import { api } from '@/lib/trpc';
interface HistoryEntry {
@@ -22,7 +22,9 @@ interface ShortInterestData {
daysToCover: number | null;
settlementDate: string | null;
history: HistoryEntry[] | null;
sources: { yfinance: { available: boolean; stale: boolean }; nasdaq: { available: boolean; stale: boolean } };
discrepancyPct: number | null;
discrepancyBetween: string | null;
sources: { yfinance: { available: boolean; stale: boolean }; nasdaq: { available: boolean; stale: boolean }; finra: { available: boolean; stale: boolean } };
stale: boolean;
}
@@ -80,6 +82,7 @@ export function ShortInterestPanel({ symbol }: { symbol: string | null }) {
const sourceTags = [] as string[];
if (data.sources.nasdaq.available) sourceTags.push('NASDAQ');
if (data.sources.finra.available) sourceTags.push('FINRA');
if (data.sources.yfinance.available) sourceTags.push('Yahoo');
const sourceLabel = sourceTags.length ? sourceTags.join(' + ') : '—';
@@ -91,6 +94,11 @@ export function ShortInterestPanel({ symbol }: { symbol: string | null }) {
{dateLabel && (
<p className="text-[10px] text-fg-muted">{dateLabel}{data.stale ? ' — stale' : ''} ({sourceLabel})</p>
)}
{data.discrepancyPct !== null && (
<div className="bg-warning/10 border border-warning/30 rounded px-2 py-1 text-[10px] text-warning">
{data.discrepancyPct}% discrepancy in shares short between sources ({data.discrepancyBetween})
</div>
)}
{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>
@@ -130,7 +138,7 @@ export function ShortInterestPanel({ symbol }: { symbol: string | null }) {
</div>
)}
<p className="text-[10px] text-fg-muted mt-1">
Reconciled from NASDAQ (days-to-cover, history) + Yahoo Finance (all tickers). Educational display only.
Reconciled across FINRA (regulatory, shares short), NASDAQ (days-to-cover, history), and Yahoo Finance (% float, universal coverage). Educational display only.
</p>
</div>
);