SPB Git

Repository Public

Futures.tsx 17.4 KB 197 lines tsx

1/**2 * Trade → Futures: market data (read-only), PAPER trading, and controlled LIVE trading on a venue account (Hyperliquid testnet3 * or mainnet, passkey per order — see derivatives/Live*.tsx). Which money the selected account moves is on a band that is4 * always on screen: « SIMULATION », « TESTNET — argent fictif », « RÉEL — argent réel ».5 * Phone: sticky market header + tabs (Chart · Book · Trade · Positions). Tablet: chart and book on the left, order panel on6 * the right. Desktop: chart | book + trades | order panel, blotter underneath. This component never listens to the stream:7 * the header, the book, the trades and the chart each subscribe to their own slice of the market store.8 */9import { useQuery } from '@tanstack/react-query';10import { useContext, useEffect, useMemo, useState } from 'react';11import type { Nav } from '../App';12import { errText, I18n, useT } from '../i18n';13import { Btn, Card, Notice, Skeleton } from '../ui';14import { AccountsView, VenueStrip } from '../derivatives/Accounts';15import { dapi } from '../derivatives/api';16import { Seg, useLocalState, useMedia, VENUE_NAME } from '../derivatives/bits';17import { Blotter } from '../derivatives/Blotter';18import { BookPanel, LiquidityPanel, TradesPanel } from '../derivatives/Book';19import { Candles, type Overlay } from '../derivatives/Candles';20import { CompareView } from '../derivatives/Compare';21import { priceDp } from '../derivatives/dec';22import { lapi, toneOf, type LiveSummary, type Tone } from '../derivatives/live';23import { AccountSwitch, ModeBand, ModeTag } from '../derivatives/LiveBits';24import { LiveBlotter } from '../derivatives/LiveBlotter';25import { LiveOrderPanel } from '../derivatives/LiveOrderPanel';26import { useLiveAccount } from '../derivatives/liveStore';27import { MarketHeader, MarketPicker, MarketStats, StaleBanner, Veil } from '../derivatives/Market';28import { OrderPanel } from '../derivatives/OrderPanel';29import { RouterView } from '../derivatives/Router';   // router (simulation)30import { hasPaperPrefill, setPaperPrefill } from '../derivatives/routerApi';31import { useMarketStore, useSlice, type MarketStore } from '../derivatives/store';32import { venueOf, type DerivAccount, type Instrument, type Interval, type Order, type Position, type VenueInfo } from '../derivatives/types';3334export type FuturesView = 'terminal' | 'compare' | 'accounts' | 'router';35const DEFAULT_ID = 'hyperliquid:BTC';36/**37 * The accounts that can trade a venue's markets, paper first, and the one in use: the stored choice when it is among them,38 * otherwise the first paper account — a venue account is never selected by default, only by the owner.39 */40function pickAccount(paper: DerivAccount[], live: LiveSummary[], accountId: string | null, venue: string): { paper: DerivAccount[]; live: LiveSummary[]; account: DerivAccount | null; venueAccount: LiveSummary | null } {41  const p = paper.filter((a) => a.venue === venue), l = live.filter((a) => a.venue === venue);42  const venueAccount = p.some((a) => a.id === accountId) ? null : l.find((a) => a.id === accountId) ?? null;43  return { paper: p, live: l, venueAccount, account: venueAccount ? null : p.find((a) => a.id === accountId) ?? p[0] ?? null };44}4546export function Futures({ instrument: routeId, view = 'terminal', nav }: { instrument?: string; view?: FuturesView; nav: Nav }) {47  const t = useT();48  const [lastId, setLastId] = useLocalState<string>('spw-fut-market', DEFAULT_ID);49  const [accountId, setAccountId] = useLocalState<string | null>('spw-fut-account', null);50  const [base, setBase] = useLocalState<string>('spw-fut-base', 'BTC');51  const id = routeId || lastId || DEFAULT_ID;52  useEffect(() => { if (routeId && routeId !== lastId) setLastId(routeId); }, [routeId]);   // eslint-disable-line react-hooks/exhaustive-deps53  const venues = useQuery({ queryKey: ['deriv', 'venues'], queryFn: dapi.venues, refetchInterval: 30_000 });54  const instruments = useQuery({ queryKey: ['deriv', 'instruments'], queryFn: () => dapi.instruments(), staleTime: 600_000, refetchInterval: 600_000 });55  const accounts = useQuery({ queryKey: ['deriv', 'accounts'], queryFn: dapi.accounts, refetchInterval: 6000 });56  const go = (v: FuturesView, inst = id) => nav({ tab: 'futures', asset: inst, view: v === 'terminal' ? undefined : v });57  const phone = !useMedia('(min-width: 768px)');58  const paper = useMemo(() => (accounts.data ?? []).filter((a) => a.kind === 'paper'), [accounts.data]);59  // venue accounts of controlled live trading; a backend without the live routes simply has none60  const liveQ = useQuery({ queryKey: ['deriv', 'live', 'accounts'], queryFn: lapi.accounts, refetchInterval: 15_000, retry: 0 });61  const live = useMemo(() => liveQ.data ?? [], [liveQ.data]);62  const sel = pickAccount(paper, live, accountId, venueOf(id));63  const tone: Tone = sel.venueAccount ? toneOf(sel.venueAccount) : 'paper', anyLive = live.some((a) => a.mode === 'MANUAL_LIVE');64  const list = instruments.data?.instruments ?? [], unread = instruments.data?.errors ?? [];6566  return (67    <div className="space-y-3">68      <div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-2 pt-1">69        <div className="hidden items-center gap-2.5 sm:flex"><h1 className="text-[24px] font-semibold tracking-[-0.02em] lg:text-[28px]">{t('fxTitle')}</h1><ModeTag tone={tone} /></div>70        <Seg tabs size="sm" label={t('fxViews')} value={view} onChange={(v) => go(v)} className="w-full sm:w-auto sm:min-w-[330px]" options={[{ id: 'terminal', label: t('fxViewTerminal') }, { id: 'compare', label: t('fxViewCompare') }, { id: 'router', label: t('rxTab') }, { id: 'accounts', label: t('fxViewAccounts') }]} />71      </div>72      <p className="text-[12.5px] leading-snug text-mute" data-testid="paper-banner">{t(tone === 'paper' ? (anyLive ? 'flBannerSimLive' : 'flBannerOff') : tone === 'testnet' ? 'flBannerTestnet' : 'flBannerReal')}</p>73      {venues.isError ? <Notice tone="danger">{t('fxApiDown')} {errText(t, venues.error)}</Notice> : venues.data && !(phone && view === 'terminal') ? <VenueStrip venues={venues.data} compact /> : null}74      {unread.length > 0 && <Notice tone="warn">{t('fxVenueErrors', { venues: unread.map((e) => VENUE_NAME[e.venue as keyof typeof VENUE_NAME] ?? e.venue).join(', ') })}</Notice>}75      {view === 'terminal' && <Terminal id={id} instruments={list} instrumentsLoading={instruments.isLoading} venues={venues.data ?? []} accounts={paper} liveAccounts={live} accountId={accountId} onAccount={setAccountId}76        onMarket={(m) => go('terminal', m)} goAccounts={() => go('accounts')} />}77      {view === 'compare' && <CompareView instruments={list} base={base} onBase={setBase} onOpen={(m) => go('terminal', m)} />}78      {/* router (simulation): the only action is to open the PAPER order panel, pre-filled */}79      {view === 'router' && <RouterView accounts={paper} onOpenPaper={(p, account) => { setPaperPrefill(p); if (account) setAccountId(account); go('terminal', p.instrument); }} />}80      {view === 'accounts' && <AccountsView venues={venues.data ?? []} instruments={list} accounts={paper} loading={accounts.isLoading} selected={accountId} onSelect={setAccountId} />}81    </div>82  );83}8485function overlaysOf(detail: { positions: Position[]; openOrders: Order[] } | null | undefined, id: string, t: ReturnType<typeof useT>, venue = false): Overlay[] {86  if (!detail) return [];87  const out: Overlay[] = [];88  for (const p of detail.positions) if (p.instrument === id) {89    out.push({ price: Number(p.entryPrice), label: `${t('fxEntry')} ${t(p.side === 'long' ? 'fxLong' : 'fxShort')}`, kind: 'entry' });90    if (p.liquidationPrice) out.push({ price: Number(p.liquidationPrice), label: venue ? t('fxLiqShort') : `${t('fxLiqShort')} (${t('estimate')})`, kind: 'liq' });91  }92  for (const o of detail.openOrders) if (o.instrument === id) {93    const side = t(o.side === 'buy' ? 'fxSide_buy' : 'fxSide_sell');94    if (o.type === 'limit') out.push({ price: Number(o.price), label: `${t('fxKindShort_limit')} ${side}`, kind: 'order' });95    else if (o.triggerPrice) out.push({ price: Number(o.triggerPrice), label: `${t(o.type.startsWith('stop') ? 'fxKindShort_stop' : 'fxKindShort_tp')} ${side}`, kind: o.type.startsWith('stop') ? 'stop' : 'tp' });96  }97  return out;98}99100function Terminal({ id, instruments, instrumentsLoading, venues, accounts, liveAccounts, accountId, onAccount, onMarket, goAccounts }: { id: string; instruments: Instrument[]; instrumentsLoading: boolean; venues: VenueInfo[]; accounts: DerivAccount[]; liveAccounts: LiveSummary[]; accountId: string | null;101  onAccount: (id: string) => void; onMarket: (id: string) => void; goAccounts: () => void }) {102  const t = useT(); const { locale } = useContext(I18n);103  const store = useMarketStore(id);104  const meta = useSlice(store, 'meta');105  const phone = !useMedia('(min-width: 768px)'), wide = useMedia('(min-width: 1360px)');106  const [interval, setIntervalV] = useLocalState<Interval>('spw-fut-interval', '1h');107  const [tab, setTab] = useState<'chart' | 'book' | 'trade' | 'positions'>(() => (hasPaperPrefill(id) ? 'trade' : 'chart'));   // coming from the router: the order panel first108  const [pick, setPick] = useState(false);109  const instrument = meta.instrument ?? instruments.find((i) => i.id === id) ?? null;110  const byId = useMemo(() => new Map(instruments.map((i) => [i.id, i])), [instruments]);111  const venue = venues.find((v) => v.id === instrument?.venue);112  const picked = useMemo(() => pickAccount(accounts, liveAccounts, accountId, instrument?.venue ?? venueOf(id)), [accounts, liveAccounts, accountId, instrument?.venue, id]);113  const mine = picked.paper, { account, venueAccount } = picked, chosen = venueAccount ?? account;114  const detail = useQuery({ queryKey: ['deriv', 'account', account?.id], enabled: !!account, queryFn: () => dapi.account(account!.id), refetchInterval: (q) => ((q.state.data?.positions.length ?? 0) + (q.state.data?.openOrders.length ?? 0) > 0 ? 3000 : 10_000) });115  const d = account && detail.data?.id === account.id ? detail.data : undefined;116  const lv = useLiveAccount(venueAccount?.id ?? null);117  const liveCfg = useQuery({ queryKey: ['deriv', 'live', 'config', locale], queryFn: () => lapi.config(locale), enabled: !!venueAccount, staleTime: 60_000, refetchInterval: 60_000, retry: 0 });118  const held = venueAccount ? (lv.detail?.venueState.error ? null : lv.detail?.venueState ?? null) : d ?? account;119  const overlays = useMemo(() => overlaysOf(held, id, t, !!venueAccount), [held, id, locale]);   // eslint-disable-line react-hooks/exhaustive-deps120  const tone: Tone = chosen ? toneOf(chosen) : 'paper';121  const switcher = <AccountSwitch value={chosen?.id ?? ''} onChange={onAccount} choices={[...mine, ...picked.live].map((a) => ({ id: a.id, label: a.label, tone: toneOf(a) }))} />;122  const band = <ModeBand tone={tone} label={chosen?.label} compact={phone} />;123  const unknown = !instrumentsLoading && instruments.length > 0 && !byId.has(id) && meta.error !== null;124125  if (unknown) return <Card><div className="space-y-3"><Notice tone="warn">{t('fxUnknownMarket', { id })}</Notice><Btn kind="soft" onClick={() => onMarket(instruments[0]!.id)}>{t('fxOpenDefault', { symbol: instruments[0]!.symbol })}</Btn></div></Card>;126  const pdp = priceDp(instrument?.tickSize, store.ticker?.mark);127  const hasBook = instrument ? instrument.venue !== 'gmx' : true;128  const chart = <Veil store={store}><Candles store={store} interval={interval} onInterval={setIntervalV} overlays={overlays} priceDp={pdp} locale={locale} stale={false} height={phone ? 300 : wide ? 430 : 380} /></Veil>;129  const book = instrument ? (hasBook ? <Veil store={store}><BookPanel store={store} instrument={instrument} rows={phone ? 9 : 10} /></Veil> : <LiquidityPanel store={store} />) : <Skeleton h="h-64" />;130  const trades = instrument && hasBook ? <Veil store={store}><TradesPanel store={store} instrument={instrument} rows={phone ? 14 : wide ? 9 : 14} /></Veil> : null;131  const order = !instrument ? <Skeleton h="h-64" />132    : venueAccount ? <LiveOrderPanel instrument={instrument} account={venueAccount} detail={lv.detail} conn={lv.conn} workerError={lv.workerError} config={liveCfg.data} switcher={switcher} goAccounts={goAccounts} />133    : <OrderPanel store={store} instrument={instrument} account={account} accounts={mine} venue={venue} onAccount={onAccount} goAccounts={goAccounts} switcher={picked.live.length > 0 && account ? switcher : undefined} />;134  const blotter = venueAccount ? <LiveBlotter account={venueAccount} detail={lv.detail} instruments={byId} ttlMs={liveCfg.data?.bounds.orderApprovalTtlMs ?? 90_000} onSelect={onMarket} />135    : <Blotter account={account} detail={d} instruments={byId} onSelect={onMarket} />;136  const nPos = held?.positions.length ?? 0;137  const head = (s: string) => <h3 className="mb-2 text-[13px] font-semibold text-mute">{s}</h3>;138  const picker = pick && <MarketPicker instruments={instruments} current={id} onClose={() => setPick(false)} onPick={(m) => { setPick(false); onMarket(m); }} />;139  const error = meta.error && !store.ticker ? <Notice tone="danger">{t('fxMarketError')}</Notice> : null;140  const facts = instrument && <VenueFacts instrument={instrument} store={store} />;141142  if (phone) return (143    <div className="space-y-3" data-testid="terminal" data-layout="phone" data-tone={tone}>144      <div className="nav-glass sticky top-[calc(env(safe-area-inset-top)+64px)] z-[5] -mx-5 space-y-2 border-b border-line px-5 pb-2 pt-2">145        {band}146        <MarketHeader store={store} instrument={instrument} onPick={() => setPick(true)} compact />147        <Seg tabs size="sm" label={t('fxPanels')} value={tab} onChange={setTab} options={[{ id: 'chart', label: t('fxTabChart') }, { id: 'book', label: t(hasBook ? 'fxTabBook' : 'fxTabLiquidity') }, { id: 'trade', label: t('fxTabTrade') },148          { id: 'positions', label: `${t('fxTabPositions')}${nPos ? ` (${nPos})` : ''}` }]} />149      </div>150      <StaleBanner store={store} />{error}151      <div role="tabpanel" aria-label={t('fxPanels')}>152        {tab === 'chart' && <div className="space-y-3"><Card className="!p-4">{chart}</Card><Card className="!p-4"><MarketStats store={store} instrument={instrument} /></Card>{facts}</div>}153        {tab === 'book' && <div className="space-y-3"><Card className="!p-4">{head(t(hasBook ? 'fxBook' : 'fxGmxLiquidity'))}{book}</Card>{trades && <Card className="!p-4">{head(t('fxTrades'))}{trades}</Card>}</div>}154        {tab === 'trade' && <Card className="!p-4">{order}</Card>}155        {tab === 'positions' && <Card className="!p-4">{blotter}</Card>}156      </div>157      {picker}158    </div>159  );160  return (161    <div className="space-y-3" data-testid="terminal" data-layout={wide ? 'desktop' : 'tablet'} data-tone={tone}>162      <div className="sticky top-[calc(env(safe-area-inset-top)+64px)] z-[5] lg:top-2">{band}</div>163      <Card className="!p-4"><MarketHeader store={store} instrument={instrument} onPick={() => setPick(true)} compact={false} /></Card>164      <StaleBanner store={store} />{error}165      {wide ? <div className="grid grid-cols-[minmax(0,1fr)_292px_324px] items-start gap-3">166        <div className="space-y-3"><Card className="!p-4">{chart}</Card>{facts}</div>167        <div className="space-y-3"><Card className="!p-3.5">{head(t(hasBook ? 'fxBook' : 'fxGmxLiquidity'))}{book}</Card>{trades && <Card className="!p-3.5">{head(t('fxTrades'))}{trades}</Card>}</div>168        <Card className="!p-4">{order}</Card>169      </div> : <div className="grid grid-cols-[minmax(0,1fr)_324px] items-start gap-3">170        <div className="space-y-3"><Card className="!p-4">{chart}</Card>171          <div className={`grid gap-3 ${trades ? 'grid-cols-2' : 'grid-cols-1'}`}><Card className="!p-3.5">{head(t(hasBook ? 'fxBook' : 'fxGmxLiquidity'))}{book}</Card>{trades && <Card className="!p-3.5">{head(t('fxTrades'))}{trades}</Card>}</div>{facts}</div>172        <Card className="!p-4">{order}</Card>173      </div>}174      <Card className="!p-4">{blotter}</Card>175      {picker}176    </div>177  );178}179180/** what this contract is, in the venue's own terms — two perpetuals on the same asset are not the same contract */181function VenueFacts({ instrument: i }: { instrument: Instrument; store: MarketStore }) {182  const t = useT(); const { locale } = useContext(I18n);183  const nf = (s: string | null) => (s === null ? '—' : s.length > 12 ? Number(s).toExponential(0) : s.replace('.', locale === 'en' ? '.' : ','));184  return <Card className="!p-4"><details data-testid="contract-facts">185    <summary className="flex min-h-11 cursor-pointer items-center text-[13.5px] font-semibold">{t('fxContract', { venue: VENUE_NAME[i.venue], symbol: i.symbol })}</summary>186    <dl className="mt-1 grid grid-cols-1 gap-x-6 gap-y-2 text-[13px] sm:grid-cols-2">187      <div><dt className="text-[11.5px] text-mute">{t('fxCollateral')}</dt><dd>{i.collateral}</dd></div>188      <div><dt className="text-[11.5px] text-mute">{t('fxMaxLeverage')} · {t('fxMarginModes')}</dt><dd>{nf(i.maxLeverage)}× · {i.marginModes.map((m) => t(m === 'cross' ? 'fxCross' : 'fxIsolated')).join(', ')}</dd></div>189      <div><dt className="text-[11.5px] text-mute">{t('fxTickLot')}</dt><dd>{nf(i.tickSize)} · {nf(i.lotSize)} {i.base}</dd></div>190      <div><dt className="text-[11.5px] text-mute">{t('fxMinOrder')}</dt><dd>{i.minNotional ? `${nf(i.minNotional)} USD` : i.minOrderSize ? `${nf(i.minOrderSize)} ${i.base}` : '—'}</dd></div>191      <div className="sm:col-span-2"><dt className="text-[11.5px] text-mute">{t('fxSettlement')}</dt><dd className="leading-snug">{i.settlement}</dd></div>192      <div className="sm:col-span-2"><dt className="text-[11.5px] text-mute">{t('fxMarkMethod')}</dt><dd className="leading-snug">{i.markPriceMethod}</dd></div>193    </dl>194    <p className="mt-2 text-[12px] leading-snug text-mute">{t('fxVenueText')}</p>195  </details></Card>;196}197
Files main
Menu

Theme