Repository Public
1/**2 * The `compare` table for one base asset across venues — a table, never a recommendation: no ranking, no "best" mark. Under3 * it, the contract differences in words (collateral, settlement, funding, leverage, fees) and the reminder that these4 * contracts are not interchangeable (docs/v4/venues/contract-differences.md).5 */6import { useQuery } from '@tanstack/react-query';7import { useContext, useMemo } from 'react';8import { errText, I18n, useT, type Key } from '../i18n';9import { Card, H, Notice, Skeleton } from '../ui';10import { dapi } from './api';11import { DataTable, Seg, VENUE_NAME, VenueBadge, type Col } from './bits';12import { compactUsd, fmt, pct, priceDp } from './dec';13import type { CompareRow, Instrument, VenueId } from './types';1415const ORDER: VenueId[] = ['hyperliquid', 'gmx', 'dydx'];16const MAJORS = ['BTC', 'ETH', 'SOL', 'XRP', 'DOGE', 'LINK', 'AVAX', 'ARB'];17const FIELDS = ['collateral', 'settlement', 'funding', 'leverage', 'fee', 'price'] as const;18const intervalText = (sec: number | null) => (sec === null ? '—' : sec % 3600 === 0 ? `${sec / 3600} h` : `${Math.round(sec / 60)} min`);1920export function CompareView({ instruments, base, onBase, onOpen }: { instruments: Instrument[]; base: string; onBase: (b: string) => void; onOpen: (id: string) => void }) {21 const t = useT(); const { locale } = useContext(I18n);22 const bases = useMemo(() => { const m = new Map<string, Set<string>>(); for (const i of instruments) if (i.status !== 'halted') (m.get(i.base) ?? m.set(i.base, new Set()).get(i.base)!).add(i.venue);23 const rank = (b: string) => { const i = MAJORS.indexOf(b); return i < 0 ? MAJORS.length : i; }; // a fixed, familiar order of assets — not a ranking of venues24 return [...m.entries()].filter(([, v]) => v.size > 1).sort((a, b) => rank(a[0]) - rank(b[0]) || b[1].size - a[1].size || a[0].localeCompare(b[0])).map(([b]) => b).slice(0, 8); }, [instruments]);25 const q = useQuery({ queryKey: ['deriv', 'compare', base], queryFn: () => dapi.compare(base), refetchInterval: 15_000 });26 const rows = useMemo(() => [...(q.data?.rows ?? [])].sort((a, b) => ORDER.indexOf(a.venue) - ORDER.indexOf(b.venue) || a.instrument.localeCompare(b.instrument)), [q.data]); // fixed venue order: never by "quality"27 const cols: Col<CompareRow>[] = [28 { id: 'v', label: t('fxVenue'), head: true, cell: (r) => <><VenueBadge venue={r.venue} />{r.restriction ? <span className="mt-1 block text-[11.5px] font-normal text-warn">{t('fxDataOnly')}</span> : null}</> },29 { id: 'i', label: t('fxInstrument'), head: true, cell: (r) => <button type="button" onClick={() => onOpen(r.instrument)} className="-my-2 inline-flex min-h-11 items-center text-left font-semibold underline decoration-line underline-offset-4" aria-label={t('fxOpenMarket', { symbol: r.instrument })}>{r.instrument.slice(r.instrument.indexOf(':') + 1)}</button> },30 { id: 'mark', label: t('fxMark'), num: true, cell: (r) => fmt(r.mark, { dp: priceDp('0.01', r.mark), locale }) },31 { id: 'index', label: t('fxIndex'), num: true, cell: (r) => (r.index ? fmt(r.index, { dp: priceDp('0.01', r.index), locale }) : '—') },32 { id: 'fund', label: t('fxFundingPerHour'), num: true, cell: (r) => (r.funding ? <>{pct(r.funding, 5, locale, true)}<span className="block text-[11.5px] text-mute">{r.venue === 'gmx' ? t('fxContinuousShort') : t('fxEveryShort', { interval: intervalText(r.fundingVenue.intervalSec) })}</span></> : '—') },33 { id: 'spread', label: t('fxSpreadBps'), num: true, cell: (r) => (r.spreadBps ? fmt(r.spreadBps, { dp: 2, locale }) : <span className="text-mute">{r.venue === 'gmx' ? t('fxNoBookShort') : '—'}</span>) },34 { id: 'depth', label: t('fxDepth1pct'), num: true, cell: (r) => (r.depthUsd1pct ? compactUsd(r.depthUsd1pct, locale) : <span className="text-mute">{r.venue === 'gmx' ? t('fxNoBookShort') : '—'}</span>) },35 { id: 'fee', label: t('fxTakerFeeBps'), num: true, cell: (r) => (r.takerFeeBps ? fmt(r.takerFeeBps, { dp: 2, locale, trim: true }) : '—') },36 { id: 'lev', label: t('fxMaxLeverage'), num: true, cell: (r) => `${fmt(r.maxLeverage, { dp: 1, locale, trim: true })}×` },37 { id: 'coll', label: t('fxCollateral'), cell: (r) => r.collateral || '—' },38 { id: 'settle', label: t('fxSettlement'), cell: (r) => <span className="block max-w-[320px] whitespace-normal text-[12.5px] leading-snug text-mute md:w-[280px]">{r.settlement || '—'}</span> },39 { id: 'notes', label: t('fxNotes'), cell: (r) => (r.notes.length ? <details className="max-w-[360px] whitespace-normal md:w-[320px]"><summary className="flex min-h-11 cursor-pointer items-center text-[12.5px] font-medium text-blue">{t('fxNotesCount', { n: r.notes.length })}</summary>40 <ul className="list-disc space-y-1 pb-1 pl-4 text-[12.5px] leading-snug text-mute">{r.restriction ? <li>{r.restriction}</li> : null}{r.notes.map((n) => <li key={n}>{n}</li>)}</ul></details> : '—') },41 ];42 const present = ORDER.filter((v) => rows.some((r) => r.venue === v));43 return (44 <div className="space-y-4" data-testid="compare-view">45 <Card>46 <H>{t('fxCompareTitle', { base })}</H>47 {bases.length > 0 && <Seg size="sm" label={t('fxBaseAsset')} value={base} onChange={onBase} options={(bases.includes(base) ? bases : [base, ...bases]).map((b) => ({ id: b, label: b }))} className="mb-3 max-w-xl" />}48 <div className="mb-3"><Notice tone="warn"><b className="font-semibold">{t('fxNotInterchangeable')}</b> {t('fxNotInterchangeableBody')}</Notice></div>49 {(q.data?.errors.length ?? 0) > 0 && <div className="mb-3"><Notice tone="warn">{t('fxVenueErrors', { venues: q.data!.errors.map((e) => VENUE_NAME[e.venue as VenueId] ?? e.venue).join(', ') })}</Notice></div>}50 {q.isLoading ? <Skeleton h="h-40" /> : q.isError ? <Notice tone="danger">{errText(t, q.error) || t('fxErrGeneric')}</Notice>51 : <DataTable label={t('fxCompareTitle', { base })} cols={cols} rows={rows} rowKey={(r) => r.instrument} empty={t('fxCompareEmpty', { base })} />}52 <p className="mt-3 text-[12px] leading-snug text-mute">{t('fxCompareNote')}</p>53 </Card>54 <Card>55 <H>{t('fxDiffTitle')}</H>56 <div className="grid grid-cols-1 gap-4 xl:grid-cols-3">{(present.length ? present : ORDER).map((v) => <section key={v} className="rounded-2xl border border-line p-4" aria-label={VENUE_NAME[v]}>57 <VenueBadge venue={v} />58 <dl className="mt-3 space-y-2.5">{FIELDS.map((k) => <div key={k}><dt className="text-[11.5px] font-semibold uppercase tracking-wide text-mute">{t(`fxDiffField_${k}` as Key)}</dt><dd className="text-[13px] leading-snug">{t(`fxDiff_${v}_${k}` as Key)}</dd></div>)}</dl>59 </section>)}</div>60 <ul className="mt-4 list-disc space-y-1.5 pl-5 text-[13px] leading-snug">{(['fxNever1', 'fxNever2', 'fxNever3', 'fxNever4'] as const).map((k) => <li key={k}>{t(k)}</li>)}</ul>61 <p className="mt-3 text-[12px] leading-snug text-mute">{t('fxDiffSource')}</p>62 </Card>63 </div>64 );65}66