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 | import { useEffect, useMemo, useState, type ReactElement } from 'react' |
| 2 | import { useData, usePanelState, usePublish, type PanelRef } from '@jaspers-ai/sdk' |
| 3 | import { |
| 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' |
| 19 | import { cleanError, Loading, NoSymbol, SymbolField, useInitialState } from './shared' |
| 20 | import './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 | |
| 26 | interface RunReport { |
| 27 | rows: Row[] | undefined |
| 28 | loading: boolean |
| 29 | error: string | null |
| 30 | } |
| 31 | |
| 32 | export 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 | |
| 38 | function 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 | |
| 69 | function 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. */ |
| 143 | function 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 | } |