FINRA Short Interest: Gets a stock's twice-monthly short interest from FINRA's public API: shares short, change since the last report, and days to cover, oldest report first.
| 1 | import { defineSource } from '@jaspers-ai/sdk' |
| 2 | import { z } from 'zod' |
| 3 | |
| 4 | |
| 5 | // FINRA's public data API. No key: short interest as brokers report it, twice a month, and what |
| 6 | // trades away from the exchanges. |
| 7 | |
| 8 | const HOST = 'api.finra.org' |
| 9 | |
| 10 | /** FINRA reads its filters from a POST body; a GET takes only the page size. */ |
| 11 | async function query(ctx: { fetch: typeof fetch }, dataset: string, body: Record<string, unknown>): Promise<Record<string, unknown>[]> { |
| 12 | const response = await ctx.fetch(`https://${HOST}/data/group/otcMarket/name/${dataset}`, { |
| 13 | method: 'POST', |
| 14 | headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, |
| 15 | body: JSON.stringify(body), |
| 16 | }) |
| 17 | if (!response.ok) { |
| 18 | const said = (await response.json().catch(() => null)) as { message?: string } | null |
| 19 | throw new Error(said?.message ?? `FINRA answered ${response.status}.`) |
| 20 | } |
| 21 | return (await response.json()) as Record<string, unknown>[] |
| 22 | } |
| 23 | |
| 24 | export const shortInterest = defineSource({ |
| 25 | description: |
| 26 | 'Short interest in one stock, as reported to FINRA twice a month: shares short, the change since last time, and days to cover against average volume. Newest last, ready to chart.', |
| 27 | hosts: [HOST], |
| 28 | input: z.object({ |
| 29 | ticker: z.string().describe('A ticker, like AAPL.'), |
| 30 | limit: z.number().int().min(1).max(1000).default(60).describe('Reports to read. They come twice a month, so 60 is about two and a half years.'), |
| 31 | }), |
| 32 | async run({ ticker, limit }, ctx) { |
| 33 | const symbol = ticker.trim().toUpperCase() |
| 34 | // FINRA refuses to sort unless every partition key is given, and answers oldest first, so a |
| 35 | // small limit reads 2020. Take the lot for this symbol and keep the newest here instead; it is |
| 36 | // two reports a month, so even a long history is a few hundred rows. |
| 37 | const found = await query(ctx, 'consolidatedShortInterest', { |
| 38 | limit: 2000, |
| 39 | compareFilters: [{ fieldName: 'symbolCode', fieldValue: symbol, compareType: 'equal' }], |
| 40 | }) |
| 41 | if (found.length === 0) throw new Error(`FINRA has no short interest for ${symbol}.`) |
| 42 | const rows = found |
| 43 | .map((row) => ({ |
| 44 | ticker: symbol, |
| 45 | name: row['issueName'], |
| 46 | settlementDate: row['settlementDate'], |
| 47 | shortShares: row['currentShortPositionQuantity'], |
| 48 | previousShortShares: row['previousShortPositionQuantity'], |
| 49 | change: row['changePreviousNumber'], |
| 50 | changePercent: row['changePercent'], |
| 51 | averageDailyVolume: row['averageDailyVolumeQuantity'], |
| 52 | daysToCover: row['daysToCoverQuantity'], |
| 53 | market: row['marketClassCode'], |
| 54 | })) |
| 55 | .sort((a, b) => String(a.settlementDate).localeCompare(String(b.settlementDate))) |
| 56 | .slice(-limit) |
| 57 | return { ticker: symbol, rows } |
| 58 | }, |
| 59 | }) |