Jaspers Hub

biseroff / alpaca

pluginReviewed★ 0↓ 3

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

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