Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion packages/uta-protocol/src/brokers/preset-catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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,
Expand Down
101 changes: 98 additions & 3 deletions services/uta/src/domain/trading/brokers/ccxt/CcxtBroker.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, any> = {}) {
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([])
Expand All @@ -40,6 +40,7 @@ vi.mock('ccxt', () => {
default: {
bybit: MockExchange,
binance: MockExchange,
bitget: MockExchange,
},
}
})
Expand Down Expand Up @@ -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<string, unknown> }>) {
return new CcxtBroker({
exchange: overrides?.exchange ?? 'bybit',
apiKey: overrides?.apiKey ?? 'k',
secret: overrides?.secret ?? 's',
sandbox: false,
options: overrides?.options,
})
}

Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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<string, unknown>) => {
if (params['planType'] === 'profit_loss') throw new Error('bitget permission denied')
return []
},
)

await expect(acc.getOpenOrders()).rejects.toThrow('permission denied')
})
})

// ==================== getOrders ====================

describe('CcxtBroker — getOrders', () => {
Expand Down
45 changes: 33 additions & 12 deletions services/uta/src/domain/trading/brokers/ccxt/CcxtBroker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import {
type CcxtExchangeOverrides,
type CcxtSubAccountDef,
exchangeOverrides,
defaultFetchBalance,
defaultFetchOrderById,
defaultCancelOrderById,
defaultPlaceOrder,
Expand Down Expand Up @@ -729,7 +730,8 @@ export class CcxtBroker implements IBroker<CcxtBrokerMeta> {
/** 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<SubAccountRef[]> {
Expand Down Expand Up @@ -769,6 +771,16 @@ export class CcxtBroker implements IBroker<CcxtBrokerMeta> {

// ---- 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.
*
Expand Down Expand Up @@ -897,16 +909,23 @@ export class CcxtBroker implements IBroker<CcxtBrokerMeta> {
const info = (b['info'] ?? {}) as Record<string, unknown>
if (info['totalInitialMargin'] !== undefined) initMargin = initMargin.plus(new Decimal(String(info['totalInitialMargin'])))
}
const fetchBalance = async (params?: Record<string, unknown>) => {
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<string, unknown>)
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<string, unknown>)
accrue(await fetchBalance())
}
return { balances, initMargin }
}
Expand Down Expand Up @@ -987,12 +1006,16 @@ export class CcxtBroker implements IBroker<CcxtBrokerMeta> {
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<string, unknown>).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 {
Expand Down Expand Up @@ -1020,12 +1043,9 @@ export class CcxtBroker implements IBroker<CcxtBrokerMeta> {
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<ReturnType<typeof defaultFetchPositions>>),
this.fetchAssetHoldings(subAccountId),
])
Expand Down Expand Up @@ -1151,9 +1171,9 @@ export class CcxtBroker implements IBroker<CcxtBrokerMeta> {

/**
* 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<OpenOrder[]> {
if (this.keyless) return []
Expand All @@ -1172,6 +1192,7 @@ export class CcxtBroker implements IBroker<CcxtBrokerMeta> {
}
return converted
} catch (err) {
if (this.overrides.fetchAllOpenOrders) throw BrokerError.from(err)
if (!this.warnedOpenOrdersUnsupported) {
this.warnedOpenOrdersUnsupported = true
console.warn(
Expand Down
Original file line number Diff line number Diff line change
@@ -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',
})
})
})
Loading