Jaspers Hub

biseroff / alpaca

pluginReviewed★ 0↓ 3

Alpaca: Alpaca market data, account, positions, and order management, with watchlist, account, and order-blotter views.

4.2 KB
1import { useData, usePanelState, usePublish, type PanelRef } from '@jaspers-ai/sdk'
2import { useEffect, useState } from 'react'
3import { fmt, int, sign } from './format'
4import './styles.css'
5
6interface Snap {
7 symbol: string
8 last: number | null
9 bid: number | null
10 ask: number | null
11 change: number | null
12 change_pct: number | null
13 day_volume: number | null
14 [field: string]: unknown
15}
16
17interface State {
18 symbols?: string[]
19 feed?: string
20}
21
22const DEFAULT = ['AAPL', 'MSFT', 'NVDA']
23
24export function Watchlist({ panel }: { panel: PanelRef }) {
25 const state = useData(`workspaces/${panel.workspaceId}/panels/${panel.id}/state`) as State | undefined
26 if (state === undefined) return <p className="al-note">Loading…</p>
27 return <List panel={panel} feed={state.feed ?? 'iex'} />
28}
29
30function List({ panel, feed }: { panel: PanelRef; feed: string }) {
31 const [symbols, setSymbols] = usePanelState(panel, 'symbols', DEFAULT) as [string[], (v: string[]) => void]
32 const [draft, setDraft] = useState('')
33 const list = symbols ?? []
34
35 const add = () => {
36 const wanted = draft
37 .split(/[\s,]+/)
38 .map((s) => s.trim().toUpperCase())
39 .filter((s) => s && !list.includes(s))
40 if (wanted.length) setSymbols([...list, ...wanted].slice(0, 100))
41 setDraft('')
42 }
43 const remove = (symbol: string) => setSymbols(list.filter((s) => s !== symbol))
44
45 return (
46 <div className="al">
47 <div className="al-head">
48 <h3 className="al-title">Alpaca watchlist</h3>
49 <span className="al-badge">{feed}</span>
50 <input
51 className="al-input"
52 placeholder="Add symbol"
53 value={draft}
54 onChange={(e) => setDraft(e.target.value)}
55 onKeyDown={(e) => {
56 if (e.key === 'Enter') add()
57 }}
58 />
59 <button className="al-btn" onClick={add}>
60 Add
61 </button>
62 </div>
63 {list.length === 0 ? <Empty panel={panel} /> : <Rows panel={panel} symbols={list} feed={feed} onRemove={remove} />}
64 </div>
65 )
66}
67
68function Empty({ panel }: { panel: PanelRef }) {
69 usePublish(panel, { symbols: [], count: 0, rows: [] })
70 return <p className="al-note">No symbols. Add one above.</p>
71}
72
73function Rows(props: { panel: PanelRef; symbols: string[]; feed: string; onRemove: (s: string) => void }) {
74 const { panel, symbols, feed, onRemove } = props
75 const { data, error, refetch } = useData('alpaca/stock-snapshots', { symbols, feed })
76 // Refresh every 15 seconds while the view is open; each refresh is one quick read.
77 useEffect(() => {
78 const timer = setInterval(() => refetch?.(), 15_000)
79 return () => clearInterval(timer)
80 }, [refetch])
81 const rows = (data ?? []) as Snap[]
82 usePublish(panel, {
83 symbols: rows.map((r) => r.symbol),
84 count: rows.length,
85 rows: rows.slice(0, 50).map((r) => ({ symbol: r.symbol, last: r.last, change_pct: r.change_pct })),
86 })
87
88 if (error) return <p className="al-note al-error">{error}</p>
89 if (!data) return <p className="al-note">Loading…</p>
90 const bySymbol = new Map(rows.map((r) => [r.symbol, r]))
91 return (
92 <table className="al-table">
93 <thead>
94 <tr>
95 <th>Symbol</th>
96 <th className="al-num">Last</th>
97 <th className="al-num">Bid</th>
98 <th className="al-num">Ask</th>
99 <th className="al-num">Change</th>
100 <th className="al-num">Change %</th>
101 <th className="al-num">Volume</th>
102 <th />
103 </tr>
104 </thead>
105 <tbody>
106 {symbols.map((symbol) => {
107 const r = bySymbol.get(symbol)
108 return (
109 <tr key={symbol}>
110 <td>{symbol}</td>
111 <td className="al-num">{fmt(r?.last)}</td>
112 <td className="al-num">{fmt(r?.bid)}</td>
113 <td className="al-num">{fmt(r?.ask)}</td>
114 <td className={`al-num ${sign(r?.change)}`}>{fmt(r?.change)}</td>
115 <td className={`al-num ${sign(r?.change_pct)}`}>{r ? `${fmt(r.change_pct)}%` : '–'}</td>
116 <td className="al-num">{int(r?.day_volume)}</td>
117 <td>
118 <button className="al-x" title={`Remove ${symbol}`} onClick={() => onRemove(symbol)}>
119 ×
120 </button>
121 </td>
122 </tr>
123 )
124 })}
125 </tbody>
126 </table>
127 )
128}