FMP: News & Technicals: Connects Financial Modeling Prep's MCP server to show company news and technical indicators, with Form 13F as a source only.
| 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 | |
| 6 | export type Row = Record<string, unknown> |
| 7 | |
| 8 | export 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. */ |
| 13 | export function readSymbol(raw: unknown): string { |
| 14 | const symbol = record(raw)?.['symbol'] |
| 15 | return typeof symbol === 'string' ? symbol.trim().toUpperCase() : '' |
| 16 | } |
| 17 | |
| 18 | // News. |
| 19 | |
| 20 | export interface NewsState { |
| 21 | symbol: string |
| 22 | limit: number |
| 23 | } |
| 24 | |
| 25 | export 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 | |
| 32 | export const KINDS = ['sma', 'ema', 'wma', 'dema', 'tema', 'rsi', 'stddev', 'williams', 'adx'] as const |
| 33 | export type Kind = (typeof KINDS)[number] |
| 34 | |
| 35 | export interface Indicator { |
| 36 | kind: Kind |
| 37 | period: number |
| 38 | } |
| 39 | |
| 40 | export const TIMEFRAMES = ['1min', '5min', '15min', '30min', '1hour', '4hour', '1day'] as const |
| 41 | export type Timeframe = (typeof TIMEFRAMES)[number] |
| 42 | |
| 43 | /** Each kind's FMP endpoint, the key its rows carry the value under, and its label. */ |
| 44 | export 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 | |
| 56 | export const MAX_INDICATORS = 6 |
| 57 | const DEFAULT_INDICATORS: Indicator[] = [ |
| 58 | { kind: 'rsi', period: 14 }, |
| 59 | { kind: 'sma', period: 50 }, |
| 60 | { kind: 'sma', period: 200 }, |
| 61 | ] |
| 62 | |
| 63 | export interface TechnicalsState { |
| 64 | symbol: string |
| 65 | timeframe: Timeframe |
| 66 | days: number |
| 67 | indicators: Indicator[] |
| 68 | } |
| 69 | |
| 70 | export 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 | |
| 88 | export 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`. */ |
| 93 | export function indicatorId(indicator: Indicator): string { |
| 94 | return `${indicator.kind}${indicator.period}` |
| 95 | } |
| 96 | |
| 97 | /** That many days before today, as FMP's from_date. */ |
| 98 | export function fromDate(today: Date, days: number): string { |
| 99 | return new Date(today.getTime() - days * 86_400_000).toISOString().slice(0, 10) |
| 100 | } |
| 101 | |
| 102 | export 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. */ |
| 109 | export 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 | |
| 133 | const MINUS = '−' |
| 134 | |
| 135 | export 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. */ |
| 141 | export function shortDate(value: unknown): string { |
| 142 | return typeof value === 'string' && /^\d{4}-\d{2}-\d{2}/.test(value) ? value.slice(0, 10) : '' |
| 143 | } |
| 144 | |
| 145 | function fixed(value: number, digits: number): string { |
| 146 | return value.toLocaleString('en-US', { minimumFractionDigits: digits, maximumFractionDigits: digits }) |
| 147 | } |
| 148 | |
| 149 | // News. |
| 150 | |
| 151 | export type NewsOutput = { |
| 152 | symbol: string |
| 153 | count: number |
| 154 | items: { date: string; site: string; title: string; url: string }[] |
| 155 | } |
| 156 | |
| 157 | export 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 | |
| 171 | export 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 | |
| 182 | export 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 | |
| 191 | export 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 | |
| 203 | export 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 | |
| 218 | function text(value: unknown): string | null { |
| 219 | return typeof value === 'string' && value !== '' ? value : null |
| 220 | } |
| 221 | |
| 222 | function int(value: unknown): number | undefined { |
| 223 | return typeof value === 'number' && Number.isInteger(value) ? value : undefined |
| 224 | } |
| 225 | |
| 226 | function clamp(value: number, min: number, max: number): number { |
| 227 | return Math.min(max, Math.max(min, value)) |
| 228 | } |