Jaspers Hub

jaspers / alpaca

pluginOfficialReviewed★ 0↓ 0

Alpaca: Connects to Alpaca for stock/options market data, account and position info, and placing or cancelling real orders.

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