Repository Public
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