SPB Git

Repository Public

adapter.test.ts 28.0 KB 380 lines typescript

1// GMX v2 read adapter against real answers of the GMX API, the Oracle API and the Subsquid GraphQL endpoint (Arbitrum),2// recorded on 2026-11-10/11 (test/fixtures/record.mjs). The account is a public address seen in the trade feed that day.3import { describe, expect, it } from 'vitest';4import { accountState, candle, fill, instrument, order, position, publicTrade, ticker } from '../venue-helpers.ts';5import { createGmxAdapter, D, VenueError, GMX_CAPABILITIES,6  gmxApiPrice, gmxBps, gmxContractPrice, gmxPerSecondToHourly, gmxPriceStep, gmxToContractPrice, gmxTokenAmount, gmxUsd, MAX_UINT256,7  fixed, toRaw, parseJsonExact, jsonNumberToDecStr, JsonNumber, canonDec } from '../../src/index.ts';8import { oracleCandles } from '../../src/venues/gmx/wire.ts';9import { fx, replay, type Route } from '../venue-helpers.ts';1011const ROUTES: Route[] = [12  ['/markets/tickers', fx('gmx', 'v1-markets-tickers')], ['/markets/config', fx('gmx', 'v1-markets-config')], [/\/v1\/markets$/, fx('gmx', 'v1-markets')],13  ['/tokens/info', fx('gmx', 'v1-tokens-info')], ['/pairs', fx('gmx', 'v1-pairs')], ['symbol=NOPE', fx('gmx', 'v1-ohlcv-unknown')], ['/prices/ohlcv', fx('gmx', 'v1-ohlcv-BTC-1h')],14  ['/rates', fx('gmx', 'v1-rates-BTC-7d')], ['gmxinfra.io/prices/tickers', fx('gmx', 'oracle-prices-tickers')], ['gmxinfra.io/tokens', fx('gmx', 'oracle-tokens')],15  ['squids.live', fx('gmx', 'graphql-tradeActions-BTC')], ['address=0x123', fx('gmx', 'v1-positions-bad-address')],16  ['/positions?address=0x1111', fx('gmx', 'v1-positions-empty')], ['/positions', fx('gmx', 'v1-positions')], ['/orders', fx('gmx', 'v1-orders')], ['/trades', fx('gmx', 'v1-trades')],17];18const NOW = Date.parse('2026-11-11T03:00:00Z');19const gmx = (patch?: (url: string, body: any) => unknown, routes: Route[] = ROUTES) => { const r = replay(routes, patch); return { a: createGmxAdapter({ fetch: r.fetch, now: () => NOW }), calls: r.calls }; };20const BTC = 'BTC/USD [BTC-USDC]', ETH = 'ETH/USD [WETH-USDC]', BTC_MARKET = '0x47c031236e19d024b42f8AE6780E44A573170703';21const ACCOUNT = '0x46a3BA57e146A4906bc68759Fb7ad1E74d3B66f6';22const rejects = async (p: Promise<unknown>, code: string, re?: RegExp) => {23  const e = await p.then(() => null, (x) => x);24  expect(e).toBeInstanceOf(VenueError);25  expect((e as VenueError).code).toBe(code);26  if (re) expect((e as VenueError).message).toMatch(re);27};28const body = (name: string) => JSON.parse(fx('gmx', name).text);29const patchAt = (path: string, mutate: (b: any) => void) => (url: string, b: any) => { if (url.includes(path)) { mutate(b); return b; } return undefined; };3031describe('GMX units (bigint only)', () => {32  it('30-decimal USD values', () => {33    expect(gmxUsd('9025899364919955331133472800000000000')).toBe('9025899.3649199553311334728');34    expect(gmxUsd('1')).toBe('0.000000000000000000000000000001');35    expect(gmxUsd('-31220937718530059282206175000000')).toBe('-31.220937718530059282206175');36    expect(gmxUsd('1000000000000000000000000000000')).toBe('1');37    expect(gmxUsd('0')).toBe('0');38    // a double keeps ~16 significant digits: this value would be corrupted by Number()39    const raw = '73634340805077142500000000000000001';40    expect(BigInt(Math.round(Number(raw))).toString()).not.toBe(raw);41    expect(toRaw(gmxUsd(raw), 30).toString()).toBe(raw);42  });43  it('per-token price precision: contract prices are scaled by 10^(30 − token decimals)', () => {44    expect(gmxContractPrice('736743447840545300000000000', 8)).toBe('73674.34478405453');       // BTC, 8 decimals → 2245    expect(gmxContractPrice('2060052770660954', 18)).toBe('2060.052770660954');                  // ETH, 18 decimals → 1246    expect(gmxContractPrice('999780700000000000000000', 6)).toBe('0.9997807');                   // USDC, 6 decimals → 2447    expect(gmxToContractPrice('73674.34478405453', 8)).toBe(736743447840545300000000000n);48    expect(gmxToContractPrice('2060.052770660954', 18)).toBe(2060052770660954n);49    expect(() => gmxToContractPrice('2060.0527706609541', 18)).toThrow(/more than 12 decimals/);50    expect(() => gmxContractPrice('1', 31)).toThrow();51    expect(gmxPriceStep(8)).toBe('0.0000000000000000000001');52    expect(gmxPriceStep(18)).toBe('0.000000000001');53    // the same BTC price in the two API formats differs by exactly 10^854    expect(gmxApiPrice('73674344784054530000000000000000000')).toBe(gmxContractPrice('736743447840545300000000000', 8));55  });56  it('token amounts, basis points, rates', () => {57    expect(gmxTokenAmount('23559543727', 8)).toBe('235.59543727');58    expect(gmxTokenAmount('342804064', 6)).toBe('342.804064');59    expect(gmxBps('384930')).toBe('38.493');60    expect(gmxBps('2000000')).toBe('200');61    expect(gmxBps('-150')).toBe('-0.015');62    expect(gmxPerSecondToHourly('3478489931057503807801')).toBe('0.0000125225637518070137080836');63    expect(MAX_UINT256.toString()).toBe('115792089237316195423570985008687907853269984665640564039457584007913129639935');64    expect(fixed(-5n, 0)).toBe('-5');65    expect(() => toRaw('1.23', 1)).toThrow();66  });67  it('JSON numbers are read from their source text, never through a double', () => {68    const p = parseJsonExact('{"a": 73503.65, "b": 0.1, "c": 12345678901234567890.123456789, "d": 1.5e-7, "e": -2E3, "f": "x", "g": [1e21, 0.30000000000000004]}') as any;69    expect(p.a).toBeInstanceOf(JsonNumber);70    expect(jsonNumberToDecStr(p.a)).toBe('73503.65');71    expect(jsonNumberToDecStr(p.b)).toBe('0.1');72    expect(jsonNumberToDecStr(p.c)).toBe('12345678901234567890.123456789');73    expect(jsonNumberToDecStr(p.d)).toBe('0.00000015');74    expect(jsonNumberToDecStr(p.e)).toBe('-2000');75    expect(p.f).toBe('x');76    expect(jsonNumberToDecStr(p.g[0])).toBe('1000000000000000000000');77    expect(jsonNumberToDecStr(p.g[1])).toBe('0.30000000000000004');78    expect(canonDec('-0.0')).toBe('0');79  });80  it('Oracle API candles (JSON floats) parse exactly', () => {81    const f = fx('gmx', 'oracle-candles-BTC-1h');82    const c = oracleCandles.parse(parseJsonExact(f.text));83    expect(c.candles.length).toBe(24);84    for (const row of c.candles) for (const n of row) expect(f.text).toContain(n.text);85    const [ts, open] = c.candles[0]!;86    expect(jsonNumberToDecStr(ts)).toMatch(/^17\d{8}$/);87    expect(jsonNumberToDecStr(open)).toMatch(/^\d{5}(\.\d+)?$/);88  });89});9091describe('GMX market data', () => {92  it('one instrument per GM market, swap-only markets excluded, every field from the venue', async () => {93    const { a } = gmx();94    const list = await a.instruments();95    const raw = body('v1-markets') as any[];96    expect(list.length).toBe(raw.filter((m) => !m.isSpotOnly).length);97    expect(list.some((i) => i.symbol.startsWith('SWAP-ONLY'))).toBe(false);98    for (const i of list) expect(instrument.safeParse(i).success, i.symbol).toBe(true);99    expect(list.find((i) => i.symbol === BTC)).toMatchObject({ venue: 'gmx', id: `gmx:${BTC}`, base: 'BTC', quote: 'USD', kind: 'perpetual', collateral: 'BTC or USDC',100      tickSize: '0.0000000000000000000001', lotSize: '0.00000001', minOrderSize: null, minNotional: '1', maxLeverage: '200', marginModes: ['isolated'], fundingIntervalSec: 3600, status: 'active' });101    expect(list.find((i) => i.symbol === ETH)).toMatchObject({ base: 'ETH', collateral: 'WETH or USDC', tickSize: '0.000000000001', lotSize: '0.000000000000000001' });102    // the same index against another pool is another instrument, with its own collateral103    expect(list.find((i) => i.symbol === 'BTC/USD [BTC-BTC]')).toMatchObject({ base: 'BTC', collateral: 'BTC' });104    expect(list.filter((i) => i.base === 'BTC').length).toBeGreaterThan(2);105  });106  it('tickers: 30-decimal prices and rates converted exactly; funding normalized to "what longs pay per hour"', async () => {107    const { a } = gmx();108    const t = (await a.tickers()).find((x) => x.instrument === `gmx:${BTC}`)!;109    expect(ticker.safeParse(t).success).toBe(true);110    const raw = (body('v1-markets-tickers') as any[]).find((x) => x.symbol === BTC);111    expect(toRaw(t.mark, 30).toString()).toBe(raw.markPrice);112    expect(t.index).toBe(t.mark);113    expect(t.mid).toBeNull();114    expect(toRaw(t.funding!, 30)).toBe(-BigInt(raw.fundingRateLong));115    expect(toRaw(t.openInterest!, 8)).toBe(BigInt(raw.longInterestInTokens) + BigInt(raw.shortInterestInTokens));116    expect(t.change24h).toBe(gmxBps(raw.priceChangePercent24hBps));117    // 24 h volume is the listing feed's number, taken from the response text digit for digit118    const pairText = fx('gmx', 'v1-pairs').text, at = pairText.indexOf(`"pool_id":"${BTC_MARKET}"`);119    const vol = /"target_volume":([-0-9.eE+]+)/.exec(pairText.slice(pairText.lastIndexOf('{', at), pairText.indexOf('}', at)))![1]!;120    expect(t.volume24h).toBe(canonDec(vol));121    expect(D.cmp(D.from(t.mark), D.from('10000'))).toBe(1);122    expect(D.cmp(D.abs(D.from(t.funding!)), D.from('0.001'))).toBe(-1);           // an hourly rate, not an annual one123  });124  it('tickers survive the listing feed being down: volume is null, nothing is invented', async () => {125    const routes = ROUTES.map((r): Route => (r[0] === '/pairs' ? ['/pairs', { status: 503, text: 'down' }] : r));126    const t = (await gmx(undefined, routes).a.tickers()).find((x) => x.instrument === `gmx:${BTC}`)!;127    expect(t.volume24h).toBeNull();128    expect(t.mark).toMatch(/^\d+\.\d+$/);129  });130  it('has no order book and says so; exposes liquidity, open interest, rates and price-impact parameters instead', async () => {131    const { a } = gmx();132    await rejects(a.book(BTC), 'unsupported', /no order book/);133    const [l] = await a.liquidity(BTC);134    const tk = (body('v1-markets-tickers') as any[]).find((x) => x.symbol === BTC), cf = (body('v1-markets-config') as any[]).find((x) => x.name === BTC);135    expect(l).toMatchObject({ instrument: `gmx:${BTC}`, market: BTC_MARKET, capacityLimitedBy: { long: 'reserve', short: 'reserve' },136      priceImpact: { maxFactorPositive: '0.004', maxFactorNegative: '0.005' }, positionFeeFactor: { balanceImproved: '0.0004', balanceNotImproved: '0.0006' },137      minCollateralFactor: '0.005', minCollateralFactorForLiquidation: '0.005' });138    expect(toRaw(l!.openInterestLongUsd, 30).toString()).toBe(tk.longInterestUsd);139    expect(toRaw(l!.availableLiquidityShortUsd, 30).toString()).toBe(tk.availableLiquidityShort);140    expect(toRaw(l!.capacityLongUsd, 30).toString()).toBe(tk.capacityLong.availableLiquidity);141    expect(toRaw(l!.maxOpenInterestLongUsd, 30).toString()).toBe(cf.maxOpenInterestLong);142    // open interest in index tokens, and the market's flag saying that the balance (price impact, fee tier) is measured on it × price143    expect(toRaw(l!.openInterestLongTokens, 8).toString()).toBe(tk.longInterestInTokens); expect(toRaw(l!.openInterestShortTokens, 8).toString()).toBe(tk.shortInterestInTokens);144    expect(l!.useOpenInterestInTokensForBalance).toBe(true); expect(cf.useOpenInterestInTokensForBalance).toBe(true);145    expect(toRaw(l!.priceImpact.factorNegative, 30).toString()).toBe(cf.positionImpactFactorNegative);146    expect(toRaw(l!.priceImpact.exponentPositive, 30).toString()).toBe(cf.positionImpactExponentFactorPositive);147    // venue sign kept here: net = funding − borrowing for each side148    expect(toRaw(l!.netRateShortPerHour, 30)).toBe(BigInt(tk.fundingRateShort) - BigInt(tk.borrowingRateShort));149    expect((await a.liquidity()).length).toBe((body('v1-markets-tickers') as any[]).filter((x) => !x.symbol.startsWith('SWAP-ONLY')).length);150    await rejects(a.liquidity('BTC/USD [NOPE]'), 'rejected', /unknown perpetual market/);151    await rejects(a.liquidity('SWAP-ONLY [USDC-USDT]'), 'rejected');152  });153  it('oracle prices in contract precision agree with the GMX API price of the same token (two formats, one value)', async () => {154    const { a } = gmx();155    const ps = await a.oraclePrices();156    const btc = ps.find((p) => p.symbol === 'BTC')!, eth = ps.find((p) => p.symbol === 'ETH')!, usdc = ps.find((p) => p.symbol === 'USDC')!;157    const raw = (body('oracle-prices-tickers') as any[]).find((x) => x.tokenSymbol === 'BTC');158    expect(toRaw(btc.min, 22).toString()).toBe(raw.minPrice);159    expect(D.cmp(D.from(btc.min), D.from(btc.max))).toBeLessThanOrEqual(0);160    const mark = (await a.tickers()).find((x) => x.instrument === `gmx:${BTC}`)!.mark;161    const ratio = D.div(D.from(btc.min), D.from(mark), 6, 'half_even');             // recorded seconds apart162    expect(D.cmp(ratio, D.from('0.99'))).toBe(1); expect(D.cmp(ratio, D.from('1.01'))).toBe(-1);163    expect(D.cmp(D.from(eth.min), D.from('100'))).toBe(1); expect(D.cmp(D.from(eth.min), D.from('100000'))).toBe(-1);164    expect(D.cmp(D.from(usdc.min), D.from('0.9'))).toBe(1); expect(D.cmp(D.from(usdc.max), D.from('1.1'))).toBe(-1);165  });166  it('public trades from the Subsquid feed: execution price in contract precision, size in tokens, no zero-size rows', async () => {167    const { a, calls } = gmx();168    const tr = await a.trades(BTC, 30);169    for (const t of tr) expect(publicTrade.safeParse(t).success).toBe(true);170    const raw = body('graphql-tradeActions-BTC').data.tradeActions as any[];171    expect(tr.length).toBe(raw.filter((x) => BigInt(x.sizeDeltaInTokens) > 0n).length);172    expect(tr.every((t) => !D.isZero(D.from(t.size)))).toBe(true);173    const first = raw.find((x) => BigInt(x.sizeDeltaInTokens) > 0n);174    expect(tr[0]).toEqual({ instrument: `gmx:${BTC}`, price: gmxContractPrice(first.executionPrice, 8), size: gmxTokenAmount(first.sizeDeltaInTokens, 8),175      side: ([2, 3, 8].includes(first.orderType)) === first.isLong ? 'buy' : 'sell', ts: first.timestamp * 1000, id: first.id });176    const call = calls.find((c) => c.url.includes('squids.live'))!;177    expect(call.method).toBe('POST');178    expect(call.body).toContain(BTC_MARKET);179    expect(call.body).toContain('OrderExecuted');180  });181  it('candles (string prices, no volume published) and hourly funding history', async () => {182    const { a, calls } = gmx();183    const cs = await a.candles(BTC, '1h', { limit: 24 });184    expect(cs.length).toBe(24);185    for (const c of cs) { expect(candle.safeParse(c).success).toBe(true); expect(c.volume).toBe('0'); }186    for (let i = 1; i < cs.length; i++) expect(cs[i]!.ts - cs[i - 1]!.ts).toBe(3_600_000);187    expect(calls.find((c) => c.url.includes('/prices/ohlcv'))!.url).toMatch(/symbol=BTC&timeframe=1h&limit=24$/);188    await a.candles(BTC, '5m', { start: 1794300000000 });189    expect(calls.at(-1)!.url).toMatch(/timeframe=5m&limit=500&since=1794300000000$/);190    const fh = await a.fundingHistory(BTC, { start: NOW - 6 * 86_400_000 });191    expect(calls.at(-1)!.url).toMatch(new RegExp(`/rates\\?period=7d&address=${BTC_MARKET}$`));192    expect(fh.length).toBeGreaterThan(100);193    for (let i = 1; i < fh.length; i++) expect(fh[i]!.ts - fh[i - 1]!.ts).toBe(3_600_000);194    const raw = body('v1-rates-BTC-7d')[0].ratesSnapshots as any[];195    const last = raw.reduce((m, x) => (x.timestamp > m.timestamp ? x : m));196    expect(fh.at(-1)).toEqual({ ts: last.timestamp * 1000, rate: gmxPerSecondToHourly(-BigInt(last.fundingRateLong)), premium: null });197    // per-second snapshots × 3600 land on the same scale as the ticker's hourly rate198    const now = (await a.tickers()).find((x) => x.instrument === `gmx:${BTC}`)!.funding!;199    const ratio = D.div(D.from(fh.at(-1)!.rate), D.from(now), 4, 'half_even');200    expect(D.cmp(ratio, D.from('0.5'))).toBe(1); expect(D.cmp(ratio, D.from('2'))).toBe(-1);201  });202  it('capabilities are honest', () => {203    const { a } = gmx();204    expect(a.capabilities).toBe(GMX_CAPABILITIES);205    expect(a.capabilities).toMatchObject({ marketData: true, account: true, orders: false, orderTypes: [], tif: [], stream: false, testnet: false, marginModes: ['isolated'], subaccounts: false });206    expect(a.stream).toBeUndefined();207    expect(a.trading).toBeUndefined();208    expect(a.capabilities.notes.join(' ')).toMatch(/NO ORDER BOOK/);209    expect(a.capabilities.notes.join(' ')).toMatch(/keeper/);210  });211});212213describe('GMX account reads by EVM address', () => {214  it('positions keep the fee and price-impact fields', async () => {215    const { a } = gmx();216    const ps = await a.positions(ACCOUNT);217    const raw = body('v1-positions') as any[];218    expect(ps.length).toBe(raw.length);219    for (const p of ps) expect(position.safeParse(p).success).toBe(true);220    const p = ps[0]!, r = raw[0];221    expect(p).toMatchObject({ instrument: 'gmx:LTC/USD [WETH-USDC]', side: r.isLong ? 'long' : 'short', marginMode: 'isolated', realizedPnl: null, source: 'venue' });222    expect(toRaw(p.size, 8).toString()).toBe(r.sizeInTokens);223    expect(toRaw(p.entryPrice, 30).toString()).toBe(r.entryPrice);224    expect(toRaw(p.markPrice, 30).toString()).toBe(r.markPrice);225    expect(toRaw(p.liquidationPrice!, 30).toString()).toBe(r.liquidationPrice);226    expect(p.leverage).toBe(gmxBps(r.leverage));227    expect(toRaw(p.unrealizedPnl, 30).toString()).toBe(r.pnl);228    expect(toRaw(p.gmx.sizeUsd, 30).toString()).toBe(r.sizeInUsd);229    expect(toRaw(p.gmx.collateralAmount, 6).toString()).toBe(r.collateralAmount);230    expect(p.gmx.collateralSymbol).toBe('USDC');231    expect(toRaw(p.gmx.closingFeeUsd, 30).toString()).toBe(r.closingFeeUsd);232    expect(toRaw(p.gmx.pendingImpactUsd, 30).toString()).toBe(r.pendingImpactUsd);233    expect(toRaw(p.gmx.closePriceImpactDeltaUsd, 30).toString()).toBe(r.closePriceImpactDeltaUsd);234    expect(toRaw(p.gmx.netPriceImpactDeltaUsd, 30).toString()).toBe(r.netPriceImapctDeltaUsd);235    expect(toRaw(p.gmx.pnlAfterAllFeesUsd, 30).toString()).toBe(r.pnlAfterAllFees);236    expect(p.gmx.contractKey).toBe(r.contractKey);237    // sanity: size × mark ≈ USD size (within 2 %), all in decimals238    const ratio = D.div(D.mul(D.from(p.size), D.from(p.markPrice)), D.from(p.gmx.sizeUsd), 6, 'half_even');239    expect(D.cmp(ratio, D.from('0.98'))).toBe(1); expect(D.cmp(ratio, D.from('1.02'))).toBe(-1);240  });241  it('account state is the exact sum of the positions (GMX has no account-level margin)', async () => {242    const { a } = gmx();243    const s = await a.account(ACCOUNT);244    expect(accountState.safeParse(s).success).toBe(true);245    const raw = body('v1-positions') as any[];246    expect(toRaw(s.equity, 30)).toBe(raw.reduce((x, p) => x + BigInt(p.netValue), 0n));247    expect(toRaw(s.collateral, 30)).toBe(raw.reduce((x, p) => x + BigInt(p.collateralUsd), 0n));248    expect(s).toMatchObject({ venue: 'gmx', account: ACCOUNT, freeCollateral: '0', marginUsed: s.collateral, maintenanceMargin: null, withdrawable: null, source: 'venue' });249    expect(await gmx().a.account('0x1111111111111111111111111111111111111111')).toMatchObject({ equity: '0', collateral: '0' });250    expect(await gmx().a.positions('0x1111111111111111111111111111111111111111')).toEqual([]);251  });252  it('open orders: trigger price in contract precision, USD size, whole-position sentinel resolved against the position', async () => {253    const { a } = gmx();254    const os = await a.openOrders(ACCOUNT);255    const raw = body('v1-orders') as any[], pos = (body('v1-positions') as any[])[0];256    expect(os.length).toBe(raw.length);257    for (const o of os) expect(order.safeParse(o).success).toBe(true);258    const stop = os.find((o) => o.gmx.orderType === 'StopLossDecrease')!, rs = raw.find((x) => x.orderType === 6);259    expect(BigInt(rs.sizeDeltaUsd)).toBe(MAX_UINT256);260    expect(stop).toMatchObject({ type: 'stop_market', side: 'buy', reduceOnly: true, price: null, status: 'open', tif: 'gtc', venueOrderId: rs.key, clientId: rs.key, filled: '0' });261    expect(stop.triggerPrice).toBe(gmxContractPrice(rs.triggerPrice, 8));262    expect(stop.gmx).toMatchObject({ closesWholePosition: true, sizeUnit: 'usd', collateralSymbol: 'USDC', autoCancel: true, isFrozen: false, receiver: ACCOUNT });263    expect(toRaw(stop.size, 30).toString()).toBe(pos.sizeInUsd);264    expect(toRaw(stop.gmx.executionFee!, 18).toString()).toBe(rs.executionFee);265    const partial = os.find((o) => !o.gmx.closesWholePosition)!, rp = raw.find((x) => BigInt(x.sizeDeltaUsd) !== MAX_UINT256);266    expect(partial.type).toBe('take_profit_market');267    expect(toRaw(partial.size, 30).toString()).toBe(rp.sizeDeltaUsd);268  });269  it('a whole-position order without a matching position keeps size 0 and the flag — no guess', async () => {270    const { a } = gmx(patchAt('/positions', (b) => { b.length = 0; }));271    const stop = (await a.openOrders(ACCOUNT)).find((o) => o.gmx.orderType === 'StopLossDecrease')!;272    expect(stop.size).toBe('0');273    expect(stop.gmx.closesWholePosition).toBe(true);274  });275  it('swap orders are not derivatives: left out; a market order still listed is pending its keeper', async () => {276    const { a } = gmx(patchAt('/orders', (b) => {277      b.push({ ...b[0], key: `0x${'ab'.repeat(32)}`, orderType: 0, marketAddress: '0x0000000000000000000000000000000000000000' });278      b.push({ ...b[0], key: `0x${'cd'.repeat(32)}`, orderType: 2, sizeDeltaUsd: '5000000000000000000000000000000000', acceptablePrice: '520000000000000000000000', triggerPrice: '0' });279    }));280    const os = await a.openOrders(ACCOUNT);281    expect(os.length).toBe((body('v1-orders') as any[]).length + 1);282    expect(os.at(-1)).toMatchObject({ type: 'market', status: 'pending', side: 'sell', reduceOnly: false, size: '5000', price: '52', triggerPrice: null });283  });284  it('fills: executed position orders with fees in the collateral token and price impact in USD', async () => {285    const { a } = gmx();286    const fs = await a.fills(ACCOUNT);287    expect(fs.length).toBeGreaterThan(1);288    for (const f of fs) expect(fill.safeParse(f).success).toBe(true);289    const raw = (body('v1-trades').trades as any[]).filter((t) => t.eventName === 'OrderExecuted' && t.orderType >= 2 && t.orderType <= 8 && BigInt(t.sizeDeltaInTokens ?? '0') > 0n);290    expect(fs.length).toBe(Math.min(raw.length, 100));291    const dec = raw.find((t) => t.orderType === 4)!, f = fs.find((x) => x.id === dec.id)!;292    expect(f).toMatchObject({ orderId: dec.orderKey, clientId: null, feeAsset: 'USDC', liquidation: false, side: dec.isLong ? 'sell' : 'buy' });293    expect(toRaw(f.price, 22).toString()).toBe(dec.executionPrice);294    expect(toRaw(f.size, 8).toString()).toBe(dec.sizeDeltaInTokens);295    expect(toRaw(f.fee, 6).toString()).toBe(dec.positionFeeAmount);296    expect(toRaw(f.closedPnl!, 30).toString()).toBe(dec.pnlUsd);297    expect(toRaw(f.gmx.borrowingFeeAmount, 6).toString()).toBe(dec.borrowingFeeAmount);298    expect(toRaw(f.gmx.fundingFeeAmount, 6).toString()).toBe(dec.fundingFeeAmount);299    expect(toRaw(f.gmx.priceImpactUsd!, 30).toString()).toBe(dec.priceImpactUsd);300    expect(toRaw(f.gmx.totalImpactUsd!, 30).toString()).toBe(dec.totalImpactUsd);301    expect(f.gmx.txHash).toBe(dec.transactionHash);302    const inc = fs.find((x) => x.gmx.orderType === 'MarketIncrease')!;303    expect(inc.closedPnl).toBeNull();304    const cutoff = fs[1]!.ts;305    expect((await a.fills(ACCOUNT, { start: cutoff })).every((x) => x.ts >= cutoff)).toBe(true);306    expect((await a.fills(ACCOUNT, { limit: 1 })).length).toBe(1);307  });308  it('order history rebuilt from trade actions: executed, cancelled (with the venue reason), still waiting', async () => {309    const { a } = gmx();310    const hist = await a.orderHistory(ACCOUNT, { limit: 50 });311    for (const o of hist) expect(order.safeParse(o).success).toBe(true);312    expect(new Set(hist.map((o) => o.venueOrderId)).size).toBe(hist.length);313    for (let i = 1; i < hist.length; i++) expect(hist[i - 1]!.updatedAt).toBeGreaterThanOrEqual(hist[i]!.updatedAt);314    const filled = hist.find((o) => o.status === 'filled')!;315    expect(filled.avgFillPrice).toMatch(/^\d+\.\d+$/);316    const cancelled = hist.find((o) => o.status === 'cancelled');317    if (cancelled) expect(cancelled.rejectReason).toBeTruthy();318    expect(hist.every((o) => o.createdAt <= o.updatedAt)).toBe(true);319  });320  it('funding payments are not a list on this venue: unsupported, with where to look', async () => {321    await rejects(gmx().a.funding(ACCOUNT), 'unsupported', /fundingFeeAmount/);322  });323  it('refuses a non-EVM account before any request; a venue 400 is a refusal', async () => {324    const { a, calls } = gmx();325    await rejects(a.positions('dydx1zzz66hkkjy6apat9w2enw37484v3xhqnlducf7'), 'rejected', /EVM address/);326    expect(calls.filter((c) => c.url.includes('/positions')).length).toBe(0);327    const direct = createGmxAdapter({ fetch: replay([[/./, fx('gmx', 'v1-positions-bad-address')]]).fetch });328    await rejects(direct.instruments(), 'rejected', /Invalid address/);329    await rejects(gmx().a.candles('NOPE/USD [X-Y]', '1h', {}), 'rejected', /unknown perpetual market/);330  });331});332333describe('GMX malformed or failing answers', () => {334  it('missing or mistyped fields are bad_answer', async () => {335    await rejects(gmx(patchAt('/markets/tickers', (b) => { delete b[0].markPrice; })).a.tickers(), 'bad_answer', /markPrice/);336    await rejects(gmx(patchAt('/markets/tickers', (b) => { b[0].markPrice = 73634.2; })).a.tickers(), 'bad_answer');337    await rejects(gmx(patchAt('/markets/tickers', (b) => { b[0].fundingRateLong = '1.5e25'; })).a.tickers(), 'bad_answer');338    await rejects(gmx(patchAt('/v1/markets', (b) => { if (Array.isArray(b) && b[0].leverageTiers) b[0].leverageTiers = []; })).a.instruments(), 'bad_answer');339    await rejects(gmx(patchAt('/tokens/info', (b) => { b.find((t: any) => t.symbol === 'USDC').decimals = '6'; })).a.instruments(), 'bad_answer');340    await rejects(gmx(patchAt('/positions', (b) => { delete b[0].collateralUsd; })).a.positions(ACCOUNT), 'bad_answer', /collateralUsd/);341    await rejects(gmx(patchAt('/positions', (b) => { b[0].sizeInUsd = 11990.45; })).a.account(ACCOUNT), 'bad_answer');342    await rejects(gmx(patchAt('/orders', (b) => { b[0].triggerPrice = null; })).a.openOrders(ACCOUNT), 'bad_answer');343    await rejects(gmx(patchAt('/trades', (b) => { delete b.trades; })).a.fills(ACCOUNT), 'bad_answer');344    await rejects(gmx(patchAt('/trades', (b) => { for (const t of b.trades) if (t.eventName === 'OrderExecuted') delete t.executionPrice; })).a.fills(ACCOUNT), 'bad_answer', /missing execution fields/);345    await rejects(gmx(patchAt('/prices/ohlcv', (b) => { b[0].close = 'n/a'; })).a.candles(BTC, '1h', {}), 'bad_answer');346    await rejects(gmx(patchAt('/rates', (b) => { b[0].ratesSnapshots[0].fundingRateLong = 12; })).a.fundingHistory(BTC, { start: 0 }), 'bad_answer');347    await rejects(gmx(patchAt('squids.live', (b) => { delete b.data; })).a.trades(BTC), 'bad_answer');348    await rejects(gmx(patchAt('/markets/config', (b) => { b.length = 0; })).a.liquidity(BTC), 'bad_answer', /no configuration/);349  });350  it('a position or order in a market or token the venue does not list is refused', async () => {351    await rejects(gmx(patchAt('/positions', (b) => { b[0].marketAddress = '0x00000000000000000000000000000000000000Aa'; })).a.positions(ACCOUNT), 'bad_answer', /unknown market/);352    await rejects(gmx(patchAt('/positions', (b) => { b[0].collateralTokenAddress = '0x00000000000000000000000000000000000000Bb'; })).a.positions(ACCOUNT), 'bad_answer', /unknown token/);353  });354  it('unknown extra fields are tolerated; transport and status errors map to the contract codes', async () => {355    expect((await gmx(patchAt('/markets/tickers', (b) => { b[0].brandNew = [1, 2]; })).a.tickers()).length).toBeGreaterThan(5);356    const one = (answer: Route[1]) => createGmxAdapter({ fetch: replay([[/./, answer]]).fetch });357    await rejects(one({ status: 429, text: '' }).instruments(), 'rate_limited');358    await rejects(one({ status: 504, text: '<html>' }).instruments(), 'unavailable');359    await rejects(one({ status: 200, text: 'oops' }).instruments(), 'bad_answer', /non-JSON/);360    await rejects(one(() => { throw new TypeError('fetch failed'); }).instruments(), 'unavailable');361  });362  it('the market catalogue is cached for a minute and refetched after a failure', async () => {363    let t = NOW;364    const r = replay(ROUTES);365    const a = createGmxAdapter({ fetch: r.fetch, now: () => t });366    await a.instruments(); await a.instruments();367    expect(r.calls.filter((c) => /\/v1\/markets$/.test(c.url)).length).toBe(1);368    t += 61_000;369    await a.instruments();370    expect(r.calls.filter((c) => /\/v1\/markets$/.test(c.url)).length).toBe(2);371    let fail = true;372    const flaky = createGmxAdapter({ now: () => NOW, fetch: (async (url: string, init: RequestInit) => {373      if (fail && /\/v1\/markets$/.test(String(url))) { fail = false; return new Response('down', { status: 503 }); }374      return r.fetch(url, init);375    }) as unknown as typeof fetch });376    await rejects(flaky.instruments(), 'unavailable');377    expect((await flaky.instruments()).length).toBeGreaterThan(50);378  });379});380
Files main
Menu

Theme