Jaspers Hub

jaspers / finra

pluginOfficialReviewed★ 0↓ 8

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.

2.6 KB
1import { defineSource } from '@jaspers-ai/sdk'
2import { 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
8const HOST = 'api.finra.org'
9
10/** FINRA reads its filters from a POST body; a GET takes only the page size. */
11async 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
24export 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})