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.

6.1 KB
1import { useData, usePublish, type PanelRef } from '@jaspers-ai/sdk'
2import { useEffect, useState } from 'react'
3import { fmt } from './format'
4import { LiveOrderGate } from './LiveOrderGate'
5import './styles.css'
6
7interface Order {
8 id: string
9 symbol: string | null
10 side: string | null
11 type: string | null
12 time_in_force: string | null
13 qty: number | null
14 notional: number | null
15 filled_qty: number | null
16 limit_price: number | null
17 stop_price: number | null
18 filled_avg_price: number | null
19 status: string | null
20 submitted_at: string | null
21 parent_id: string | null
22 [field: string]: unknown
23}
24
25interface State {
26 status?: 'open' | 'closed' | 'all'
27 limit?: number
28 live_account?: boolean
29}
30
31const OPEN = new Set(['new', 'accepted', 'pending_new', 'partially_filled', 'accepted_for_bidding', 'held', 'calculated', 'pending_replace'])
32
33function statusClass(status: string | null): string {
34 if (status === 'filled') return 'al-status-filled'
35 if (status && OPEN.has(status)) return 'al-status-open'
36 if (status === 'rejected' || status === 'suspended') return 'al-status-bad'
37 return 'al-status-dead'
38}
39
40export function Blotter({ panel }: { panel: PanelRef }) {
41 const state = useData(`workspaces/${panel.workspaceId}/panels/${panel.id}/state`) as State | undefined
42 if (state === undefined) return <p className="al-note">Loading…</p>
43 return (
44 <Table panel={panel} status={state.status ?? 'all'} limit={state.limit ?? 50} live={state.live_account === true} />
45 )
46}
47
48function Table({ panel, status, limit, live }: { panel: PanelRef; status: string; limit: number; live: boolean }) {
49 const { data, error, refetch } = useData('alpaca/orders', { status, limit, live_account: live })
50 const rows = (data ?? []) as Order[]
51 // Per order: 'confirm' after the first click, 'cancelling' after the second, or a result message.
52 const [phase, setPhase] = useState<Record<string, string>>({})
53 const set = (id: string, value: string | null) =>
54 setPhase((p) => {
55 const next = { ...p }
56 if (value === null) delete next[id]
57 else next[id] = value
58 return next
59 })
60 usePublish(panel, { status, count: rows.length, open: rows.filter((r) => r.status && OPEN.has(r.status)).length })
61
62 return (
63 <div className="al">
64 <LiveOrderGate />
65 <div className="al-head">
66 <h3 className="al-title">Alpaca orders · {status}</h3>
67 <span className={live ? 'al-badge al-badge-live' : 'al-badge'}>{live ? 'LIVE' : 'paper'}</span>
68 <button className="al-btn" onClick={() => refetch?.()}>
69 Refresh
70 </button>
71 </div>
72 {error ? (
73 <p className="al-note al-error">{error}</p>
74 ) : !data ? (
75 <p className="al-note">Loading…</p>
76 ) : rows.length === 0 ? (
77 <p className="al-note">No {status === 'all' ? '' : `${status} `}orders.</p>
78 ) : (
79 <table className="al-table">
80 <thead>
81 <tr>
82 <th>Submitted</th>
83 <th>Symbol</th>
84 <th>Side</th>
85 <th>Type</th>
86 <th className="al-num">Qty</th>
87 <th className="al-num">Filled</th>
88 <th className="al-num">Limit</th>
89 <th className="al-num">Stop</th>
90 <th className="al-num">Avg fill</th>
91 <th>TIF</th>
92 <th>Status</th>
93 <th />
94 </tr>
95 </thead>
96 <tbody>
97 {rows.map((o) => {
98 const p = phase[o.id]
99 const cancellable = o.status !== null && OPEN.has(o.status)
100 return (
101 <tr key={o.id}>
102 <td className="al-muted">{o.submitted_at?.replace('T', ' ').slice(0, 19) ?? '–'}</td>
103 <td>{o.parent_id ? `↳ ${o.symbol}` : o.symbol}</td>
104 <td className={o.side === 'buy' ? 'al-up' : 'al-down'}>{o.side}</td>
105 <td>{o.type}</td>
106 <td className="al-num">{o.qty !== null ? fmt(o.qty, 0) : o.notional !== null ? `$${fmt(o.notional)}` : '–'}</td>
107 <td className="al-num">{fmt(o.filled_qty, 0)}</td>
108 <td className="al-num">{fmt(o.limit_price)}</td>
109 <td className="al-num">{fmt(o.stop_price)}</td>
110 <td className="al-num">{fmt(o.filled_avg_price)}</td>
111 <td>{o.time_in_force}</td>
112 <td className={statusClass(o.status)}>{o.status}</td>
113 <td>
114 {!cancellable && !p ? null : p === undefined ? (
115 <button className="al-btn" onClick={() => set(o.id, 'confirm')}>
116 Cancel
117 </button>
118 ) : p === 'confirm' ? (
119 <>
120 <button className="al-btn al-btn-danger" onClick={() => set(o.id, 'cancelling')}>
121 Confirm cancel{live ? ' (LIVE)' : ''}
122 </button>{' '}
123 <button className="al-btn" onClick={() => set(o.id, null)}>
124 Keep
125 </button>
126 </>
127 ) : p === 'cancelling' ? (
128 <Canceller
129 id={o.id}
130 live={live}
131 onDone={(message) => {
132 set(o.id, message)
133 refetch?.()
134 }}
135 />
136 ) : (
137 <span className="al-muted">{p}</span>
138 )}
139 </td>
140 </tr>
141 )
142 })}
143 </tbody>
144 </table>
145 )}
146 </div>
147 )
148}
149
150/** Mounted only after the user clicked Cancel and then Confirm: this is the one place cancel-order runs. */
151function Canceller({ id, live, onDone }: { id: string; live: boolean; onDone: (message: string) => void }) {
152 const { data, error } = useData('alpaca/cancel-order', { order_id: id, live_account: live })
153 useEffect(() => {
154 if (error) onDone(`Not cancelled: ${error}`)
155 else if (data) onDone('Cancel requested')
156 // eslint-disable-next-line react-hooks/exhaustive-deps
157 }, [data, error])
158 return <span className="al-muted">Cancelling…</span>
159}