From 5bc8226fc1f7b52a61f94b5134498f37ab934a5f Mon Sep 17 00:00:00 2001 From: Ame Date: Sat, 11 Apr 2026 17:36:41 +0800 Subject: [PATCH 1/3] fix: hyperliquid market loading + e2e support for wallet-based CCXT MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CcxtBroker hardcoded fetchMarkets.types = ['spot', 'linear', 'inverse'], which overrode the exchange's own defaults. Bybit happened to work because its defaults matched, but Hyperliquid uses ['spot', 'swap', 'hip3'] — so all swap/perp markets were silently dropped, leaving users unable to find BTC-PERP or any derivative. - Stop overriding fetchMarkets.types in the constructor; let each exchange use its declared defaults - fetchMarkets wrapper reads exchange's own types and only filters out 'option' (the universal option-stripping intent stays) - Guard searchContracts against markets with undefined base/quote (some hyperliquid spot markets have these fields missing) E2E setup - hasCredentials() previously hardcoded `bc.apiKey` for ccxt, filtering out any wallet-based account. Use CCXT_CREDENTIAL_FIELDS.some() so wallet-based exchanges (hyperliquid, dYdX) are recognized New e2e specs - ccxt-hyperliquid-markets.e2e.spec.ts: credential-free regression test using dummy keys, validates spot+swap markets load and BTC perp is searchable. Always runs. - ccxt-hyperliquid.e2e.spec.ts: full e2e against hyperliquid testnet. Skips when no hyperliquid sandbox account is configured. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../e2e/ccxt-hyperliquid-markets.e2e.spec.ts | 79 +++++++++ .../__test__/e2e/ccxt-hyperliquid.e2e.spec.ts | 162 ++++++++++++++++++ src/domain/trading/__test__/e2e/setup.ts | 13 +- src/domain/trading/brokers/ccxt/CcxtBroker.ts | 26 +-- 4 files changed, 267 insertions(+), 13 deletions(-) create mode 100644 src/domain/trading/__test__/e2e/ccxt-hyperliquid-markets.e2e.spec.ts create mode 100644 src/domain/trading/__test__/e2e/ccxt-hyperliquid.e2e.spec.ts diff --git a/src/domain/trading/__test__/e2e/ccxt-hyperliquid-markets.e2e.spec.ts b/src/domain/trading/__test__/e2e/ccxt-hyperliquid-markets.e2e.spec.ts new file mode 100644 index 00000000..da8225ab --- /dev/null +++ b/src/domain/trading/__test__/e2e/ccxt-hyperliquid-markets.e2e.spec.ts @@ -0,0 +1,79 @@ +/** + * CcxtBroker hyperliquid markets loading e2e. + * + * Verifies that OpenAlice's CcxtBroker can load ALL hyperliquid market types + * (spot AND swap), not just the subset that intersects with bybit-style + * type names (linear/inverse). + * + * This test does NOT require real wallet credentials — it uses dummy values + * that pass checkRequiredCredentials() but never make any private API calls. + * Hyperliquid's loadMarkets is a public endpoint. + * + * Run: pnpm test:e2e + */ + +import { describe, it, expect, beforeAll } from 'vitest' +import { CcxtBroker } from '../../brokers/ccxt/CcxtBroker.js' + +const DUMMY_WALLET = '0x0000000000000000000000000000000000000001' +const DUMMY_PRIVATE_KEY = '0x' + '0'.repeat(64) + +let broker: CcxtBroker | null = null +let initError: unknown = null + +beforeAll(async () => { + try { + broker = new CcxtBroker({ + id: 'hyperliquid-markets-test', + exchange: 'hyperliquid', + sandbox: true, // testnet — sandbox flag is the official ccxt mechanism + walletAddress: DUMMY_WALLET, + privateKey: DUMMY_PRIVATE_KEY, + }) + await broker.init() + } catch (err) { + initError = err + console.warn('hyperliquid markets test: init failed:', err instanceof Error ? err.message : err) + } +}, 60_000) + +describe('CcxtBroker — hyperliquid markets loading', () => { + it('connects to hyperliquid testnet via sandbox flag', () => { + expect(initError, `init failed: ${String(initError)}`).toBeNull() + expect(broker).not.toBeNull() + }) + + it('loads at least 100 markets total', () => { + if (!broker) return + const exchange = (broker as unknown as { exchange: { markets: Record } }).exchange + const count = Object.keys(exchange.markets).length + console.log(` hyperliquid testnet: ${count} markets loaded`) + expect(count).toBeGreaterThan(100) + }) + + it('loads BOTH spot AND swap market types (regression: was only spot)', () => { + if (!broker) return + const exchange = (broker as unknown as { exchange: { markets: Record } }).exchange + const types = new Set() + for (const m of Object.values(exchange.markets)) types.add(m.type) + console.log(` market types: ${[...types].join(', ')}`) + expect(types.has('spot'), 'spot markets missing').toBe(true) + expect(types.has('swap'), 'swap markets missing — fetchMarkets is filtering them out').toBe(true) + }) + + it('can search for a BTC perpetual contract', async () => { + if (!broker) return + const results = await broker.searchContracts('BTC') + expect(results.length).toBeGreaterThan(0) + // Hyperliquid perp BTC should appear in results + const btcPerp = results.find(r => { + const sym = r.contract.symbol ?? '' + const local = r.contract.localSymbol ?? '' + return sym === 'BTC' || local.startsWith('BTC') + }) + expect(btcPerp, `BTC perpetual not found in results: ${results.slice(0, 5).map(r => r.contract.localSymbol).join(', ')}`).toBeDefined() + if (btcPerp) { + console.log(` found BTC perp: localSymbol=${btcPerp.contract.localSymbol}, secType=${btcPerp.contract.secType}`) + } + }) +}) diff --git a/src/domain/trading/__test__/e2e/ccxt-hyperliquid.e2e.spec.ts b/src/domain/trading/__test__/e2e/ccxt-hyperliquid.e2e.spec.ts new file mode 100644 index 00000000..81b29d07 --- /dev/null +++ b/src/domain/trading/__test__/e2e/ccxt-hyperliquid.e2e.spec.ts @@ -0,0 +1,162 @@ +/** + * CcxtBroker e2e — real orders against Hyperliquid testnet. + * + * Reads Alice's config, picks the first CCXT Hyperliquid account on a + * sandbox (testnet) platform. If none configured, entire suite skips. + * + * Required configuration in data/config/accounts.json: + * { + * "id": "hyperliquid-test", + * "type": "ccxt", + * "enabled": true, + * "guards": [], + * "brokerConfig": { + * "exchange": "hyperliquid", + * "sandbox": true, // <-- testnet + * "walletAddress": "0x...", // <-- Hyperliquid testnet wallet + * "privateKey": "0x..." // <-- corresponding private key + * } + * } + * + * Get testnet funds at app.hyperliquid-testnet.xyz/drip. + * + * Run: pnpm test:e2e + */ + +import { describe, it, expect, beforeAll, beforeEach } from 'vitest' +import Decimal from 'decimal.js' +import { Order } from '@traderalice/ibkr' +import { getTestAccounts, filterByProvider } from './setup.js' +import type { IBroker } from '../../brokers/types.js' +import '../../contract-ext.js' + +let broker: IBroker | null = null + +beforeAll(async () => { + const all = await getTestAccounts() + const hl = filterByProvider(all, 'ccxt').find(a => a.id.includes('hyperliquid')) + if (!hl) { + console.log('e2e: No Hyperliquid testnet account configured, skipping') + return + } + broker = hl.broker + console.log(`e2e: ${hl.label} connected`) +}, 60_000) + +describe('CcxtBroker — Hyperliquid e2e', () => { + beforeEach(({ skip }) => { if (!broker) skip('no Hyperliquid account') }) + + /** Narrow broker type — beforeEach guarantees non-null via skip(). */ + function b(): IBroker { return broker! } + + // ==================== Connectivity ==================== + + it('fetches account info with USD baseCurrency', async () => { + const account = await b().getAccount() + expect(account.baseCurrency).toBeDefined() + expect(account.netLiquidation).toBeGreaterThanOrEqual(0) + console.log(` equity: $${account.netLiquidation.toFixed(2)}, cash: $${account.totalCashValue.toFixed(2)}, base=${account.baseCurrency}`) + }) + + it('fetches positions with currency field', async () => { + const positions = await b().getPositions() + expect(Array.isArray(positions)).toBe(true) + console.log(` ${positions.length} open positions`) + for (const p of positions) { + expect(p.currency).toBeDefined() + console.log(` ${p.contract.symbol}: ${p.side} ${p.quantity} @ ${p.marketPrice} ${p.currency}`) + } + }) + + // ==================== Markets / search ==================== + + it('searches BTC contracts and finds a perpetual', async () => { + const results = await b().searchContracts('BTC') + expect(results.length).toBeGreaterThan(0) + // Hyperliquid uses USDC as the perpetual settle currency + const perp = results.find(r => r.contract.localSymbol?.includes('USDC:USDC')) + expect(perp, `BTC perp not found. Results: ${results.slice(0, 5).map(r => r.contract.localSymbol).join(', ')}`).toBeDefined() + console.log(` found ${results.length} BTC contracts, perp: ${perp!.contract.localSymbol}`) + }) + + it('searches ETH contracts and finds a perpetual', async () => { + const results = await b().searchContracts('ETH') + expect(results.length).toBeGreaterThan(0) + const perp = results.find(r => r.contract.localSymbol?.includes('USDC:USDC')) + expect(perp).toBeDefined() + console.log(` found ${results.length} ETH contracts, perp: ${perp!.contract.localSymbol}`) + }) + + // ==================== Trading ==================== + + it('places market buy 0.001 BTC perp → execution returned', async ({ skip }) => { + const matches = await b().searchContracts('BTC') + const btcPerp = matches.find(m => m.contract.localSymbol?.includes('USDC:USDC')) + if (!btcPerp) return skip('BTC perp not found') + + // Hyperliquid minimum order value: $10. At ~$60k BTC, 0.001 = $60 (well above min). + // Adjust quantity if BTC price drops dramatically. + const order = new Order() + order.action = 'BUY' + order.orderType = 'MKT' + order.totalQuantity = new Decimal('0.001') + + const result = await b().placeOrder(btcPerp.contract, order) + expect(result.success, `placeOrder failed: ${result.error}`).toBe(true) + expect(result.orderId).toBeDefined() + console.log(` placeOrder result: orderId=${result.orderId}, execution=${!!result.execution}, orderState=${result.orderState?.status}`) + + if (result.execution) { + expect(result.execution.shares.toNumber()).toBeGreaterThan(0) + expect(result.execution.price).toBeGreaterThan(0) + console.log(` filled: ${result.execution.shares} @ $${result.execution.price}`) + } + }, 30_000) + + it('verifies BTC position exists after buy', async () => { + const positions = await b().getPositions() + const btcPos = positions.find(p => p.contract.symbol === 'BTC') + expect(btcPos, `BTC position not found. Positions: ${positions.map(p => p.contract.symbol).join(', ')}`).toBeDefined() + if (btcPos) { + console.log(` BTC position: ${btcPos.quantity} ${btcPos.side} @ ${btcPos.marketPrice} ${btcPos.currency}`) + expect(btcPos.currency).toBe('USD') // CCXT broker normalizes USDC stablecoin → USD + } + }) + + it('closes BTC position with reduceOnly', async ({ skip }) => { + const matches = await b().searchContracts('BTC') + const btcPerp = matches.find(m => m.contract.localSymbol?.includes('USDC:USDC')) + if (!btcPerp) return skip('BTC perp not found') + + const result = await b().closePosition(btcPerp.contract, new Decimal('0.001')) + expect(result.success, `closePosition failed: ${result.error}`).toBe(true) + console.log(` close orderId=${result.orderId}, success=${result.success}`) + }, 15_000) + + it('queries order by ID after place', async ({ skip }) => { + const matches = await b().searchContracts('BTC') + const btcPerp = matches.find(m => m.contract.localSymbol?.includes('USDC:USDC')) + if (!btcPerp) return skip('BTC perp not found') + + const order = new Order() + order.action = 'BUY' + order.orderType = 'MKT' + order.totalQuantity = new Decimal('0.001') + + const placed = await b().placeOrder(btcPerp.contract, order) + if (!placed.orderId) return skip('no orderId returned') + + // Wait for exchange to settle + await new Promise(r => setTimeout(r, 3000)) + + const detail = await b().getOrder(placed.orderId) + console.log(` getOrder(${placed.orderId}): ${detail ? `status=${detail.orderState.status}` : 'null'}`) + expect(detail).not.toBeNull() + if (detail) { + expect(detail.orderState.status).toBe('Filled') + } + + // Clean up + await b().closePosition(btcPerp.contract, new Decimal('0.001')) + }, 30_000) +}) diff --git a/src/domain/trading/__test__/e2e/setup.ts b/src/domain/trading/__test__/e2e/setup.ts index dcc64c12..60711886 100644 --- a/src/domain/trading/__test__/e2e/setup.ts +++ b/src/domain/trading/__test__/e2e/setup.ts @@ -12,6 +12,7 @@ import net from 'node:net' import { readAccountsConfig, type AccountConfig } from '@/core/config.js' import type { IBroker } from '../../brokers/types.js' import { createBroker } from '../../brokers/factory.js' +import { CCXT_CREDENTIAL_FIELDS } from '../../brokers/ccxt/ccxt-types.js' export interface TestAccount { id: string @@ -38,9 +39,15 @@ function hasCredentials(acct: AccountConfig): boolean { const bc = acct.brokerConfig switch (acct.type) { case 'alpaca': - case 'ccxt': return !!bc.apiKey - case 'ibkr': return true // no API key — auth via TWS/Gateway login - default: return true + return !!bc.apiKey + case 'ccxt': + // CCXT exchanges use different credential schemes — apiKey/secret for most, + // walletAddress/privateKey for Hyperliquid, etc. Match any standard CCXT field. + return CCXT_CREDENTIAL_FIELDS.some(k => !!(bc as Record)[k]) + case 'ibkr': + return true // no API key — auth via TWS/Gateway login + default: + return true } } diff --git a/src/domain/trading/brokers/ccxt/CcxtBroker.ts b/src/domain/trading/brokers/ccxt/CcxtBroker.ts index 946c62e9..ce867f27 100644 --- a/src/domain/trading/brokers/ccxt/CcxtBroker.ts +++ b/src/domain/trading/brokers/ccxt/CcxtBroker.ts @@ -141,15 +141,13 @@ export class CcxtBroker implements IBroker { throw new BrokerError('CONFIG', `Unknown CCXT exchange: ${config.exchange}`) } - // Default: skip option markets to reduce concurrent requests during loadMarkets - const defaultOptions: Record = { - fetchMarkets: { types: ['spot', 'linear', 'inverse'] }, - } - const mergedOptions = { ...defaultOptions, ...config.options } - // Pass through all CCXT standard credential fields. CCXT ignores undefined. + // Do NOT override the exchange's default fetchMarkets.types — each exchange + // has its own (e.g. bybit: spot/linear/inverse/option, hyperliquid: spot/swap/hip3). + // The init() wrapper below handles option-skipping uniformly via type filtering. const cfgRecord = config as unknown as Record - const credentials: Record = { options: mergedOptions } + const credentials: Record = {} + if (config.options !== undefined) credentials.options = config.options for (const field of CCXT_CREDENTIAL_FIELDS) { const v = cfgRecord[field] if (v !== undefined) credentials[field] = v @@ -202,7 +200,13 @@ export class CcxtBroker implements IBroker { const ex = this.exchange as unknown as Record const opts = (ex['options'] ?? {}) as Record const fmOpts = (opts['fetchMarkets'] ?? {}) as Record - const types = (fmOpts['types'] ?? ['spot', 'linear', 'inverse']) as string[] + // Use the exchange's own default types (set in its CCXT class describe()). + // Skip 'option' type — option markets are typically thousands of contracts + // (Bybit alone has ~10k+) and rarely useful for automated trading. + const allTypes = (fmOpts['types'] ?? []) as string[] + const types = allTypes.length > 0 + ? allTypes.filter(t => t !== 'option') + : ['spot', 'linear', 'inverse'] // fallback for exchanges that don't declare types const allMarkets: unknown[] = [] for (const type of types) { @@ -258,6 +262,8 @@ export class CcxtBroker implements IBroker { for (const market of Object.values(this.markets)) { if (market.active === false) continue + // Some exchanges (e.g. hyperliquid spot) have markets without base/quote populated + if (!market.base || !market.quote) continue if (market.base.toUpperCase() !== searchBase) continue const quote = market.quote.toUpperCase() @@ -274,8 +280,8 @@ export class CcxtBroker implements IBroker { const aType = typeOrder[a.type as keyof typeof typeOrder] ?? 99 const bType = typeOrder[b.type as keyof typeof typeOrder] ?? 99 if (aType !== bType) return aType - bType - const aQuote = quoteOrder[a.quote.toUpperCase()] ?? 99 - const bQuote = quoteOrder[b.quote.toUpperCase()] ?? 99 + const aQuote = quoteOrder[(a.quote ?? '').toUpperCase()] ?? 99 + const bQuote = quoteOrder[(b.quote ?? '').toUpperCase()] ?? 99 return aQuote - bQuote }) From 7fa0409483b4d2ccb6724b7364d8a1d30a592f03 Mon Sep 17 00:00:00 2001 From: Ame Date: Sat, 11 Apr 2026 18:01:08 +0800 Subject: [PATCH 2/3] fix: hyperliquid market orders via placeOrder override MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hyperliquid has no native market order type — orders are emulated as IOC limit orders bounded by a slippage price (default 5%). CCXT requires the caller to pass a reference price even for type='market' so it can compute the slippage bound, otherwise it throws ArgumentsRequired. The hyperliquid server also enforces an 80% deviation cap from mark price, ruling out extreme dummy values — a real reference price is required. - Add placeOrder hook to CcxtExchangeOverrides interface (mirrors the existing fetchOrderById/cancelOrderById hooks) - defaultPlaceOrder passes through to ccxt.createOrder unchanged - exchanges/hyperliquid.ts overrides placeOrder to fetchTicker first when the order is a market order missing a reference price - CcxtBroker.placeOrder routes through this.overrides.placeOrder ?? default - Bump hyperliquid e2e test timeouts from 15s/30s to 60s — testnet is noticeably slower than bybit (extra fetchTicker RTT plus base latency) E2E result on hyperliquid testnet: 8/8 passing — market buy/sell, position verification, reduceOnly close, and getOrder all work end-to-end. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../__test__/e2e/ccxt-hyperliquid.e2e.spec.ts | 4 +-- src/domain/trading/brokers/ccxt/CcxtBroker.ts | 10 ++++-- .../brokers/ccxt/exchanges/hyperliquid.ts | 35 +++++++++++++++++++ src/domain/trading/brokers/ccxt/overrides.ts | 26 ++++++++++++++ 4 files changed, 71 insertions(+), 4 deletions(-) create mode 100644 src/domain/trading/brokers/ccxt/exchanges/hyperliquid.ts diff --git a/src/domain/trading/__test__/e2e/ccxt-hyperliquid.e2e.spec.ts b/src/domain/trading/__test__/e2e/ccxt-hyperliquid.e2e.spec.ts index 81b29d07..cc0287e1 100644 --- a/src/domain/trading/__test__/e2e/ccxt-hyperliquid.e2e.spec.ts +++ b/src/domain/trading/__test__/e2e/ccxt-hyperliquid.e2e.spec.ts @@ -131,7 +131,7 @@ describe('CcxtBroker — Hyperliquid e2e', () => { const result = await b().closePosition(btcPerp.contract, new Decimal('0.001')) expect(result.success, `closePosition failed: ${result.error}`).toBe(true) console.log(` close orderId=${result.orderId}, success=${result.success}`) - }, 15_000) + }, 60_000) it('queries order by ID after place', async ({ skip }) => { const matches = await b().searchContracts('BTC') @@ -158,5 +158,5 @@ describe('CcxtBroker — Hyperliquid e2e', () => { // Clean up await b().closePosition(btcPerp.contract, new Decimal('0.001')) - }, 30_000) + }, 60_000) }) diff --git a/src/domain/trading/brokers/ccxt/CcxtBroker.ts b/src/domain/trading/brokers/ccxt/CcxtBroker.ts index ce867f27..0ad0ea22 100644 --- a/src/domain/trading/brokers/ccxt/CcxtBroker.ts +++ b/src/domain/trading/brokers/ccxt/CcxtBroker.ts @@ -39,6 +39,7 @@ import { exchangeOverrides, defaultFetchOrderById, defaultCancelOrderById, + defaultPlaceOrder, } from './overrides.js' const STABLECOIN_TO_USD = new Set(['USDT', 'USDC', 'BUSD', 'DAI', 'TUSD']) @@ -364,13 +365,18 @@ export class CcxtBroker implements IBroker { const ccxtOrderType = ibkrOrderTypeToCcxt(order.orderType) const side = order.action.toLowerCase() as 'buy' | 'sell' + const refPrice = ccxtOrderType === 'limit' && order.lmtPrice !== UNSET_DOUBLE + ? order.lmtPrice + : undefined - const ccxtOrder = await this.exchange.createOrder( + const place = this.overrides.placeOrder ?? defaultPlaceOrder + const ccxtOrder = await place( + this.exchange, ccxtSymbol, ccxtOrderType, side, parseFloat(size), - ccxtOrderType === 'limit' && order.lmtPrice !== UNSET_DOUBLE ? order.lmtPrice : undefined, + refPrice, params, ) diff --git a/src/domain/trading/brokers/ccxt/exchanges/hyperliquid.ts b/src/domain/trading/brokers/ccxt/exchanges/hyperliquid.ts new file mode 100644 index 00000000..87a89d71 --- /dev/null +++ b/src/domain/trading/brokers/ccxt/exchanges/hyperliquid.ts @@ -0,0 +1,35 @@ +/** + * Hyperliquid-specific overrides for CcxtBroker. + * + * Hyperliquid quirks: + * - No native market orders. CCXT emulates them as IOC limit orders with + * a slippage-bounded price (default 5%). To compute the bound, CCXT + * requires the caller to pass a reference price even for type='market'. + * - Server enforces an 80% deviation cap from mark price, so we can't + * just send an extreme dummy value — we have to fetchTicker first. + */ + +import type { Exchange, Order as CcxtOrder } from 'ccxt' +import type { CcxtExchangeOverrides } from '../overrides.js' + +export const hyperliquidOverrides: CcxtExchangeOverrides = { + async placeOrder( + exchange: Exchange, + symbol: string, + type: string, + side: 'buy' | 'sell', + amount: number, + price: number | undefined, + params: Record, + ): Promise { + let refPrice = price + if (type === 'market' && refPrice === undefined) { + const ticker = await exchange.fetchTicker(symbol) + refPrice = ticker.last ?? ticker.close ?? undefined + if (refPrice === undefined) { + throw new Error(`hyperliquid: cannot fetch reference price for market order on ${symbol}`) + } + } + return await exchange.createOrder(symbol, type, side, amount, refPrice, params) + }, +} diff --git a/src/domain/trading/brokers/ccxt/overrides.ts b/src/domain/trading/brokers/ccxt/overrides.ts index 33ac0aad..f180aa73 100644 --- a/src/domain/trading/brokers/ccxt/overrides.ts +++ b/src/domain/trading/brokers/ccxt/overrides.ts @@ -18,6 +18,7 @@ import type { Exchange, Order as CcxtOrder } from 'ccxt' import { bybitOverrides } from './exchanges/bybit.js' +import { hyperliquidOverrides } from './exchanges/hyperliquid.js' // ==================== Override interface ==================== @@ -26,6 +27,17 @@ export interface CcxtExchangeOverrides { fetchOrderById?(exchange: Exchange, orderId: string, symbol: string): Promise /** Cancel an order by ID (regular + conditional). */ cancelOrderById?(exchange: Exchange, orderId: string, symbol?: string): Promise + /** Place an order via ccxt.createOrder. Override when an exchange needs custom prep + * (e.g. hyperliquid market orders require a reference price for slippage bounds). */ + placeOrder?( + exchange: Exchange, + symbol: string, + type: string, + side: 'buy' | 'sell', + amount: number, + price: number | undefined, + params: Record, + ): Promise } // ==================== Default implementations ==================== @@ -57,8 +69,22 @@ export async function defaultCancelOrderById(exchange: Exchange, orderId: string } } +/** Default: pass straight through to ccxt.createOrder. Works for bybit, binance, alpaca-via-ccxt, etc. */ +export async function defaultPlaceOrder( + exchange: Exchange, + symbol: string, + type: string, + side: 'buy' | 'sell', + amount: number, + price: number | undefined, + params: Record, +): Promise { + return await exchange.createOrder(symbol, type, side, amount, price, params) +} + // ==================== Registry ==================== export const exchangeOverrides: Record = { bybit: bybitOverrides, + hyperliquid: hyperliquidOverrides, } From 00cf9bc50cd5a21d16004aae089f6814c7e0712f Mon Sep 17 00:00:00 2001 From: Ame Date: Sat, 11 Apr 2026 23:11:59 +0800 Subject: [PATCH 3/3] refactor: ccxt override hooks accept defaultImpl + fix hyperliquid markPrice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reshape CcxtExchangeOverrides so each hook receives the original args plus a defaultImpl as the final parameter. The override decides what to do with it: invoke directly, modify inputs and call it, post-process its result, or ignore it entirely. This replaces the previous either/or pattern that forced overrides to either fully replace the default or copy its body. - All four hooks (fetchOrderById, cancelOrderById, placeOrder, fetchPositions) now follow the same convention - New hook fetchPositions + defaultFetchPositions for cases where ccxt's parsePosition leaves fields undefined - bybit's fetchOrderById is unchanged (it fully replaces); just adopts the new signature with an unused _defaultImpl parameter - hyperliquid's placeOrder now calls defaultImpl(modifiedArgs) instead of exchange.createOrder directly — future enhancements to defaultPlaceOrder (retries, logging) will flow through automatically - New hyperliquid fetchPositions override recovers markPrice from notional / contracts. CCXT's parsePosition hardcodes markPrice: undefined for hyperliquid, but it does expose positionValue (mapped to notional), so the math works out: |notional| / |contracts| = mark price E2E confirms: BTC perp position now reports `@ 72931 USD` instead of `@ 0`, and the new regression assertion (marketPrice > 0, marketValue ≈ qty × price) catches future regressions. All 8 hyperliquid e2e tests pass. - Bump version to 0.9.0-beta.12 Co-Authored-By: Claude Opus 4.6 (1M context) --- package.json | 2 +- .../__test__/e2e/ccxt-hyperliquid.e2e.spec.ts | 5 ++ src/domain/trading/brokers/ccxt/CcxtBroker.ts | 40 ++++++++------- .../trading/brokers/ccxt/exchanges/bybit.ts | 2 +- .../brokers/ccxt/exchanges/hyperliquid.ts | 27 ++++++++-- src/domain/trading/brokers/ccxt/overrides.ts | 50 ++++++++++++++++--- 6 files changed, 97 insertions(+), 29 deletions(-) diff --git a/package.json b/package.json index 16fcc674..5424fb7f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "open-alice", - "version": "0.9.0-beta.11", + "version": "0.9.0-beta.12", "description": "File-based trading agent engine", "type": "module", "scripts": { diff --git a/src/domain/trading/__test__/e2e/ccxt-hyperliquid.e2e.spec.ts b/src/domain/trading/__test__/e2e/ccxt-hyperliquid.e2e.spec.ts index cc0287e1..0e7904a3 100644 --- a/src/domain/trading/__test__/e2e/ccxt-hyperliquid.e2e.spec.ts +++ b/src/domain/trading/__test__/e2e/ccxt-hyperliquid.e2e.spec.ts @@ -64,6 +64,11 @@ describe('CcxtBroker — Hyperliquid e2e', () => { console.log(` ${positions.length} open positions`) for (const p of positions) { expect(p.currency).toBeDefined() + // Regression: hyperliquid's CCXT parsePosition leaves markPrice undefined. + // Our override recovers it from notional / contracts — verify it's > 0. + expect(p.marketPrice, `marketPrice missing for ${p.contract.symbol}`).toBeGreaterThan(0) + // marketValue should equal qty × markPrice + expect(p.marketValue).toBeCloseTo(p.quantity.toNumber() * p.marketPrice, 2) console.log(` ${p.contract.symbol}: ${p.side} ${p.quantity} @ ${p.marketPrice} ${p.currency}`) } }) diff --git a/src/domain/trading/brokers/ccxt/CcxtBroker.ts b/src/domain/trading/brokers/ccxt/CcxtBroker.ts index 0ad0ea22..7cc8637c 100644 --- a/src/domain/trading/brokers/ccxt/CcxtBroker.ts +++ b/src/domain/trading/brokers/ccxt/CcxtBroker.ts @@ -40,6 +40,7 @@ import { defaultFetchOrderById, defaultCancelOrderById, defaultPlaceOrder, + defaultFetchPositions, } from './overrides.js' const STABLECOIN_TO_USD = new Set(['USDT', 'USDC', 'BUSD', 'DAI', 'TUSD']) @@ -369,16 +370,10 @@ export class CcxtBroker implements IBroker { ? order.lmtPrice : undefined - const place = this.overrides.placeOrder ?? defaultPlaceOrder - const ccxtOrder = await place( - this.exchange, - ccxtSymbol, - ccxtOrderType, - side, - parseFloat(size), - refPrice, - params, - ) + const placeOverride = this.overrides.placeOrder + const ccxtOrder = placeOverride + ? await placeOverride(this.exchange, ccxtSymbol, ccxtOrderType, side, parseFloat(size), refPrice, params, defaultPlaceOrder) + : await defaultPlaceOrder(this.exchange, ccxtSymbol, ccxtOrderType, side, parseFloat(size), refPrice, params) // Cache orderId → symbol if (ccxtOrder.id) { @@ -400,8 +395,12 @@ export class CcxtBroker implements IBroker { try { const ccxtSymbol = this.orderSymbolCache.get(orderId) - const cancel = this.overrides.cancelOrderById ?? defaultCancelOrderById - await cancel(this.exchange, orderId, ccxtSymbol) + const cancelOverride = this.overrides.cancelOrderById + if (cancelOverride) { + await cancelOverride(this.exchange, orderId, ccxtSymbol, defaultCancelOrderById) + } else { + await defaultCancelOrderById(this.exchange, orderId, ccxtSymbol) + } const orderState = new OrderState() orderState.status = 'Cancelled' return { success: true, orderId, orderState } @@ -420,8 +419,10 @@ export class CcxtBroker implements IBroker { } // editOrder requires type and side — fetch the original order to fill in defaults. - const fetch = this.overrides.fetchOrderById ?? defaultFetchOrderById - const original = await fetch(this.exchange, orderId, ccxtSymbol) + const fetchOverride = this.overrides.fetchOrderById + const original = fetchOverride + ? await fetchOverride(this.exchange, orderId, ccxtSymbol, defaultFetchOrderById) + : await defaultFetchOrderById(this.exchange, orderId, ccxtSymbol) const qty = changes.totalQuantity != null && !changes.totalQuantity.equals(UNSET_DECIMAL) ? parseFloat(changes.totalQuantity.toString()) : original.amount const price = changes.lmtPrice != null && changes.lmtPrice !== UNSET_DOUBLE ? changes.lmtPrice : original.price @@ -534,7 +535,10 @@ export class CcxtBroker implements IBroker { this.ensureInit() try { - const raw = await this.exchange.fetchPositions() + const fetchOverride = this.overrides.fetchPositions + const raw = fetchOverride + ? await fetchOverride(this.exchange, defaultFetchPositions) + : await defaultFetchPositions(this.exchange) const result: Position[] = [] for (const p of raw) { @@ -589,9 +593,11 @@ export class CcxtBroker implements IBroker { const ccxtSymbol = this.orderSymbolCache.get(orderId) if (!ccxtSymbol) return null - const fetch = this.overrides.fetchOrderById ?? defaultFetchOrderById + const fetchOverride = this.overrides.fetchOrderById try { - const order = await fetch(this.exchange, orderId, ccxtSymbol) + const order = fetchOverride + ? await fetchOverride(this.exchange, orderId, ccxtSymbol, defaultFetchOrderById) + : await defaultFetchOrderById(this.exchange, orderId, ccxtSymbol) return this.convertCcxtOrder(order) } catch { return null diff --git a/src/domain/trading/brokers/ccxt/exchanges/bybit.ts b/src/domain/trading/brokers/ccxt/exchanges/bybit.ts index 4dc79351..c1e64085 100644 --- a/src/domain/trading/brokers/ccxt/exchanges/bybit.ts +++ b/src/domain/trading/brokers/ccxt/exchanges/bybit.ts @@ -11,7 +11,7 @@ import type { Exchange, Order as CcxtOrder } from 'ccxt' import type { CcxtExchangeOverrides } from '../overrides.js' export const bybitOverrides: CcxtExchangeOverrides = { - async fetchOrderById(exchange: Exchange, orderId: string, symbol: string): Promise { + async fetchOrderById(exchange: Exchange, orderId: string, symbol: string, _defaultImpl): Promise { // Try open regular → open conditional → closed regular → closed conditional try { return await (exchange as any).fetchOpenOrder(orderId, symbol) diff --git a/src/domain/trading/brokers/ccxt/exchanges/hyperliquid.ts b/src/domain/trading/brokers/ccxt/exchanges/hyperliquid.ts index 87a89d71..c26c26cb 100644 --- a/src/domain/trading/brokers/ccxt/exchanges/hyperliquid.ts +++ b/src/domain/trading/brokers/ccxt/exchanges/hyperliquid.ts @@ -5,14 +5,20 @@ * - No native market orders. CCXT emulates them as IOC limit orders with * a slippage-bounded price (default 5%). To compute the bound, CCXT * requires the caller to pass a reference price even for type='market'. - * - Server enforces an 80% deviation cap from mark price, so we can't - * just send an extreme dummy value — we have to fetchTicker first. + * Server enforces an 80% deviation cap from mark price, so we can't + * send an extreme dummy value — we have to fetchTicker first. + * + * - CCXT's parsePosition leaves markPrice undefined for hyperliquid (hardcoded + * in node_modules/ccxt/js/src/hyperliquid.js, line 3613). Hyperliquid does + * return positionValue (mapped to notional by CCXT), so we recover markPrice + * from notional / contracts. */ -import type { Exchange, Order as CcxtOrder } from 'ccxt' +import type { Exchange, Order as CcxtOrder, Position as CcxtPosition } from 'ccxt' import type { CcxtExchangeOverrides } from '../overrides.js' export const hyperliquidOverrides: CcxtExchangeOverrides = { + /** Inject a fetched ticker price for market orders, then delegate to default. */ async placeOrder( exchange: Exchange, symbol: string, @@ -21,6 +27,7 @@ export const hyperliquidOverrides: CcxtExchangeOverrides = { amount: number, price: number | undefined, params: Record, + defaultImpl, ): Promise { let refPrice = price if (type === 'market' && refPrice === undefined) { @@ -30,6 +37,18 @@ export const hyperliquidOverrides: CcxtExchangeOverrides = { throw new Error(`hyperliquid: cannot fetch reference price for market order on ${symbol}`) } } - return await exchange.createOrder(symbol, type, side, amount, refPrice, params) + return await defaultImpl(exchange, symbol, type, side, amount, refPrice, params) + }, + + /** Recover markPrice that CCXT's parsePosition omits, by inverting notional / contracts. */ + async fetchPositions(exchange: Exchange, defaultImpl): Promise { + const raw = await defaultImpl(exchange) + return raw.map(p => { + if (p.markPrice == null && p.notional != null && p.contracts != null && p.contracts !== 0) { + const recovered = Math.abs(p.notional) / Math.abs(p.contracts) + return { ...p, markPrice: recovered } + } + return p + }) }, } diff --git a/src/domain/trading/brokers/ccxt/overrides.ts b/src/domain/trading/brokers/ccxt/overrides.ts index f180aa73..928121ed 100644 --- a/src/domain/trading/brokers/ccxt/overrides.ts +++ b/src/domain/trading/brokers/ccxt/overrides.ts @@ -5,10 +5,17 @@ * - Bybit: fetchOrder requires { acknowledged: true }, limited to last 500 orders * - Binance: fetchOrder works fine, but conditional orders need { stop: true } * - OKX/Bitget: no fetchOpenOrder/fetchClosedOrder singular methods + * - Hyperliquid: market orders require a ref price, fetchPositions omits markPrice * - * Rather than patching one code path with exchange-specific if/else, - * each tested exchange gets its own override file in exchanges/. - * Only override what's different — unset methods fall through to the default. + * Each tested exchange gets its own override file in exchanges/. Only override + * what's different — unset methods fall through to the default. + * + * Override convention: every override receives the original args plus a final + * `defaultImpl` parameter. The override can choose to: + * - call defaultImpl(...args) → run the default behavior + * - call defaultImpl(modifiedArgs) → modify inputs, then run default + * - postprocess defaultImpl's result → modify outputs + * - ignore defaultImpl entirely → completely replace the implementation * * To add a new exchange: * 1. Create exchanges/.ts exporting a CcxtExchangeOverrides object @@ -16,17 +23,32 @@ * 3. Register it in exchangeOverrides below */ -import type { Exchange, Order as CcxtOrder } from 'ccxt' +import type { Exchange, Order as CcxtOrder, Position as CcxtPosition } from 'ccxt' import { bybitOverrides } from './exchanges/bybit.js' import { hyperliquidOverrides } from './exchanges/hyperliquid.js' // ==================== Override interface ==================== +/** A function that calls the default implementation with the same arg shape. */ +type DefaultImpl = (...args: TArgs) => Promise + export interface CcxtExchangeOverrides { /** Fetch a single order by ID (regular + conditional). */ - fetchOrderById?(exchange: Exchange, orderId: string, symbol: string): Promise + fetchOrderById?( + exchange: Exchange, + orderId: string, + symbol: string, + defaultImpl: DefaultImpl<[Exchange, string, string], CcxtOrder>, + ): Promise + /** Cancel an order by ID (regular + conditional). */ - cancelOrderById?(exchange: Exchange, orderId: string, symbol?: string): Promise + cancelOrderById?( + exchange: Exchange, + orderId: string, + symbol: string | undefined, + defaultImpl: DefaultImpl<[Exchange, string, string | undefined], void>, + ): Promise + /** Place an order via ccxt.createOrder. Override when an exchange needs custom prep * (e.g. hyperliquid market orders require a reference price for slippage bounds). */ placeOrder?( @@ -37,7 +59,18 @@ export interface CcxtExchangeOverrides { amount: number, price: number | undefined, params: Record, + defaultImpl: DefaultImpl< + [Exchange, string, string, 'buy' | 'sell', number, number | undefined, Record], + CcxtOrder + >, ): Promise + + /** Fetch positions. Override when CCXT's parsePosition leaves important + * fields undefined (e.g. hyperliquid omits markPrice). */ + fetchPositions?( + exchange: Exchange, + defaultImpl: DefaultImpl<[Exchange], CcxtPosition[]>, + ): Promise } // ==================== Default implementations ==================== @@ -82,6 +115,11 @@ export async function defaultPlaceOrder( return await exchange.createOrder(symbol, type, side, amount, price, params) } +/** Default: pass straight through to ccxt.fetchPositions. */ +export async function defaultFetchPositions(exchange: Exchange): Promise { + return await exchange.fetchPositions() +} + // ==================== Registry ==================== export const exchangeOverrides: Record = {