Repository Public
1/**2 * Bottom of the terminal for a VENUE account. Positions and resting orders are the venue's own (`venueState`, source: venue);3 * the history is the wallet's record of every proposal and what became of it; fills and funding were read from the venue by4 * the reconciliation worker. Closing a position is a proposal approved with the passkey; cancelling an order needs none.5 */6import { useQueryClient } from '@tanstack/react-query';7import { useContext, useState } from 'react';8import { PriceChart } from '../chart';9import { I18n, useT, type Key } from '../i18n';10import { Icon, Notice, Stat as Tile, StatGrid, Tag } from '../ui';11import { RISK_CODES } from './api';12import { DataTable, Pnl, Seg, SideTag, timeOf, type Col } from './bits';13import { D, dec, dpOf, fmt, pct, priceDp, usd } from './dec';14import { ApprovalSheet } from './LiveApproval';15import { LiveErrNotice, LiveStatePill, ModeTag, SourceTag } from './LiveBits';16import { inFlight, lapi, liveError, toneOf, type LiveDetail, type LiveErr, type LiveFunding, type LiveOrder, type LiveRiskEvent, type LiveSummary } from './live';17import { refreshLive } from './liveStore';18import type { BlotterTab } from './Blotter';19import type { Fill, Instrument, Order, Position } from './types';2021const sym = (id: string) => id.slice(id.indexOf(':') + 1);22const mini = 'press inline-flex min-h-11 min-w-11 shrink-0 whitespace-nowrap items-center justify-center rounded-xl border border-line px-3 text-[13px] font-semibold text-ink hover:bg-elev disabled:opacity-45';23/** a resting order as the venue lists it, with the wallet's own record of it when the wallet placed it */24interface Resting { o: Order; ours: LiveOrder | null }2526export function LiveBlotter({ account, detail, instruments, ttlMs, onSelect }: { account: LiveSummary; detail: LiveDetail | undefined; instruments: Map<string, Instrument>; ttlMs: number; onSelect: (id: string) => void }) {27 const t = useT(); const { locale } = useContext(I18n); const qc = useQueryClient();28 const [tab, setTab] = useState<BlotterTab>('positions');29 const [busy, setBusy] = useState(''), [err, setErr] = useState<LiveErr | null>(null), [note, setNote] = useState('');30 const [closing, setClosing] = useState<{ position: Position; fraction: string } | null>(null);31 const a = detail ?? account, tone = toneOf(account), st = detail?.venueState;32 const positions = st?.positions ?? [], orders = detail?.orders ?? [];33 const resting: Resting[] = (st?.openOrders ?? []).map((o) => ({ o, ours: orders.find((x) => x.cloid !== null && x.cloid === o.clientId) ?? null }));34 const flying = orders.filter((o) => inFlight(o.state) && !o.final);35 const px = (id: string, v: string | null | undefined) => (v ? fmt(v, { dp: priceDp(instruments.get(id)?.tickSize, v), locale }) : '—');36 const sz = (id: string, v: string) => fmt(v, { dp: Math.min(8, dpOf(instruments.get(id)?.lotSize ?? '0.0001')), locale, trim: true });37 const market = (id: string) => <button type="button" onClick={() => onSelect(id)} className="-my-2 inline-flex min-h-11 items-center font-semibold underline decoration-line underline-offset-4 hover:decoration-ink" aria-label={t('fxOpenMarket', { symbol: sym(id) })}>{sym(id)}</button>;38 const off = account.network === 'mainnet' && !account.mainnetEnabled, canAct = a.mode === 'MANUAL_LIVE' && !off;39 const cancel = async (o: LiveOrder) => {40 setBusy(`x${o.clientId}`); setErr(null); setNote('');41 try { const r = await lapi.cancel(account.id, o.clientId); if (r.venue && r.venue.outcome !== 'accepted') setNote(`${t(r.venue.outcome === 'refused' ? 'flCancelRefused' : 'flCancelUnknown')}${r.venue.error ? ` — ${r.venue.error}` : ''}`); }42 catch (e) { setErr(liveError(e)); } finally { setBusy(''); void refreshLive(qc, account.id); }43 };4445 const posCols: Col<Position>[] = [46 { id: 'm', label: t('fxMarket'), head: true, cell: (p) => market(p.instrument) },47 { id: 'side', label: t('fxDirection'), head: true, cell: (p) => <SideTag side={p.side} /> },48 { id: 'close', label: t('flClosePos'), full: true, cell: (p) => <span className="inline-flex gap-1.5" role="group" aria-label={t('fxCloseAria', { symbol: sym(p.instrument) })}>49 {(['0.25', '0.5', '1'] as const).map((fr) => <button key={fr} type="button" disabled={!canAct || !!closing} className={mini} onClick={() => { setErr(null); setNote(''); setClosing({ position: p, fraction: fr }); }}50 aria-label={t('fxCloseFrac', { pct: fr === '1' ? 100 : fr === '0.5' ? 50 : 25, symbol: sym(p.instrument) })}>{fr === '1' ? '100' : fr === '0.5' ? '50' : '25'} %</button>)}</span> },51 { id: 'size', label: t('fxSize'), num: true, cell: (p) => <>{sz(p.instrument, p.size)} <span className="text-mute">≈ {usd(D.mul(dec(p.size) ?? D.zero, dec(p.markPrice) ?? D.zero), locale)}</span></> },52 { id: 'entry', label: t('fxEntry'), num: true, cell: (p) => px(p.instrument, p.entryPrice) },53 { id: 'mark', label: t('fxMark'), num: true, cell: (p) => px(p.instrument, p.markPrice) },54 { id: 'liq', label: t('flLiqVenue'), num: true, cell: (p) => px(p.instrument, p.liquidationPrice) },55 { id: 'lev', label: t('fxLeverage'), num: true, cell: (p) => `${fmt(p.leverage, { dp: 1, locale, trim: true })}× · ${t(p.marginMode === 'cross' ? 'fxCross' : 'fxIsolated')}` },56 { id: 'margin', label: t('fxMargin'), num: true, cell: (p) => usd(p.margin, locale) },57 { id: 'upnl', label: t('fxUnrealized'), num: true, cell: (p) => <b className="font-semibold"><Pnl value={p.unrealizedPnl} locale={locale} /></b> },58 { id: 'fund', label: t('fxFundingPaid'), num: true, cell: (p) => (p.fundingPaid === null ? '—' : usd(p.fundingPaid, locale, true)) },59 ];60 const typeCell = (o: { type: string; reduceOnly: boolean; tif: string }) => <>{t(`fxType_${o.type}` as Key)}{o.reduceOnly ? <span className="text-mute"> · {t('fxReduceOnly')}</span> : null}{o.tif === 'alo' ? <span className="text-mute"> · {t('fxPostOnly')}</span> : o.tif === 'ioc' ? <span className="text-mute"> · IOC</span> : null}</>;61 const restCols: Col<Resting>[] = [62 { id: 'm', label: t('fxMarket'), head: true, cell: ({ o }) => market(o.instrument) },63 { id: 'side', label: t('fxSideCol'), head: true, cell: ({ o }) => <SideTag side={o.side} /> },64 { id: 'type', label: t('fxOrderType'), cell: ({ o }) => typeCell(o) },65 { id: 'size', label: t('fxSize'), num: true, cell: ({ o }) => sz(o.instrument, o.size) },66 { id: 'filled', label: t('fxFilled'), num: true, cell: ({ o }) => sz(o.instrument, o.filled) },67 { id: 'price', label: t('fxPrice'), num: true, cell: ({ o }) => px(o.instrument, o.price) },68 { id: 'trig', label: t('fxTriggerPrice'), num: true, cell: ({ o }) => px(o.instrument, o.triggerPrice) },69 { id: 'st', label: t('fxStatus'), cell: ({ ours }) => (ours ? <LiveStatePill state={ours.state} /> : <Tag tone="danger" icon="warn">{t('flForeign')}</Tag>) },70 { id: 'at', label: t('fxTime'), cell: ({ o }) => <span className="text-mute">{timeOf(o.createdAt, locale, true)}</span> },71 { id: 'x', label: '', cell: ({ o, ours }) => (ours ? <button type="button" disabled={!!busy || !canAct} className={mini} onClick={() => void cancel(ours)} aria-label={t('fxCancelAria', { symbol: sym(o.instrument) })}>{t('fxCancelOrder')}</button>72 : <span className="block max-w-[260px] whitespace-normal text-[11.5px] leading-snug text-mute">{t('flForeignHint')}</span>) },73 ];74 const histCols: Col<LiveOrder>[] = [75 { id: 'm', label: t('fxMarket'), head: true, cell: (o) => market(o.instrument) },76 { id: 'side', label: t('fxSideCol'), head: true, cell: (o) => <SideTag side={o.side} /> },77 { id: 'st', label: t('fxStatus'), head: true, cell: (o) => <LiveStatePill state={o.state} /> },78 { id: 'type', label: t('fxOrderType'), cell: (o) => <>{typeCell(o)}{o.origin === 'close' ? <span className="text-mute"> · {t('flOriginClose')}</span> : null}</> },79 { id: 'size', label: t('fxSize'), num: true, cell: (o) => sz(o.instrument, o.size) },80 { id: 'filled', label: t('fxFilled'), num: true, cell: (o) => sz(o.instrument, o.filled) },81 { id: 'price', label: t('fxPrice'), num: true, cell: (o) => px(o.instrument, o.avgFillPrice ?? o.price) },82 { id: 'src', label: t('flSource'), cell: (o) => <SourceTag source={o.source} /> },83 { id: 'at', label: t('fxTime'), cell: (o) => <span className="text-mute">{timeOf(o.createdAt, locale, true)}</span> },84 { id: 'why', label: t('fxReason'), full: true, cell: (o) => <span className="block max-w-[420px] whitespace-normal break-words text-[12px] text-mute">{inFlight(o.state) && !o.final ? `${t('flUnknownShort')} ` : ''}{o.reason ?? '—'}</span> },85 ];86 const fillCols: Col<Fill>[] = [87 { id: 'm', label: t('fxMarket'), head: true, cell: (f) => market(f.instrument) },88 { id: 'side', label: t('fxSideCol'), head: true, cell: (f) => <><SideTag side={f.side} />{f.liquidation && <span className="ml-2"><Tag tone="danger" icon="warn">{t('fxLiquidation')}</Tag></span>}</> },89 { id: 'price', label: t('fxPrice'), num: true, cell: (f) => px(f.instrument, f.price) },90 { id: 'size', label: t('fxSize'), num: true, cell: (f) => sz(f.instrument, f.size) },91 { id: 'fee', label: t('fxFee'), num: true, cell: (f) => `${fmt(f.fee, { dp: 4, locale })} ${f.feeAsset}` },92 { id: 'pnl', label: t('fxClosedPnl'), num: true, cell: (f) => <Pnl value={f.closedPnl} locale={locale} /> },93 { id: 'at', label: t('fxTime'), cell: (f) => <span className="text-mute">{timeOf(f.ts, locale, true)}</span> },94 ];95 const fundCols: Col<LiveFunding>[] = [96 { id: 'm', label: t('fxMarket'), head: true, cell: (f) => market(f.instrument) },97 { id: 'amt', label: t('fxAmount'), num: true, head: true, cell: (f) => <Pnl value={f.amount} locale={locale} dp={4} /> },98 { id: 'rate', label: t('fxRate'), num: true, cell: (f) => pct(f.rate, 5, locale, true) },99 { id: 'size', label: t('fxSize'), num: true, cell: (f) => sz(f.instrument, f.size) },100 { id: 'at', label: t('fxTime'), cell: (f) => <span className="text-mute">{timeOf(f.ts, locale, true)}</span> },101 ];102 const evCols: Col<LiveRiskEvent>[] = [103 { id: 'k', label: t('fxEvent'), head: true, cell: (e) => { const k = t(`flEv_${e.kind}` as Key); return k.startsWith('flEv ') || k.startsWith('lEv') ? e.kind : k; } },104 { id: 'd', label: t('fxDetail'), cell: (e) => <span className="block max-w-[560px] whitespace-normal">{e.code && RISK_CODES.has(e.code) ? <span className="block">{t(`rk_${e.code}` as Key)}</span> : null}<span className="block break-words text-[12px] text-mute">{e.code ? `${e.code} — ` : ''}{e.message}</span></span> },105 { id: 'at', label: t('fxTime'), cell: (e) => <span className="text-mute">{timeOf(e.ts, locale, true)}</span> },106 ];107 const eq = detail?.equity ?? [], state = st?.state ?? null;108 const unreal = positions.reduce((x, p) => D.add(x, dec(p.unrealizedPnl) ?? D.zero), D.zero);109 const n = (k: number) => (k ? ` (${k})` : '');110 return (111 <div data-testid="blotter" data-account-kind="venue">112 <div className="mb-3 flex items-center gap-2">113 <Seg tabs size="sm" label={t('fxBlotter')} value={tab} onChange={(v) => { setTab(v); setErr(null); setNote(''); }} className="min-w-0 flex-1" options={[114 { id: 'positions', label: `${t('fxPositions')}${n(positions.length)}` }, { id: 'orders', label: `${t('fxOpenOrders')}${n(resting.length)}` }, { id: 'history', label: t('fxOrderHistory') },115 { id: 'fills', label: t('fxFills') }, { id: 'funding', label: t('fxFunding') }, { id: 'pnl', label: t('fxPnl') }, { id: 'risk', label: `${t('fxRiskEvents')}${n(detail?.riskEvents.length ?? 0)}` }]} />116 <ModeTag tone={tone} className="hidden sm:inline-flex" />117 </div>118 {st?.error && <div className="mb-3"><Notice tone="danger">{t('flUnreadable')} <span className="mt-1 block break-words font-mono text-[11.5px] text-mute">{st.error}</span></Notice></div>}119 {flying.length > 0 && <div className="mb-3"><Notice tone="warn"><div data-testid="in-flight">{t('flInFlight', { n: flying.length })}</div></Notice></div>}120 {err && <div className="mb-3"><LiveErrNotice err={err} /></div>}121 {note && <div className="mb-3"><Notice tone="warn">{note}</Notice></div>}122 <div role="tabpanel" aria-label={t('fxBlotter')}>123 {(tab === 'positions' || tab === 'orders') && !st?.error && <p className="mb-2.5 flex flex-wrap items-center gap-1.5 text-[12px] text-mute"><SourceTag />{st ? t('flReadAt', { time: timeOf(st.ts, locale) }) : t('fxLoading')}</p>}124 {tab === 'positions' && !st?.error && <DataTable label={t('fxPositions')} cols={posCols} rows={positions} rowKey={(p) => p.instrument} empty={t('fxNoPositions')} />}125 {tab === 'orders' && !st?.error && <DataTable label={t('fxOpenOrders')} cols={restCols} rows={resting} rowKey={({ o }) => o.venueOrderId ?? o.clientId} empty={t('fxNoOpenOrders')} />}126 {tab === 'history' && <DataTable label={t('fxOrderHistory')} cols={histCols} rows={orders} rowKey={(o) => o.clientId} empty={t('fxNoHistory')} />}127 {tab === 'fills' && <DataTable label={t('fxFills')} cols={fillCols} rows={detail?.fills ?? []} rowKey={(f) => f.id} empty={t('fxNoFills')} />}128 {tab === 'funding' && <DataTable label={t('fxFunding')} cols={fundCols} rows={detail?.funding ?? []} rowKey={(f) => `${f.instrument}${f.ts}`} empty={t('fxNoFunding')} />}129 {tab === 'risk' && <DataTable label={t('fxRiskEvents')} cols={evCols} rows={detail?.riskEvents ?? []} rowKey={(e) => e.id} empty={t('fxNoRiskEvents')} />}130 {tab === 'pnl' && <div className="space-y-4">131 <StatGrid cols={4}>132 <Tile label={t('fxEquity')} value={usd(state?.equity, locale)} /><Tile label={t('fxCollateral')} value={usd(state?.collateral, locale)} />133 <Tile label={t('fxUnrealized')} value={<Pnl value={D.str(unreal)} locale={locale} />} /><Tile label={t('fxWithdrawable')} value={usd(state?.withdrawable, locale)} /></StatGrid>134 {eq.length >= 2 ? <PriceChart points={eq.map((p) => ({ t: p.ts, p: Number(p.equity) }))} format={(v) => usd(v.toFixed(2), locale)} formatTime={(ts) => timeOf(ts, locale, true)} />135 : <div className="flex items-center gap-3 rounded-[18px] bg-elev/50 px-4 py-4 text-[13.5px] text-mute"><span className="grid h-9 w-9 shrink-0 place-items-center rounded-full bg-surface"><Icon n="chart" className="h-[18px] w-[18px]" /></span><span className="min-w-0">{t('fxNoEquity')}</span></div>}136 <p className="flex items-start gap-1.5 text-[12px] leading-snug text-mute"><SourceTag /><span className="min-w-0">{t('flPnlNote')}</span></p>137 </div>}138 </div>139 {closing && <ApprovalSheet account={a} instrument={instruments.get(closing.position.instrument)} request={{ kind: 'close', position: closing.position, fraction: closing.fraction }} orders={orders} ttlMs={ttlMs} onClose={() => setClosing(null)} />}140 </div>141 );142}143