feat(exchanges): add Ondo Perps exchange adapter

This commit is contained in:
discountry
2026-07-11 15:10:40 +08:00
parent 6b5f2542a4
commit 1f2c2150e0
20 changed files with 2181 additions and 4 deletions
+15 -1
View File
@@ -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)
+15 -1
View File
@@ -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)
+58
View File
@@ -0,0 +1,58 @@
# Ondo Perps 接入说明
## 链接
- 邀请注册:https://app.ondoperps.xyz/?ref=4A3ACQ
- 官方文档索引:https://ondoperps.mintlify.app/llms.txt
- REST OpenAPIhttps://docs.ondoperps.xyz/api-reference/rest-spec.json
- WebSocket OpenAPIhttps://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 订单费率,适配器限制在 110 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 只授予策略需要的权限。
+2
View File
@@ -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";
}
}
+9
View File
@@ -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 {
+1
View File
@@ -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}'`);
}
+1
View File
@@ -98,6 +98,7 @@ const SYMBOL_PRIORITY_BY_EXCHANGE: Record<SupportedExchangeId, { envKeys: string
nado: { envKeys: ["NADO_SYMBOL", "TRADE_SYMBOL"], fallback: "BTC-PERP" },
standx: { envKeys: ["STANDX_SYMBOL", "TRADE_SYMBOL"], fallback: "BTC-USD" },
binance: { envKeys: ["BINANCE_SYMBOL", "TRADE_SYMBOL"], fallback: "BTCUSDT" },
ondoperps: { envKeys: ["ONDOPERPS_SYMBOL", "ONDOPERP_SYMBOL", "TRADE_SYMBOL"], fallback: "BTC-USD.P" },
};
export function resolveSymbolFromEnv(explicitExchangeId?: SupportedExchangeId | string | null): string {
+13
View File
@@ -7,6 +7,7 @@ import { ParadexExchangeAdapter, type ParadexCredentials } from "./paradex/adapt
import { NadoExchangeAdapter, type NadoCredentials } from "./nado/adapter";
import { StandxExchangeAdapter, type StandxCredentials } from "./standx/adapter";
import { BinanceExchangeAdapter, type BinanceCredentials } from "./binance/adapter";
import { OndoperpsExchangeAdapter, type OndoperpsCredentials } from "./ondoperps/adapter";
export const SUPPORTED_EXCHANGE_IDS = [
"aster",
@@ -17,6 +18,7 @@ export const SUPPORTED_EXCHANGE_IDS = [
"nado",
"standx",
"binance",
"ondoperps",
] as const;
export const BASIS_SUPPORTED_EXCHANGE_IDS = [
@@ -37,6 +39,8 @@ export interface ExchangeFactoryOptions {
nado?: NadoCredentials;
standx?: StandxCredentials;
binance?: BinanceCredentials;
ondoperps?: OndoperpsCredentials;
ondoperp?: OndoperpsCredentials;
}
export type SupportedExchangeId = (typeof SUPPORTED_EXCHANGE_IDS)[number];
@@ -51,6 +55,7 @@ const EXCHANGE_DISPLAY_NAME: Record<SupportedExchangeId, string> = {
nado: "Nado",
standx: "StandX",
binance: "Binance",
ondoperps: "Ondo Perps",
};
const EXCHANGE_ALIAS_MAP: Record<string, SupportedExchangeId> = {
@@ -63,6 +68,9 @@ const EXCHANGE_ALIAS_MAP: Record<string, SupportedExchangeId> = {
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,
});
}
}
+150
View File
@@ -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<typeof createInitManager>;
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<Order> {
await this.init.ensureInitialized("createOrder");
return this.gateway.createOrder(params);
}
async cancelOrder(params: { symbol: string; orderId: number | string }): Promise<void> {
await this.init.ensureInitialized("cancelOrder");
await this.gateway.cancelOrder(params);
}
async cancelOrders(params: { symbol: string; orderIdList: Array<number | string> }): Promise<void> {
await this.init.ensureInitialized("cancelOrders");
await this.gateway.cancelOrders(params);
}
async cancelAllOrders(params: { symbol: string }): Promise<void> {
await this.init.ensureInitialized("cancelAllOrders");
await this.gateway.cancelAllOrders(params);
}
async getPrecision(): Promise<ExchangePrecision | null> {
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<Order[]> {
await this.init.ensureInitialized("queryOpenOrders");
return this.gateway.queryOpenOrders();
}
async queryAccountSnapshot() {
await this.init.ensureInitialized("queryAccountSnapshot");
return this.gateway.queryAccountSnapshot();
}
async forceCancelAllOrders(): Promise<boolean> {
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;
}
File diff suppressed because it is too large Load Diff
+23
View File
@@ -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;
+173
View File
@@ -0,0 +1,173 @@
export interface OndoperpsApiResponse<T> {
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;
}
+10 -1
View File
@@ -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<ExchangeKey, ExchangeOrderHandlers> = {
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 {
+25
View File
@@ -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());
}
+4
View File
@@ -426,6 +426,10 @@ const translations: Record<string, TranslationEntry> = {
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}",
+10
View File
@@ -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);
});
+15
View File
@@ -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");
});
});
+23 -1
View File
@@ -42,7 +42,7 @@ const REQUIRED_ENV_BY_EXCHANGE: Record<SupportedExchangeId, Record<string, strin
},
lighter: {
LIGHTER_ACCOUNT_INDEX: "1",
LIGHTER_API_PRIVATE_KEY: "lighter-private-key",
LIGHTER_API_PRIVATE_KEY: "0xed636277f3753b6c0275f7a28c2678a7f3a95655e09deaebec15179b50c5da7f903152e50f594f7b",
LIGHTER_API_KEY_INDEX: "0",
},
backpack: {
@@ -64,6 +64,10 @@ const REQUIRED_ENV_BY_EXCHANGE: Record<SupportedExchangeId, Record<string, strin
BINANCE_API_KEY: "binance-key",
BINANCE_API_SECRET: "binance-secret",
},
ondoperps: {
ONDOPERPS_API_KEY_ID: "ondoKeyId_test",
ONDOPERPS_API_SECRET: "ondoApiSecret_test",
},
};
class RecorderAdapter implements ExchangeAdapter {
@@ -151,6 +155,7 @@ describe("exchange contract suite", () => {
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");
});
});
+41
View File
@@ -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");
});
});
+208
View File
@@ -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",
});
});
});