SPB Git

Repository Public

adapter.ts 34.4 KB 406 lines typescript

1// GMX v2 (GMX Synthetics) — READ side of the venue adapter (see docs/v4/venues/gmx.md).2// Plain HTTP on the documented public surfaces: the GMX API (`https://{chain}.gmxapi.io/v1`), the Oracle API3// (`https://{chain}-api.gmxinfra.io`) and the Subsquid GraphQL endpoint for the public trade feed. No SDK, no transaction.4//5// GMX is not an order-book venue: traders open against a liquidity pool at the signed oracle price, adjusted by price impact.6// `book()` therefore throws `unsupported`; what the venue really has is exposed by `liquidity()`.7import { D, type DecStr } from '../../decimal.ts';8import type { AccountState, Book, Candle, Fill, FundingPayment, Instrument, Order, Position, PublicTrade, Ticker } from '../../models.ts';9import type { Capabilities, VenueAdapter } from '../../venue.ts';10import { ds, jsonNumberToDecStr, venueError, venueHttp, type HttpAnswer } from '../http.ts';11import * as W from './wire.ts';12import { z } from 'zod';1314const GMX = 'gmx' as const;15export type GmxChain = 'arbitrum' | 'avalanche';16/** Pinned public endpoints — https://docs.gmx.io/docs/api/overview/ , /rest-api/fallback-urls/ , /graphql/ */17export const GMX_ENDPOINTS: Record<GmxChain, { api: string; apiPeer: string; oracle: string; oracleFallbacks: string[]; graphql: string; chainId: number }> = {18  arbitrum: { api: 'https://arbitrum.gmxapi.io/v1', apiPeer: 'https://arbitrum.gmxapi.ai/v1', oracle: 'https://arbitrum-api.gmxinfra.io',19    oracleFallbacks: ['https://arbitrum-api-fallback.gmxinfra.io', 'https://arbitrum-api-fallback.gmxinfra2.io'],20    graphql: 'https://gmx.squids.live/gmx-synthetics-arbitrum:prod/api/graphql', chainId: 42161 },21  avalanche: { api: 'https://avalanche.gmxapi.io/v1', apiPeer: 'https://avalanche.gmxapi.ai/v1', oracle: 'https://avalanche-api.gmxinfra.io',22    oracleFallbacks: ['https://avalanche-api-fallback.gmxinfra.io', 'https://avalanche-api-fallback.gmxinfra2.io'],23    graphql: 'https://gmx.squids.live/gmx-synthetics-avalanche:prod/api/graphql', chainId: 43114 },24};25/** Funding accrues continuously (per second); the venue quotes it per hour, which is the interval used for `Ticker.funding`. */26export const GMX_FUNDING_INTERVAL_SEC = 3600;27export const GMX_SETTLEMENT = 'isolated position against a GM liquidity pool; PnL is computed in USD and paid in the pool token of the side (long token for longs, short token for shorts) on top of the returned collateral; settlement happens on chain when a keeper executes the order';28export const GMX_MARK_METHOD = 'no order book and no separate mark price: orders execute at the Chainlink Data Streams signed min/max oracle price (longs open at max, close at min) adjusted by price impact; the displayed mark is (min + max) / 2';2930export const GMX_CAPABILITIES: Capabilities = {31  marketData: true, account: true, orders: false, orderTypes: [], tif: [], modify: false, leverage: false,32  marginModes: ['isolated'], subaccounts: false, testnet: false, stream: false,33  notes: [34    'Read-only delivery: no order, approval or transaction is built or sent.',35    'Trading would require two on-chain steps: ExchangeRouter.multicall([sendWnt, sendTokens, createOrder]) signed by the account (or by a registered GMX subaccount, or relayed through GMX Express), then execution by a keeper at a signed oracle price; an order can be cancelled or frozen instead of filled — see docs/v4/venues/gmx.md.',36    'NO ORDER BOOK: book() throws "unsupported". Use liquidity(symbol): available liquidity and open interest per side, price-impact parameters, funding and borrowing rates.',37    'One instrument per GM market: the same index (e.g. BTC/USD) is listed against several pools with different collateral, liquidity and rates; they are distinct instruments.',38    'Instrument.tickSize is the price precision of the contracts (10^-(30 − token decimals)), lotSize the token precision; orders are sized in USD (30 decimals), minNotional is the venue minimum position size. Instrument.maxLeverage is the contract bound published by the API; the effective maximum is lower and falls as open interest grows.',39    'Order.size is in USD on this venue (GMX orders carry sizeDeltaUsd), Position.size and Fill.size are in index tokens. A decrease order for the whole position has gmx.closesWholePosition = true.',40    'Ticker: mark = index = oracle mid; mid is null; funding is the rate longs pay per hour (negative = longs receive); openInterest is long + short in index tokens (the two sides are not matched against each other); volume24h comes from the /pairs listing feed.',41    'Candles carry no volume (GMX publishes none): Candle.volume is "0", not a measurement.',42    'account(): GMX has no account-level margin. equity = sum of position net values, collateral = marginUsed = sum of position collateral, freeCollateral = 0; wallet balances are not venue state.',43    'funding(account) throws "unsupported": funding is settled into collateral at each position change (Fill.gmx.fundingFeeAmount) and pending amounts are on Position.gmx. Swap orders are not derivatives and are left out of order lists.',44    'No documented public websocket; no testnet API host. Avalanche is served by the same code (chain option); MegaETH is not wired.',45  ],46};4748export interface GmxLiquidity {49  instrument: string; symbol: string; market: string;50  /** USD */ openInterestLongUsd: DecStr; openInterestShortUsd: DecStr; maxOpenInterestLongUsd: DecStr; maxOpenInterestShortUsd: DecStr;51  /** open interest in index tokens per side, and whether the contracts use it (× price) to measure the imbalance — `null` when the venue does not say.52   *  Recorded proof (test/gmx): the venue's own `closePriceImpactDeltaUsd` is reproduced with tokens × mark, and is 3× off with the USD figures above. */53  openInterestLongTokens: DecStr; openInterestShortTokens: DecStr; useOpenInterestInTokensForBalance: boolean | null;54  /** virtual inventory of the index token for positions, shared by every market on that index. `applies`: the market has one (null: not published).55   *  `tokens` / `usd`: its signed value in the contracts' convention — negative = traders are net LONG across those markets — or null when56   *  GET /markets/values could not be read. A NEGATIVE price impact is the worse of the market's own and the one computed on this inventory. */57  virtualInventory: { applies: boolean | null; tokens: DecStr | null; usd: DecStr | null };58  /** USD that can still be opened on each side: pool view, then the JIT-aware capacity the venue recommends for sizing */59  availableLiquidityLongUsd: DecStr; availableLiquidityShortUsd: DecStr; capacityLongUsd: DecStr; capacityShortUsd: DecStr; capacityLimitedBy: { long: string; short: string };60  /** per hour, as a fraction; sign as published by the venue: positive = the side RECEIVES, negative = the side PAYS */61  fundingRateLongPerHour: DecStr; fundingRateShortPerHour: DecStr; borrowingRateLongPerHour: DecStr; borrowingRateShortPerHour: DecStr; netRateLongPerHour: DecStr; netRateShortPerHour: DecStr;62  /** price impact = f(change in |long OI − short OI|): factor × imbalance^exponent, capped at the max factors (fractions of size).63   *  The imbalance is measured on tokens × price when `useOpenInterestInTokensForBalance` is true (see packages/derivatives/src/router.ts). */64  priceImpact: { factorPositive: DecStr; factorNegative: DecStr; exponentPositive: DecStr; exponentNegative: DecStr; maxFactorPositive: DecStr; maxFactorNegative: DecStr; maxFactorForLiquidations: DecStr };65  positionFeeFactor: { balanceImproved: DecStr; balanceNotImproved: DecStr };66  minCollateralFactor: DecStr; minCollateralFactorForLiquidation: DecStr; minCollateralFactorPerUsdOfOpenInterest: { long: DecStr; short: DecStr };67  oracleMin: DecStr; oracleMax: DecStr; ts: number;68}69export interface GmxPositionDetail {70  key: string; contractKey: string; market: string; collateralToken: string; collateralSymbol: string;71  sizeUsd: DecStr; collateralAmount: DecStr; collateralUsd: DecStr; remainingCollateralUsd: DecStr; netValueUsd: DecStr; netValueAfterAllFeesUsd: DecStr;72  pnlAfterFeesUsd: DecStr; pnlAfterAllFeesUsd: DecStr; hasLowCollateral: boolean;73  pendingBorrowingFeesUsd: DecStr; pendingFundingFeesUsd: DecStr; pendingClaimableFundingFeesUsd: DecStr; closingFeeUsd: DecStr; uiFeeUsd: DecStr;74  pendingImpactUsd: DecStr; closePriceImpactDeltaUsd: DecStr; netPriceImpactDeltaUsd: DecStr; priceImpactDiffUsd: DecStr;75  increasedAt: number; decreasedAt: number;76}77export type GmxPosition = Position & { gmx: GmxPositionDetail };78export interface GmxOrderDetail {79  key: string; orderType: string; isLong: boolean; market: string; sizeUnit: 'usd'; closesWholePosition: boolean;80  collateralToken: string; collateralSymbol: string; collateralDelta: DecStr; swapPath: string[];81  /** native token (wei → 18 decimals) */ executionFee: DecStr | null; isFrozen: boolean; autoCancel: boolean; receiver: string | null; validFrom: number | null; cancelReason: string | null;82}83export type GmxOrder = Order & { gmx: GmxOrderDetail };84export interface GmxFillDetail {85  orderKey: string; orderType: string; txHash: string; isLong: boolean; sizeDeltaUsd: DecStr; feeToken: string;86  positionFeeAmount: DecStr; borrowingFeeAmount: DecStr; fundingFeeAmount: DecStr; liquidationFeeAmount: DecStr; traderDiscountAmount: DecStr;87  priceImpactUsd: DecStr | null; totalImpactUsd: DecStr | null; proportionalPendingImpactUsd: DecStr | null; priceImpactDiffUsd: DecStr | null;88  swapImpactUsd: DecStr | null; swapFeeUsd: DecStr | null; basePnlUsd: DecStr | null; indexPriceMin: DecStr | null; indexPriceMax: DecStr | null;89}90export type GmxFill = Fill & { gmx: GmxFillDetail };9192export interface GmxAdapterOptions { chain?: GmxChain; fetch?: typeof fetch; now?: () => number; apiUrl?: string; oracleUrl?: string; graphqlUrl?: string; cacheMs?: number }93export interface GmxAdapter extends VenueAdapter {94  readonly chain: GmxChain;95  positions(account: string): Promise<GmxPosition[]>;96  openOrders(account: string): Promise<GmxOrder[]>;97  orderHistory(account: string, o?: { limit?: number }): Promise<GmxOrder[]>;98  fills(account: string, o?: { start?: number; limit?: number }): Promise<GmxFill[]>;99  /** what GMX has instead of a book: liquidity, open interest, rates and price-impact parameters of one market (or all) */100  liquidity(symbol?: string): Promise<GmxLiquidity[]>;101  /** raw signed-oracle price range per token from the Oracle API (contract precision converted exactly) */102  oraclePrices(): Promise<{ token: string; symbol: string; min: DecStr; max: DecStr; ts: number }[]>;103}104105interface Ctx { markets: W.Market[]; byAddress: Map<string, W.Market>; bySymbol: Map<string, W.Market>; tokens: Map<string, { symbol: string; decimals: number }> }106const lc = (a: string) => a.toLowerCase();107const ZERO = '0x0000000000000000000000000000000000000000';108109export function createGmxAdapter(o: GmxAdapterOptions = {}): GmxAdapter {110  const chain = o.chain ?? 'arbitrum', now = o.now ?? Date.now, ep = GMX_ENDPOINTS[chain];111  const api = o.apiUrl ?? ep.api, oracle = o.oracleUrl ?? ep.oracle, graphql = o.graphqlUrl ?? ep.graphql, cacheMs = o.cacheMs ?? 60_000;112  const http = venueHttp(GMX, o.fetch);113  const bad = (m: string) => venueError(GMX, 'bad_answer', m);114  const on4xx = (a: HttpAnswer) => {115    let msg = a.text.slice(0, 200);116    try { const p = z.object({ message: z.string() }).safeParse(JSON.parse(a.text)); if (p.success) msg = p.data.message; } catch { /* keep the raw text */ }117    return venueError(GMX, 'rejected', `HTTP ${a.status}: ${msg.slice(0, 200)}`);118  };119  const get = <T>(url: string, schema: z.ZodType<T>, exact = false) => http.get(url, schema, { on4xx, exact });120121  let cache: { at: number; ctx: Promise<Ctx> } | null = null;122  const context = (): Promise<Ctx> => {123    if (cache && now() - cache.at < cacheMs) return cache.ctx;124    const ctx = (async (): Promise<Ctx> => {125      const [ms, ts] = await Promise.all([get(`${api}/markets`, W.markets), get(`${api}/tokens/info`, W.tokensInfo)]);126      return { markets: ms, byAddress: new Map(ms.map((m) => [lc(m.marketTokenAddress), m])), bySymbol: new Map(ms.map((m) => [m.symbol, m])),127        tokens: new Map(ts.map((t) => [lc(t.address), { symbol: t.symbol, decimals: t.decimals }])) };128    })();129    cache = { at: now(), ctx };130    ctx.catch(() => { if (cache?.ctx === ctx) cache = null; });131    return ctx;132  };133  const token = (c: Ctx, addr: string) => { const t = c.tokens.get(lc(addr)); if (!t) throw bad(`unknown token ${addr}`); return t; };134  const perp = (c: Ctx, symbol: string) => {135    const m = c.bySymbol.get(symbol);136    if (!m || m.isSpotOnly) throw venueError(GMX, 'rejected', `unknown perpetual market "${symbol.slice(0, 60)}"`);137    return m;138  };139  const marketAt = (c: Ctx, addr: string) => { const m = c.byAddress.get(lc(addr)); if (!m) throw bad(`unknown market ${addr}`); return m; };140  const instrumentId = (symbol: string) => `${GMX}:${symbol}`;141  const baseOf = (symbol: string) => { const i = symbol.indexOf('/'); if (i <= 0) throw bad(`market symbol ${symbol}`); return symbol.slice(0, i); };142  const indexDecimals = (c: Ctx, m: W.Market) => token(c, m.indexTokenAddress).decimals;143  const evm = (account: string) => { if (!W.address.safeParse(account).success) throw venueError(GMX, 'rejected', 'account must be an EVM address'); return account; };144  /** token a swap path ends in (each hop swaps the market's long token for its short token or the reverse) */145  const afterSwaps = (c: Ctx, tokenIn: string, path: string[]): string => path.reduce((t, mk) => {146    const m = marketAt(c, mk);147    return lc(t) === lc(m.longTokenAddress) ? m.shortTokenAddress : m.longTokenAddress;148  }, tokenIn);149150  const instrumentOf = (c: Ctx, m: W.Market): Instrument => {151    const dec = indexDecimals(c, m), long = token(c, m.longTokenAddress).symbol, short = token(c, m.shortTokenAddress).symbol;152    return {153      venue: GMX, symbol: m.symbol, id: instrumentId(m.symbol), base: baseOf(m.symbol), quote: 'USD', kind: 'perpetual',154      collateral: long === short ? long : `${long} or ${short}`, settlement: GMX_SETTLEMENT,155      tickSize: W.priceStep(dec), lotSize: W.tokenAmount(1n, dec), minOrderSize: null, minNotional: W.usd(m.minPositionSizeUsd),156      maxLeverage: W.bps(m.leverageTiers[0]!.maxLeverage), marginModes: ['isolated'],157      fundingIntervalSec: GMX_FUNDING_INTERVAL_SEC, markPriceMethod: GMX_MARK_METHOD, status: m.isListed ? 'active' : 'halted',158    };159  };160161  const ORDER_KIND: Record<number, Order['type']> = { 2: 'market', 3: 'limit', 4: 'market', 5: 'take_profit_market', 6: 'stop_market', 7: 'market', 8: 'stop_market' };162  const orderShape = (c: Ctx, w: { orderType: number; isLong: boolean; marketAddress: string; sizeDeltaUsd: string; triggerPrice: string; acceptablePrice: string;163    initialCollateralTokenAddress: string; initialCollateralDeltaAmount: string; swapPath: string[] }, key: string) => {164    const type = ORDER_KIND[w.orderType];165    if (!type) throw venueError(GMX, 'unsupported', `order ${key}: type ${W.ORDER_TYPE[w.orderType] ?? w.orderType} is not a position order`);166    const m = marketAt(c, w.marketAddress), dec = indexDecimals(c, m), increase = W.isIncrease(w.orderType);167    const whole = BigInt(w.sizeDeltaUsd) === W.MAX_UINT256, acceptable = BigInt(w.acceptablePrice), trigger = BigInt(w.triggerPrice);168    const coll = token(c, w.initialCollateralTokenAddress);169    return {170      m, whole,171      base: {172        instrument: instrumentId(m.symbol), side: (increase === w.isLong ? 'buy' : 'sell') as Order['side'], type,173        size: whole ? '0' : W.usd(w.sizeDeltaUsd),174        // acceptable price: the worst execution price the order tolerates; 0 or a value near max uint256 mean "no bound"175        price: acceptable === 0n || acceptable >= 1n << 200n ? null : W.contractPrice(acceptable, dec),176        triggerPrice: trigger === 0n ? null : W.contractPrice(trigger, dec),177        reduceOnly: !increase, tif: 'gtc' as const, clientId: key, venueOrderId: key,178      },179      detail: { key, orderType: W.ORDER_TYPE[w.orderType]!, isLong: w.isLong, market: m.marketTokenAddress, sizeUnit: 'usd' as const, closesWholePosition: whole,180        collateralToken: w.initialCollateralTokenAddress, collateralSymbol: coll.symbol, collateralDelta: W.tokenAmount(w.initialCollateralDeltaAmount, coll.decimals), swapPath: w.swapPath },181    };182  };183184  const readPositions = async (account: string) => Promise.all([context(), get(`${api}/positions?address=${evm(account)}`, W.positions)]);185  const positionOf = (c: Ctx, p: W.WirePosition): GmxPosition => {186    const m = marketAt(c, p.marketAddress), dec = indexDecimals(c, m), coll = token(c, p.collateralTokenAddress);187    return {188      instrument: instrumentId(m.symbol), side: p.isLong ? 'long' : 'short', size: W.tokenAmount(p.sizeInTokens, dec),189      entryPrice: p.entryPrice === undefined ? '0' : W.apiPrice(p.entryPrice), markPrice: W.apiPrice(p.markPrice),190      liquidationPrice: p.liquidationPrice === undefined ? null : W.apiPrice(p.liquidationPrice),191      leverage: p.leverage === undefined ? '0' : W.bps(p.leverage), marginMode: 'isolated', margin: W.usd(p.collateralUsd),192      unrealizedPnl: W.usd(p.pnl),                                // before fees and price impact; see gmx.pnlAfterAllFeesUsd193      realizedPnl: null, fundingPaid: null, source: 'venue',194      gmx: {195        key: p.key, contractKey: p.contractKey, market: m.marketTokenAddress, collateralToken: p.collateralTokenAddress, collateralSymbol: coll.symbol,196        sizeUsd: W.usd(p.sizeInUsd), collateralAmount: W.tokenAmount(p.collateralAmount, coll.decimals), collateralUsd: W.usd(p.collateralUsd),197        remainingCollateralUsd: W.usd(p.remainingCollateralUsd), netValueUsd: W.usd(p.netValue), netValueAfterAllFeesUsd: W.usd(p.netValueAfterAllFees),198        pnlAfterFeesUsd: W.usd(p.pnlAfterFees), pnlAfterAllFeesUsd: W.usd(p.pnlAfterAllFees), hasLowCollateral: p.hasLowCollateral,199        pendingBorrowingFeesUsd: W.usd(p.pendingBorrowingFeesUsd), pendingFundingFeesUsd: W.usd(p.pendingFundingFeesUsd),200        pendingClaimableFundingFeesUsd: W.usd(p.pendingClaimableFundingFeesUsd), closingFeeUsd: W.usd(p.closingFeeUsd), uiFeeUsd: W.usd(p.uiFeeUsd),201        pendingImpactUsd: W.usd(p.pendingImpactUsd), closePriceImpactDeltaUsd: W.usd(p.closePriceImpactDeltaUsd),202        netPriceImpactDeltaUsd: W.usd(p.netPriceImapctDeltaUsd), priceImpactDiffUsd: W.usd(p.priceImpactDiffUsd),203        increasedAt: Number(p.increasedAtTime) * 1000, decreasedAt: Number(p.decreasedAtTime) * 1000,204      },205    };206  };207  const tradeActions = async (account: string, limit: number) =>208    Promise.all([context(), get(`${api}/trades?address=${evm(account)}&limit=${Math.min(Math.max(1, limit), 1000)}`, W.tradesList)]);209  const sum = (xs: string[]) => ds(xs.reduce((a, x) => D.add(a, D.from(x)), D.from('0')));210211  return {212    id: GMX, network: 'mainnet', chain, capabilities: GMX_CAPABILITIES,213214    async instruments(): Promise<Instrument[]> { const c = await context(); return c.markets.filter((m) => !m.isSpotOnly).map((m) => instrumentOf(c, m)); },215216    async tickers(): Promise<Ticker[]> {217      const ts = now();218      const [c, tk, vol] = await Promise.all([219        context(), get(`${api}/markets/tickers`, W.marketTickers),220        // the listing feed is the only place the venue publishes 24 h volume; if it is down the tickers are still valid without it221        get(`${api}/pairs`, W.pairs, true).then((p) => new Map(p.filter((x) => x.product_type === 'Perpetual').map((x) => [lc(x.pool_id), jsonNumberToDecStr(x.target_volume)])), () => null),222      ]);223      const out: Ticker[] = [];224      for (const t of tk) {225        const m = c.byAddress.get(lc(t.marketTokenAddress));226        if (!m || m.isSpotOnly) continue;227        const dec = indexDecimals(c, m), mark = W.apiPrice(t.markPrice);228        out.push({229          instrument: instrumentId(m.symbol), mark, index: mark, mid: null,230          funding: W.factor(-BigInt(t.fundingRateLong)),             // venue sign is "what the side receives"; normalized to "what longs pay", per hour231          openInterest: W.tokenAmount(BigInt(t.longInterestInTokens) + BigInt(t.shortInterestInTokens), dec),232          volume24h: vol?.get(lc(t.marketTokenAddress)) ?? null,233          change24h: W.bps(t.priceChangePercent24hBps), ts,234        });235      }236      return out;237    },238239    async book(): Promise<Book> { throw venueError(GMX, 'unsupported', 'GMX has no order book; use liquidity(symbol)'); },240241    async liquidity(symbol?: string): Promise<GmxLiquidity[]> {242      const ts = now();243      const [c, tk, cf, vals] = await Promise.all([context(), get(`${api}/markets/tickers`, W.marketTickers), get(`${api}/markets/config`, W.marketConfigs),244        // only the virtual inventory comes from here: if it cannot be read the liquidity view stands, with that one input unknown (null)245        get(`${api}/markets/values`, W.marketValues).then((v) => new Map(v.map((x) => [lc(x.marketTokenAddress), x])), () => null)]);246      if (symbol !== undefined) perp(c, symbol);247      const config = new Map(cf.map((x) => [lc(x.marketTokenAddress), x]));248      const out: GmxLiquidity[] = [];249      for (const t of tk) {250        const m = c.byAddress.get(lc(t.marketTokenAddress)), g = config.get(lc(t.marketTokenAddress));251        if (!m || m.isSpotOnly || (symbol !== undefined && m.symbol !== symbol)) continue;252        if (!g) throw bad(`no configuration for market ${m.symbol}`);253        out.push({254          instrument: instrumentId(m.symbol), symbol: m.symbol, market: m.marketTokenAddress,255          openInterestLongUsd: W.usd(t.longInterestUsd), openInterestShortUsd: W.usd(t.shortInterestUsd),256          openInterestLongTokens: W.tokenAmount(t.longInterestInTokens, indexDecimals(c, m)), openInterestShortTokens: W.tokenAmount(t.shortInterestInTokens, indexDecimals(c, m)),257          useOpenInterestInTokensForBalance: g.useOpenInterestInTokensForBalance ?? null,258          virtualInventory: { applies: g.virtualIndexTokenId === undefined ? null : BigInt(g.virtualIndexTokenId) !== 0n,259            tokens: vals?.get(lc(t.marketTokenAddress)) ? W.tokenAmount(vals.get(lc(t.marketTokenAddress))!.virtualInventoryForPositionsInTokens, indexDecimals(c, m)) : null,260            usd: vals?.get(lc(t.marketTokenAddress)) ? W.usd(vals.get(lc(t.marketTokenAddress))!.virtualInventoryForPositions) : null },261          maxOpenInterestLongUsd: W.usd(g.maxOpenInterestLong), maxOpenInterestShortUsd: W.usd(g.maxOpenInterestShort),262          availableLiquidityLongUsd: W.usd(t.availableLiquidityLong), availableLiquidityShortUsd: W.usd(t.availableLiquidityShort),263          capacityLongUsd: W.usd(t.capacityLong.availableLiquidity), capacityShortUsd: W.usd(t.capacityShort.availableLiquidity),264          capacityLimitedBy: { long: t.capacityLong.limitingFactor, short: t.capacityShort.limitingFactor },265          fundingRateLongPerHour: W.factor(t.fundingRateLong), fundingRateShortPerHour: W.factor(t.fundingRateShort),266          borrowingRateLongPerHour: W.factor(t.borrowingRateLong), borrowingRateShortPerHour: W.factor(t.borrowingRateShort),267          netRateLongPerHour: W.factor(t.netRateLong), netRateShortPerHour: W.factor(t.netRateShort),268          priceImpact: { factorPositive: W.factor(g.positionImpactFactorPositive), factorNegative: W.factor(g.positionImpactFactorNegative),269            exponentPositive: W.factor(g.positionImpactExponentFactorPositive), exponentNegative: W.factor(g.positionImpactExponentFactorNegative),270            maxFactorPositive: W.factor(g.maxPositionImpactFactorPositive), maxFactorNegative: W.factor(g.maxPositionImpactFactorNegative),271            maxFactorForLiquidations: W.factor(g.maxPositionImpactFactorForLiquidations) },272          positionFeeFactor: { balanceImproved: W.factor(g.positionFeeFactorForBalanceWasImproved), balanceNotImproved: W.factor(g.positionFeeFactorForBalanceWasNotImproved) },273          minCollateralFactor: W.factor(g.minCollateralFactor), minCollateralFactorForLiquidation: W.factor(g.minCollateralFactorForLiquidation),274          minCollateralFactorPerUsdOfOpenInterest: { long: W.factor(g.minCollateralFactorForOpenInterestLong), short: W.factor(g.minCollateralFactorForOpenInterestShort) },275          oracleMin: W.apiPrice(t.minPrice), oracleMax: W.apiPrice(t.maxPrice), ts,276        });277      }278      return out;279    },280281    async oraclePrices() {282      const [tk, tokens] = await Promise.all([get(`${oracle}/prices/tickers`, W.oracleTickers), get(`${oracle}/tokens`, W.oracleTokens)]);283      const dec = new Map(tokens.tokens.map((t) => [lc(t.address), t.decimals]));284      return tk.flatMap((t) => {285        const d = dec.get(lc(t.tokenAddress));286        // GM/GLV pseudo-tokens of the ticker list are not in /tokens: no decimals, no conversion287        return d === undefined ? [] : [{ token: t.tokenAddress, symbol: t.tokenSymbol, min: W.contractPrice(t.minPrice, d), max: W.contractPrice(t.maxPrice, d), ts: t.updatedAt }];288      });289    },290291    /** Executed position orders of one market from the public Subsquid feed, newest first. */292    async trades(symbol: string, limit = 100): Promise<PublicTrade[]> {293      const c = await context(), m = perp(c, symbol), dec = indexDecimals(c, m), n = Math.min(Math.max(1, Math.trunc(limit)), 1000);294      const query = `{ tradeActions(limit: ${n}, orderBy: timestamp_DESC, where: { eventName_eq: "OrderExecuted", marketAddress_eq: "${m.marketTokenAddress}", orderType_in: [2,3,4,5,6,7,8] }) { id orderType isLong sizeDeltaInTokens executionPrice timestamp } }`;295      const r = await http.get(graphql, W.graphqlTrades, { body: { query }, on4xx });296      // an executed order with no size change is a collateral deposit/withdrawal, not a trade297      return r.data.tradeActions.filter((t) => BigInt(t.sizeDeltaInTokens) > 0n).map((t): PublicTrade => ({298        instrument: instrumentId(m.symbol), price: W.contractPrice(t.executionPrice, dec), size: W.tokenAmount(t.sizeDeltaInTokens, dec),299        side: W.isIncrease(t.orderType) === t.isLong ? 'buy' : 'sell', ts: t.timestamp * 1000, id: t.id,300      }));301    },302303    async candles(symbol, interval, q): Promise<Candle[]> {304      const c = await context(), m = perp(c, symbol);305      const qs = `symbol=${encodeURIComponent(baseOf(m.symbol))}&timeframe=${interval}&limit=${Math.min(Math.max(1, q.limit ?? 500), 10_000)}${q.start === undefined ? '' : `&since=${Math.trunc(q.start)}`}`;306      const r = await get(`${api}/prices/ohlcv?${qs}`, W.ohlcv);307      const price = (s: string) => { try { return ds(D.from(s)); } catch { throw bad(`candle price ${s.slice(0, 30)}`); } };308      return r.filter((x) => (q.start === undefined || x.timestamp >= q.start) && (q.end === undefined || x.timestamp <= q.end))309        .map((x): Candle => ({ ts: x.timestamp, open: price(x.open), high: price(x.high), low: price(x.low), close: price(x.close), volume: '0' }))310        .sort((a, b) => a.ts - b.ts);311    },312313    /** Hourly snapshots of the funding rate, normalized like Ticker.funding (what longs pay per hour). */314    async fundingHistory(symbol, f) {315      const c = await context(), m = perp(c, symbol), age = now() - f.start, DAY = 86_400_000;316      const period = age <= DAY ? '1d' : age <= 7 * DAY ? '7d' : age <= 30 * DAY ? '30d' : age <= 90 * DAY ? '90d' : age <= 180 * DAY ? '180d' : age <= 365 * DAY ? '1y' : 'total';317      const r = await get(`${api}/rates?period=${period}&address=${m.marketTokenAddress}`, W.rates);318      const mine = r.find((x) => lc(x.marketAddress) === lc(m.marketTokenAddress));319      return (mine?.ratesSnapshots ?? [])320        .map((s) => ({ ts: s.timestamp * 1000, rate: W.perSecondToHourly(-BigInt(s.fundingRateLong)), premium: null }))321        .filter((x) => x.ts >= f.start && (f.end === undefined || x.ts <= f.end)).sort((a, b) => a.ts - b.ts);322    },323324    async account(account: string): Promise<AccountState> {325      const [, ps] = await readPositions(account);326      const collateral = sum(ps.map((p) => W.usd(p.collateralUsd)));327      return { venue: GMX, account, equity: sum(ps.map((p) => W.usd(p.netValue))), collateral, freeCollateral: '0', marginUsed: collateral,328        maintenanceMargin: null, withdrawable: null, ts: now(), source: 'venue' };329    },330    async positions(account: string): Promise<GmxPosition[]> { const [c, ps] = await readPositions(account); return ps.map((p) => positionOf(c, p)); },331332    async openOrders(account: string): Promise<GmxOrder[]> {333      const [c, os, ps] = await Promise.all([context(), get(`${api}/orders?address=${evm(account)}`, W.orders), get(`${api}/positions?address=${evm(account)}`, W.positions)]);334      return os.filter((w) => w.orderType >= 2 && w.orderType <= 8 && lc(w.marketAddress) !== ZERO).map((w): GmxOrder => {335        const s = orderShape(c, w, w.key), at = Number(w.updatedAtTime) * 1000;336        // a decrease order sized max uint256 closes the whole position: report that position's current USD size337        const target = s.whole ? ps.find((p) => lc(p.marketAddress) === lc(w.marketAddress) && p.isLong === w.isLong && lc(p.collateralTokenAddress) === lc(afterSwaps(c, w.initialCollateralTokenAddress, W.isIncrease(w.orderType) ? w.swapPath : []))) : undefined;338        return {339          // a market order that is still listed is waiting for its keeper; limit and trigger orders rest until their price is reached340          ...s.base, size: target ? W.usd(target.sizeInUsd) : s.base.size, status: s.base.type === 'market' ? 'pending' : 'open', filled: '0', avgFillPrice: null, createdAt: at, updatedAt: at, rejectReason: null,341          gmx: { ...s.detail, executionFee: W.tokenAmount(w.executionFee, 18), isFrozen: w.isFrozen, autoCancel: w.autoCancel, receiver: w.receiver,342            validFrom: w.validFromTime === '0' ? null : Number(w.validFromTime) * 1000, cancelReason: null },343        };344      });345    },346347    /** Order life cycles rebuilt from the account's trade actions (created → executed / cancelled / frozen), newest first. */348    async orderHistory(account: string, h?: { limit?: number }): Promise<GmxOrder[]> {349      const limit = Math.max(1, h?.limit ?? 100);350      const [c, r] = await tradeActions(account, limit * 4);351      const byKey = new Map<string, W.TradeAction[]>();352      for (const t of r.trades) { if (t.orderType < 2 || t.orderType > 8 || !t.marketAddress) continue; const l = byKey.get(t.orderKey) ?? []; l.push(t); byKey.set(t.orderKey, l); }353      const out: GmxOrder[] = [];354      for (const [key, evs] of byKey) {355        evs.sort((a, b) => a.timestamp - b.timestamp);356        const last = evs[evs.length - 1]!, first = evs[0]!;357        if (last.isLong === undefined || last.sizeDeltaUsd === undefined) throw bad(`trade action ${last.id}: missing order fields`);358        const s = orderShape(c, { orderType: last.orderType, isLong: last.isLong, marketAddress: last.marketAddress!, sizeDeltaUsd: last.sizeDeltaUsd, triggerPrice: last.triggerPrice ?? '0',359          acceptablePrice: last.acceptablePrice ?? '0', initialCollateralTokenAddress: last.initialCollateralTokenAddress, initialCollateralDeltaAmount: last.initialCollateralDeltaAmount, swapPath: last.swapPath }, key);360        const executed = last.eventName === 'OrderExecuted', dec = indexDecimals(c, s.m);361        const status: Order['status'] = executed ? 'filled' : last.eventName === 'OrderCancelled' ? 'cancelled' : last.eventName === 'OrderFrozen' || s.base.type === 'market' ? 'pending' : 'open';362        out.push({363          ...s.base, status, filled: executed ? s.base.size : '0',364          avgFillPrice: executed && last.executionPrice !== undefined ? W.contractPrice(last.executionPrice, dec) : null,365          createdAt: first.timestamp * 1000, updatedAt: last.timestamp * 1000, rejectReason: last.reason ?? null,366          gmx: { ...s.detail, executionFee: null, isFrozen: last.eventName === 'OrderFrozen', autoCancel: false, receiver: null, validFrom: null, cancelReason: last.reason ?? null },367        });368      }369      return out.sort((a, b) => b.updatedAt - a.updatedAt).slice(0, limit);370    },371372    async fills(account: string, f?: { start?: number; limit?: number }): Promise<GmxFill[]> {373      const [c, r] = await tradeActions(account, (f?.limit ?? 100) * 3);374      const out: GmxFill[] = [];375      for (const t of r.trades) {376        if (t.eventName !== 'OrderExecuted' || t.orderType < 2 || t.orderType > 8 || !t.marketAddress) continue;377        if (f?.start !== undefined && t.timestamp * 1000 < f.start) continue;378        if (t.isLong === undefined || t.sizeDeltaInTokens === undefined || t.executionPrice === undefined || t.sizeDeltaUsd === undefined) throw bad(`trade action ${t.id}: missing execution fields`);379        if (BigInt(t.sizeDeltaInTokens) === 0n) continue;             // collateral-only change: no size traded380        const m = marketAt(c, t.marketAddress), dec = indexDecimals(c, m), increase = W.isIncrease(t.orderType);381        // fee amounts are in the position's collateral token: for an increase that is what the swap path ends in382        const feeToken = increase ? afterSwaps(c, t.initialCollateralTokenAddress, t.swapPath) : t.initialCollateralTokenAddress, ft = token(c, feeToken);383        const amt = (x?: string) => W.tokenAmount(x ?? '0', ft.decimals), u = (x?: string) => (x === undefined ? null : W.usd(x));384        out.push({385          instrument: instrumentId(m.symbol), orderId: t.orderKey, clientId: null, side: increase === t.isLong ? 'buy' : 'sell',386          price: W.contractPrice(t.executionPrice, dec), size: W.tokenAmount(t.sizeDeltaInTokens, dec),387          fee: amt(t.positionFeeAmount),                              // position (open/close) fee only; borrowing, funding, liquidation fees are in `gmx`388          feeAsset: ft.symbol, closedPnl: increase ? null : u(t.pnlUsd), ts: t.timestamp * 1000, id: t.id, liquidation: t.orderType === 7,389          gmx: { orderKey: t.orderKey, orderType: W.ORDER_TYPE[t.orderType]!, txHash: t.transactionHash, isLong: t.isLong, sizeDeltaUsd: W.usd(t.sizeDeltaUsd), feeToken,390            positionFeeAmount: amt(t.positionFeeAmount), borrowingFeeAmount: amt(t.borrowingFeeAmount), fundingFeeAmount: amt(t.fundingFeeAmount),391            liquidationFeeAmount: amt(t.liquidationFeeAmount), traderDiscountAmount: amt(t.traderDiscountAmount),392            priceImpactUsd: u(t.priceImpactUsd), totalImpactUsd: u(t.totalImpactUsd), proportionalPendingImpactUsd: u(t.proportionalPendingImpactUsd),393            priceImpactDiffUsd: u(t.priceImpactDiffUsd), swapImpactUsd: u(t.swapImpactUsd), swapFeeUsd: u(t.swapFeeUsd), basePnlUsd: u(t.basePnlUsd),394            indexPriceMin: t.indexTokenPriceMin === undefined ? null : W.contractPrice(t.indexTokenPriceMin, dec),395            indexPriceMax: t.indexTokenPriceMax === undefined ? null : W.contractPrice(t.indexTokenPriceMax, dec) },396        });397      }398      return out.slice(0, f?.limit ?? 100);399    },400401    async funding(): Promise<FundingPayment[]> {402      throw venueError(GMX, 'unsupported', 'GMX settles funding into collateral at each position change: see fills()[].gmx.fundingFeeAmount and positions()[].gmx.pendingFundingFeesUsd');403    },404  };405}406
Files main
Menu

Theme