SPB Git

Repository Public

Blotter.tsx 12.2 KB 124 lines tsx

1/**2 * Bottom of the terminal: positions (with partial closes), open orders (cancel), order history, fills, funding, PnL (equity3 * curve) and risk events of the selected PAPER account. Values come from the account routes; nothing is recomputed locally4 * except the notional shown beside a size.5 */6import { useQueryClient } from '@tanstack/react-query';7import { useContext, useState } from 'react';8import { PriceChart } from '../chart';9import { errText, I18n, useT, type Key } from '../i18n';10import { Notice } from '../ui';11import { dapi, RISK_CODES, riskRefusal } from './api';12import { DataTable, PaperTag, Pnl, Seg, SideTag, Stat, timeOf, type Col } from './bits';13import { D, dec, dpOf, fmt, pct, priceDp, usd } from './dec';14import { RiskVerdict } from './OrderPanel';15import type { AccountDetail, DerivAccount, Fill, FundingPayment, Instrument, Order, Position, RiskEvent } from './types';1617export type BlotterTab = 'positions' | 'orders' | 'history' | 'fills' | 'funding' | 'pnl' | 'risk';18const sym = (id: string) => id.slice(id.indexOf(':') + 1);19const mini = 'inline-flex min-h-11 min-w-11 shrink-0 whitespace-nowrap items-center justify-center rounded-xl border border-line px-2.5 text-[12.5px] font-semibold text-ink transition hover:bg-elev disabled:opacity-45';2021export function Blotter({ account, detail, instruments, onSelect }: { account: DerivAccount | null; detail: AccountDetail | undefined; instruments: Map<string, Instrument>; onSelect: (id: string) => void }) {22  const t = useT(); const { locale } = useContext(I18n); const qc = useQueryClient();23  const [tab, setTab] = useState<BlotterTab>('positions');24  const [busy, setBusy] = useState(''), [err, setErr] = useState<{ code?: string; reason?: string; text?: string } | null>(null);25  const positions = detail?.positions ?? account?.positions ?? [], open = detail?.openOrders ?? account?.openOrders ?? [];26  const px = (id: string, v: string | null | undefined) => (v ? fmt(v, { dp: priceDp(instruments.get(id)?.tickSize, v), locale }) : '—');27  const sz = (id: string, v: string) => fmt(v, { dp: Math.min(8, dpOf(instruments.get(id)?.lotSize ?? '0.0001')), locale, trim: true });28  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>;29  const run = async (id: string, f: () => Promise<unknown>) => {30    setBusy(id); setErr(null);31    try { await f(); } catch (e) { const r = riskRefusal(e); setErr(r ?? { text: errText(t, e) || t('fxErrGeneric') }); }32    finally { setBusy(''); void qc.invalidateQueries({ queryKey: ['deriv', 'accounts'] }); void qc.invalidateQueries({ queryKey: ['deriv', 'account'] }); }33  };34  if (!account) return <p className="py-8 text-center text-[13.5px] text-mute">{t('fxBlotterNoAccount')}</p>;3536  const posCols: Col<Position>[] = [37    { id: 'm', label: t('fxMarket'), head: true, cell: (p) => market(p.instrument) },38    { id: 'side', label: t('fxDirection'), head: true, cell: (p) => <SideTag side={p.side} /> },39    { id: 'close', label: t('fxClosePos'), full: true, cell: (p) => <span className="inline-flex gap-1.5" role="group" aria-label={t('fxCloseAria', { symbol: sym(p.instrument) })}>40      {(['0.25', '0.5', '1'] as const).map((fr) => <button key={fr} type="button" disabled={!!busy} className={mini} onClick={() => void run(`c${p.instrument}${fr}`, () => dapi.close(account.id, p.instrument, fr, crypto.randomUUID()))}41        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> },42    { 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></> },43    { id: 'entry', label: t('fxEntry'), num: true, cell: (p) => px(p.instrument, p.entryPrice) },44    { id: 'mark', label: t('fxMark'), num: true, cell: (p) => px(p.instrument, p.markPrice) },45    { id: 'liq', label: <>{t('fxLiqShort')} <em className="not-italic text-warn">({t('estimate')})</em></>, num: true, cell: (p) => px(p.instrument, p.liquidationPrice) },46    { id: 'lev', label: t('fxLeverage'), num: true, cell: (p) => `${fmt(p.leverage, { dp: 1, locale, trim: true })}×` },47    { id: 'margin', label: t('fxMargin'), num: true, cell: (p) => usd(p.margin, locale) },48    { id: 'upnl', label: t('fxUnrealized'), num: true, cell: (p) => <b className="font-semibold"><Pnl value={p.unrealizedPnl} locale={locale} /></b> },49    { id: 'rpnl', label: t('fxRealized'), num: true, cell: (p) => <Pnl value={p.realizedPnl} locale={locale} /> },50    { id: 'fund', label: t('fxFundingPaid'), num: true, cell: (p) => (p.fundingPaid === null ? '—' : usd(p.fundingPaid, locale, true)) },51  ];52  const orderCols = (withCancel: boolean): Col<Order>[] => [53    { id: 'm', label: t('fxMarket'), head: true, cell: (o) => market(o.instrument) },54    { id: 'side', label: t('fxSideCol'), head: true, cell: (o) => <SideTag side={o.side} /> },55    { id: 'type', label: t('fxOrderType'), cell: (o) => <>{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> : null}</> },56    { id: 'size', label: t('fxSize'), num: true, cell: (o) => sz(o.instrument, o.size) },57    { id: 'filled', label: t('fxFilled'), num: true, cell: (o) => sz(o.instrument, o.filled) },58    { id: 'price', label: t('fxPrice'), num: true, cell: (o) => (o.type === 'limit' || o.type.endsWith('_limit') ? px(o.instrument, o.price) : o.avgFillPrice ? px(o.instrument, o.avgFillPrice) : t('fxAtMarket')) },59    { id: 'trig', label: t('fxTriggerPrice'), num: true, cell: (o) => px(o.instrument, o.triggerPrice) },60    { id: 'st', label: t('fxStatus'), cell: (o) => <span className="inline-flex rounded-full bg-elev px-2 py-0.5 text-[11.5px] font-semibold">{t(`fxSt_${o.status}` as Key)}</span> },61    { id: 'at', label: t('fxTime'), cell: (o) => <span className="text-mute">{timeOf(o.createdAt, locale, true)}</span> },62    ...(withCancel ? [{ id: 'x', label: '', cell: (o: Order) => <button type="button" disabled={!!busy} className={mini} onClick={() => void run(`x${o.clientId}`, () => dapi.cancel(account.id, o.clientId))} aria-label={t('fxCancelAria', { symbol: sym(o.instrument) })}>{t('fxCancelOrder')}</button> }]63      : [{ id: 'why', label: t('fxReason'), cell: (o: Order) => <span className="text-mute">{o.rejectReason ?? '—'}</span> }]),64  ];65  const fillCols: Col<Fill>[] = [66    { id: 'm', label: t('fxMarket'), head: true, cell: (f) => market(f.instrument) },67    { id: 'side', label: t('fxSideCol'), head: true, cell: (f) => <><SideTag side={f.side} />{f.liquidation && <span className="ml-2 rounded-full bg-danger/10 px-2 py-0.5 text-[11.5px] font-semibold text-danger">{t('fxLiquidation')}</span>}</> },68    { id: 'price', label: t('fxPrice'), num: true, cell: (f) => px(f.instrument, f.price) },69    { id: 'size', label: t('fxSize'), num: true, cell: (f) => sz(f.instrument, f.size) },70    { id: 'fee', label: t('fxFee'), num: true, cell: (f) => `${fmt(f.fee, { dp: 4, locale })} ${f.feeAsset}` },71    { id: 'pnl', label: t('fxClosedPnl'), num: true, cell: (f) => <Pnl value={f.closedPnl} locale={locale} /> },72    { id: 'at', label: t('fxTime'), cell: (f) => <span className="text-mute">{timeOf(f.ts, locale, true)}</span> },73  ];74  const fundCols: Col<FundingPayment>[] = [75    { id: 'm', label: t('fxMarket'), head: true, cell: (f) => market(f.instrument) },76    { id: 'amt', label: t('fxAmount'), num: true, head: true, cell: (f) => <Pnl value={f.amount} locale={locale} dp={4} /> },77    { id: 'rate', label: t('fxRate'), num: true, cell: (f) => pct(f.rate, 5, locale, true) },78    { id: 'size', label: t('fxSize'), num: true, cell: (f) => sz(f.instrument, f.size) },79    { id: 'at', label: t('fxTime'), cell: (f) => <span className="text-mute">{timeOf(f.ts, locale, true)}</span> },80  ];81  const evCols: Col<RiskEvent>[] = [82    { id: 'k', label: t('fxEvent'), head: true, cell: (e) => { const k = t(`fxEv_${e.kind}` as Key); return k.startsWith('fxEv ') ? e.kind : k; } },83    { 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.detail}</span></span> },84    { id: 'at', label: t('fxTime'), cell: (e) => <span className="text-mute">{timeOf(e.ts, locale, true)}</span> },85  ];86  const eq = detail?.equityHistory ?? [];87  const first = dec(eq[0]?.equity), lastEq = dec(account.state.equity), change = first && lastEq ? D.sub(lastEq, first) : null;88  const realized = positions.reduce((a, p) => D.add(a, dec(p.realizedPnl) ?? D.zero), D.zero), unreal = positions.reduce((a, p) => D.add(a, dec(p.unrealizedPnl) ?? D.zero), D.zero);89  const n = (k: number) => (k ? ` (${k})` : '');90  return (91    <div data-testid="blotter">92      <div className="mb-3 flex items-center gap-2">93        <Seg tabs size="sm" label={t('fxBlotter')} value={tab} onChange={(v) => { setTab(v); setErr(null); }} className="min-w-0 flex-1" options={[94          { id: 'positions', label: `${t('fxPositions')}${n(positions.length)}` }, { id: 'orders', label: `${t('fxOpenOrders')}${n(open.length)}` }, { id: 'history', label: t('fxOrderHistory') },95          { 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)}` }]} />96        <PaperTag className="hidden sm:inline-flex" />97      </div>98      {err && <div className="mb-3">{err.code ? <RiskVerdict code={err.code} reason={err.reason ?? ''} /> : <Notice tone="danger">{err.text}</Notice>}</div>}99      <div role="tabpanel" aria-label={t('fxBlotter')}>100        {tab === 'positions' && <DataTable label={t('fxPositions')} cols={posCols} rows={positions} rowKey={(p) => p.instrument} empty={t('fxNoPositions')} />}101        {tab === 'orders' && <DataTable label={t('fxOpenOrders')} cols={orderCols(true)} rows={open} rowKey={(o) => o.clientId} empty={t('fxNoOpenOrders')} />}102        {tab === 'history' && <DataTable label={t('fxOrderHistory')} cols={orderCols(false)} rows={detail?.orders ?? []} rowKey={(o) => o.clientId} empty={t('fxNoHistory')} />}103        {tab === 'fills' && <DataTable label={t('fxFills')} cols={fillCols} rows={detail?.fills ?? []} rowKey={(f) => f.id} empty={t('fxNoFills')} />}104        {tab === 'funding' && <DataTable label={t('fxFunding')} cols={fundCols} rows={detail?.funding ?? []} rowKey={(f) => `${f.instrument}${f.ts}`} empty={t('fxNoFunding')} />}105        {tab === 'risk' && <DataTable label={t('fxRiskEvents')} cols={evCols} rows={detail?.riskEvents ?? []} rowKey={(e) => e.id} empty={t('fxNoRiskEvents')} />}106        {tab === 'pnl' && <div className="space-y-4">107          <div className="grid grid-cols-2 gap-3 sm:grid-cols-4">108            <Stat label={t('fxEquity')}>{usd(account.state.equity, locale)}</Stat>109            <Stat label={t('fxSinceStart')}><Pnl value={change ? D.str(change) : null} locale={locale} /></Stat>110            <Stat label={t('fxUnrealized')}><Pnl value={D.str(unreal)} locale={locale} /></Stat>111            <Stat label={t('fxRealizedOpen')}><Pnl value={D.str(realized)} locale={locale} /></Stat>112          </div>113          {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)} />114            : <p className="py-8 text-center text-[13.5px] text-mute">{t('fxNoEquity')}</p>}115          {account.totals && <div className="grid grid-cols-2 gap-3 sm:grid-cols-4">116            <Stat label={t('fxTotRealized')}><Pnl value={account.totals.realizedPnl} locale={locale} /></Stat><Stat label={t('fxTotFees')}>{usd(account.totals.feesPaid, locale)}</Stat>117            <Stat label={t('fxTotFunding')}><Pnl value={account.totals.fundingNet} locale={locale} /></Stat><Stat label={t('fxTotLiquidations')}>{account.totals.liquidations}</Stat></div>}118          <p className="text-[12px] leading-snug text-mute">{t('fxPnlNote')}</p>119        </div>}120      </div>121    </div>122  );123}124
Files main
Menu

Theme