Jaspers Hub

jaspers / alpaca

pluginOfficialReviewed★ 0↓ 0

Alpaca: Alpaca brokerage plugin: stock/option market data, account, positions, orders, and order placement/cancellation, with watchlist, account, and blotter views.

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