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-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..0e7904a3 --- /dev/null +++ b/src/domain/trading/__test__/e2e/ccxt-hyperliquid.e2e.spec.ts @@ -0,0 +1,167 @@ +/** + * 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() + // 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}`) + } + }) + + // ==================== 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}`) + }, 60_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')) + }, 60_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..7cc8637c 100644 --- a/src/domain/trading/brokers/ccxt/CcxtBroker.ts +++ b/src/domain/trading/brokers/ccxt/CcxtBroker.ts @@ -39,6 +39,8 @@ import { exchangeOverrides, defaultFetchOrderById, defaultCancelOrderById, + defaultPlaceOrder, + defaultFetchPositions, } from './overrides.js' const STABLECOIN_TO_USD = new Set(['USDT', 'USDC', 'BUSD', 'DAI', 'TUSD']) @@ -141,15 +143,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 +202,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 +264,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 +282,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 }) @@ -358,15 +366,14 @@ 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( - ccxtSymbol, - ccxtOrderType, - side, - parseFloat(size), - ccxtOrderType === 'limit' && order.lmtPrice !== UNSET_DOUBLE ? order.lmtPrice : undefined, - 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) { @@ -388,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 } @@ -408,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 @@ -522,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) { @@ -577,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 new file mode 100644 index 00000000..c26c26cb --- /dev/null +++ b/src/domain/trading/brokers/ccxt/exchanges/hyperliquid.ts @@ -0,0 +1,54 @@ +/** + * 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 + * 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, 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, + type: string, + side: 'buy' | 'sell', + amount: number, + price: number | undefined, + params: Record, + defaultImpl, + ): 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 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 33ac0aad..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,16 +23,54 @@ * 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?( + exchange: Exchange, + symbol: string, + type: string, + side: 'buy' | 'sell', + 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 ==================== @@ -57,8 +102,27 @@ 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) +} + +/** Default: pass straight through to ccxt.fetchPositions. */ +export async function defaultFetchPositions(exchange: Exchange): Promise { + return await exchange.fetchPositions() +} + // ==================== Registry ==================== export const exchangeOverrides: Record = { bybit: bybitOverrides, + hyperliquid: hyperliquidOverrides, }