SPB Git

Repository Public

Accounts.tsx 20.6 KB 188 lines tsx

1/**2 * Accounts of the Futures area: venue status, paper accounts (create, choose, reset, edit the risk limits), a read-only look3 * at any public address on a venue, and controlled live trading (`LiveSection`: venue accounts, authorization, revocation).4 * Nothing in THIS file can send a real order; the live section sends none either without a passkey approval per order.5 */6import { useQuery, useQueryClient } from '@tanstack/react-query';7import { useContext, useState } from 'react';8import { errText, I18n, useT, type Key } from '../i18n';9import { Btn, Card, Field, H, Icon, inputCls, Notice, short, Skeleton } from '../ui';10import { dapi, paperAllowed } from './api';11import { LiveSection } from './LiveAccounts';12import { ageText, DataTable, PaperTag, Pnl, Row, ServerSays, Sheet, SideTag, Stat, VENUE_NAME, VenueBadge, type Col } from './bits';13import { D, dec, fmt, parseInput, pct, priceDp, usd } from './dec';14import type { DerivAccount, Instrument, Position, RiskPolicy, VenueId, VenueInfo, Watch } from './types';1516type LimitKey = 'maxLeverage' | 'maxOrderNotional' | 'maxPositionNotional' | 'maxGrossExposure' | 'minFreeCollateral' | 'maxDailyLoss' | 'maxTotalLoss' | 'minLiquidationDistanceBps' | 'maxDeviationBps' | 'maxMarkIndexBps' | 'maxDataAgeMs' | 'maxOrdersPerMinute' | 'maxOrdersPerDay';17const LIMITS: { k: LimitKey; unit: string; int?: boolean; advanced?: boolean }[] = [18  { k: 'maxLeverage', unit: '×' }, { k: 'maxOrderNotional', unit: 'USD' }, { k: 'maxPositionNotional', unit: 'USD' }, { k: 'maxGrossExposure', unit: 'USD' },19  { k: 'maxDailyLoss', unit: 'USD' }, { k: 'maxTotalLoss', unit: 'USD' }, { k: 'minFreeCollateral', unit: 'USD' }, { k: 'minLiquidationDistanceBps', unit: 'pb' },20  { k: 'maxDeviationBps', unit: 'pb', advanced: true }, { k: 'maxMarkIndexBps', unit: 'pb', advanced: true }, { k: 'maxDataAgeMs', unit: 'ms', int: true, advanced: true },21  { k: 'maxOrdersPerMinute', unit: '/ min', int: true, advanced: true }, { k: 'maxOrdersPerDay', unit: '/ j', int: true, advanced: true },22];23type Limits = Record<LimitKey, string>;24/**25 * The backend's own default policy for a collateral C (services/api/src/derivatives/paper.ts, `defaultPolicy`), shown in the form26 * so that what is created is what was read: leverage 3, order ≤ C, position ≤ 2C, exposure ≤ 3C, free collateral ≥ 0.1C,27 * daily loss 0.05C, total loss 0.2C. Every limit is explained under its field and can be changed.28 */29export function defaultLimits(collateral: string | null): Limits {30  const c = dec(collateral) ?? D.from(10000n), times = (k: string) => D.str(D.mul(c, D.from(k)));31  return { maxLeverage: '3', maxOrderNotional: times('1'), maxPositionNotional: times('2'), maxGrossExposure: times('3'), minFreeCollateral: times('0.1'), maxDailyLoss: times('0.05'), maxTotalLoss: times('0.2'),32    minLiquidationDistanceBps: '1000', maxDeviationBps: '200', maxMarkIndexBps: '100', maxDataAgeMs: '10000', maxOrdersPerMinute: '10', maxOrdersPerDay: '200' };33}34const defaultAllowed = (venue: string, list: Instrument[]) => [`${venue}:BTC`, `${venue}:ETH`].filter((id) => !list.length || list.some((i) => i.id === id));35const limitsOf = (p: RiskPolicy): Limits => Object.fromEntries(LIMITS.map(({ k }) => [k, String(p[k])])) as Limits;36function toPolicy(l: Limits): Partial<RiskPolicy> | null {37  const out: Record<string, string | number> = {};38  for (const { k, int } of LIMITS) {39    const v = parseInput(l[k]); if (v === null) return null;40    if (int) { if (!/^\d+$/.test(v)) return null; out[k] = Number(v); } else out[k] = v;41  }42  if (!/[1-9]/.test(String(out.maxLeverage))) return null;43  return out as Partial<RiskPolicy>;44}4546/** markets the account may trade: anything not listed is refused by the risk engine (`instrument_not_allowed`) */47function AllowedEditor({ value, onChange, options }: { value: string[]; onChange: (v: string[]) => void; options: Instrument[] }) {48  const t = useT(); const sym = (id: string) => id.slice(id.indexOf(':') + 1);49  const rest = options.filter((i) => !value.includes(i.id));50  return <div data-testid="allowed-editor">51    <div className="mb-1.5 text-[13px] text-mute">{t('fxAllowed')}</div>52    <div className="flex flex-wrap items-center gap-2">53      {value.map((id) => <span key={id} className="inline-flex items-center gap-1 rounded-full border border-line bg-surface pl-3 text-[13.5px] font-medium">{sym(id)}54        <button type="button" onClick={() => onChange(value.filter((x) => x !== id))} aria-label={t('fxAllowedRemove', { symbol: sym(id) })} className="grid h-11 w-9 place-items-center text-mute"><Icon n="x" className="h-3.5 w-3.5" /></button></span>)}55      {rest.length > 0 && <select aria-label={t('fxAllowedAdd')} value="" onChange={(e) => { if (e.target.value) onChange([...value, e.target.value]); }} className="min-h-11 rounded-full border border-line bg-surface px-3 text-[13.5px] text-ink">56        <option value="">{t('fxAllowedAdd')}</option>{rest.map((i) => <option key={i.id} value={i.id}>{i.symbol}</option>)}</select>}57    </div>58    <p className="mt-1.5 text-[12px] leading-snug text-mute">{value.length ? t('fxAllowedHint') : <span className="text-danger">{t('fxAllowedNone')}</span>}</p>59  </div>;60}61function LimitsForm({ value, onChange, allowed, onAllowed, options }: { value: Limits; onChange: (l: Limits) => void; allowed: string[]; onAllowed: (v: string[]) => void; options: Instrument[] }) {62  const t = useT(); const [more, setMore] = useState(false);63  return <div className="space-y-3">64    <AllowedEditor value={allowed} onChange={onAllowed} options={options} />65    <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">{LIMITS.filter((x) => more || !x.advanced).map(({ k, unit }) => <Field key={k} label={t(`fxLim_${k}` as Key)} hint={t(`fxLimHint_${k}` as Key)}>66      <div className="flex items-center gap-2"><input className={inputCls} inputMode="decimal" autoComplete="off" value={value[k]} onChange={(e) => onChange({ ...value, [k]: e.target.value.replace(/[^0-9.,]/g, '') })} /><span className="w-10 shrink-0 text-[12.5px] text-mute">{unit === 'pb' ? t('fxBps') : unit === '/ j' ? t('fxPerDay') : unit}</span></div></Field>)}</div>67    <button type="button" onClick={() => setMore(!more)} aria-expanded={more} className="min-h-11 text-[13.5px] font-medium text-blue">{t(more ? 'fxLimLess' : 'fxLimMore')}</button>68  </div>;69}7071const STATUS: Record<string, [string, string, Key]> = { ok: ['text-emerald bg-emerald/10', 'check', 'fxVenue_ok'], degraded: ['text-warn bg-warn/12', 'warn', 'fxVenue_degraded'], down: ['text-danger bg-danger/10', 'x', 'fxVenue_down'] };72export function VenuePill({ v }: { v: VenueInfo }) {73  const t = useT(); const [cls, ic, key] = STATUS[v.status] ?? STATUS.down!;74  return <span className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11.5px] font-semibold ${cls}`}><Icon n={ic} className="h-3 w-3" />{t(key)}</span>;75}76/** one line per venue: status in words, and what the venue is limited to here */77export function VenueStrip({ venues, compact }: { venues: VenueInfo[]; compact?: boolean }) {78  const t = useT();79  if (compact) return <ul className="no-scrollbar -mx-5 flex gap-2 overflow-x-auto px-5 lg:mx-0 lg:px-0" aria-label={t('fxVenues')} data-testid="venue-strip">{venues.map((v) => <li key={v.id} className="flex shrink-0 items-center gap-2 rounded-full border border-line bg-surface py-1 pl-2.5 pr-1.5 text-[12px]">80    <span className="font-semibold">{VENUE_NAME[v.id] ?? v.name}</span><VenuePill v={v} />{(v.restriction || !paperAllowed(v)) && <span className="pr-1.5 text-mute">{t(v.id === 'dydx' || v.restriction ? 'fxDataOnly' : 'fxReadOnly')}</span>}</li>)}</ul>;81  return <ul className="divide-y divide-line" data-testid="venue-list">{venues.map((v) => <li key={v.id} className="py-3 first:pt-0 last:pb-0">82    <div className="flex flex-wrap items-center gap-2"><VenueBadge venue={v.id} /><VenuePill v={v} /><span className="text-[12px] text-mute">{v.network}{v.lastOkAt ? ` · ${t('fxLastOk', { age: ageText(Date.now() - v.lastOkAt) })}` : ''}</span></div>83    <p className="mt-1.5 text-[13px] leading-snug">{t(`fxVenueWhat_${v.id}` as Key)}</p>84    {v.restriction && <ServerSays text={v.restriction} className="mt-1 text-[12px] leading-snug text-mute" />}</li>)}</ul>;85}8687export function AccountsView({ venues, instruments, accounts, loading, selected, onSelect }: { venues: VenueInfo[]; instruments: Instrument[]; accounts: DerivAccount[]; loading: boolean; selected: string | null; onSelect: (id: string) => void }) {88  const t = useT(); const { locale } = useContext(I18n); const qc = useQueryClient();89  const eligible = venues.filter(paperAllowed);90  const [open, setOpen] = useState(false), [busy, setBusy] = useState(''), [err, setErr] = useState('');91  // `limits` / `allowed` stay null until the owner edits them: until then they follow the collateral and the venue (the backend's defaults)92  const [f, setF] = useState<{ venue: VenueId | ''; label: string; collateral: string; limits: Limits | null; allowed: string[] | null }>({ venue: '', label: '', collateral: '10000', limits: null, allowed: null });93  const [edit, setEdit] = useState<{ a: DerivAccount; limits: Limits; allowed: string[] } | null>(null), [reset, setReset] = useState<DerivAccount | null>(null);94  const venue = f.venue || eligible[0]?.id || '';95  const ofVenue = (v: string) => instruments.filter((i) => i.venue === v && i.status !== 'halted');96  const limits = f.limits ?? defaultLimits(parseInput(f.collateral)), allowed = f.allowed ?? defaultAllowed(venue, ofVenue(venue));97  const refresh = () => Promise.all([qc.invalidateQueries({ queryKey: ['deriv', 'accounts'] }), qc.invalidateQueries({ queryKey: ['deriv', 'account'] })]);98  const act = async (id: string, fn: () => Promise<unknown>, after?: () => void) => { setBusy(id); setErr(''); try { await fn(); await refresh(); after?.(); } catch (e) { setErr(errText(t, e) || t('fxErrGeneric')); } finally { setBusy(''); } };99  const collateral = parseInput(f.collateral), policy = toPolicy(limits);100  const collateralOk = !!collateral && D.gte(D.from(collateral), D.from(10n)) && D.lte(D.from(collateral), D.from(1_000_000_000n));101  const create = () => act('create', async () => { const a = await dapi.createAccount({ venue: venue as VenueId, label: f.label.trim() || t('fxDefaultLabel', { venue: VENUE_NAME[venue as VenueId] }), collateral: collateral!, policy: { ...policy!, allowedInstruments: allowed } }); onSelect(a.id); }, () => { setOpen(false); setF({ ...f, label: '' }); });102103  return (104    <div className="space-y-4" data-testid="accounts-view">105      <Card><H>{t('fxVenues')}</H>{venues.length ? <VenueStrip venues={venues} /> : <Skeleton h="h-24" />}</Card>106107      <Card>108        <H right={<Btn kind="soft" onClick={() => { setOpen(!open); setErr(''); }} disabled={!eligible.length}><Icon n={open ? 'x' : 'plus'} className="h-4 w-4" />{open ? t('cancel') : t('fxNewAccount')}</Btn>}>{t('fxPaperAccounts')}</H>109        <p className="mb-3 text-[13px] leading-snug text-mute">{t('fxPaperIntro')}</p>110        {open && <div className="mb-4 space-y-3 rounded-2xl border border-line p-4" data-testid="account-form">111          <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">112            <Field label={t('fxVenue')} hint={t('fxVenueHint')}><select className={inputCls} value={venue} onChange={(e) => setF({ ...f, venue: e.target.value as VenueId, allowed: null })}>113              {venues.map((v) => <option key={v.id} value={v.id} disabled={!paperAllowed(v)}>{VENUE_NAME[v.id] ?? v.name}{paperAllowed(v) ? '' : ` — ${t('fxNotAvailable')}`}</option>)}</select></Field>114            <Field label={t('fxLabel')}><input className={inputCls} maxLength={40} value={f.label} placeholder={venue ? t('fxDefaultLabel', { venue: VENUE_NAME[venue as VenueId] }) : ''} onChange={(e) => setF({ ...f, label: e.target.value })} /></Field>115            <Field label={t('fxStartCollateral')} hint={collateral && !collateralOk ? <span className="text-danger">{t('fxCollateralRange')}</span> : t('fxStartCollateralHint')}><div className="flex items-center gap-2"><input className={inputCls} inputMode="decimal" value={f.collateral} onChange={(e) => setF({ ...f, collateral: e.target.value.replace(/[^0-9.,]/g, '') })} /><span className="w-12 shrink-0 text-[12.5px] text-mute">USDC</span></div></Field>116          </div>117          <h3 className="pt-1 text-[14px] font-semibold">{t('fxLimits')}</h3>118          <p className="text-[12.5px] leading-snug text-mute">{t('fxLimitsIntro')}</p>119          <LimitsForm value={limits} onChange={(l) => setF({ ...f, limits: l })} allowed={allowed} onAllowed={(a) => setF({ ...f, allowed: a })} options={ofVenue(venue)} />120          {f.limits && <button type="button" className="min-h-11 text-[13.5px] font-medium text-blue" onClick={() => setF({ ...f, limits: null })}>{t('fxLimitsDefault')}</button>}121          {err && <Notice tone="danger">{err}</Notice>}122          <Btn full busy={busy === 'create'} disabled={!venue || !collateralOk || !policy || !allowed.length} onClick={() => void create()}>{t('fxCreatePaper')}</Btn>123        </div>}124        {loading ? <Skeleton h="h-24" /> : accounts.length === 0 ? !open && <p className="py-6 text-center text-[13.5px] text-mute">{t('fxNoAccounts')}</p> :125          <ul className="space-y-3">{[...accounts].sort((x, y) => Number(y.id === selected) - Number(x.id === selected)).map((a) => { const on = a.id === selected; return <li key={a.id} className={`rounded-2xl border p-4 ${on ? 'border-ink' : 'border-line'}`}>126            <div className="flex flex-wrap items-center gap-2"><span className="text-[15.5px] font-semibold">{a.label}</span><VenueBadge venue={a.venue} />{a.kind === 'paper' ? <PaperTag /> : <span className="rounded-md border border-line px-1.5 py-px text-[10.5px] font-bold uppercase text-mute">{t('fxReadOnly')}</span>}127              {on && <span className="inline-flex items-center gap-1 text-[12px] font-semibold text-ink"><Icon n="check" className="h-3.5 w-3.5" />{t('fxSelected')}</span>}</div>128            <div className="mt-3 grid grid-cols-2 gap-3 sm:grid-cols-4">129              <Stat label={t('fxEquity')}>{usd(a.state.equity, locale)}</Stat><Stat label={t('fxFreeCollateral')}>{usd(a.state.freeCollateral, locale)}</Stat>130              <Stat label={t('fxMarginUsed')}>{usd(a.state.marginUsed, locale)}</Stat><Stat label={t('fxPositions')}>{a.positions.length} · {a.openOrders.length} {t('fxOrdersSmall')}</Stat></div>131            {a.kind === 'paper' && <p className="mt-2 text-[12px] leading-snug text-mute">{t('fxPaperSimpl', { mm: a.settings ? pct(a.settings.maintenanceMarginFraction, 1, locale) : '2 %', taker: a.settings ? pct(a.settings.takerFeeRate, 3, locale) : '—' })} {t('fxAllowedNow', { list: a.policy.allowedInstruments.map((x) => x.slice(x.indexOf(':') + 1)).join(', ') || '—' })}</p>}132            {a.policy.halt && <div className="mt-3"><Notice tone="danger">{t('fxHalted')} <span className="font-mono text-[12px]">{a.policy.halt}</span>133              <button type="button" className="mt-1 block min-h-11 font-semibold underline" disabled={!!busy} onClick={() => void act(`h${a.id}`, () => dapi.setPolicy(a.id, { ...a.policy, halt: null }))}>{t('fxLiftHalt')}</button></Notice></div>}134            {a.kind === 'paper' && <div className="mt-3 flex flex-wrap gap-2">135              {!on && <Btn kind="soft" onClick={() => onSelect(a.id)}>{t('fxUse')}</Btn>}136              <Btn kind="ghost" onClick={() => { setErr(''); setEdit({ a, limits: limitsOf(a.policy), allowed: a.policy.allowedInstruments }); }}>{t('fxEditLimits')}</Btn>137              <Btn kind="ghost" onClick={() => { setErr(''); setReset(a); }}>{t('fxReset')}</Btn></div>}138          </li>; })}</ul>}139      </Card>140141      <WatchCard venues={venues} />142143      <LiveSection selected={selected} onSelect={onSelect} />144145      {edit && <Sheet wide title={t('fxEditLimitsTitle', { label: edit.a.label })} onClose={() => !busy && setEdit(null)}><div className="space-y-3">146        <p className="text-[12.5px] leading-snug text-mute">{t('fxLimitsIntro')}</p>147        <LimitsForm value={edit.limits} onChange={(l) => setEdit({ ...edit, limits: l })} allowed={edit.allowed} onAllowed={(a) => setEdit({ ...edit, allowed: a })} options={ofVenue(edit.a.venue)} />148        {err && <Notice tone="danger">{err}</Notice>}149        <Btn full busy={busy === 'policy'} disabled={!toPolicy(edit.limits) || !edit.allowed.length} onClick={() => void act('policy', () => dapi.setPolicy(edit.a.id, { ...edit.a.policy, ...toPolicy(edit.limits)!, allowedInstruments: edit.allowed }), () => setEdit(null))}>{t('fxSaveLimits')}</Btn></div></Sheet>}150      {reset && <Sheet title={t('fxResetTitle', { label: reset.label })} onClose={() => !busy && setReset(null)}><div className="space-y-3">151        <Notice tone="warn">{t('fxResetBody')}</Notice>{err && <Notice tone="danger">{err}</Notice>}152        <Btn full kind="danger" busy={busy === 'reset'} onClick={() => void act('reset', () => dapi.reset(reset.id), () => setReset(null))}>{t('fxResetConfirm')}</Btn>153        <Btn full kind="ghost" disabled={!!busy} onClick={() => setReset(null)}>{t('cancel')}</Btn></div></Sheet>}154    </div>155  );156}157158function WatchCard({ venues }: { venues: VenueInfo[] }) {159  const t = useT(); const { locale } = useContext(I18n);160  const can = venues.filter((v) => v.capabilities?.account && !v.restriction);161  const [venue, setVenue] = useState<VenueId | ''>(''), [addr, setAddr] = useState(''), [asked, setAsked] = useState<{ venue: VenueId; addr: string } | null>(null);162  const v = venue || can[0]?.id || '';163  const q = useQuery({ queryKey: ['deriv', 'watch', asked?.venue, asked?.addr], enabled: !!asked, queryFn: () => dapi.watch(asked!.addr, asked!.venue), refetchInterval: 15_000, retry: 0 });164  const w: Watch | undefined = asked ? q.data : undefined;165  const cols: Col<Position>[] = [166    { id: 'm', label: t('fxMarket'), head: true, cell: (p) => p.instrument.slice(p.instrument.indexOf(':') + 1) }, { id: 's', label: t('fxDirection'), head: true, cell: (p) => <SideTag side={p.side} /> },167    { id: 'size', label: t('fxSize'), num: true, cell: (p) => fmt(p.size, { locale, trim: true }) }, { id: 'e', label: t('fxEntry'), num: true, cell: (p) => fmt(p.entryPrice, { dp: priceDp('0.000001', p.entryPrice), locale }) },168    { id: 'mk', label: t('fxMark'), num: true, cell: (p) => fmt(p.markPrice, { dp: priceDp('0.000001', p.markPrice), locale }) }, { id: 'l', label: t('fxLiqShort'), num: true, cell: (p) => (p.liquidationPrice ? fmt(p.liquidationPrice, { dp: priceDp('0.000001', p.liquidationPrice), locale }) : '—') },169    { id: 'lev', label: t('fxLeverage'), num: true, cell: (p) => `${fmt(p.leverage, { dp: 1, locale, trim: true })}×` }, { id: 'u', label: t('fxUnrealized'), num: true, cell: (p) => <Pnl value={p.unrealizedPnl} locale={locale} /> },170  ];171  return <Card><div data-testid="watch-card">172    <H>{t('fxWatch')}</H>173    <p className="mb-3 text-[13px] leading-snug text-mute">{t('fxWatchIntro')}</p>174    <form className="grid grid-cols-1 gap-3 sm:grid-cols-[180px_1fr_auto] sm:items-end" onSubmit={(e) => { e.preventDefault(); if (v && addr.trim()) setAsked({ venue: v as VenueId, addr: addr.trim() }); }}>175      <Field label={t('fxVenue')}><select className={inputCls} value={v} onChange={(e) => setVenue(e.target.value as VenueId)}>{venues.map((x) => <option key={x.id} value={x.id} disabled={!can.includes(x)}>{VENUE_NAME[x.id] ?? x.name}{can.includes(x) ? '' : ` — ${t('fxNotAvailable')}`}</option>)}</select></Field>176      <Field label={t('fxAddress')}><input className={`${inputCls} font-mono text-[14px]`} value={addr} onChange={(e) => setAddr(e.target.value)} placeholder="0x…" autoCapitalize="off" autoCorrect="off" spellCheck={false} /></Field>177      <Btn type="submit" kind="soft" busy={q.isFetching && !q.data} disabled={!v || !addr.trim()}><Icon n="eye" className="h-4 w-4" />{t('fxWatchGo')}</Btn>178    </form>179    {asked && q.isError && <div className="mt-3"><Notice tone="danger">{errText(t, q.error) || t('fxErrGeneric')}</Notice></div>}180    {w && asked && <div className="mt-4 space-y-3" data-testid="watch-result">181      <div className="flex flex-wrap items-center gap-2 text-[12.5px]"><VenueBadge venue={asked.venue} /><span className="font-mono">{short(asked.addr)}</span><span className="rounded-md border border-line px-1.5 py-px text-[10.5px] font-bold uppercase text-mute">{t('fxReadOnly')}</span><span className="text-mute">{t('fxSourceVenue')}</span></div>182      <div className="grid grid-cols-2 gap-3 sm:grid-cols-4"><Stat label={t('fxEquity')}>{usd(w.state?.equity, locale)}</Stat><Stat label={t('fxFreeCollateral')}>{usd(w.state?.freeCollateral, locale)}</Stat><Stat label={t('fxMarginUsed')}>{usd(w.state?.marginUsed, locale)}</Stat><Stat label={t('fxWithdrawable')}>{usd(w.state?.withdrawable, locale)}</Stat></div>183      <DataTable label={t('fxPositions')} cols={cols} rows={w.positions} rowKey={(p) => p.instrument} empty={t('fxNoPositions')} />184      {w.openOrders.length > 0 && <Row label={t('fxOpenOrders')}>{w.openOrders.length}</Row>}185    </div>}</div>186  </Card>;187}188
Files main
Menu

Theme