Jaspers Hub

jaspers / fmp

pluginOfficialReviewed★ 0↓ 4

FMP: News & Technicals: Connects Financial Modeling Prep's MCP server to show company news and technical indicators, with Form 13F as a source only.

8.2 KB
1// The plugin's pure half: each view's state read the forgiving way (main stores state as written, so
2// a key can be missing or the wrong shape), FMP's numbers formatted for a cell, the indicator runs
3// merged into one table, and what each view publishes and says in the model's map. No React and no
4// bridge here, so it runs under the tests.
5
6export type Row = Record<string, unknown>
7
8export function record(value: unknown): Row | null {
9 return typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Row) : null
10}
11
12/** The ticker every view is about: trimmed and upper case, or nothing. */
13export function readSymbol(raw: unknown): string {
14 const symbol = record(raw)?.['symbol']
15 return typeof symbol === 'string' ? symbol.trim().toUpperCase() : ''
16}
17
18// News.
19
20export interface NewsState {
21 symbol: string
22 limit: number
23}
24
25export function readNewsState(raw: unknown): NewsState {
26 const limit = int(record(raw)?.['limit'])
27 return { symbol: readSymbol(raw), limit: limit === undefined ? 20 : clamp(limit, 1, 50) }
28}
29
30// Technicals: which indicators, on which bars, over how many days.
31
32export const KINDS = ['sma', 'ema', 'wma', 'dema', 'tema', 'rsi', 'stddev', 'williams', 'adx'] as const
33export type Kind = (typeof KINDS)[number]
34
35export interface Indicator {
36 kind: Kind
37 period: number
38}
39
40export const TIMEFRAMES = ['1min', '5min', '15min', '30min', '1hour', '4hour', '1day'] as const
41export type Timeframe = (typeof TIMEFRAMES)[number]
42
43/** Each kind's FMP endpoint, the key its rows carry the value under, and its label. */
44export const INDICATORS: Record<Kind, { endpoint: string; key: string; label: string }> = {
45 sma: { endpoint: 'simple-moving-average', key: 'sma', label: 'SMA' },
46 ema: { endpoint: 'exponential-moving-average', key: 'ema', label: 'EMA' },
47 wma: { endpoint: 'weighted-moving-average', key: 'wma', label: 'WMA' },
48 dema: { endpoint: 'double-exponential-moving-average', key: 'dema', label: 'DEMA' },
49 tema: { endpoint: 'triple-exponential-moving-average', key: 'tema', label: 'TEMA' },
50 rsi: { endpoint: 'relative-strength-index', key: 'rsi', label: 'RSI' },
51 stddev: { endpoint: 'standard-deviation', key: 'standardDeviation', label: 'StdDev' },
52 williams: { endpoint: 'williams', key: 'williams', label: 'Williams %R' },
53 adx: { endpoint: 'average-directional-index', key: 'adx', label: 'ADX' },
54}
55
56export const MAX_INDICATORS = 6
57const DEFAULT_INDICATORS: Indicator[] = [
58 { kind: 'rsi', period: 14 },
59 { kind: 'sma', period: 50 },
60 { kind: 'sma', period: 200 },
61]
62
63export interface TechnicalsState {
64 symbol: string
65 timeframe: Timeframe
66 days: number
67 indicators: Indicator[]
68}
69
70export function readTechnicalsState(raw: unknown): TechnicalsState {
71 const state = record(raw) ?? {}
72 const timeframe = TIMEFRAMES.find((t) => t === state['timeframe']) ?? '1day'
73 const days = clamp(int(state['days']) ?? 30, 1, 365)
74 const given = state['indicators']
75 const indicators: Indicator[] = []
76 for (const entry of Array.isArray(given) ? given : []) {
77 const item = record(entry)
78 const kind = KINDS.find((k) => k === item?.['kind'])
79 const period = item?.['period']
80 if (!kind || !Number.isInteger(period) || (period as number) < 1 || (period as number) > 500) continue
81 if (indicators.some((i) => i.kind === kind && i.period === period)) continue
82 if (indicators.length === MAX_INDICATORS) break
83 indicators.push({ kind, period: period as number })
84 }
85 return { symbol: readSymbol(raw), timeframe, days, indicators: Array.isArray(given) ? indicators : DEFAULT_INDICATORS }
86}
87
88export function indicatorLabel(indicator: Indicator): string {
89 return `${INDICATORS[indicator.kind].label} ${indicator.period}`
90}
91
92/** The key an indicator's value is published under: `rsi14`, `sma50`. */
93export function indicatorId(indicator: Indicator): string {
94 return `${indicator.kind}${indicator.period}`
95}
96
97/** That many days before today, as FMP's from_date. */
98export function fromDate(today: Date, days: number): string {
99 return new Date(today.getTime() - days * 86_400_000).toISOString().slice(0, 10)
100}
101
102export interface MergedRow {
103 date: string
104 close: number | null
105 values: Record<string, number | null>
106}
107
108/** One table out of one run per indicator: a row per bar, latest first, a column per indicator. */
109export function mergeByDate(runs: { indicator: Indicator; rows: Row[] | undefined }[]): MergedRow[] {
110 const ids = runs.map((run) => indicatorId(run.indicator))
111 const byDate = new Map<string, MergedRow>()
112 for (const run of runs) {
113 const id = indicatorId(run.indicator)
114 const key = INDICATORS[run.indicator.kind].key
115 for (const row of run.rows ?? []) {
116 const date = typeof row['date'] === 'string' ? row['date'] : ''
117 if (!date) continue
118 let merged = byDate.get(date)
119 if (!merged) {
120 merged = { date, close: null, values: Object.fromEntries(ids.map((i) => [i, null])) }
121 byDate.set(date, merged)
122 }
123 if (merged.close === null && typeof row['close'] === 'number') merged.close = row['close']
124 const value = row[key]
125 if (typeof value === 'number') merged.values[id] = value
126 }
127 }
128 return [...byDate.values()].sort((a, b) => (a.date < b.date ? 1 : a.date > b.date ? -1 : 0))
129}
130
131// Formatting.
132
133const MINUS = '−'
134
135export function num(value: unknown, digits = 2): string {
136 if (typeof value !== 'number' || !Number.isFinite(value)) return '—'
137 return value < 0 ? `${MINUS}${fixed(-value, digits)}` : fixed(value, digits)
138}
139
140/** FMP writes '2026-09-11 00:00:00'; the day is what a cell wants. */
141export function shortDate(value: unknown): string {
142 return typeof value === 'string' && /^\d{4}-\d{2}-\d{2}/.test(value) ? value.slice(0, 10) : ''
143}
144
145function fixed(value: number, digits: number): string {
146 return value.toLocaleString('en-US', { minimumFractionDigits: digits, maximumFractionDigits: digits })
147}
148
149// News.
150
151export type NewsOutput = {
152 symbol: string
153 count: number
154 items: { date: string; site: string; title: string; url: string }[]
155}
156
157export function newsOutput(symbol: string, rows: Row[] | undefined, cap = 10): NewsOutput {
158 const all = rows ?? []
159 return {
160 symbol,
161 count: all.length,
162 items: all.slice(0, cap).map((row) => ({
163 date: text(row['publishedDate']) ?? '',
164 site: text(row['site']) ?? '',
165 title: text(row['title']) ?? '',
166 url: text(row['url']) ?? '',
167 })),
168 }
169}
170
171export function newsSummary(state: unknown, output: unknown): string {
172 const symbol = readSymbol(state)
173 if (!symbol) return 'News: no symbol'
174 const out = record(output)
175 const first = record(Array.isArray(out?.['items']) ? out['items'][0] : null)
176 if (!out || !first) return `${symbol} news`
177 return `${symbol} news: ${num(out['count'], 0)} items, latest ${shortDate(first['date'])} ${text(first['site']) ?? ''}`.trimEnd()
178}
179
180// Technicals.
181
182export type TechnicalsOutput = {
183 symbol: string
184 timeframe: Timeframe
185 date: string | null
186 close: number | null
187 values: Record<string, number | null>
188 rows: number
189}
190
191export function technicalsOutput(state: TechnicalsState, merged: MergedRow[]): TechnicalsOutput {
192 const latest = merged[0]
193 return {
194 symbol: state.symbol,
195 timeframe: state.timeframe,
196 date: latest?.date ?? null,
197 close: latest?.close ?? null,
198 values: latest?.values ?? {},
199 rows: merged.length,
200 }
201}
202
203export function technicalsSummary(state: unknown, output: unknown): string {
204 const read = readTechnicalsState(state)
205 if (!read.symbol) return 'Technicals: no symbol'
206 const values = record(record(output)?.['values'])
207 const parts = values
208 ? read.indicators
209 .map((indicator) => ({ indicator, value: values[indicatorId(indicator)] }))
210 .filter(({ value }) => typeof value === 'number')
211 .map(({ indicator, value }) => `${indicatorLabel(indicator)} ${num(value, 1)}`)
212 : []
213 return parts.length ? `${read.symbol} ${read.timeframe}: ${parts.join(', ')}` : `${read.symbol} ${read.timeframe} technicals`
214}
215
216// Small readers.
217
218function text(value: unknown): string | null {
219 return typeof value === 'string' && value !== '' ? value : null
220}
221
222function int(value: unknown): number | undefined {
223 return typeof value === 'number' && Number.isInteger(value) ? value : undefined
224}
225
226function clamp(value: number, min: number, max: number): number {
227 return Math.min(max, Math.max(min, value))
228}