From 1f2c2150e00eda3614d27f1732e14f43b5e24e47 Mon Sep 17 00:00:00 2001 From: discountry Date: Sat, 11 Jul 2026 15:10:40 +0800 Subject: [PATCH] feat(exchanges): add Ondo Perps exchange adapter --- README.md | 16 +- README_en.md | 16 +- docs/ondoperps/README.md | 58 ++ src/cli/args.ts | 2 + src/cli/command-executor.ts | 9 + src/cli/command-parser.ts | 1 + src/config.ts | 1 + src/exchanges/create-adapter.ts | 13 + src/exchanges/ondoperps/adapter.ts | 150 +++ src/exchanges/ondoperps/gateway.ts | 1385 +++++++++++++++++++++++++ src/exchanges/ondoperps/order.ts | 23 + src/exchanges/ondoperps/types.ts | 173 +++ src/exchanges/order-router.ts | 11 +- src/exchanges/resolve-from-env.ts | 25 + src/i18n/index.ts | 4 + tests/cli-command-parser.test.ts | 10 + tests/config.test.ts | 15 + tests/exchange-contract-suite.test.ts | 24 +- tests/exchange-factory.test.ts | 41 + tests/ondoperps-gateway.test.ts | 208 ++++ 20 files changed, 2181 insertions(+), 4 deletions(-) create mode 100644 docs/ondoperps/README.md create mode 100644 src/exchanges/ondoperps/adapter.ts create mode 100644 src/exchanges/ondoperps/gateway.ts create mode 100644 src/exchanges/ondoperps/order.ts create mode 100644 src/exchanges/ondoperps/types.ts create mode 100644 tests/ondoperps-gateway.test.ts diff --git a/README.md b/README.md index 1382d6e..90496b7 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ A Bun-powered multi-exchange perpetuals workstation that ships an SMA30 trend en 如果您希望获取优惠并支持本项目,请考虑使用以下注册链接: * [Lighter 手续费优惠注册链接](https://app.lighter.xyz/?referral=111909FA) +* [Ondo Perps 邀请注册链接](https://app.ondoperps.xyz/?ref=4A3ACQ) * [Aster 手续费优惠注册链接](https://www.asterdex.com/zh-CN/referral/4665f3) * [StandX 手续费优惠注册链接](https://standx.com/referral?code=xingxingjun) * [Binance 手续费优惠注册链接](https://www.binance.com/join?ref=KNKCA9XC) @@ -41,6 +42,7 @@ npx skills add https://github.com/discountry/ritmex-bot --skill use-ritmex-bot - [ritmex-bot CLI User Guide (English)](cli-guide.en.md) - [简明上手指南(零基础)](simple-readme.md) - [基础网格策略使用教程](grid-trading.md) +- [Ondo Perps 接入说明](docs/ondoperps/README.md) ## 核心特性 - **实时行情与风控**:Websocket + REST 自动同步账户、挂单与仓位,断线后自动恢复。 @@ -59,6 +61,7 @@ npx skills add https://github.com/discountry/ritmex-bot --skill use-ritmex-bot | Backpack | USDC 永续 | `BACKPACK_API_KEY`, `BACKPACK_API_SECRET`, `BACKPACK_PASSWORD` | `BACKPACK_SANDBOX=true` 启用沙盒 | Paradex | StarkEx 永续 | `PARADEX_PRIVATE_KEY`, `PARADEX_WALLET_ADDRESS` | `PARADEX_SANDBOX=true` 使用测试网 | Nado | USDC 永续 | `NADO_SIGNER_PRIVATE_KEY`, `NADO_SUBACCOUNT_OWNER` | `NADO_ENV` 可切换 `inkMainnet`/`inkTestnet` +| Ondo Perps | USD 加密资产/股票/商品永续 | `ONDOPERPS_API_KEY_ID`, `ONDOPERPS_API_SECRET` | API Key HMAC 鉴权;支持生产与沙盒环境 ## 系统要求 - Bun ≥ 1.2(需同时包含 `bun`、`bunx` 命令) @@ -103,7 +106,7 @@ curl -fsSL https://github.com/discountry/ritmex-bot/raw/refs/heads/main/setup.sh | 变量 | 说明 | | --- | --- | -| `EXCHANGE` | 选择交易所(`aster`/`binance`/`standx`/`grvt`/`lighter`/`backpack`/`paradex`/`nado`) | +| `EXCHANGE` | 选择交易所(`aster`/`binance`/`standx`/`grvt`/`lighter`/`backpack`/`paradex`/`nado`/`ondoperps`) | | `TRADE_SYMBOL` | 交易对(默认 `BTCUSDT`) | | `TRADE_AMOUNT` | 单笔下单数量(标的资产计) | | `LOSS_LIMIT` | 单笔最大亏损触发的强平额度(USDT) | @@ -160,6 +163,17 @@ EXCHANGE=binance BINANCE_MARKET_TYPE=perp BINANCE_SYMBOL=BTCUSDT_PERP bun run in EXCHANGE=binance BINANCE_MARKET_TYPE=spot BINANCE_SYMBOL=BTCUSDT bun run index.ts --strategy grid ``` +### Ondo Perps +1. 使用[邀请链接](https://app.ondoperps.xyz/?ref=4A3ACQ)注册并在账户页面创建具备交易权限的 API Key。 +2. 设置 `EXCHANGE=ondoperps`,填写 `ONDOPERPS_API_KEY_ID` 与 `ONDOPERPS_API_SECRET`。 +3. 设置官方市场格式的 `ONDOPERPS_SYMBOL`,默认 `BTC-USD.P`;其他示例包括 `XAU-USD.P` 与 `NVDA-USD.P`。 +4. 沙盒环境设置 `ONDOPERPS_SANDBOX=true`;自定义端点可使用 `ONDOPERPS_BASE_URL` 与 `ONDOPERPS_WS_URL`。 +5. Builder 接入可选配置 `ONDOPERPS_BUILDER_CODE` 与 `ONDOPERPS_BUILDER_FEE_RATE_BPS`,费率上限为 10 bps。 + +兼容配置:`ondoperp` 可作为 `ondoperps` 的交易所别名,旧 `ONDOPERP_*` 环境变量前缀继续生效。 + +适配器使用 REST HMAC 签名执行下单、撤单和账户查询,通过 WebSocket 接收深度、标记价、K 线、订单、仓位与余额更新。仓位级止损映射为统一的 `STOP_MARKET` 订单。官方接入索引:[Ondo Perps llms.txt](https://ondoperps.mintlify.app/llms.txt)。 + ### StandX * [StandX 做市策略教程](docs/standx/maker-points-guide.md) diff --git a/README_en.md b/README_en.md index 4d68abc..bf4107e 100644 --- a/README_en.md +++ b/README_en.md @@ -7,6 +7,7 @@ A Bun-powered multi-exchange perpetuals workstation that ships an SMA30 trend en If you'd like to support this project and get fee discounts, please consider using these referral links: * [Lighter referral link](https://app.lighter.xyz/?referral=111909FA) +* [Ondo Perps invite link](https://app.ondoperps.xyz/?ref=4A3ACQ) * [Aster referral link](https://www.asterdex.com/en/referral/4665f3) * [StandX referral link](https://standx.com/referral?code=xingxingjun) * [Binance referral link](https://www.binance.com/join?ref=KNKCA9XC) @@ -37,6 +38,7 @@ Full guide: [ritmex-bot CLI User Guide (English)](cli-guide.en.md) - [ritmex-bot CLI 使用手册(中文)](cli-guide.md) - [Beginner-friendly Quick Start](simple-readme.md) - [Grid Trading Strategy Guide](grid-trading.md) +- [Ondo Perps Integration Guide](docs/ondoperps/README.md) ## Highlights - **Live data & risk sync** via websockets with REST fallbacks and full reconciliation on restart. @@ -55,6 +57,7 @@ Full guide: [ritmex-bot CLI User Guide (English)](cli-guide.en.md) | Backpack | USDC perpetuals | `BACKPACK_API_KEY`, `BACKPACK_API_SECRET`, `BACKPACK_PASSWORD` | Set `BACKPACK_SANDBOX=true` for the sandbox | | Paradex | StarkEx perpetuals | `PARADEX_PRIVATE_KEY`, `PARADEX_WALLET_ADDRESS` | Toggle `PARADEX_SANDBOX=true` for the testnet | | Nado | USDC perpetuals | `NADO_SIGNER_PRIVATE_KEY`, `NADO_SUBACCOUNT_OWNER` | Switch `NADO_ENV` between `inkMainnet` and `inkTestnet` | +| Ondo Perps | USD crypto/equity/commodity perpetuals | `ONDOPERPS_API_KEY_ID`, `ONDOPERPS_API_SECRET` | API-key HMAC authentication with production and sandbox endpoints | ## Requirements - Bun >= 1.2 (both `bun` and `bunx` on PATH) @@ -99,7 +102,7 @@ The script installs Bun, project dependencies, collects Aster API credentials, g | Variable | Purpose | | --- | --- | -| `EXCHANGE` | Choose the venue (`aster` / `binance` / `standx` / `grvt` / `lighter` / `backpack` / `paradex` / `nado`) | +| `EXCHANGE` | Choose the venue (`aster` / `binance` / `standx` / `grvt` / `lighter` / `backpack` / `paradex` / `nado` / `ondoperps`) | | `TRADE_SYMBOL` | Contract symbol (defaults to `BTCUSDT`) | | `TRADE_AMOUNT` | Order size in base asset units | | `LOSS_LIMIT` | Max per-trade loss in USDT before forced close | @@ -157,6 +160,17 @@ EXCHANGE=binance BINANCE_MARKET_TYPE=perp BINANCE_SYMBOL=BTCUSDT_PERP bun run in EXCHANGE=binance BINANCE_MARKET_TYPE=spot BINANCE_SYMBOL=BTCUSDT bun run index.ts --strategy grid ``` +### Ondo Perps +1. Register through the [invite link](https://app.ondoperps.xyz/?ref=4A3ACQ) and create an API key with trading permissions from the account page. +2. Set `EXCHANGE=ondoperps`, `ONDOPERPS_API_KEY_ID`, and `ONDOPERPS_API_SECRET`. +3. Set `ONDOPERPS_SYMBOL` with the official market format. The default is `BTC-USD.P`; other examples include `XAU-USD.P` and `NVDA-USD.P`. +4. Use `ONDOPERPS_SANDBOX=true` for sandbox endpoints. `ONDOPERPS_BASE_URL` and `ONDOPERPS_WS_URL` provide explicit endpoint overrides. +5. Builder integrations can set `ONDOPERPS_BUILDER_CODE` and `ONDOPERPS_BUILDER_FEE_RATE_BPS`; the fee is capped at 10 bps. + +Compatibility: `ondoperp` remains an exchange alias for `ondoperps`, and legacy `ONDOPERP_*` environment variables remain supported. + +The adapter signs REST requests with the Ondo API-key HMAC scheme and consumes WebSocket depth, mark price, kline, order, position, and balance updates. Position-level stops map into the shared `STOP_MARKET` order contract. Official integration index: [Ondo Perps llms.txt](https://ondoperps.mintlify.app/llms.txt). + ### StandX * [StandX Maker Points Strategy Guide](docs/standx/maker-points-guide.md) diff --git a/docs/ondoperps/README.md b/docs/ondoperps/README.md new file mode 100644 index 0000000..07a7f6b --- /dev/null +++ b/docs/ondoperps/README.md @@ -0,0 +1,58 @@ +# Ondo Perps 接入说明 + +## 链接 + +- 邀请注册:https://app.ondoperps.xyz/?ref=4A3ACQ +- 官方文档索引:https://ondoperps.mintlify.app/llms.txt +- REST OpenAPI:https://docs.ondoperps.xyz/api-reference/rest-spec.json +- WebSocket OpenAPI:https://docs.ondoperps.xyz/api-reference/ws-spec.json +- API Key 鉴权:https://docs.ondoperps.xyz/api-reference/api_key_authentication.md + +## 配置 + +```bash +EXCHANGE=ondoperps +ONDOPERPS_API_KEY_ID=ondoKeyId_xxx +ONDOPERPS_API_SECRET=ondoApiSecret_xxx +ONDOPERPS_SYMBOL=BTC-USD.P +``` + +Ondo Perps 市场使用 `{TICKER}-USD.P` 格式。默认值为 `BTC-USD.P`,其他示例包括 `ETH-USD.P`、`XAU-USD.P`、`NVDA-USD.P` 与 `AMD-USD.P`。 + +兼容入口:`EXCHANGE=ondoperp` 会解析为 `ondoperps`,旧 `ONDOPERP_*` 环境变量会在对应 `ONDOPERPS_*` 变量缺失时使用。 + +可选配置: + +| 变量 | 说明 | +| --- | --- | +| `ONDOPERPS_SANDBOX` | `true` 时连接官方沙盒 REST 与 WebSocket 地址 | +| `ONDOPERPS_BASE_URL` | 覆盖 REST API 地址 | +| `ONDOPERPS_WS_URL` | 覆盖 WebSocket 地址 | +| `ONDOPERPS_BUILDER_CODE` | Ondo 分配的 Builder Code | +| `ONDOPERPS_BUILDER_FEE_RATE_BPS` | Builder 订单费率,适配器限制在 1–10 bps | + +## 已接入能力 + +- API Key HMAC-SHA256 REST 鉴权 +- 限价单、Post Only 限价单、市价单、批量撤单与全部撤单 +- 仓位级止损与止盈,映射到统一订单类型 +- 账户余额、仓位、活动订单与市场精度查询 +- 深度、标记价、K 线、资金费率、订单、仓位与余额 WebSocket 订阅 +- REST 定时校准,覆盖 WebSocket 断线和私有频道鉴权异常场景 +- 生产与沙盒端点切换 + +## 鉴权规则 + +REST 请求发送以下请求头: + +- `ONDO-KEY-ID` +- `ONDO-TIMESTAMP` +- `ONDO-SIGN` + +签名内容为 `timestamp + uppercaseMethod + requestPathWithQuery + body`,使用 API Secret 进行 HMAC-SHA256 并输出十六进制字符串。 + +WebSocket 登录消息使用 API Key ID、毫秒时间戳与 `time + "ondo_perps_ws_login"` 的 HMAC-SHA256 签名。连接空闲限制为 180 秒,适配器每 30 秒发送一次 ping。 + +## 安全 + +API Secret 只通过运行环境传入。仓库文件、日志与错误信息均不保存请求签名头或凭证内容。建议在 Ondo Perps 后台配置固定 IPv4 白名单,并为 API Key 只授予策略需要的权限。 diff --git a/src/cli/args.ts b/src/cli/args.ts index 6ac4c4e..c6b58d6 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -88,6 +88,8 @@ function assignExchange(options: CliOptions, raw: string): void { options.exchange = normalized as CliOptions["exchange"]; } else if (normalized === "gravity" || normalized === "grav" || normalized === "grv") { options.exchange = "grvt"; + } else if (normalized === "ondo" || normalized === "ondoperp") { + options.exchange = "ondoperps"; } } diff --git a/src/cli/command-executor.ts b/src/cli/command-executor.ts index 5a780f7..aafab96 100644 --- a/src/cli/command-executor.ts +++ b/src/cli/command-executor.ts @@ -117,6 +117,15 @@ const STATIC_CAPABILITIES: Record< changeMarginMode: true, forceCancelAllOrders: true, }, + ondoperps: { + trailingStops: false, + fundingRate: true, + precision: true, + queryOpenOrders: true, + queryAccountSnapshot: true, + changeMarginMode: false, + forceCancelAllOrders: true, + }, }; export interface CommandExecutorDependencies { diff --git a/src/cli/command-parser.ts b/src/cli/command-parser.ts index c521d76..8292cf3 100644 --- a/src/cli/command-parser.ts +++ b/src/cli/command-parser.ts @@ -470,6 +470,7 @@ function normalizeExchange(value: string | undefined): SupportedExchangeId | und const normalized = value.trim().toLowerCase(); if (normalized === "gravity" || normalized === "grav" || normalized === "grv") return "grvt"; if (normalized === "bnb") return "binance"; + if (normalized === "ondo" || normalized === "ondoperp") return "ondoperps"; if (isSupportedExchangeId(normalized)) return normalized; throw new CommandParseError(`Unsupported exchange '${value}'`); } diff --git a/src/config.ts b/src/config.ts index 77de7ed..6c995f2 100644 --- a/src/config.ts +++ b/src/config.ts @@ -98,6 +98,7 @@ const SYMBOL_PRIORITY_BY_EXCHANGE: Record = { nado: "Nado", standx: "StandX", binance: "Binance", + ondoperps: "Ondo Perps", }; const EXCHANGE_ALIAS_MAP: Record = { @@ -63,6 +68,9 @@ const EXCHANGE_ALIAS_MAP: Record = { standx: "standx", binance: "binance", bnb: "binance", + ondoperps: "ondoperps", + ondoperp: "ondoperps", + ondo: "ondoperps", }; export function isSupportedExchangeId(value: string): value is SupportedExchangeId { @@ -104,5 +112,10 @@ export function createExchangeAdapter(options: ExchangeFactoryOptions): Exchange return new StandxExchangeAdapter({ ...options.standx, symbol: options.symbol }); case "binance": return new BinanceExchangeAdapter({ ...options.binance, symbol: options.symbol }); + case "ondoperps": + return new OndoperpsExchangeAdapter({ + ...(options.ondoperps ?? options.ondoperp), + symbol: options.symbol, + }); } } diff --git a/src/exchanges/ondoperps/adapter.ts b/src/exchanges/ondoperps/adapter.ts new file mode 100644 index 0000000..2f17ae7 --- /dev/null +++ b/src/exchanges/ondoperps/adapter.ts @@ -0,0 +1,150 @@ +import type { + AccountListener, + ConnectionEventListener, + DepthListener, + ExchangeAdapter, + ExchangePrecision, + FundingRateListener, + KlineListener, + OrderListener, + TickerListener, +} from "../adapter"; +import { createInitManager, createSafeInvoke } from "../adapter-utils"; +import type { CreateOrderParams, Order } from "../types"; +import { OndoperpsGateway, type OndoperpsGatewayOptions } from "./gateway"; + +export interface OndoperpsCredentials { + apiKeyId?: string; + apiSecret?: string; + symbol?: string; + baseUrl?: string; + wsUrl?: string; + builderCode?: string; + builderFeeRateBps?: number; + logger?: OndoperpsGatewayOptions["logger"]; +} + +export class OndoperpsExchangeAdapter implements ExchangeAdapter { + readonly id = "ondoperps"; + + private readonly gateway: OndoperpsGateway; + private readonly symbol: string; + private readonly safeInvoke = createSafeInvoke("OndoperpsExchangeAdapter"); + private readonly init: ReturnType; + + constructor(credentials: OndoperpsCredentials = {}) { + const apiKeyId = credentials.apiKeyId ?? process.env.ONDOPERPS_API_KEY_ID ?? process.env.ONDOPERP_API_KEY_ID ?? process.env.ONDO_KEY_ID; + const apiSecret = credentials.apiSecret ?? process.env.ONDOPERPS_API_SECRET ?? process.env.ONDOPERP_API_SECRET ?? process.env.ONDO_API_SECRET; + if (!apiKeyId || !apiSecret) { + throw new Error("Missing ONDOPERPS_API_KEY_ID or ONDOPERPS_API_SECRET environment variable"); + } + this.symbol = credentials.symbol ?? process.env.ONDOPERPS_SYMBOL ?? process.env.ONDOPERP_SYMBOL ?? process.env.TRADE_SYMBOL ?? "BTC-USD.P"; + const sandbox = parseBoolean(process.env.ONDOPERPS_SANDBOX ?? process.env.ONDOPERP_SANDBOX); + this.gateway = new OndoperpsGateway({ + apiKeyId, + apiSecret, + symbol: this.symbol, + baseUrl: credentials.baseUrl ?? process.env.ONDOPERPS_BASE_URL ?? process.env.ONDOPERP_BASE_URL ?? (sandbox ? "https://api.ondoperps-sandbox.xyz" : undefined), + wsUrl: credentials.wsUrl ?? process.env.ONDOPERPS_WS_URL ?? process.env.ONDOPERP_WS_URL ?? (sandbox ? "wss://api.ondoperps-sandbox.xyz/ws" : undefined), + builderCode: credentials.builderCode ?? process.env.ONDOPERPS_BUILDER_CODE ?? process.env.ONDOPERP_BUILDER_CODE, + builderFeeRateBps: credentials.builderFeeRateBps ?? parseNumber( + process.env.ONDOPERPS_BUILDER_FEE_RATE_BPS ?? process.env.ONDOPERP_BUILDER_FEE_RATE_BPS, + ), + logger: credentials.logger, + }); + this.init = createInitManager("OndoperpsExchangeAdapter", () => this.gateway.ensureInitialized()); + } + + supportsTrailingStops(): boolean { + return false; + } + + watchAccount(cb: AccountListener): void { + void this.init.ensureInitialized("watchAccount"); + this.gateway.onAccount(this.safeInvoke("watchAccount", cb)); + } + + watchOrders(cb: OrderListener): void { + void this.init.ensureInitialized("watchOrders"); + this.gateway.onOrders(this.safeInvoke("watchOrders", cb)); + } + + watchDepth(symbol: string, cb: DepthListener): void { + void this.init.ensureInitialized("watchDepth"); + this.gateway.onDepth(symbol, this.safeInvoke("watchDepth", cb)); + } + + watchTicker(symbol: string, cb: TickerListener): void { + void this.init.ensureInitialized("watchTicker"); + this.gateway.onTicker(symbol, this.safeInvoke("watchTicker", cb)); + } + + watchKlines(symbol: string, interval: string, cb: KlineListener): void { + void this.init.ensureInitialized("watchKlines"); + this.gateway.onKlines(symbol, interval, this.safeInvoke("watchKlines", cb)); + } + + watchFundingRate(symbol: string, cb: FundingRateListener): void { + void this.init.ensureInitialized("watchFundingRate"); + this.gateway.onFundingRate(symbol, this.safeInvoke("watchFundingRate", cb)); + } + + async createOrder(params: CreateOrderParams): Promise { + await this.init.ensureInitialized("createOrder"); + return this.gateway.createOrder(params); + } + + async cancelOrder(params: { symbol: string; orderId: number | string }): Promise { + await this.init.ensureInitialized("cancelOrder"); + await this.gateway.cancelOrder(params); + } + + async cancelOrders(params: { symbol: string; orderIdList: Array }): Promise { + await this.init.ensureInitialized("cancelOrders"); + await this.gateway.cancelOrders(params); + } + + async cancelAllOrders(params: { symbol: string }): Promise { + await this.init.ensureInitialized("cancelAllOrders"); + await this.gateway.cancelAllOrders(params); + } + + async getPrecision(): Promise { + await this.init.ensureInitialized("getPrecision"); + return this.gateway.getPrecision(this.symbol); + } + + onConnectionEvent(listener: ConnectionEventListener): void { + this.gateway.onConnectionEvent(listener); + } + + offConnectionEvent(listener: ConnectionEventListener): void { + this.gateway.offConnectionEvent(listener); + } + + async queryOpenOrders(): Promise { + await this.init.ensureInitialized("queryOpenOrders"); + return this.gateway.queryOpenOrders(); + } + + async queryAccountSnapshot() { + await this.init.ensureInitialized("queryAccountSnapshot"); + return this.gateway.queryAccountSnapshot(); + } + + async forceCancelAllOrders(): Promise { + await this.init.ensureInitialized("forceCancelAllOrders"); + return this.gateway.forceCancelAllOrders(); + } +} + +function parseBoolean(value: string | undefined): boolean { + if (!value) return false; + return ["1", "true", "yes", "on"].includes(value.trim().toLowerCase()); +} + +function parseNumber(value: string | undefined): number | undefined { + if (!value) return undefined; + const number = Number(value); + return Number.isFinite(number) ? number : undefined; +} diff --git a/src/exchanges/ondoperps/gateway.ts b/src/exchanges/ondoperps/gateway.ts new file mode 100644 index 0000000..3e2319d --- /dev/null +++ b/src/exchanges/ondoperps/gateway.ts @@ -0,0 +1,1385 @@ +import { createHmac } from "node:crypto"; +import type { + AccountListener, + ConnectionEventListener, + DepthListener, + ExchangePrecision, + FundingRateListener, + KlineListener, + OrderListener, + TickerListener, +} from "../adapter"; +import type { + AccountPosition, + AccountSnapshot, + CreateOrderParams, + Depth, + Kline, + Order, + OrderType, + Ticker, + TimeInForce, +} from "../types"; +import type { + OndoperpsApiResponse, + OndoperpsBalance, + OndoperpsBookLevel, + OndoperpsBookSnapshot, + OndoperpsCandle, + OndoperpsContract, + OndoperpsFundingRate, + OndoperpsMarketsResult, + OndoperpsMarkPrice, + OndoperpsOrder, + OndoperpsPosition, + OndoperpsStopOrders, + OndoperpsWsKline, + OndoperpsWsMessage, +} from "./types"; + +const DEFAULT_BASE_URL = "https://api.ondoperps.xyz"; +const DEFAULT_WS_URL = "wss://api.ondoperps.xyz/ws"; +const DEFAULT_SYMBOL = "BTC-USD.P"; +const REQUEST_TIMEOUT_MS = 15_000; +const WS_HEARTBEAT_MS = 30_000; +const WS_RECONNECT_MAX_MS = 30_000; + +type Timer = ReturnType; +type Timeout = ReturnType; + +export interface OndoperpsGatewayOptions { + apiKeyId: string; + apiSecret: string; + symbol?: string; + baseUrl?: string; + wsUrl?: string; + builderCode?: string; + builderFeeRateBps?: number; + fetchFn?: typeof fetch; + webSocketFactory?: (url: string) => WebSocket; + now?: () => number; + logger?: (context: string, error: unknown) => void; +} + +export interface OndoperpsAuthInput { + apiKeyId: string; + apiSecret: string; + timestamp: string; + method: string; + requestPath: string; + body: string; +} + +export function createOndoperpsRestSignature(input: Omit): string { + const message = `${input.timestamp}${input.method.toUpperCase()}${input.requestPath}${input.body}`; + return createHmac("sha256", input.apiSecret).update(message).digest("hex"); +} + +export function buildOndoperpsAuthHeaders(input: OndoperpsAuthInput): Record { + return { + "ONDO-KEY-ID": input.apiKeyId, + "ONDO-TIMESTAMP": input.timestamp, + "ONDO-SIGN": createOndoperpsRestSignature(input), + }; +} + +export function createOndoperpsWsSignature( + apiSecret: string, + timestamp: string, + prefixFirst = false, +): string { + const message = prefixFirst + ? `ondo_perps_ws_login${timestamp}` + : `${timestamp}ondo_perps_ws_login`; + return createHmac("sha256", apiSecret) + .update(message) + .digest("hex"); +} + +export function normalizeOndoperpsSymbol(value: string): string { + const normalized = value.trim().toUpperCase().replaceAll("/", "-"); + if (!normalized) return DEFAULT_SYMBOL; + if (normalized.endsWith(".P")) return normalized; + if (normalized.includes("-")) { + const [base] = normalized.split("-", 1); + return `${base ?? normalized}-USD.P`; + } + for (const quote of ["USDT", "USDC", "USD"]) { + if (normalized.endsWith(quote) && normalized.length > quote.length) { + return `${normalized.slice(0, -quote.length)}-USD.P`; + } + } + return `${normalized}-USD.P`; +} + +export function mapOndoperpsOrder(raw: OndoperpsOrder, displaySymbol?: string): Order { + const createdAt = parseTimestamp(raw.createdAt); + const updateTime = parseTimestamp(raw.filledAt ?? raw.canceledAt ?? raw.createdAt); + const executedQty = raw.filledSize ?? "0"; + return { + orderId: raw.orderId, + clientOrderId: raw.clientOrderId ?? "", + symbol: displaySymbol ?? raw.market, + side: raw.side === "sell" ? "SELL" : "BUY", + type: mapOrderType(raw.type), + status: mapOrderStatus(raw.status), + price: raw.price ?? "0", + origQty: raw.size, + executedQty, + stopPrice: raw.triggerPrice ?? "0", + time: createdAt, + updateTime, + reduceOnly: raw.reduceOnly ?? false, + closePosition: raw.closePosition ?? false, + avgPrice: calculateAveragePrice(raw.filledCost, executedQty), + cumQuote: raw.filledCost ?? "0", + positionSide: "BOTH", + timeInForce: normalizeTimeInForce(raw.timeInForce), + workingType: raw.triggerPrice ? "MARK_PRICE" : undefined, + }; +} + +export function mapOndoperpsAccountSnapshot( + balance: OndoperpsBalance, + positions: OndoperpsPosition[], + displaySymbol: string, + marketSymbol: string, + now = Date.now(), +): AccountSnapshot { + const mappedPositions = positions.map((position) => + mapOndoperpsPosition( + position, + position.market === marketSymbol ? displaySymbol : position.market, + now, + ), + ); + const baseAsset = marketSymbol.split("-")[0] ?? marketSymbol; + return { + canTrade: !balance.underLiquidation, + canDeposit: true, + canWithdraw: !balance.underLiquidation, + updateTime: now, + totalWalletBalance: balance.walletBalance, + totalUnrealizedProfit: balance.unrealizedPnl, + totalMarginBalance: balance.marginBalance, + totalInitialMargin: balance.usedMargin, + totalMaintMargin: balance.totalMaintenanceMargin, + totalPositionInitialMargin: balance.usedMargin, + totalOpenOrderInitialMargin: "0", + availableBalance: balance.availableMargin, + maxWithdrawAmount: balance.withdrawableMargin, + positions: mappedPositions, + assets: [ + { + asset: "USDC", + walletBalance: balance.walletBalance, + availableBalance: balance.availableMargin, + updateTime: now, + unrealizedProfit: balance.unrealizedPnl, + marginBalance: balance.marginBalance, + maintMargin: balance.totalMaintenanceMargin, + initialMargin: balance.usedMargin, + positionInitialMargin: balance.usedMargin, + openOrderInitialMargin: "0", + maxWithdrawAmount: balance.withdrawableMargin, + marginAvailable: !balance.underLiquidation, + }, + ], + marketType: "perp", + baseAsset, + quoteAsset: "USDC", + }; +} + +export class OndoperpsGateway { + private readonly apiKeyId: string; + private readonly apiSecret: string; + private readonly displaySymbol: string; + private readonly marketSymbol: string; + private readonly baseUrl: string; + private readonly wsUrl: string; + private readonly builderCode?: string; + private readonly builderFeeRateBps?: number; + private readonly fetchFn: typeof fetch; + private readonly webSocketFactory: (url: string) => WebSocket; + private readonly now: () => number; + private readonly logger: (context: string, error: unknown) => void; + + private initPromise: Promise | null = null; + private ws: WebSocket | null = null; + private wsAuthenticated = false; + private wsLoginInFlight = false; + private wsLoginFallbackAttempted = false; + private wsEverOpened = false; + private wsReconnectDelayMs = 1_000; + private wsReconnectTimer: Timeout | null = null; + private heartbeatTimer: Timer | null = null; + private readonly sentSubscriptions = new Set(); + + private readonly accountListeners = new Set(); + private readonly orderListeners = new Set(); + private readonly depthListeners = new Map>(); + private readonly tickerListeners = new Map>(); + private readonly klineListeners = new Map>>(); + private readonly fundingListeners = new Map>(); + private readonly connectionListeners = new Set(); + + private balance: OndoperpsBalance | null = null; + private positions: OndoperpsPosition[] = []; + private readonly orders = new Map(); + private readonly contracts = new Map(); + private readonly markPrices = new Map(); + private readonly precisions = new Map(); + private readonly klineHistory = new Map(); + + private accountPollTimer: Timer | null = null; + private orderPollTimer: Timer | null = null; + private readonly depthPollTimers = new Map(); + private readonly tickerPollTimers = new Map(); + private readonly klinePollTimers = new Map(); + private readonly fundingPollTimers = new Map(); + + constructor(options: OndoperpsGatewayOptions) { + this.apiKeyId = options.apiKeyId; + this.apiSecret = options.apiSecret; + this.displaySymbol = options.symbol?.trim() || DEFAULT_SYMBOL; + this.marketSymbol = normalizeOndoperpsSymbol(this.displaySymbol); + this.baseUrl = trimTrailingSlash(options.baseUrl ?? DEFAULT_BASE_URL); + this.wsUrl = options.wsUrl ?? DEFAULT_WS_URL; + this.builderCode = options.builderCode?.trim() || undefined; + this.builderFeeRateBps = normalizeBuilderFee(options.builderFeeRateBps); + this.fetchFn = options.fetchFn ?? fetch; + this.webSocketFactory = options.webSocketFactory ?? ((url) => new WebSocket(url)); + this.now = options.now ?? (() => Date.now()); + this.logger = options.logger ?? ((context, error) => console.error(`[OndoperpsGateway] ${context}`, error)); + } + + async ensureInitialized(): Promise { + if (!this.initPromise) { + this.initPromise = (async () => { + try { + await this.loadPrecisions(); + } catch (error) { + this.logger("loadPrecisions", error); + } + this.connectWebSocket(); + })(); + } + return this.initPromise; + } + + onAccount(listener: AccountListener): void { + this.accountListeners.add(listener); + void this.ensureInitialized(); + this.startAccountPolling(); + this.sendActiveSubscriptions(); + } + + onOrders(listener: OrderListener): void { + this.orderListeners.add(listener); + void this.ensureInitialized(); + this.startOrderPolling(); + this.sendActiveSubscriptions(); + } + + onDepth(symbol: string, listener: DepthListener): void { + addMapListener(this.depthListeners, symbol, listener); + void this.ensureInitialized(); + this.startDepthPolling(symbol); + this.sendActiveSubscriptions(); + } + + onTicker(symbol: string, listener: TickerListener): void { + addMapListener(this.tickerListeners, symbol, listener); + void this.ensureInitialized(); + this.startTickerPolling(symbol); + this.sendActiveSubscriptions(); + } + + onKlines(symbol: string, interval: string, listener: KlineListener): void { + let intervals = this.klineListeners.get(symbol); + if (!intervals) { + intervals = new Map(); + this.klineListeners.set(symbol, intervals); + } + addMapListener(intervals, interval, listener); + void this.ensureInitialized(); + this.startKlinePolling(symbol, interval); + this.sendActiveSubscriptions(); + } + + onFundingRate(symbol: string, listener: FundingRateListener): void { + addMapListener(this.fundingListeners, symbol, listener); + void this.ensureInitialized(); + this.startFundingPolling(symbol); + this.sendActiveSubscriptions(); + } + + onConnectionEvent(listener: ConnectionEventListener): void { + this.connectionListeners.add(listener); + } + + offConnectionEvent(listener: ConnectionEventListener): void { + this.connectionListeners.delete(listener); + } + + async createOrder(params: CreateOrderParams): Promise { + await this.ensureInitialized(); + if (params.type === "TRAILING_STOP_MARKET") { + throw new Error("Ondo Perps does not support trailing stop orders"); + } + if (isStopOrderType(params.type)) { + return this.createStopOrder(params); + } + + const market = normalizeOndoperpsSymbol(params.symbol); + if (params.quantity == null || !Number.isFinite(params.quantity) || params.quantity <= 0) { + throw new Error("Ondo Perps order quantity must be a positive number"); + } + const type = params.type === "MARKET" ? "market" : "limit"; + if (type === "limit" && (params.price == null || !Number.isFinite(params.price))) { + throw new Error("Ondo Perps limit orders require a valid price"); + } + + const body: Record = { + market, + type, + side: params.side.toLowerCase(), + size: String(params.quantity), + }; + if (type === "limit") { + body.price = String(params.price); + const tif = params.timeInForce ?? "GTC"; + body.timeInForce = tif === "IOC" || tif === "FOK" ? "IOC" : "GTC"; + if (tif === "GTX") body.postOnly = true; + } + if (params.reduceOnly != null) body.reduceOnly = params.reduceOnly === "true"; + if (params.clientOrderId) body.clientOrderId = params.clientOrderId; + if (params.slPrice != null) body.stopLoss = { triggerPrice: String(params.slPrice) }; + if (params.tpPrice != null) body.takeProfit = { triggerPrice: String(params.tpPrice) }; + const builderCode = this.createBuilderCodePayload(); + if (builderCode) body.builderCode = builderCode; + + const raw = await this.request("POST", "/v1/perps/orders", { body }); + const order = mapOndoperpsOrder(raw, this.displayForMarket(raw.market, params.symbol)); + if (isActiveOrder(order)) this.orders.set(String(order.orderId), order); + this.emitOrders(); + return order; + } + + async cancelOrder(params: { symbol: string; orderId: number | string }): Promise { + await this.ensureInitialized(); + const stop = parseSyntheticStopId(String(params.orderId)); + if (stop) { + await this.request("DELETE", "/v1/perps/stop_order", { + query: { market: stop.market, type: stop.type }, + }); + } else { + await this.request( + "DELETE", + `/v1/perps/orders/${encodeURIComponent(String(params.orderId))}`, + ); + } + this.orders.delete(String(params.orderId)); + this.emitOrders(); + } + + async cancelOrders(params: { symbol: string; orderIdList: Array }): Promise { + await this.ensureInitialized(); + const regularIds: string[] = []; + const stopOrders: Array<{ id: string; market: string; type: "stopLoss" | "takeProfit" }> = []; + for (const orderId of params.orderIdList) { + const id = String(orderId); + const stop = parseSyntheticStopId(id); + if (stop) stopOrders.push({ id, ...stop }); + else regularIds.push(id); + } + + await Promise.all([ + regularIds.length + ? this.request("DELETE", "/v1/perps/orders/batch", { + query: { orderIDs: regularIds.join(",") }, + }) + : Promise.resolve(), + ...stopOrders.map((stop) => + this.request("DELETE", "/v1/perps/stop_order", { + query: { market: stop.market, type: stop.type }, + }), + ), + ]); + for (const orderId of params.orderIdList) this.orders.delete(String(orderId)); + this.emitOrders(); + } + + async cancelAllOrders(params: { symbol: string }): Promise { + await this.ensureInitialized(); + const market = normalizeOndoperpsSymbol(params.symbol); + await this.request("DELETE", "/v1/perps/orders", { query: { market } }); + for (const [id, order] of this.orders) { + if (normalizeOndoperpsSymbol(order.symbol) === market && !isProtectiveOrder(order)) { + this.orders.delete(id); + } + } + this.emitOrders(); + } + + async getPrecision(symbol: string): Promise { + await this.ensureInitialized(); + const market = normalizeOndoperpsSymbol(symbol); + const cached = this.precisions.get(market); + if (cached) return cached; + await this.loadPrecisions(); + return this.precisions.get(market) ?? null; + } + + async queryOpenOrders(): Promise { + await this.ensureInitialized(); + const [rawOrders, rawStops, positions] = await Promise.all([ + this.request("GET", "/v1/perps/orders", { + query: { market: this.marketSymbol, status: "open", limit: 1000 }, + }), + this.request("GET", "/v1/perps/stop_order"), + this.request("GET", "/v1/perps/positions"), + ]); + this.positions = positions; + this.orders.clear(); + for (const raw of rawOrders) { + const order = mapOndoperpsOrder(raw, this.displayForMarket(raw.market)); + if (isActiveOrder(order)) this.orders.set(String(order.orderId), order); + } + for (const stop of rawStops) { + if (stop.market !== this.marketSymbol) continue; + for (const order of mapStopOrders(stop, positions, this.displaySymbol, this.now())) { + this.orders.set(String(order.orderId), order); + } + } + const result = this.currentOrders(); + this.emitOrders(); + return result; + } + + async queryAccountSnapshot(): Promise { + await this.ensureInitialized(); + const [balance, positions] = await Promise.all([ + this.request("GET", "/v1/perps/balance"), + this.request("GET", "/v1/perps/positions"), + ]); + this.balance = balance; + this.positions = positions; + return mapOndoperpsAccountSnapshot( + balance, + positions, + this.displaySymbol, + this.marketSymbol, + this.now(), + ); + } + + async forceCancelAllOrders(): Promise { + await this.cancelAllOrders({ symbol: this.displaySymbol }); + const remaining = await this.queryOpenOrders(); + return remaining.every((order) => isProtectiveOrder(order)); + } + + private async createStopOrder(params: CreateOrderParams): Promise { + if (params.stopPrice == null || !Number.isFinite(params.stopPrice)) { + throw new Error("Ondo Perps stop orders require a valid stop price"); + } + const market = normalizeOndoperpsSymbol(params.symbol); + const type = resolveStopType(params); + const positionDirection = params.side === "SELL" ? "long" : "short"; + const raw = await this.request("POST", "/v1/perps/stop_order", { + body: { + market, + positionDirection, + type, + triggerPrice: String(params.stopPrice), + }, + }); + const mappedOrders = mapStopOrders(raw, this.positions, params.symbol, this.now()); + for (const mappedOrder of mappedOrders) { + this.orders.set(String(mappedOrder.orderId), mappedOrder); + } + const mapped = mappedOrders + .find((order) => order.stopPrice === String(params.stopPrice) && order.type === mapStopType(type)); + const order = mapped ?? createSyntheticStopOrder({ + market, + displaySymbol: params.symbol, + positionDirection, + type, + triggerPrice: String(params.stopPrice), + quantity: params.quantity == null ? "0" : String(params.quantity), + now: this.now(), + }); + this.orders.set(String(order.orderId), order); + this.emitOrders(); + return order; + } + + private async request( + method: string, + path: string, + options: { + query?: Record; + body?: unknown; + authenticated?: boolean; + } = {}, + ): Promise { + const requestPath = buildRequestPath(path, options.query); + const body = options.body == null ? "" : JSON.stringify(options.body); + const timestamp = String(this.now()); + const headers: Record = { Accept: "application/json" }; + if (body) headers["Content-Type"] = "application/json"; + if (options.authenticated !== false) { + Object.assign(headers, buildOndoperpsAuthHeaders({ + apiKeyId: this.apiKeyId, + apiSecret: this.apiSecret, + timestamp, + method, + requestPath, + body, + })); + } + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + try { + const response = await this.fetchFn(`${this.baseUrl}${requestPath}`, { + method, + headers, + body: body || undefined, + signal: controller.signal, + }); + const text = await response.text(); + let payload: OndoperpsApiResponse | null = null; + if (text) { + try { + payload = JSON.parse(text) as OndoperpsApiResponse; + } catch { + throw new Error(`Ondo Perps returned invalid JSON for ${method} ${requestPath}`); + } + } + if (!response.ok || payload?.success === false) { + const detail = payload?.error_code ?? payload?.error ?? response.statusText; + throw new Error(`Ondo Perps ${method} ${requestPath} failed (${response.status}): ${detail}`); + } + return payload?.result as T; + } finally { + clearTimeout(timeout); + } + } + + private async loadPrecisions(): Promise { + const result = await this.request("GET", "/v1/markets", { + authenticated: false, + }); + for (const pair of result.perps?.tradingPairs ?? []) { + const priceTick = Number(pair.quoteIncrement); + const qtyStep = Number(pair.baseIncrement); + if (!Number.isFinite(priceTick) || priceTick <= 0 || !Number.isFinite(qtyStep) || qtyStep <= 0) { + continue; + } + this.precisions.set(pair.market, { + priceTick, + qtyStep, + priceDecimals: decimalPlaces(pair.quoteIncrement), + sizeDecimals: decimalPlaces(pair.baseIncrement), + }); + } + } + + private connectWebSocket(): void { + if (this.ws && (this.ws.readyState === 0 || this.ws.readyState === 1)) return; + if (this.wsReconnectTimer) { + clearTimeout(this.wsReconnectTimer); + this.wsReconnectTimer = null; + } + try { + const ws = this.webSocketFactory(this.wsUrl); + this.ws = ws; + ws.addEventListener("open", () => this.handleWsOpen(ws)); + ws.addEventListener("message", (event) => { + void this.handleWsMessage(event.data); + }); + ws.addEventListener("close", () => this.handleWsClose(ws)); + ws.addEventListener("error", (event) => this.logger("websocket", event)); + } catch (error) { + this.logger("connectWebSocket", error); + this.scheduleReconnect(); + } + } + + private handleWsOpen(ws: WebSocket): void { + if (this.ws !== ws) return; + const reconnected = this.wsEverOpened; + this.wsEverOpened = true; + this.wsReconnectDelayMs = 1_000; + this.wsAuthenticated = false; + this.wsLoginInFlight = false; + this.wsLoginFallbackAttempted = false; + this.sentSubscriptions.clear(); + this.startHeartbeat(); + this.sendActiveSubscriptions(); + if (reconnected) this.emitConnection("reconnected"); + } + + private handleWsClose(ws: WebSocket): void { + if (this.ws !== ws) return; + this.ws = null; + this.wsAuthenticated = false; + this.wsLoginInFlight = false; + this.stopHeartbeat(); + this.emitConnection("disconnected"); + this.scheduleReconnect(); + } + + private async handleWsMessage(raw: unknown): Promise { + const message = await parseWsMessage(raw); + if (!message) return; + if (message.type === "loggedIn") { + this.wsAuthenticated = true; + this.wsLoginInFlight = false; + this.sendActiveSubscriptions(); + return; + } + if (message.type === "error") { + if ( + !this.wsAuthenticated && + !this.wsLoginFallbackAttempted && + /signature|sign|auth|credential|timestamp/i.test(message.msg ?? "") + ) { + this.wsLoginFallbackAttempted = true; + this.sendWsLogin(true); + return; + } + if (!this.wsAuthenticated) this.wsLoginInFlight = false; + this.logger(`websocket:${message.channel ?? "general"}`, message.msg ?? message.code ?? message); + return; + } + if (message.type !== "update" || !message.channel) return; + + switch (message.channel) { + case "depthBooksPerps": + case "topOfBooksPerps": + this.handleDepthUpdate(message.data); + break; + case "markPricesPerps": + this.handleMarkPriceUpdate(message.data); + break; + case "kLinePerps": + this.handleKlineUpdate(message.data); + break; + case "fundingRatesPerps": + this.handleFundingUpdate(message.data); + break; + case "ordersPerps": + this.handleOrderUpdate(message.data); + break; + case "positionsPerps": + if (Array.isArray(message.data)) { + this.positions = message.data as OndoperpsPosition[]; + this.emitAccount(); + } + break; + case "balancePerps": + if (message.data && typeof message.data === "object") { + this.balance = message.data as OndoperpsBalance; + this.emitAccount(); + } + break; + } + } + + private sendActiveSubscriptions(): void { + if (!this.ws || this.ws.readyState !== 1) return; + for (const symbol of this.depthListeners.keys()) { + this.subscribe({ + channel: "depthBooksPerps", + markets: [normalizeOndoperpsSymbol(symbol)], + limit: 100, + }); + } + for (const symbol of this.tickerListeners.keys()) { + this.subscribe({ channel: "markPricesPerps", markets: [normalizeOndoperpsSymbol(symbol)] }); + } + for (const [symbol, intervals] of this.klineListeners) { + for (const interval of intervals.keys()) { + const resolution = wsResolution(interval); + if (resolution) { + this.subscribe({ + channel: "kLinePerps", + markets: [normalizeOndoperpsSymbol(symbol)], + resolution, + }); + } + } + } + for (const symbol of this.fundingListeners.keys()) { + this.subscribe({ channel: "fundingRatesPerps", markets: [normalizeOndoperpsSymbol(symbol)] }); + } + const needsPrivateChannels = this.accountListeners.size > 0 || this.orderListeners.size > 0; + if (!needsPrivateChannels) return; + if (!this.wsAuthenticated) { + if (!this.wsLoginInFlight) this.sendWsLogin(false); + return; + } + if (this.accountListeners.size) { + this.subscribe({ channel: "positionsPerps" }); + this.subscribe({ channel: "balancePerps" }); + } + if (this.orderListeners.size) { + this.subscribe({ channel: "ordersPerps", markets: [this.marketSymbol] }); + } + } + + private subscribe(payload: Record): void { + const key = JSON.stringify(payload); + if (this.sentSubscriptions.has(key)) return; + this.sendWs({ op: "subscribe", ...payload }); + this.sentSubscriptions.add(key); + } + + private sendWs(payload: unknown): void { + if (!this.ws || this.ws.readyState !== 1) return; + try { + this.ws.send(JSON.stringify(payload)); + } catch (error) { + this.logger("sendWebSocket", error); + } + } + + private sendWsLogin(prefixFirst: boolean): void { + const timestamp = String(this.now()); + this.wsLoginInFlight = true; + this.sendWs({ + op: "login", + args: { + key: this.apiKeyId, + time: timestamp, + sign: createOndoperpsWsSignature(this.apiSecret, timestamp, prefixFirst), + }, + }); + } + + private startHeartbeat(): void { + this.stopHeartbeat(); + this.heartbeatTimer = setInterval(() => this.sendWs({ op: "ping" }), WS_HEARTBEAT_MS); + } + + private stopHeartbeat(): void { + if (!this.heartbeatTimer) return; + clearInterval(this.heartbeatTimer); + this.heartbeatTimer = null; + } + + private scheduleReconnect(): void { + if (this.wsReconnectTimer) return; + const delay = this.wsReconnectDelayMs; + this.wsReconnectDelayMs = Math.min(this.wsReconnectDelayMs * 2, WS_RECONNECT_MAX_MS); + this.wsReconnectTimer = setTimeout(() => { + this.wsReconnectTimer = null; + this.connectWebSocket(); + }, delay); + } + + private startAccountPolling(): void { + if (this.accountPollTimer) return; + const poll = async () => { + try { + const snapshot = await this.queryAccountSnapshot(); + for (const listener of this.accountListeners) listener(snapshot); + } catch (error) { + this.logger("pollAccount", error); + } + }; + void poll(); + this.accountPollTimer = setInterval(poll, 5_000); + } + + private startOrderPolling(): void { + if (this.orderPollTimer) return; + const poll = async () => { + try { + await this.queryOpenOrders(); + } catch (error) { + this.logger("pollOrders", error); + } + }; + void poll(); + this.orderPollTimer = setInterval(poll, 3_000); + } + + private startDepthPolling(symbol: string): void { + const market = normalizeOndoperpsSymbol(symbol); + if (this.depthPollTimers.has(market)) return; + const poll = async () => { + try { + const raw = await this.request("GET", "/v1/perps/depth", { + query: { market, depth: 100 }, + authenticated: false, + }); + this.emitDepth(raw, symbol); + } catch (error) { + this.logger(`pollDepth:${market}`, error); + } + }; + void poll(); + this.depthPollTimers.set(market, setInterval(poll, 5_000)); + } + + private startTickerPolling(symbol: string): void { + const market = normalizeOndoperpsSymbol(symbol); + if (this.tickerPollTimers.has(market)) return; + const poll = async () => { + try { + const [contracts, markPrices] = await Promise.all([ + this.request("GET", "/v1/perps/contracts", { + query: { sparkline: false }, + authenticated: false, + }), + this.request>("GET", "/v1/perps/mark_prices", { + authenticated: false, + }), + ]); + for (const contract of contracts) this.contracts.set(contract.market, contract); + for (const [key, mark] of Object.entries(markPrices)) { + this.markPrices.set(mark.market || key, mark); + } + this.emitTicker(market, symbol); + } catch (error) { + this.logger(`pollTicker:${market}`, error); + } + }; + void poll(); + this.tickerPollTimers.set(market, setInterval(poll, 5_000)); + } + + private startKlinePolling(symbol: string, interval: string): void { + const market = normalizeOndoperpsSymbol(symbol); + const key = `${market}|${interval}`; + if (this.klinePollTimers.has(key)) return; + const poll = async () => { + try { + const { resolution, seconds } = restResolution(interval); + const to = Math.floor(this.now() / 1000); + const from = to - seconds * 200; + const candles = await this.request("GET", "/v1/perps/candles", { + query: { market, resolution, from, to }, + }); + const klines = candles.map((candle) => mapRestCandle(candle, symbol, interval, seconds)); + this.klineHistory.set(key, klines); + this.emitKlines(symbol, interval, klines); + } catch (error) { + this.logger(`pollKlines:${key}`, error); + } + }; + void poll(); + this.klinePollTimers.set(key, setInterval(poll, 15_000)); + } + + private startFundingPolling(symbol: string): void { + const market = normalizeOndoperpsSymbol(symbol); + if (this.fundingPollTimers.has(market)) return; + const poll = async () => { + try { + const rate = await this.request("GET", "/v1/perps/funding_rates", { + query: { market }, + authenticated: false, + }); + this.emitFunding(rate, symbol); + } catch (error) { + this.logger(`pollFunding:${market}`, error); + } + }; + void poll(); + this.fundingPollTimers.set(market, setInterval(poll, 60_000)); + } + + private handleDepthUpdate(data: unknown): void { + const snapshots = Array.isArray(data) ? data : data ? [data] : []; + for (const snapshot of snapshots as OndoperpsBookSnapshot[]) { + const symbol = this.listenerSymbolForMarket(this.depthListeners, snapshot.market); + if (symbol) this.emitDepth(snapshot, symbol); + } + } + + private handleMarkPriceUpdate(data: unknown): void { + const prices = Array.isArray(data) ? data : data ? [data] : []; + for (const price of prices as OndoperpsMarkPrice[]) { + this.markPrices.set(price.market, price); + const symbol = this.listenerSymbolForMarket(this.tickerListeners, price.market); + if (symbol) this.emitTicker(price.market, symbol); + } + } + + private handleKlineUpdate(data: unknown): void { + const entries = Array.isArray(data) ? data : data ? [data] : []; + for (const raw of entries as OndoperpsWsKline[]) { + const symbol = this.listenerSymbolForMarket(this.klineListeners, raw.m); + if (!symbol) continue; + const intervals = this.klineListeners.get(symbol); + if (!intervals) continue; + const durationSeconds = Math.max(1, raw.e - raw.s); + for (const interval of intervals.keys()) { + if (wsResolution(interval) == null) continue; + if (Math.abs(restResolution(interval).seconds - durationSeconds) > 1) continue; + const key = `${raw.m}|${interval}`; + const next = mapWsKline(raw, symbol, interval); + const history = mergeKline(this.klineHistory.get(key) ?? [], next); + this.klineHistory.set(key, history); + this.emitKlines(symbol, interval, history); + } + } + } + + private handleFundingUpdate(data: unknown): void { + const entries = Array.isArray(data) ? data : data ? [data] : []; + for (const raw of entries as OndoperpsFundingRate[]) { + const symbol = this.listenerSymbolForMarket(this.fundingListeners, raw.market); + if (symbol) this.emitFunding(raw, symbol); + } + } + + private handleOrderUpdate(data: unknown): void { + const entries = Array.isArray(data) ? data : data ? [data] : []; + for (const raw of entries as OndoperpsOrder[]) { + const order = mapOndoperpsOrder(raw, this.displayForMarket(raw.market)); + const id = String(order.orderId); + if (isActiveOrder(order)) this.orders.set(id, order); + else this.orders.delete(id); + } + this.emitOrders(); + } + + private emitAccount(): void { + if (!this.balance) return; + const snapshot = mapOndoperpsAccountSnapshot( + this.balance, + this.positions, + this.displaySymbol, + this.marketSymbol, + this.now(), + ); + for (const listener of this.accountListeners) listener(snapshot); + } + + private emitOrders(): void { + const orders = this.currentOrders(); + for (const listener of this.orderListeners) listener(orders); + } + + private emitDepth(raw: OndoperpsBookSnapshot, symbol: string): void { + const depth: Depth = { + lastUpdateId: parseTimestamp(raw.time), + bids: normalizeBook(raw.bids, "bid"), + asks: normalizeBook(raw.asks, "ask"), + eventTime: parseTimestamp(raw.time), + symbol, + }; + for (const listener of this.depthListeners.get(symbol) ?? []) listener(depth); + } + + private emitTicker(market: string, symbol: string): void { + const contract = this.contracts.get(market); + const mark = this.markPrices.get(market); + if (!contract && !mark) return; + const ticker = mapTicker(contract, mark, symbol, this.now()); + for (const listener of this.tickerListeners.get(symbol) ?? []) listener(ticker); + } + + private emitKlines(symbol: string, interval: string, klines: Kline[]): void { + for (const listener of this.klineListeners.get(symbol)?.get(interval) ?? []) listener(klines); + } + + private emitFunding(raw: OndoperpsFundingRate, symbol: string): void { + const fundingRate = Number(raw.rate); + if (!Number.isFinite(fundingRate)) return; + const updateTime = this.now(); + for (const listener of this.fundingListeners.get(symbol) ?? []) { + listener({ symbol, fundingRate, updateTime }); + } + } + + private emitConnection(event: "disconnected" | "reconnected"): void { + for (const listener of this.connectionListeners) listener(event, this.displaySymbol); + } + + private currentOrders(): Order[] { + return [...this.orders.values()].sort((a, b) => a.time - b.time); + } + + private displayForMarket(market: string, fallback?: string): string { + return market === this.marketSymbol ? (fallback ?? this.displaySymbol) : market; + } + + private listenerSymbolForMarket(listeners: Map, market: string): string | undefined { + for (const symbol of listeners.keys()) { + if (normalizeOndoperpsSymbol(symbol) === market) return symbol; + } + return undefined; + } + + private createBuilderCodePayload(): Record | undefined { + if (!this.builderCode && this.builderFeeRateBps == null) return undefined; + const payload: Record = {}; + if (this.builderCode) payload.code = this.builderCode; + if (this.builderFeeRateBps != null) payload.feeRateBps = this.builderFeeRateBps; + return payload; + } +} + +function mapOndoperpsPosition(raw: OndoperpsPosition, symbol: string, now: number): AccountPosition { + const absoluteQuantity = Math.abs(Number(raw.netQuantity)); + const positionAmt = raw.direction === "short" + ? -absoluteQuantity + : raw.direction === "long" + ? absoluteQuantity + : 0; + return { + symbol, + positionAmt: Number.isFinite(positionAmt) ? String(positionAmt) : "0", + entryPrice: raw.averageEntryPrice, + unrealizedProfit: raw.unrealizedPnl, + positionSide: "BOTH", + updateTime: now, + initialMargin: raw.usedMargin, + maintMargin: raw.maintenanceMargin, + positionInitialMargin: raw.usedMargin, + openOrderInitialMargin: "0", + leverage: raw.leverage, + isolated: false, + marginType: "cross", + liquidationPrice: raw.liquidationPrice, + markPrice: raw.markPrice, + }; +} + +function mapTicker( + contract: OndoperpsContract | undefined, + mark: OndoperpsMarkPrice | undefined, + symbol: string, + now: number, +): Ticker { + const bid = contract?.bid ?? "0"; + const ask = contract?.ask ?? "0"; + const midpoint = averageStrings(bid, ask); + const lastPrice = contract?.lastPrice ?? mark?.markPrice ?? mark?.price ?? midpoint ?? contract?.indexPrice ?? "0"; + const changePercent = contract?.priceChangePercent ?? "0"; + const last = Number(lastPrice); + const pct = Number(changePercent); + const derivedOpen = Number.isFinite(last) && Number.isFinite(pct) && pct > -100 + ? String(last / (1 + pct / 100)) + : lastPrice; + return { + symbol, + lastPrice, + openPrice: derivedOpen, + highPrice: contract?.high ?? lastPrice, + lowPrice: contract?.low ?? lastPrice, + volume: contract?.baseVolume ?? "0", + quoteVolume: contract?.quoteVolume ?? contract?.usdVolume ?? "0", + eventTime: mark?.lastUpdatedTime ? parseTimestamp(mark.lastUpdatedTime) : now, + priceChangePercent: changePercent, + bidPrice: bid, + askPrice: ask, + markPrice: mark?.markPrice ?? mark?.price ?? contract?.indexPrice, + }; +} + +function mapRestCandle(raw: OndoperpsCandle, symbol: string, interval: string, seconds: number): Kline { + const openTime = parseTimestamp(raw.startTime); + return { + symbol, + interval, + openTime, + open: raw.open, + high: raw.high, + low: raw.low, + close: raw.close, + volume: raw.volume, + closeTime: openTime + seconds * 1000 - 1, + numberOfTrades: 0, + isClosed: true, + }; +} + +function mapWsKline(raw: OndoperpsWsKline, symbol: string, interval: string): Kline { + return { + symbol, + interval, + openTime: raw.s * 1000, + open: String(raw.o), + high: String(raw.h), + low: String(raw.l), + close: String(raw.c), + volume: String(raw.v), + closeTime: raw.e * 1000, + numberOfTrades: 0, + isClosed: raw.x ?? false, + }; +} + +function mapStopOrders( + raw: OndoperpsStopOrders, + positions: OndoperpsPosition[], + displaySymbol: string, + now: number, +): Order[] { + if (raw.positionDirection === "neutral") return []; + const position = positions.find((entry) => entry.market === raw.market); + const quantity = position?.netQuantity ?? "0"; + const result: Order[] = []; + if (raw.stopLoss) { + result.push(createSyntheticStopOrder({ + market: raw.market, + displaySymbol, + positionDirection: raw.positionDirection, + type: "stopLoss", + triggerPrice: raw.stopLoss, + quantity, + now, + })); + } + if (raw.takeProfit) { + result.push(createSyntheticStopOrder({ + market: raw.market, + displaySymbol, + positionDirection: raw.positionDirection, + type: "takeProfit", + triggerPrice: raw.takeProfit, + quantity, + now, + })); + } + return result; +} + +function createSyntheticStopOrder(input: { + market: string; + displaySymbol: string; + positionDirection: "long" | "short"; + type: "stopLoss" | "takeProfit"; + triggerPrice: string; + quantity: string; + now: number; +}): Order { + const orderId = syntheticStopId(input.market, input.positionDirection, input.type); + return { + orderId, + clientOrderId: orderId, + symbol: input.displaySymbol, + side: input.positionDirection === "long" ? "SELL" : "BUY", + type: mapStopType(input.type), + status: "NEW", + price: "0", + origQty: input.quantity, + executedQty: "0", + stopPrice: input.triggerPrice, + time: input.now, + updateTime: input.now, + reduceOnly: true, + closePosition: true, + positionSide: "BOTH", + workingType: "MARK_PRICE", + }; +} + +function syntheticStopId( + market: string, + positionDirection: "long" | "short", + type: "stopLoss" | "takeProfit", +): string { + return `ondoperps-stop:${encodeURIComponent(market)}:${positionDirection}:${type}`; +} + +function parseSyntheticStopId(value: string): { + market: string; + positionDirection: "long" | "short"; + type: "stopLoss" | "takeProfit"; +} | null { + const match = value.match(/^ondoperps?-stop:([^:]+):(long|short):(stopLoss|takeProfit)$/); + if (!match) return null; + return { + market: decodeURIComponent(match[1] ?? ""), + positionDirection: match[2] as "long" | "short", + type: match[3] as "stopLoss" | "takeProfit", + }; +} + +function mapOrderType(value: string): OrderType { + switch (value) { + case "market": + return "MARKET"; + case "stopMarket": + return "STOP_MARKET"; + case "takeProfitMarket": + return "TAKE_PROFIT_MARKET"; + default: + return "LIMIT"; + } +} + +function mapStopType(value: "stopLoss" | "takeProfit"): OrderType { + return value === "takeProfit" ? "TAKE_PROFIT_MARKET" : "STOP_MARKET"; +} + +function mapOrderStatus(value: string): string { + switch (value.toLowerCase()) { + case "fullyfilled": + return "FILLED"; + case "canceled": + case "cancelled": + return "CANCELED"; + case "open": + case "pending": + case "untriggered": + return "NEW"; + default: + return value.toUpperCase(); + } +} + +function normalizeTimeInForce(value: string | undefined): TimeInForce | undefined { + return value === "GTC" || value === "IOC" || value === "FOK" ? value : undefined; +} + +function isStopOrderType(value: OrderType): boolean { + return value === "STOP" || value === "STOP_MARKET" || value === "TAKE_PROFIT" || value === "TAKE_PROFIT_MARKET"; +} + +function resolveStopType(params: CreateOrderParams): "stopLoss" | "takeProfit" { + if (params.type === "TAKE_PROFIT" || params.type === "TAKE_PROFIT_MARKET") return "takeProfit"; + return params.triggerType === "TAKE_PROFIT" ? "takeProfit" : "stopLoss"; +} + +function isActiveOrder(order: Order): boolean { + const status = order.status.toUpperCase(); + return status !== "FILLED" && status !== "CANCELED" && status !== "CANCELLED" && status !== "REJECTED"; +} + +function isProtectiveOrder(order: Order): boolean { + return order.type === "STOP_MARKET" || order.type === "TAKE_PROFIT_MARKET"; +} + +function calculateAveragePrice(filledCost: string | undefined, filledSize: string): string | undefined { + const cost = Number(filledCost); + const size = Number(filledSize); + if (!Number.isFinite(cost) || !Number.isFinite(size) || size <= 0) return undefined; + return String(cost / size); +} + +function averageStrings(left: string, right: string): string | undefined { + const a = Number(left); + const b = Number(right); + if (!Number.isFinite(a) || !Number.isFinite(b) || a <= 0 || b <= 0) return undefined; + return String((a + b) / 2); +} + +function normalizeBook(levels: OndoperpsBookLevel[], side: "bid" | "ask"): OndoperpsBookLevel[] { + return [...levels] + .filter(([price, size]) => Number.isFinite(Number(price)) && Number.isFinite(Number(size))) + .sort((a, b) => side === "bid" ? Number(b[0]) - Number(a[0]) : Number(a[0]) - Number(b[0])); +} + +function buildRequestPath( + path: string, + query?: Record, +): string { + if (!query) return path; + const params = new URLSearchParams(); + for (const [key, value] of Object.entries(query)) { + if (value != null) params.set(key, String(value)); + } + const encoded = params.toString(); + return encoded ? `${path}?${encoded}` : path; +} + +function restResolution(interval: string): { resolution: string; seconds: number } { + const normalized = interval.trim().toLowerCase(); + const minuteMatch = normalized.match(/^(\d+)m?$/); + if (minuteMatch) { + const minutes = Math.max(1, Number(minuteMatch[1])); + return { resolution: String(minutes), seconds: minutes * 60 }; + } + const hourMatch = normalized.match(/^(\d+)h$/); + if (hourMatch) { + const hours = Math.max(1, Number(hourMatch[1])); + return { resolution: String(hours * 60), seconds: hours * 3600 }; + } + if (normalized === "1d") return { resolution: "1D", seconds: 86_400 }; + if (normalized === "1w") return { resolution: "1W", seconds: 604_800 }; + if (normalized === "1mo" || normalized === "1mth") return { resolution: "1M", seconds: 2_592_000 }; + return { resolution: "1", seconds: 60 }; +} + +function wsResolution(interval: string): string | null { + const normalized = interval.trim().toLowerCase(); + if (normalized === "1" || normalized === "1m") return "1"; + if (normalized === "5" || normalized === "5m") return "5"; + if (normalized === "15" || normalized === "15m") return "15"; + if (normalized === "1h" || normalized === "60") return "1H"; + if (normalized === "4h" || normalized === "240") return "4H"; + if (normalized === "1d") return "1D"; + if (normalized === "1w") return "1W"; + return null; +} + +function mergeKline(history: Kline[], next: Kline): Kline[] { + const merged = [...history]; + const index = merged.findIndex((entry) => entry.openTime === next.openTime); + if (index >= 0) merged[index] = next; + else merged.push(next); + merged.sort((a, b) => a.openTime - b.openTime); + return merged.slice(-500); +} + +async function parseWsMessage(raw: unknown): Promise { + try { + let text: string; + if (typeof raw === "string") text = raw; + else if (raw instanceof ArrayBuffer) text = Buffer.from(raw).toString("utf8"); + else if (ArrayBuffer.isView(raw)) text = Buffer.from(raw.buffer, raw.byteOffset, raw.byteLength).toString("utf8"); + else if (typeof Blob !== "undefined" && raw instanceof Blob) text = await raw.text(); + else text = String(raw); + return JSON.parse(text) as OndoperpsWsMessage; + } catch { + return null; + } +} + +function decimalPlaces(value: string): number { + const normalized = value.trim().toLowerCase(); + if (normalized.includes("e-")) { + const exponent = Number(normalized.split("e-")[1]); + return Number.isFinite(exponent) ? exponent : 0; + } + const decimal = normalized.split(".")[1]?.replace(/0+$/, "") ?? ""; + return decimal.length; +} + +function parseTimestamp(value: string | number | undefined): number { + if (typeof value === "number") return value > 1e12 ? value : value * 1000; + if (!value) return Date.now(); + const numeric = Number(value); + if (Number.isFinite(numeric)) return numeric > 1e12 ? numeric : numeric * 1000; + const parsed = Date.parse(value); + return Number.isFinite(parsed) ? parsed : Date.now(); +} + +function normalizeBuilderFee(value: number | undefined): number | undefined { + if (value == null || !Number.isInteger(value) || value <= 0) return undefined; + return Math.min(value, 10); +} + +function trimTrailingSlash(value: string): string { + return value.replace(/\/+$/, ""); +} + +function addMapListener(map: Map>, key: string, listener: T): void { + let listeners = map.get(key); + if (!listeners) { + listeners = new Set(); + map.set(key, listeners); + } + listeners.add(listener); +} diff --git a/src/exchanges/ondoperps/order.ts b/src/exchanges/ondoperps/order.ts new file mode 100644 index 0000000..55b2c1c --- /dev/null +++ b/src/exchanges/ondoperps/order.ts @@ -0,0 +1,23 @@ +import { createOrderHandlers } from "../order-handlers"; + +const handlers = createOrderHandlers({ + exchangeName: "Ondo Perps", + defaultLimitTimeInForce: "GTX", + defaultMarketTimeInForce: "IOC", + defaultCloseTimeInForce: "IOC", + defaultStopTimeInForce: "GTC", + defaultStopTriggerType: "STOP_LOSS", + supportsTrailingStop: false, + supportsTriggerType: true, + stopDefaultReduceOnly: true, + stopDefaultClosePosition: true, + closeDefaultClosePosition: true, +}); + +export const { + createLimitOrder, + createMarketOrder, + createStopOrder, + createTrailingStopOrder, + createClosePositionOrder, +} = handlers; diff --git a/src/exchanges/ondoperps/types.ts b/src/exchanges/ondoperps/types.ts new file mode 100644 index 0000000..626fc42 --- /dev/null +++ b/src/exchanges/ondoperps/types.ts @@ -0,0 +1,173 @@ +export interface OndoperpsApiResponse { + success: boolean; + result?: T; + error?: string; + error_code?: string; +} + +export interface OndoperpsOrder { + orderId: string; + clientOrderId?: string; + parentOrderId?: string; + side: "buy" | "sell"; + price?: string; + size: string; + market: string; + filledSize?: string; + lastFillSize?: string; + filledCost?: string; + realizedPnl?: string; + fee?: string; + feeRebate?: string; + status: "open" | "fullyfilled" | "canceled" | "pending" | "untriggered" | string; + createdAt: string; + filledAt?: string; + canceledAt?: string; + cancelReason?: string; + type: "limit" | "market" | "stopMarket" | "takeProfitMarket" | string; + timeInForce?: "GTC" | "IOC" | "FOK"; + reduceOnly?: boolean; + closePosition?: boolean; + stopOrderType?: "stopLoss" | "takeProfit"; + triggerPrice?: string; +} + +export interface OndoperpsPosition { + market: string; + direction: "long" | "short" | "neutral"; + netQuantity: string; + averageEntryPrice: string; + usedMargin: string; + unrealizedPnl: string; + markPrice: string; + liquidationPrice: string; + bankruptcyPrice: string; + maintenanceMargin: string; + notionalValue: string; + leverage: string; + netFundingSinceNeutral: string; + returnOnEquity: string; + stopLossTriggerPrice?: string; + takeProfitTriggerPrice?: string; +} + +export interface OndoperpsBalance { + walletBalance: string; + realizedPnl: string; + unrealizedPnl: string; + marginBalance: string; + usedMargin: string; + availableMargin: string; + withdrawableMargin: string; + maintenanceMarginRequirement: string; + totalMaintenanceMargin: string; + marginRatio: string; + leverage: string; + underLiquidation: boolean; + totalFundingPayments: string; + totalTradingFees: string; + totalPnL: string; + netInvested?: string; +} + +export interface OndoperpsStopOrders { + market: string; + positionDirection: "long" | "short" | "neutral"; + stopLoss?: string | null; + takeProfit?: string | null; +} + +export type OndoperpsBookLevel = [string, string]; + +export interface OndoperpsBookSnapshot { + market: string; + time: string; + bids: OndoperpsBookLevel[]; + asks: OndoperpsBookLevel[]; + depthLevels?: string; +} + +export interface OndoperpsCandle { + startTime: string; + open: string; + high: string; + low: string; + close: string; + volume: string; +} + +export interface OndoperpsWsKline { + m: string; + t: number; + s: number; + e: number; + o: number; + h: number; + l: number; + c: number; + v: number; + x?: boolean; +} + +export interface OndoperpsContract { + market: string; + displayName?: string; + productType: string; + contractType: string; + baseCurrency: string; + quoteCurrency: string; + disabled: boolean; + lastPrice?: string; + baseVolume?: string; + quoteVolume?: string; + usdVolume?: string; + bid?: string; + ask?: string; + high?: string; + low?: string; + openInterest?: string; + openInterestUsd?: string; + indexPrice?: string; + fundingRate?: string; + nextFundingRate?: string; + nextFundingRateTimestamp?: string; + makerFee?: string; + takerFee?: string; + priceChangePercent?: string; + isClosed?: boolean; +} + +export interface OndoperpsMarkPrice { + market: string; + price: string; + markPrice: string; + oraclePrice?: string; + lastExternalPrice?: string; + lastUpdatedTime?: string; +} + +export interface OndoperpsFundingRate { + market: string; + rate: string; + intervalEnds?: string; +} + +export interface OndoperpsTradingPair { + market: string; + baseIncrement: string; + quoteIncrement: string; +} + +export interface OndoperpsMarketsResult { + perps?: { + tradingPairs?: OndoperpsTradingPair[]; + }; +} + +export interface OndoperpsWsMessage { + type: "pong" | "loggedIn" | "subscribed" | "unsubscribed" | "update" | "error" | string; + channel?: string; + code?: number; + msg?: string; + data?: unknown; +} diff --git a/src/exchanges/order-router.ts b/src/exchanges/order-router.ts index 19ccbc9..7e1f90c 100644 --- a/src/exchanges/order-router.ts +++ b/src/exchanges/order-router.ts @@ -17,6 +17,7 @@ import * as paradexOrders from "./paradex/order"; import * as nadoOrders from "./nado/order"; import * as standxOrders from "./standx/order"; import * as binanceOrders from "./binance/order"; +import * as ondoperpsOrders from "./ondoperps/order"; type ExchangeKey = SupportedExchangeId; @@ -85,13 +86,21 @@ const handlerMap: Record = { trailingStop: binanceOrders.createTrailingStopOrder, close: binanceOrders.createClosePositionOrder, }, + ondoperps: { + limit: ondoperpsOrders.createLimitOrder, + market: ondoperpsOrders.createMarketOrder, + stop: ondoperpsOrders.createStopOrder, + trailingStop: ondoperpsOrders.createTrailingStopOrder, + close: ondoperpsOrders.createClosePositionOrder, + }, }; const knownExchanges: ExchangeKey[] = [...SUPPORTED_EXCHANGE_IDS]; function normalizeExchangeId(value: string | undefined | null): string | undefined { if (!value) return undefined; - return value.trim().toLowerCase(); + const normalized = value.trim().toLowerCase(); + return normalized === "ondoperp" ? "ondoperps" : normalized; } function resolveExchangeKey(adapter: ExchangeAdapter): ExchangeKey { diff --git a/src/exchanges/resolve-from-env.ts b/src/exchanges/resolve-from-env.ts index f5004c2..eb55293 100644 --- a/src/exchanges/resolve-from-env.ts +++ b/src/exchanges/resolve-from-env.ts @@ -7,6 +7,7 @@ import type { ParadexCredentials } from "./paradex/adapter"; import type { NadoCredentials } from "./nado/adapter"; import type { StandxCredentials } from "./standx/adapter"; import type { BinanceCredentials } from "./binance/adapter"; +import type { OndoperpsCredentials } from "./ondoperps/adapter"; import { t } from "../i18n"; import type { Address } from "viem"; @@ -50,6 +51,10 @@ export function buildAdapterFromEnv(options: BuildAdapterOptions): ExchangeAdapt const credentials = resolveBinanceCredentials(symbol); return createExchangeAdapter({ exchange: id, symbol, binance: credentials }); } + case "ondoperps": { + const credentials = resolveOndoperpsCredentials(symbol); + return createExchangeAdapter({ exchange: id, symbol, ondoperps: credentials }); + } } } @@ -200,6 +205,26 @@ function resolveBinanceCredentials(symbol: string): BinanceCredentials { }; } +function resolveOndoperpsCredentials(symbol: string): OndoperpsCredentials { + const apiKeyId = process.env.ONDOPERPS_API_KEY_ID ?? process.env.ONDOPERP_API_KEY_ID ?? process.env.ONDO_KEY_ID; + const apiSecret = process.env.ONDOPERPS_API_SECRET ?? process.env.ONDOPERP_API_SECRET ?? process.env.ONDO_API_SECRET; + if (!apiKeyId || !apiSecret) { + throw new Error(t("env.missingOndoperps")); + } + const sandbox = parseOptionalBoolean(process.env.ONDOPERPS_SANDBOX ?? process.env.ONDOPERP_SANDBOX) === true; + return { + apiKeyId, + apiSecret, + symbol: process.env.ONDOPERPS_SYMBOL ?? process.env.ONDOPERP_SYMBOL ?? symbol, + baseUrl: process.env.ONDOPERPS_BASE_URL ?? process.env.ONDOPERP_BASE_URL ?? (sandbox ? "https://api.ondoperps-sandbox.xyz" : undefined), + wsUrl: process.env.ONDOPERPS_WS_URL ?? process.env.ONDOPERP_WS_URL ?? (sandbox ? "wss://api.ondoperps-sandbox.xyz/ws" : undefined), + builderCode: process.env.ONDOPERPS_BUILDER_CODE ?? process.env.ONDOPERP_BUILDER_CODE ?? undefined, + builderFeeRateBps: parseOptionalNumber( + process.env.ONDOPERPS_BUILDER_FEE_RATE_BPS ?? process.env.ONDOPERP_BUILDER_FEE_RATE_BPS, + ), + }; +} + function isHex32(value: string): boolean { return /^0x[0-9a-fA-F]{64}$/.test(value.trim()); } diff --git a/src/i18n/index.ts b/src/i18n/index.ts index 7c2eb9f..d72f0d2 100644 --- a/src/i18n/index.ts +++ b/src/i18n/index.ts @@ -426,6 +426,10 @@ const translations: Record = { zh: "StandX 需要配置 STANDX_TOKEN", en: "StandX requires STANDX_TOKEN", }, + "env.missingOndoperps": { + zh: "Ondo Perps 需要配置 ONDOPERPS_API_KEY_ID 与 ONDOPERPS_API_SECRET(兼容旧 ONDOPERP_ 前缀)", + en: "Ondo Perps requires ONDOPERPS_API_KEY_ID and ONDOPERPS_API_SECRET (legacy ONDOPERP_ prefix is supported)", + }, "log.subscribe.accountFail": { zh: "订阅账户失败: {error}", en: "Failed to subscribe account: {error}", diff --git a/tests/cli-command-parser.test.ts b/tests/cli-command-parser.test.ts index 96d2298..adc456b 100644 --- a/tests/cli-command-parser.test.ts +++ b/tests/cli-command-parser.test.ts @@ -81,6 +81,16 @@ describe("command parser", () => { }); }); + it("parses the Ondo Perps exchange alias", () => { + for (const exchange of ["ondoperps", "ondoperp"]) { + const command = parseCommandArgv(["exchange", "capabilities", "--exchange", exchange]); + expect(command).toMatchObject({ + kind: "exchange-capabilities", + exchange: "ondoperps", + }); + } + }); + it("throws for unsupported option", () => { expect(() => parseCommandArgv(["doctor", "--unknown"])).toThrow(CommandParseError); }); diff --git a/tests/config.test.ts b/tests/config.test.ts index 207a4a3..67f59c0 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -56,4 +56,19 @@ describe("resolveSymbolFromEnv", () => { expect(resolveSymbolFromEnv("binance")).toBe("ETHUSDT"); }); + + it("supports Ondo Perps symbol defaults when explicit exchange id is provided", () => { + delete process.env.EXCHANGE; + process.env.ONDOPERPS_SYMBOL = "NVDA-USD.P"; + + expect(resolveSymbolFromEnv("ondoperps")).toBe("NVDA-USD.P"); + }); + + it("supports the legacy Ondo Perps exchange id and symbol prefix", () => { + delete process.env.EXCHANGE; + delete process.env.ONDOPERPS_SYMBOL; + process.env.ONDOPERP_SYMBOL = "ETH-USD.P"; + + expect(resolveSymbolFromEnv("ondoperp")).toBe("ETH-USD.P"); + }); }); diff --git a/tests/exchange-contract-suite.test.ts b/tests/exchange-contract-suite.test.ts index 0c53a10..89d7a36 100644 --- a/tests/exchange-contract-suite.test.ts +++ b/tests/exchange-contract-suite.test.ts @@ -42,7 +42,7 @@ const REQUIRED_ENV_BY_EXCHANGE: Record { for (const id of SUPPORTED_EXCHANGE_IDS) { expect(output).toContain(id); } + expect(parseCliArgs(["--exchange", "ondoperp"]).exchange).toBe("ondoperps"); spy.mockRestore(); }); @@ -258,4 +263,21 @@ describe("exchange contract suite", () => { } } }); + + it("routes orders from adapters that still report the legacy ondoperp id", async () => { + delete process.env.EXCHANGE; + delete process.env.TRADE_EXCHANGE; + const adapter = new RecorderAdapter("ondoperps"); + (adapter as unknown as { id: string }).id = "ondoperp"; + + await routeLimitOrder({ + adapter, + symbol: "BTC-USD.P", + side: "BUY", + quantity: 0.01, + price: 100_000, + }); + + expect(adapter.lastCreateOrderParams?.type).toBe("LIMIT"); + }); }); diff --git a/tests/exchange-factory.test.ts b/tests/exchange-factory.test.ts index db24498..e5ce7ea 100644 --- a/tests/exchange-factory.test.ts +++ b/tests/exchange-factory.test.ts @@ -1,11 +1,13 @@ import { describe, expect, it, beforeEach, afterEach } from "vitest"; import { createExchangeAdapter, resolveExchangeId } from "../src/exchanges/create-adapter"; +import { buildAdapterFromEnv } from "../src/exchanges/resolve-from-env"; import { AsterExchangeAdapter } from "../src/exchanges/aster/adapter"; import { GrvtExchangeAdapter } from "../src/exchanges/grvt/adapter"; import { BackpackExchangeAdapter } from "../src/exchanges/backpack/adapter"; import { ParadexExchangeAdapter } from "../src/exchanges/paradex/adapter"; import { StandxExchangeAdapter } from "../src/exchanges/standx/adapter"; import { BinanceExchangeAdapter } from "../src/exchanges/binance/adapter"; +import { OndoperpsExchangeAdapter } from "../src/exchanges/ondoperps/adapter"; const ORIGINAL_ENV = { ...process.env }; @@ -34,6 +36,8 @@ describe("exchange factory", () => { expect(resolveExchangeId("PaRaDeX")).toBe("paradex"); expect(resolveExchangeId("StandX")).toBe("standx"); expect(resolveExchangeId("BiNaNcE")).toBe("binance"); + expect(resolveExchangeId("OndoPerps")).toBe("ondoperps"); + expect(resolveExchangeId("OndoPerp")).toBe("ondoperps"); }); it("creates grvt adapter when EXCHANGE=grvt", () => { @@ -92,4 +96,41 @@ describe("exchange factory", () => { expect(adapter).toBeInstanceOf(BinanceExchangeAdapter); expect(adapter.id).toBe("binance"); }); + + it("creates ondoperps adapter when EXCHANGE=ondoperps", () => { + process.env.EXCHANGE = "ondoperps"; + process.env.ONDOPERPS_API_KEY_ID = "ondoKeyId_test"; + process.env.ONDOPERPS_API_SECRET = "ondoApiSecret_test"; + process.env.ONDOPERPS_SYMBOL = "XAU-USD.P"; + + const adapter = createExchangeAdapter({ symbol: "XAU-USD.P" }); + expect(adapter).toBeInstanceOf(OndoperpsExchangeAdapter); + expect(adapter.id).toBe("ondoperps"); + }); + + it("accepts the legacy ondoperp id and environment prefix", () => { + delete process.env.ONDOPERPS_API_KEY_ID; + delete process.env.ONDOPERPS_API_SECRET; + delete process.env.ONDOPERPS_SYMBOL; + process.env.ONDOPERP_API_KEY_ID = "ondoKeyId_legacy"; + process.env.ONDOPERP_API_SECRET = "ondoApiSecret_legacy"; + process.env.ONDOPERP_SYMBOL = "ETH-USD.P"; + + const adapter = buildAdapterFromEnv({ exchangeId: "ondoperp", symbol: "BTC-USD.P" }); + expect(adapter).toBeInstanceOf(OndoperpsExchangeAdapter); + expect(adapter.id).toBe("ondoperps"); + }); + + it("accepts the legacy ondoperp factory option", () => { + const adapter = createExchangeAdapter({ + exchange: "ondoperp", + symbol: "BTC-USD.P", + ondoperp: { + apiKeyId: "ondoKeyId_legacy", + apiSecret: "ondoApiSecret_legacy", + }, + }); + expect(adapter).toBeInstanceOf(OndoperpsExchangeAdapter); + expect(adapter.id).toBe("ondoperps"); + }); }); diff --git a/tests/ondoperps-gateway.test.ts b/tests/ondoperps-gateway.test.ts new file mode 100644 index 0000000..aa4223f --- /dev/null +++ b/tests/ondoperps-gateway.test.ts @@ -0,0 +1,208 @@ +import { describe, expect, it, vi } from "vitest"; +import { + buildOndoperpsAuthHeaders, + createOndoperpsWsSignature, + mapOndoperpsAccountSnapshot, + mapOndoperpsOrder, + normalizeOndoperpsSymbol, + OndoperpsGateway, +} from "../src/exchanges/ondoperps/gateway"; + +describe("Ondo Perps gateway", () => { + it("normalizes common symbols to the official market format", () => { + expect(normalizeOndoperpsSymbol("")).toBe("BTC-USD.P"); + expect(normalizeOndoperpsSymbol("xau")).toBe("XAU-USD.P"); + expect(normalizeOndoperpsSymbol("NVDA/USD")).toBe("NVDA-USD.P"); + expect(normalizeOndoperpsSymbol("AAPL-USD.P")).toBe("AAPL-USD.P"); + }); + + it("builds REST and WebSocket HMAC signatures from the documented payload", () => { + const body = JSON.stringify({ market: "XAU-USD.P", side: "buy", size: "1" }); + const headers = buildOndoperpsAuthHeaders({ + apiKeyId: "ondoKeyId_test", + apiSecret: "ondoApiSecret_secret", + timestamp: "1700000000000", + method: "POST", + requestPath: "/v1/perps/orders?market=XAU-USD.P", + body, + }); + + expect(headers).toEqual({ + "ONDO-KEY-ID": "ondoKeyId_test", + "ONDO-TIMESTAMP": "1700000000000", + "ONDO-SIGN": "3cf0628da79a19225d79fd17ab4767ee1ecae0219a52e1b1fc1b0c1a8bc7a880", + }); + expect(createOndoperpsWsSignature("ondoApiSecret_secret", "1700000000000")).toBe( + "eb87f259c41c7cba728b1ce5baf0a385e6e5c1b1031424d73206cacbaebea742", + ); + expect(createOndoperpsWsSignature("ondoApiSecret_secret", "1700000000000", true)).toBe( + "46ca7822ee8444eccd96feaa596e30072c8428aa8ec2cedbe0fdc6744bc21ec7", + ); + }); + + it("maps Ondo order fields into the shared exchange contract", () => { + const order = mapOndoperpsOrder( + { + orderId: "order-1", + clientOrderId: "client-1", + side: "sell", + price: "201.25", + size: "2", + market: "NVDA-USD.P", + filledSize: "0.5", + filledCost: "100.625", + status: "open", + createdAt: "2026-07-11T00:00:00Z", + type: "limit", + timeInForce: "GTC", + reduceOnly: true, + }, + "NVDA-USD.P", + ); + + expect(order).toMatchObject({ + orderId: "order-1", + clientOrderId: "client-1", + symbol: "NVDA-USD.P", + side: "SELL", + type: "LIMIT", + status: "NEW", + origQty: "2", + executedQty: "0.5", + avgPrice: "201.25", + reduceOnly: true, + }); + }); + + it("creates post-only orders with signed REST headers", async () => { + const requests: Array<{ url: string; init?: RequestInit }> = []; + const fetchFn = vi.fn(async (input: string | URL | Request, init?: RequestInit) => { + const url = String(input); + requests.push({ url, init }); + if (url.endsWith("/v1/markets")) { + return new Response(JSON.stringify({ + success: true, + result: { + perps: { + tradingPairs: [ + { market: "XAU-USD.P", baseIncrement: "0.01", quoteIncrement: "0.1" }, + ], + }, + }, + })); + } + return new Response(JSON.stringify({ + success: true, + result: { + orderId: "order-2", + clientOrderId: "client-2", + side: "buy", + price: "2500", + size: "0.5", + market: "XAU-USD.P", + filledSize: "0", + status: "open", + createdAt: "2026-07-11T00:00:00Z", + type: "limit", + timeInForce: "GTC", + reduceOnly: false, + }, + })); + }); + const fakeWebSocket = { + readyState: 0, + addEventListener: vi.fn(), + send: vi.fn(), + } as unknown as WebSocket; + const gateway = new OndoperpsGateway({ + apiKeyId: "ondoKeyId_test", + apiSecret: "ondoApiSecret_secret", + symbol: "XAU-USD.P", + fetchFn: fetchFn as unknown as typeof fetch, + webSocketFactory: () => fakeWebSocket, + now: () => 1_700_000_000_000, + }); + + const order = await gateway.createOrder({ + symbol: "XAU-USD.P", + side: "BUY", + type: "LIMIT", + quantity: 0.5, + price: 2500, + timeInForce: "GTX", + clientOrderId: "client-2", + }); + + const request = requests[1]; + expect(request?.url).toBe("https://api.ondoperps.xyz/v1/perps/orders"); + expect(JSON.parse(String(request?.init?.body))).toMatchObject({ + market: "XAU-USD.P", + type: "limit", + side: "buy", + size: "0.5", + price: "2500", + timeInForce: "GTC", + postOnly: true, + clientOrderId: "client-2", + }); + expect(request?.init?.headers).toMatchObject({ + "ONDO-KEY-ID": "ondoKeyId_test", + "ONDO-TIMESTAMP": "1700000000000", + }); + expect(order).toMatchObject({ orderId: "order-2", status: "NEW", type: "LIMIT" }); + }); + + it("maps short positions and USDC margin fields into an account snapshot", () => { + const snapshot = mapOndoperpsAccountSnapshot( + { + walletBalance: "5000", + realizedPnl: "10", + unrealizedPnl: "-25", + marginBalance: "4975", + usedMargin: "1000", + availableMargin: "3975", + withdrawableMargin: "3975", + maintenanceMarginRequirement: "50", + totalMaintenanceMargin: "80", + marginRatio: "0.016", + leverage: "1.5", + underLiquidation: false, + totalFundingPayments: "-1", + totalTradingFees: "2", + totalPnL: "-18", + }, + [ + { + market: "NVDA-USD.P", + direction: "short", + netQuantity: "3", + averageEntryPrice: "200", + usedMargin: "1000", + unrealizedPnl: "-25", + markPrice: "208.33", + liquidationPrice: "260", + bankruptcyPrice: "275", + maintenanceMargin: "50", + notionalValue: "625", + leverage: "1.5", + netFundingSinceNeutral: "-1", + returnOnEquity: "-0.025", + }, + ], + "NVDA-USD.P", + "NVDA-USD.P", + 1234, + ); + + expect(snapshot.totalWalletBalance).toBe("5000"); + expect(snapshot.availableBalance).toBe("3975"); + expect(snapshot.assets[0]?.asset).toBe("USDC"); + expect(snapshot.positions[0]).toMatchObject({ + symbol: "NVDA-USD.P", + positionAmt: "-3", + entryPrice: "200", + markPrice: "208.33", + positionSide: "BOTH", + }); + }); +});