yfinance: Free Yahoo Finance market data (quotes, history, fundamentals, options, news, movers) via a bundled MCP server, with five views.
| 1 | import { useEffect, useState, type ReactElement, type ReactNode } from 'react' |
| 2 | import { useBridge, useData, usePublish, type PanelRef } from '@jaspers-ai/sdk' |
| 3 | import { display as d } from './format' |
| 4 | import { cleanError, position, tone, type Run } from './views' |
| 5 | |
| 6 | // What the five views share: the panel's state as one value, writes to it, the bar and its symbol |
| 7 | // field, a section that shows its run's state before its content, figures with their sign, a range |
| 8 | // bar, and the minute timer the quote views ask again on. |
| 9 | |
| 10 | /** The panel's whole state, a round trip after the frame mounts; undefined until then. */ |
| 11 | export function usePanelStateValue(panel: PanelRef): unknown { |
| 12 | return useData(`workspaces/${panel.workspaceId}/panels/${panel.id}/state`) |
| 13 | } |
| 14 | |
| 15 | /** Writes keys of the panel's state in order; main checks each write against the view's schema. */ |
| 16 | export function useStateWriter(panel: PanelRef): (changes: [string, unknown][]) => void { |
| 17 | const bridge = useBridge() |
| 18 | return (changes) => { |
| 19 | void (async () => { |
| 20 | for (const [key, value] of changes) await bridge.setState(panel, [key], value) |
| 21 | })() |
| 22 | } |
| 23 | } |
| 24 | |
| 25 | /** Runs `refetch` every `ms` while the view is mounted. */ |
| 26 | export function useEvery(refetch: () => void, ms: number): void { |
| 27 | useEffect(() => { |
| 28 | const timer = setInterval(refetch, ms) |
| 29 | return () => clearInterval(timer) |
| 30 | }, [refetch, ms]) |
| 31 | } |
| 32 | |
| 33 | export function Loading(): ReactElement { |
| 34 | return ( |
| 35 | <div className="yf-root"> |
| 36 | <div className="yf-bar"> |
| 37 | <span className="yf-muted">Loading…</span> |
| 38 | </div> |
| 39 | </div> |
| 40 | ) |
| 41 | } |
| 42 | |
| 43 | export function Bar({ children }: { children: ReactNode }): ReactElement { |
| 44 | return <div className="yf-bar">{children}</div> |
| 45 | } |
| 46 | |
| 47 | /** |
| 48 | * The symbol field. Enter hands the typed symbol over; a symbol the orchestrator sets shows here. |
| 49 | * Enter is read off the key: the frame is sandboxed without allow-forms, so a form never submits. |
| 50 | */ |
| 51 | export function SymbolField({ |
| 52 | symbol, |
| 53 | onSymbol, |
| 54 | placeholder = 'Symbol', |
| 55 | clear = false, |
| 56 | }: { |
| 57 | symbol: string |
| 58 | onSymbol: (symbol: string) => void |
| 59 | placeholder?: string |
| 60 | /** Empty the field after Enter, for a field that adds rather than sets. */ |
| 61 | clear?: boolean |
| 62 | }): ReactElement { |
| 63 | const [draft, setDraft] = useState(symbol) |
| 64 | useEffect(() => setDraft(symbol), [symbol]) |
| 65 | return ( |
| 66 | <input |
| 67 | className="yf-input yf-symbol" |
| 68 | name="symbol" |
| 69 | value={draft} |
| 70 | placeholder={placeholder} |
| 71 | spellCheck={false} |
| 72 | autoComplete="off" |
| 73 | onChange={(event) => setDraft(event.target.value)} |
| 74 | onKeyDown={(event) => { |
| 75 | if (event.key !== 'Enter' || event.nativeEvent.isComposing) return |
| 76 | event.preventDefault() |
| 77 | const next = draft.trim().toUpperCase() |
| 78 | if (next) onSymbol(next) |
| 79 | if (clear) setDraft('') |
| 80 | }} |
| 81 | /> |
| 82 | ) |
| 83 | } |
| 84 | |
| 85 | /** A view with nothing to show yet says so, and publishes that. */ |
| 86 | export function Empty({ panel, output, children }: { panel: PanelRef; output: Record<string, unknown>; children: ReactNode }): ReactElement { |
| 87 | usePublish(panel, output) |
| 88 | return <p className="yf-empty">{children}</p> |
| 89 | } |
| 90 | |
| 91 | /** A titled block: its run's error (Yahoo's own words) or loading line first, then its content. */ |
| 92 | export function Section({ |
| 93 | title, |
| 94 | run, |
| 95 | aside, |
| 96 | empty, |
| 97 | children, |
| 98 | }: { |
| 99 | title: string |
| 100 | run?: Pick<Run, 'loading' | 'error'> |
| 101 | aside?: ReactNode |
| 102 | empty?: boolean |
| 103 | children?: ReactNode |
| 104 | }): ReactElement { |
| 105 | return ( |
| 106 | <section className="yf-section"> |
| 107 | <h2 className="yf-h"> |
| 108 | <span>{title}</span> |
| 109 | {aside && <span className="yf-muted yf-aside">{aside}</span>} |
| 110 | {run?.loading && <span className="yf-muted yf-aside">loading…</span>} |
| 111 | </h2> |
| 112 | {run?.error ? <p className="yf-error">{cleanError(run.error)}</p> : empty && !run?.loading ? <p className="yf-muted">Nothing.</p> : children} |
| 113 | </section> |
| 114 | ) |
| 115 | } |
| 116 | |
| 117 | /** A signed figure, green up and red down. */ |
| 118 | export function Signed({ value, percent = false, hint }: { value: number | null | undefined; percent?: boolean; hint?: number | null }): ReactElement { |
| 119 | const shown = percent ? d.signedPercent(value) : d.signed(value, typeof hint === 'number' ? hint : 2) |
| 120 | return <span className={`yf-num yf-${tone(value) || 'flat'}`}>{shown}</span> |
| 121 | } |
| 122 | |
| 123 | /** A track from low to high with a mark where the value is, and the ends written under it. */ |
| 124 | export function RangeBar({ |
| 125 | label, |
| 126 | low, |
| 127 | high, |
| 128 | value, |
| 129 | mark, |
| 130 | hint, |
| 131 | }: { |
| 132 | label: string |
| 133 | low: number | null |
| 134 | high: number | null |
| 135 | value: number | null |
| 136 | /** A second, hollow mark, like the mean target. */ |
| 137 | mark?: { value: number | null; title: string } |
| 138 | hint?: number | null |
| 139 | }): ReactElement | null { |
| 140 | const at = position(low, high, value) |
| 141 | if (at === null) return null |
| 142 | const second = mark ? position(low, high, mark.value) : null |
| 143 | return ( |
| 144 | <div className="yf-range"> |
| 145 | <div className="yf-range-label yf-muted">{label}</div> |
| 146 | <div className="yf-track"> |
| 147 | {second !== null && <span className="yf-mark yf-mark-hollow" style={{ left: `${second * 100}%` }} title={mark!.title} />} |
| 148 | <span className="yf-mark" style={{ left: `${at * 100}%` }} title={d.price(value, hint)} /> |
| 149 | </div> |
| 150 | <div className="yf-range-ends yf-num yf-muted"> |
| 151 | <span>{d.price(low, hint)}</span> |
| 152 | <span>{d.price(high, hint)}</span> |
| 153 | </div> |
| 154 | </div> |
| 155 | ) |
| 156 | } |
| 157 | |
| 158 | /** Market state in words. */ |
| 159 | export function marketState(state: string | null): string { |
| 160 | switch (state) { |
| 161 | case 'PRE': |
| 162 | return 'Pre-market' |
| 163 | case 'REGULAR': |
| 164 | return 'Market open' |
| 165 | case 'POST': |
| 166 | return 'After hours' |
| 167 | case null: |
| 168 | return '' |
| 169 | default: |
| 170 | return 'Market closed' |
| 171 | } |
| 172 | } |