diff --git a/packages/uta-protocol/src/brokers/preset-catalog.ts b/packages/uta-protocol/src/brokers/preset-catalog.ts index 2d89a68ed..d0a6f7aa8 100644 --- a/packages/uta-protocol/src/brokers/preset-catalog.ts +++ b/packages/uta-protocol/src/brokers/preset-catalog.ts @@ -261,7 +261,7 @@ export const BITGET_PRESET: BrokerPresetDef = { label: 'Bitget', description: 'Bitget — spot and USDT-M perpetuals.', category: 'crypto', - hint: 'Bitget requires API key + secret + passphrase (set when creating the key). Demo Trading routes orders to a simulated environment using the production domain.', + hint: 'Bitget requires API key + secret + passphrase (set when creating the key). Choose **Unified** only after the Bitget account has been upgraded to UTA; UTA keys cannot read the Classic v2 account endpoints. Existing OpenAlice accounts remain **Classic** by default for backward compatibility. Demo Trading routes orders to a simulated environment using the production domain.', defaultName: 'bitget-main', badge: 'BG', badgeColor: 'text-primary', @@ -273,18 +273,21 @@ export const BITGET_PRESET: BrokerPresetDef = { ], zodSchema: z.object({ mode: z.enum(['live', 'demo']).default('live').describe('Mode'), + accountType: z.enum(['classic', 'unified']).default('classic').describe('Account Type (Classic or Unified UTA)'), apiKey: z.string().min(1).describe('API Key'), secret: z.string().min(1).describe('API Secret'), password: z.string().min(1).describe('Passphrase'), }), subtitleFields: [ { field: 'mode', prefix: 'Bitget · ' }, + { field: 'accountType', prefix: 'Account · ' }, ], writeOnlyFields: ['apiKey', 'secret', 'password'], fingerprintFields: ['mode', 'apiKey'], toEngineConfig: (d) => ({ exchange: 'bitget', demoTrading: d.mode === 'demo', + options: { uta: d.accountType === 'unified' }, apiKey: d.apiKey, secret: d.secret, password: d.password, diff --git a/services/uta/src/domain/trading/brokers/ccxt/CcxtBroker.spec.ts b/services/uta/src/domain/trading/brokers/ccxt/CcxtBroker.spec.ts index 6cb6fcfe5..69f972877 100644 --- a/services/uta/src/domain/trading/brokers/ccxt/CcxtBroker.spec.ts +++ b/services/uta/src/domain/trading/brokers/ccxt/CcxtBroker.spec.ts @@ -12,9 +12,9 @@ import { Contract, Order, UNSET_DOUBLE, UNSET_DECIMAL } from '@traderalice/ibkr' // Mock ccxt BEFORE importing CcxtBroker vi.mock('ccxt', () => { // Create a fake exchange class that can be used as a constructor - const MockExchange = vi.fn(function (this: any) { + const MockExchange = vi.fn(function (this: any, config: Record = {}) { this.markets = {} - this.options = { fetchMarkets: { types: ['spot', 'linear'] } } + this.options = { fetchMarkets: { types: ['spot', 'linear'] }, ...(config.options ?? {}) } this.setSandboxMode = vi.fn() this.loadMarkets = vi.fn().mockResolvedValue({}) this.fetchMarkets = vi.fn().mockResolvedValue([]) @@ -40,6 +40,7 @@ vi.mock('ccxt', () => { default: { bybit: MockExchange, binance: MockExchange, + bitget: MockExchange, }, } }) @@ -77,12 +78,13 @@ function makeSwapMarket(base: string, quote: string, symbol?: string): any { } } -function makeAccount(overrides?: Partial<{ exchange: string; apiKey: string; secret: string }>) { +function makeAccount(overrides?: Partial<{ exchange: string; apiKey: string; secret: string; options: Record }>) { return new CcxtBroker({ exchange: overrides?.exchange ?? 'bybit', apiKey: overrides?.apiKey ?? 'k', secret: overrides?.secret ?? 's', sandbox: false, + options: overrides?.options, }) } @@ -999,6 +1001,82 @@ describe('CcxtBroker — sub-accounts', () => { ]) }) + it('Bitget Classic exposes separate spot + USDT-M wallets', async () => { + const acc = makeAccount({ exchange: 'bitget', options: { uta: false } }) + expect(await acc.listSubAccounts()).toEqual([ + { id: 'spot', label: 'Spot', kind: 'spot' }, + { id: 'derivatives', label: 'USDT-M Futures', kind: 'derivatives' }, + ]) + }) + + it('Bitget UTA exposes one unified account', async () => { + const acc = makeAccount({ exchange: 'bitget', options: { uta: true } }) + expect(await acc.listSubAccounts()).toEqual([ + { id: 'default', label: 'Unified Account', kind: 'unified' }, + ]) + }) + + it('Bitget Classic account aggregation reads spot + USDT-M and explicit futures PnL', async () => { + const acc = makeAccount({ exchange: 'bitget', options: { uta: false } }) + setInitialized(acc, {}) + const fetchBalance = vi.fn() + .mockResolvedValueOnce({ USDT: { total: 4.44 } }) + .mockResolvedValueOnce({ USDT: { total: 1000 } }) + ;(acc as any).exchange.fetchBalance = fetchBalance + ;(acc as any).exchange.fetchPositions = vi.fn().mockResolvedValue([ + { unrealizedPnl: 12.5, realizedPnl: 3 }, + ]) + + const info = await acc.getAccount() + + expect(fetchBalance.mock.calls.map(call => call[0])).toEqual([ + { type: 'spot' }, + { type: 'swap', productType: 'USDT-FUTURES' }, + ]) + expect((acc as any).exchange.fetchPositions).toHaveBeenCalledWith(undefined, { + productType: 'USDT-FUTURES', + }) + expect(info.netLiquidation).toBe('1004.44') + expect(info.unrealizedPnL).toBe('12.5') + }) + + it('fails a Bitget Classic account read when the USDT-M wallet is unreadable', async () => { + const acc = makeAccount({ exchange: 'bitget', options: { uta: false } }) + setInitialized(acc, {}) + ;(acc as any).exchange.fetchBalance = vi.fn() + .mockResolvedValueOnce({ USDT: { total: 4.44 } }) + .mockRejectedValueOnce(new Error('USDT-M permission denied')) + + await expect(acc.getAccount()).rejects.toThrow('USDT-M permission denied') + }) + + it('fails a Bitget account read when positions are unreadable', async () => { + const acc = makeAccount({ exchange: 'bitget', options: { uta: true } }) + setInitialized(acc, {}) + ;(acc as any).exchange.fetchBalance = vi.fn().mockResolvedValue({ USDT: { total: 1000 } }) + ;(acc as any).exchange.fetchPositions = vi.fn().mockRejectedValue(new Error('positions permission denied')) + + await expect(acc.getAccount()).rejects.toThrow('positions permission denied') + }) + + it('Bitget UTA account aggregation uses one v3 wallet and v3 positions', async () => { + const acc = makeAccount({ exchange: 'bitget', options: { uta: true } }) + setInitialized(acc, {}) + const fetchBalance = vi.fn().mockResolvedValue({ USDT: { total: 1000 } }) + ;(acc as any).exchange.fetchBalance = fetchBalance + ;(acc as any).exchange.fetchPositions = vi.fn().mockResolvedValue([]) + + const info = await acc.getAccount() + + expect(fetchBalance).toHaveBeenCalledTimes(1) + expect(fetchBalance).toHaveBeenCalledWith() + expect((acc as any).exchange.fetchPositions).toHaveBeenCalledWith(undefined, { + productType: 'USDT-FUTURES', + uta: true, + }) + expect(info.netLiquidation).toBe('1000') + }) + it('subAccountForContract routes spot vs derivative instruments (binance)', () => { const acc = makeAccount({ exchange: 'binance' }) const spot = new Contract(); spot.secType = 'CRYPTO' @@ -1390,6 +1468,23 @@ describe('CcxtBroker — getPositions', () => { }) }) +// ==================== getOpenOrders ==================== + +describe('CcxtBroker — getOpenOrders', () => { + it('propagates Bitget namespace failures instead of reporting a false empty list', async () => { + const acc = makeAccount({ exchange: 'bitget', options: { uta: false } }) + setInitialized(acc, {}) + ;(acc as any).exchange.fetchOpenOrders = vi.fn().mockImplementation( + async (_symbol: unknown, _since: unknown, _limit: unknown, params: Record) => { + if (params['planType'] === 'profit_loss') throw new Error('bitget permission denied') + return [] + }, + ) + + await expect(acc.getOpenOrders()).rejects.toThrow('permission denied') + }) +}) + // ==================== getOrders ==================== describe('CcxtBroker — getOrders', () => { diff --git a/services/uta/src/domain/trading/brokers/ccxt/CcxtBroker.ts b/services/uta/src/domain/trading/brokers/ccxt/CcxtBroker.ts index 2cac14261..dd1fd49d6 100644 --- a/services/uta/src/domain/trading/brokers/ccxt/CcxtBroker.ts +++ b/services/uta/src/domain/trading/brokers/ccxt/CcxtBroker.ts @@ -45,6 +45,7 @@ import { type CcxtExchangeOverrides, type CcxtSubAccountDef, exchangeOverrides, + defaultFetchBalance, defaultFetchOrderById, defaultCancelOrderById, defaultPlaceOrder, @@ -729,7 +730,8 @@ export class CcxtBroker implements IBroker { /** The sub-account decomposition for this venue: the override's list for * separate-wallet venues (binance), else the single unified default. */ private resolveSubAccounts(): CcxtSubAccountDef[] { - return this.overrides.subAccounts?.length ? this.overrides.subAccounts : [UNIFIED_SUBACCOUNT] + const resolved = this.overrides.resolveSubAccounts?.(this.exchange) ?? this.overrides.subAccounts + return resolved?.length ? resolved : [UNIFIED_SUBACCOUNT] } async listSubAccounts(): Promise { @@ -769,6 +771,16 @@ export class CcxtBroker implements IBroker { // ---- Queries ---- + /** Fetch derivative positions through the venue override on every account + * surface. Keeping getAccount and getPositions on one path prevents a UTA + * account from showing correct rows but zero account-level PnL (or vice versa). */ + private async fetchDerivativePositions() { + const fetchOverride = this.overrides.fetchPositions + return fetchOverride + ? await fetchOverride(this.exchange, defaultFetchPositions) + : await defaultFetchPositions(this.exchange) + } + /** * Synthesize asset holdings (BTC/ETH/etc balances) into Position records. * @@ -897,16 +909,23 @@ export class CcxtBroker implements IBroker { const info = (b['info'] ?? {}) as Record if (info['totalInitialMargin'] !== undefined) initMargin = initMargin.plus(new Decimal(String(info['totalInitialMargin']))) } + const fetchBalance = async (params?: Record) => { + const fetchOverride = this.overrides.fetchBalance + return fetchOverride + ? await fetchOverride(this.exchange, params, defaultFetchBalance) + : await defaultFetchBalance(this.exchange, params) + } if (walletTypes?.length) { for (const type of walletTypes) { try { - accrue(await this.exchange.fetchBalance({ type }) as unknown as Record) + accrue(await fetchBalance({ type })) } catch (err) { + if (this.overrides.strictPrivateReads) throw err console.warn(`CcxtBroker[${this.id}]: fetchBalance(${type}) skipped — ${err instanceof Error ? err.message.slice(0, 120) : String(err)}`) } } } else { - accrue(await this.exchange.fetchBalance() as unknown as Record) + accrue(await fetchBalance()) } return { balances, initMargin } } @@ -987,12 +1006,16 @@ export class CcxtBroker implements IBroker { let realizedPnL = new Decimal(0) if (includesDerivatives) { try { - const rawPositions = await this.exchange.fetchPositions() + const rawPositions = await this.fetchDerivativePositions() for (const p of rawPositions) { unrealizedPnL = unrealizedPnL.plus(new Decimal(String(p.unrealizedPnl ?? 0))) realizedPnL = realizedPnL.plus(new Decimal(String((p as unknown as Record).realizedPnl ?? 0))) } - } catch { /* positions are display-only here — don't fail the account read */ } + } catch (err) { + if (this.overrides.strictPrivateReads) throw err + // Positions are display-only for permissive venues; preserve the + // account balance read even when their optional PnL endpoint fails. + } } return { @@ -1020,12 +1043,9 @@ export class CcxtBroker implements IBroker { const includesDerivatives = scoped.some(s => s.kind === 'derivatives' || s.kind === 'unified') try { - const fetchOverride = this.overrides.fetchPositions const [raw, spotHoldings] = await Promise.all([ includesDerivatives - ? (fetchOverride - ? fetchOverride(this.exchange, defaultFetchPositions) - : defaultFetchPositions(this.exchange)) + ? this.fetchDerivativePositions() : Promise.resolve([] as Awaited>), this.fetchAssetHoldings(subAccountId), ]) @@ -1151,9 +1171,9 @@ export class CcxtBroker implements IBroker { /** * All open orders on the account — the surface external-order observation - * diffs against. Venue-dependent: some exchanges can't enumerate open - * orders without a symbol scope; those degrade to [] with a once-per- - * instance warning rather than failing the observation pass. + * diffs against. Verified venue overrides fail loudly when any namespace is + * incomplete. Unverified defaults that cannot enumerate without a symbol + * degrade to [] with a once-per-instance warning. */ async getOpenOrders(): Promise { if (this.keyless) return [] @@ -1172,6 +1192,7 @@ export class CcxtBroker implements IBroker { } return converted } catch (err) { + if (this.overrides.fetchAllOpenOrders) throw BrokerError.from(err) if (!this.warnedOpenOrdersUnsupported) { this.warnedOpenOrdersUnsupported = true console.warn( diff --git a/services/uta/src/domain/trading/brokers/ccxt/exchanges/bitget.ccxt.spec.ts b/services/uta/src/domain/trading/brokers/ccxt/exchanges/bitget.ccxt.spec.ts new file mode 100644 index 000000000..334a92fde --- /dev/null +++ b/services/uta/src/domain/trading/brokers/ccxt/exchanges/bitget.ccxt.spec.ts @@ -0,0 +1,60 @@ +import { describe, expect, it, vi } from 'vitest' +import ccxt from 'ccxt' + +describe('CCXT 4.5.38 Bitget routing contract', () => { + it('uses the UTA account-assets endpoint when options.uta=true', async () => { + const exchange = new ccxt.bitget({ options: { uta: true } }) + exchange.loadMarkets = vi.fn().mockResolvedValue({}) as typeof exchange.loadMarkets + const fetchUtaAssets = vi.fn().mockResolvedValue({ data: { assets: [] } }) + ;(exchange as any).privateUtaGetV3AccountAssets = fetchUtaAssets + + await exchange.fetchBalance() + + expect(fetchUtaAssets).toHaveBeenCalledWith({}) + }) + + it('uses the Classic contract-account endpoint for an explicit USDT-M balance read', async () => { + const exchange = new ccxt.bitget({ options: { uta: false } }) + exchange.loadMarkets = vi.fn().mockResolvedValue({}) as typeof exchange.loadMarkets + const fetchClassicAssets = vi.fn().mockResolvedValue({ data: [] }) + ;(exchange as any).privateMixGetV2MixAccountAccounts = fetchClassicAssets + + await exchange.fetchBalance({ type: 'swap', productType: 'USDT-FUTURES' }) + + expect(fetchClassicAssets).toHaveBeenCalledWith({ productType: 'USDT-FUTURES' }) + }) + + it('pins UTA spot open orders to category=SPOT', async () => { + const exchange = new ccxt.bitget({ options: { uta: true } }) + exchange.loadMarkets = vi.fn().mockResolvedValue({}) as typeof exchange.loadMarkets + const fetchOpenOrders = vi.fn().mockResolvedValue({ data: [] }) + ;(exchange as any).privateUtaGetV3TradeUnfilledOrders = fetchOpenOrders + + await exchange.fetchOpenOrders(undefined, undefined, undefined, { + type: 'spot', + productType: 'SPOT', + uta: true, + }) + + expect(fetchOpenOrders).toHaveBeenCalledWith({ category: 'SPOT' }) + }) + + it('routes Classic TP/SL reads to the profit_loss plan namespace', async () => { + const exchange = new ccxt.bitget({ options: { uta: false } }) + exchange.loadMarkets = vi.fn().mockResolvedValue({}) as typeof exchange.loadMarkets + const fetchPlans = vi.fn().mockResolvedValue({ data: { entrustedList: [] } }) + ;(exchange as any).privateMixGetV2MixOrderOrdersPlanPending = fetchPlans + + await exchange.fetchOpenOrders(undefined, undefined, undefined, { + type: 'swap', + productType: 'USDT-FUTURES', + trigger: true, + planType: 'profit_loss', + }) + + expect(fetchPlans).toHaveBeenCalledWith({ + productType: 'USDT-FUTURES', + planType: 'profit_loss', + }) + }) +}) diff --git a/services/uta/src/domain/trading/brokers/ccxt/exchanges/bitget.spec.ts b/services/uta/src/domain/trading/brokers/ccxt/exchanges/bitget.spec.ts new file mode 100644 index 000000000..faaee3aee --- /dev/null +++ b/services/uta/src/domain/trading/brokers/ccxt/exchanges/bitget.spec.ts @@ -0,0 +1,153 @@ +import { describe, expect, it, vi } from 'vitest' +import type { Exchange, Order as CcxtOrder } from 'ccxt' +import { bitgetOverrides } from './bitget.js' + +function fakeOrder(id: string, symbol: string): CcxtOrder { + return { id, symbol } as CcxtOrder +} + +function fakeExchange(uta: boolean): Exchange { + return { + options: { uta }, + fetchPositions: vi.fn().mockResolvedValue([]), + fetchOpenOrders: vi.fn().mockResolvedValue([]), + } as unknown as Exchange +} + +describe('bitgetOverrides', () => { + it('models classic accounts as separate spot and USDT-M wallets', () => { + const exchange = fakeExchange(false) + + expect(bitgetOverrides.resolveSubAccounts!(exchange)).toEqual([ + { id: 'spot', label: 'Spot', kind: 'spot', walletTypes: ['spot'] }, + { id: 'derivatives', label: 'USDT-M Futures', kind: 'derivatives', walletTypes: ['swap'] }, + ]) + }) + + it('models UTA accounts as one unified wallet', () => { + const exchange = fakeExchange(true) + + expect(bitgetOverrides.resolveSubAccounts!(exchange)).toEqual([ + { id: 'default', label: 'Unified Account', kind: 'unified', walletTypes: [] }, + ]) + }) + + it('uses Bitget UTA equity rather than CCXT 4.5.38 balance for normalized totals', async () => { + const exchange = fakeExchange(true) + const defaultImpl = vi.fn().mockResolvedValue({ + info: [{ coin: 'USDT', balance: '100', equity: '112.5' }], + USDT: { free: 80, used: 20, total: 100 }, + }) + + const balance = await bitgetOverrides.fetchBalance!(exchange, undefined, defaultImpl) + + expect((balance['USDT'] as Record)['total']).toBe('112.5') + }) + + it('does not rewrite Classic balance totals', async () => { + const exchange = fakeExchange(false) + const defaultImpl = vi.fn().mockResolvedValue({ + info: [{ coin: 'USDT', balance: '100', equity: '112.5' }], + USDT: { total: 100 }, + }) + + const balance = await bitgetOverrides.fetchBalance!(exchange, { type: 'spot' }, defaultImpl) + + expect((balance['USDT'] as Record)['total']).toBe(100) + expect(defaultImpl).toHaveBeenCalledWith(exchange, { type: 'spot' }) + }) + + it('pins Classic swap balances to USDT-FUTURES', async () => { + const exchange = fakeExchange(false) + const defaultImpl = vi.fn().mockResolvedValue({ USDT: { total: 100 } }) + + await bitgetOverrides.fetchBalance!(exchange, { type: 'swap' }, defaultImpl) + + expect(defaultImpl).toHaveBeenCalledWith(exchange, { + type: 'swap', + productType: 'USDT-FUTURES', + }) + }) + + it('pins classic positions to USDT-FUTURES instead of relying on CCXT defaults', async () => { + const exchange = fakeExchange(false) + + await bitgetOverrides.fetchPositions!(exchange, async () => []) + + expect(exchange.fetchPositions).toHaveBeenCalledWith(undefined, { + productType: 'USDT-FUTURES', + }) + }) + + it('routes UTA positions to the v3 account surface', async () => { + const exchange = fakeExchange(true) + + await bitgetOverrides.fetchPositions!(exchange, async () => []) + + expect(exchange.fetchPositions).toHaveBeenCalledWith(undefined, { + productType: 'USDT-FUTURES', + uta: true, + }) + }) + + it('sweeps every classic spot and USDT-M open-order namespace', async () => { + const exchange = fakeExchange(false) + const fetchOpenOrders = exchange.fetchOpenOrders as ReturnType + fetchOpenOrders.mockImplementation(async (_symbol, _since, _limit, params: Record) => { + return [fakeOrder(JSON.stringify(params), params['type'] === 'spot' ? 'ETH/USDT' : 'BTC/USDT:USDT')] + }) + + const result = await bitgetOverrides.fetchAllOpenOrders!(exchange, async () => []) + + expect(fetchOpenOrders.mock.calls.map(call => call[3])).toEqual([ + { type: 'spot' }, + { type: 'spot', trigger: true }, + { type: 'swap', productType: 'USDT-FUTURES' }, + { type: 'swap', productType: 'USDT-FUTURES', trigger: true, planType: 'normal_plan' }, + { type: 'swap', productType: 'USDT-FUTURES', trigger: true, planType: 'profit_loss' }, + { type: 'swap', productType: 'USDT-FUTURES', trailing: true, planType: 'track_plan' }, + ]) + expect(result).toHaveLength(6) + }) + + it('sweeps regular and strategy orders for both UTA categories', async () => { + const exchange = fakeExchange(true) + const fetchOpenOrders = exchange.fetchOpenOrders as ReturnType + fetchOpenOrders.mockImplementation(async (_symbol, _since, _limit, params: Record) => { + return [fakeOrder(JSON.stringify(params), params['type'] === 'spot' ? 'ETH/USDT' : 'BTC/USDT:USDT')] + }) + + const result = await bitgetOverrides.fetchAllOpenOrders!(exchange, async () => []) + + expect(fetchOpenOrders.mock.calls.map(call => call[3])).toEqual([ + { type: 'spot', productType: 'SPOT', uta: true }, + { type: 'spot', productType: 'SPOT', uta: true, trigger: true }, + { type: 'swap', productType: 'USDT-FUTURES', uta: true }, + { type: 'swap', productType: 'USDT-FUTURES', uta: true, trigger: true }, + ]) + expect(result).toHaveLength(4) + }) + + it('deduplicates orders that appear in more than one namespace', async () => { + const exchange = fakeExchange(true) + ;(exchange.fetchOpenOrders as ReturnType).mockResolvedValue([ + fakeOrder('same-id', 'BTC/USDT:USDT'), + ]) + + const result = await bitgetOverrides.fetchAllOpenOrders!(exchange, async () => []) + + expect(result.map(order => order.id)).toEqual(['same-id']) + }) + + it('throws when any namespace fails so partial reads cannot masquerade as no orders', async () => { + const exchange = fakeExchange(false) + ;(exchange.fetchOpenOrders as ReturnType).mockImplementation( + async (_symbol, _since, _limit, params: Record) => { + if (params['planType'] === 'profit_loss') throw new Error('bitget permission denied') + return [] + }, + ) + + await expect(bitgetOverrides.fetchAllOpenOrders!(exchange, async () => [])).rejects.toThrow('permission denied') + }) +}) diff --git a/services/uta/src/domain/trading/brokers/ccxt/exchanges/bitget.ts b/services/uta/src/domain/trading/brokers/ccxt/exchanges/bitget.ts new file mode 100644 index 000000000..0b3701898 --- /dev/null +++ b/services/uta/src/domain/trading/brokers/ccxt/exchanges/bitget.ts @@ -0,0 +1,111 @@ +/** + * Bitget-specific overrides for CcxtBroker. + * + * Bitget exposes two incompatible private API families: + * - Classic accounts: separate spot + contract wallets on v2 endpoints + * - Unified Trading Accounts (UTA): one shared wallet on v3 endpoints + * + * CCXT defaults Bitget to `type=spot` and `uta=false`. Unscoped balance and + * open-order reads therefore look healthy while silently hiding USDT-M funds + * and orders. Keep the account family explicit and enumerate every namespace + * the preset claims to observe. + */ + +import type { Exchange, Order as CcxtOrder, Position as CcxtPosition } from 'ccxt' +import type { CcxtExchangeOverrides } from '../overrides.js' + +const USDT_FUTURES = 'USDT-FUTURES' + +function usesUta(exchange: Exchange): boolean { + return (exchange.options as Record | undefined)?.['uta'] === true +} + +async function fetchAndMergeOpenOrders( + exchange: Exchange, + parameterSets: Array>, +): Promise { + const merged = new Map() + for (const params of parameterSets) { + const orders = await exchange.fetchOpenOrders(undefined, undefined, undefined, params) + for (const order of orders) { + if (order.id) merged.set(order.id, order) + } + } + return Array.from(merged.values()) +} + +export const bitgetOverrides: CcxtExchangeOverrides = { + // Bitget reads are account-family scoped. If one claimed namespace fails, + // returning the remaining wallets or zero PnL is actively misleading. + strictPrivateReads: true, + + resolveSubAccounts(exchange: Exchange) { + if (usesUta(exchange)) { + return [ + { id: 'default', label: 'Unified Account', kind: 'unified', walletTypes: [] }, + ] + } + return [ + { id: 'spot', label: 'Spot', kind: 'spot', walletTypes: ['spot'] }, + { id: 'derivatives', label: 'USDT-M Futures', kind: 'derivatives', walletTypes: ['swap'] }, + ] + }, + + async fetchBalance(exchange: Exchange, params, defaultImpl): Promise> { + const routedParams = !usesUta(exchange) && params?.['type'] === 'swap' + ? { ...params, productType: USDT_FUTURES } + : params + const balance = await defaultImpl(exchange, routedParams) + if (!usesUta(exchange)) return balance + + // CCXT 4.5.38's parseUtaBalance maps `balance` to total and discards the + // per-asset `equity` field. Bitget defines equity after account PnL/debt + // adjustments, which is what the account roll-up must use. + const rawAssets = Array.isArray(balance['info']) ? balance['info'] : [] + for (const raw of rawAssets) { + if (typeof raw !== 'object' || raw === null) continue + const asset = raw as Record + const coin = typeof asset['coin'] === 'string' ? asset['coin'].toUpperCase() : undefined + const equity = asset['equity'] + if (!coin || equity === undefined) continue + const normalized = balance[coin] + if (typeof normalized === 'object' && normalized !== null) { + ;(normalized as Record)['total'] = equity + } + } + return balance + }, + + async fetchPositions(exchange: Exchange, _defaultImpl): Promise { + return await exchange.fetchPositions(undefined, { + productType: USDT_FUTURES, + ...(usesUta(exchange) ? { uta: true } : {}), + }) + }, + + async fetchAllOpenOrders(exchange: Exchange, _defaultImpl): Promise { + if (usesUta(exchange)) { + // CCXT 4.5.38 derives USDT-FUTURES from defaultSubType even when + // `type=spot`, so UTA spot calls must pin productType=SPOT explicitly. + // One strategy call per category returns both trigger and TP/SL orders. + return await fetchAndMergeOpenOrders(exchange, [ + { type: 'spot', productType: 'SPOT', uta: true }, + { type: 'spot', productType: 'SPOT', uta: true, trigger: true }, + { type: 'swap', productType: USDT_FUTURES, uta: true }, + { type: 'swap', productType: USDT_FUTURES, uta: true, trigger: true }, + ]) + } + + // Classic Bitget splits regular, trigger, TP/SL, and trailing orders into + // separate endpoints. A failure in any namespace propagates: a partial + // listing must never masquerade as an authoritative empty order book. + return await fetchAndMergeOpenOrders(exchange, [ + { type: 'spot' }, + { type: 'spot', trigger: true }, + { type: 'swap', productType: USDT_FUTURES }, + { type: 'swap', productType: USDT_FUTURES, trigger: true, planType: 'normal_plan' }, + { type: 'swap', productType: USDT_FUTURES, trigger: true, planType: 'profit_loss' }, + { type: 'swap', productType: USDT_FUTURES, trailing: true, planType: 'track_plan' }, + ]) + }, +} diff --git a/services/uta/src/domain/trading/brokers/ccxt/overrides.ts b/services/uta/src/domain/trading/brokers/ccxt/overrides.ts index 47e121b2d..61383d9a9 100644 --- a/services/uta/src/domain/trading/brokers/ccxt/overrides.ts +++ b/services/uta/src/domain/trading/brokers/ccxt/overrides.ts @@ -24,6 +24,7 @@ */ import type { Exchange, Order as CcxtOrder, Position as CcxtPosition } from 'ccxt' +import { bitgetOverrides } from './exchanges/bitget.js' import { bybitOverrides } from './exchanges/bybit.js' import { hyperliquidOverrides } from './exchanges/hyperliquid.js' @@ -33,6 +34,23 @@ import { hyperliquidOverrides } from './exchanges/hyperliquid.js' type DefaultImpl = (...args: TArgs) => Promise export interface CcxtExchangeOverrides { + /** Fail account reads when any declared wallet or position namespace is + * unreadable. Use where a partial response would look valid while omitting + * material funds or risk (Bitget Classic/UTA account-family routing). */ + strictPrivateReads?: boolean + + /** Resolve wallet/sub-account topology from exchange configuration. Use for + * venues whose account family changes the topology (Bitget Classic vs UTA). */ + resolveSubAccounts?(exchange: Exchange): CcxtSubAccountDef[] + + /** Fetch one normalized balance wallet. Override for venue parser gaps + * (Bitget UTA exposes per-asset equity but CCXT 4.5.38 uses balance). */ + fetchBalance?( + exchange: Exchange, + params: Record | undefined, + defaultImpl: DefaultImpl<[Exchange, Record | undefined], Record>, + ): Promise> + /** Fetch a single order by ID (regular + conditional). */ fetchOrderById?( exchange: Exchange, @@ -126,6 +144,16 @@ export interface CcxtSubAccountDef { // ==================== Default implementations ==================== +/** Default: fetch one wallet balance, preserving an actually-unscoped call. */ +export async function defaultFetchBalance( + exchange: Exchange, + params?: Record, +): Promise> { + return await (params === undefined + ? exchange.fetchBalance() + : exchange.fetchBalance(params)) as unknown as Record +} + /** Default: fetchOrder + { stop: true } fallback. Works for binance, okx, bitget, etc. */ export async function defaultFetchOrderById(exchange: Exchange, orderId: string, symbol: string): Promise { try { @@ -199,6 +227,7 @@ const binanceOverrides: CcxtExchangeOverrides = { export const exchangeOverrides: Record = { binance: binanceOverrides, + bitget: bitgetOverrides, bybit: bybitOverrides, hyperliquid: hyperliquidOverrides, } diff --git a/services/uta/src/domain/trading/brokers/presets.spec.ts b/services/uta/src/domain/trading/brokers/presets.spec.ts index 1d566b28f..5742e284b 100644 --- a/services/uta/src/domain/trading/brokers/presets.spec.ts +++ b/services/uta/src/domain/trading/brokers/presets.spec.ts @@ -39,7 +39,7 @@ const SAMPLE_CONFIGS: Record> = { okx: { mode: 'live', apiKey: 'k', secret: 's', password: 'p' }, bybit: { mode: 'live', apiKey: 'k', secret: 's' }, hyperliquid: { mode: 'live', walletAddress: '0xabc', privateKey: 'pk' }, - bitget: { mode: 'live', apiKey: 'k', secret: 's', password: 'p' }, + bitget: { mode: 'live', accountType: 'classic', apiKey: 'k', secret: 's', password: 'p' }, alpaca: { mode: 'paper', apiKey: 'k', apiSecret: 's' }, 'ibkr-tws': { host: '127.0.0.1', port: 7497, clientId: 0 }, longbridge: { mode: 'live', appKey: 'k', appSecret: 's', accessToken: 't' }, @@ -132,10 +132,23 @@ describe('preset → engine config translation', () => { }) it('Bitget mode=demo sets demoTrading=true', () => { - const cfg = BITGET_PRESET.toEngineConfig({ mode: 'demo', apiKey: 'k', secret: 's', password: 'p' }) + const cfg = BITGET_PRESET.toEngineConfig({ mode: 'demo', accountType: 'classic', apiKey: 'k', secret: 's', password: 'p' }) expect(cfg.demoTrading).toBe(true) }) + it('Bitget defaults legacy configs to classic account routing', () => { + const parsed = BITGET_PRESET.zodSchema.parse({ mode: 'live', apiKey: 'k', secret: 's', password: 'p' }) as Record + const cfg = BITGET_PRESET.toEngineConfig(parsed) + expect(parsed.accountType).toBe('classic') + expect(cfg.options).toEqual({ uta: false }) + }) + + it('Bitget unified accounts enable CCXT UTA routing', () => { + const parsed = BITGET_PRESET.zodSchema.parse({ mode: 'live', accountType: 'unified', apiKey: 'k', secret: 's', password: 'p' }) as Record + const cfg = BITGET_PRESET.toEngineConfig(parsed) + expect(cfg.options).toEqual({ uta: true }) + }) + it('Alpaca mode=paper sets paper=true', () => { const cfg = ALPACA_PRESET.toEngineConfig({ mode: 'paper', apiKey: 'k', apiSecret: 's' }) expect(cfg.paper).toBe(true)