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.

5.6 KB
1import { useEffect, useMemo, useState, type ReactElement } from 'react'
2import { useData, usePanelState, usePublish, type PanelRef } from '@jaspers-ai/sdk'
3import {
4 fromDate,
5 INDICATORS,
6 indicatorId,
7 indicatorLabel,
8 mergeByDate,
9 num,
10 readSymbol,
11 readTechnicalsState,
12 shortDate,
13 technicalsOutput,
14 TIMEFRAMES,
15 type Indicator,
16 type Row,
17 type Timeframe,
18} from './fmp'
19import { cleanError, Loading, NoSymbol, SymbolField, useInitialState } from './shared'
20import './styles.css'
21
22// Technical indicators for one company: one run per indicator in the panel's state, merged into
23// one table by bar. Each run is a child of its own, since hooks cannot be called in a loop whose
24// length the state decides; the children report their rows up and the table is made here.
25
26interface RunReport {
27 rows: Row[] | undefined
28 loading: boolean
29 error: string | null
30}
31
32export function TechnicalsView({ panel }: { panel: PanelRef }): ReactElement {
33 const initial = useInitialState(panel)
34 if (initial === undefined) return <Loading />
35 return <Technicals panel={panel} initial={initial} />
36}
37
38function Technicals({ panel, initial }: { panel: PanelRef; initial: unknown }): ReactElement {
39 const first = readTechnicalsState(initial)
40 const [symbol, setSymbol] = usePanelState<string>(panel, 'symbol', first.symbol)
41 const [timeframe, setTimeframe] = usePanelState<Timeframe>(panel, 'timeframe', first.timeframe)
42 const [days] = usePanelState<number>(panel, 'days', first.days)
43 const [indicators] = usePanelState<Indicator[]>(panel, 'indicators', first.indicators)
44 const state = readTechnicalsState({ symbol, timeframe, days, indicators })
45 return (
46 <div className="fm-root">
47 <div className="fm-bar">
48 <SymbolField symbol={state.symbol} onSymbol={setSymbol} />
49 <select className="fm-select" value={state.timeframe} onChange={(event) => setTimeframe(event.target.value as Timeframe)}>
50 {TIMEFRAMES.map((t) => (
51 <option key={t} value={t}>
52 {t}
53 </option>
54 ))}
55 </select>
56 <span className="fm-muted">{state.indicators.map(indicatorLabel).join(', ') || 'no indicators'}</span>
57 </div>
58 <div className="fm-body">
59 {state.symbol ? (
60 <Body panel={panel} symbol={state.symbol} timeframe={state.timeframe} days={state.days} indicators={state.indicators} />
61 ) : (
62 <NoSymbol panel={panel} output={technicalsOutput(state, [])} />
63 )}
64 </div>
65 </div>
66 )
67}
68
69function Body({
70 panel,
71 symbol,
72 timeframe,
73 days,
74 indicators,
75}: {
76 panel: PanelRef
77 symbol: string
78 timeframe: Timeframe
79 days: number
80 indicators: Indicator[]
81}): ReactElement {
82 // The from date is fixed for as long as `days` holds, or a new date every render would re-run everything.
83 const from = useMemo(() => fromDate(new Date(), days), [days])
84 const [reports, setReports] = useState<Record<string, RunReport>>({})
85 const ids = indicators.map(indicatorId)
86 const merged = mergeByDate(indicators.map((indicator) => ({ indicator, rows: reports[indicatorId(indicator)]?.rows })))
87 const loading = ids.some((id) => reports[id]?.loading ?? true)
88 const errors = ids.flatMap((id) => (reports[id]?.error ? [`${id}: ${cleanError(reports[id]!.error!)}`] : []))
89 usePublish(panel, technicalsOutput({ symbol, timeframe, days, indicators }, merged))
90 const intraday = timeframe !== '1day'
91 return (
92 <>
93 {indicators.map((indicator) => (
94 <IndicatorRun
95 key={indicatorId(indicator)}
96 id={indicatorId(indicator)}
97 args={{ endpoint: INDICATORS[indicator.kind].endpoint, symbol, periodLength: indicator.period, timeframe, from_date: from }}
98 onReport={(id, report) => setReports((previous) => ({ ...previous, [id]: report }))}
99 />
100 ))}
101 {errors.map((error) => (
102 <p key={error} className="fm-error">
103 {error}
104 </p>
105 ))}
106 {indicators.length === 0 ? (
107 <p className="fm-empty">No indicators in state.</p>
108 ) : (
109 <table className="fm-table">
110 <thead>
111 <tr>
112 <th>{intraday ? 'Bar' : 'Date'}</th>
113 <th className="fm-num">Close</th>
114 {indicators.map((indicator) => (
115 <th key={indicatorId(indicator)} className="fm-num">
116 {indicatorLabel(indicator)}
117 </th>
118 ))}
119 </tr>
120 </thead>
121 <tbody>
122 {merged.map((row) => (
123 <tr key={row.date}>
124 <td>{intraday ? row.date.slice(0, 16) : shortDate(row.date)}</td>
125 <td className="fm-num">{num(row.close)}</td>
126 {ids.map((id) => (
127 <td key={id} className="fm-num">
128 {num(row.values[id])}
129 </td>
130 ))}
131 </tr>
132 ))}
133 </tbody>
134 </table>
135 )}
136 {merged.length === 0 && !loading && errors.length === 0 && indicators.length > 0 && <p className="fm-muted">Nothing returned.</p>}
137 {loading && <p className="fm-muted fm-loading">loading…</p>}
138 </>
139 )
140}
141
142/** One indicator's run. Renders nothing; the parent draws the table once every run has reported. */
143function IndicatorRun({ id, args, onReport }: { id: string; args: Record<string, unknown>; onReport: (id: string, report: RunReport) => void }): null {
144 const run = useData('fmp/technical-indicators', args)
145 useEffect(() => {
146 onReport(id, { rows: run.data, loading: run.loading, error: run.error })
147 // The parent's callback is a new function every render; what matters is the run's result.
148 // eslint-disable-next-line react-hooks/exhaustive-deps
149 }, [id, run.data, run.loading, run.error])
150 return null
151}