Alpaca: Alpaca market data, account, positions, and order management, with watchlist, account, and order-blotter views.
| 1 | import { useData, usePublish, type PanelRef } from '@jaspers-ai/sdk' |
| 2 | import { useEffect, useState } from 'react' |
| 3 | import { fmt } from './format' |
| 4 | import './styles.css' |
| 5 | |
| 6 | interface 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 | |
| 23 | interface State { |
| 24 | status?: 'open' | 'closed' | 'all' |
| 25 | limit?: number |
| 26 | live_account?: boolean |
| 27 | } |
| 28 | |
| 29 | const OPEN = new Set(['new', 'accepted', 'pending_new', 'partially_filled', 'accepted_for_bidding', 'held', 'calculated', 'pending_replace']) |
| 30 | |
| 31 | function 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 | |
| 38 | export 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 | |
| 46 | function 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. */ |
| 148 | function 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 | } |