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