From c12594a72ba2ac524821fdb5164ad15f86ee4ff6 Mon Sep 17 00:00:00 2001
From: Ame <123734885+luokerenx4@users.noreply.github.com>
Date: Sat, 8 Aug 2026 23:20:19 +0800
Subject: [PATCH] fix Bitget Classic account reads
Reported in #951; reimplemented on the maintainer-owned branch and credited in CONTRIBUTORS.md.
---
CONTRIBUTORS.md | 1 +
README.md | 1 +
.../src/brokers/preset-catalog.ts | 2 +-
packages/uta-protocol/src/types/broker.ts | 5 +-
.../domain/trading/UnifiedTradingAccount.ts | 2 +-
.../trading/brokers/ccxt/CcxtBroker.spec.ts | 80 +++++++++++++
.../domain/trading/brokers/ccxt/CcxtBroker.ts | 45 +++++--
.../ccxt/exchanges/bitget.ccxt.spec.ts | 45 +++++++
.../brokers/ccxt/exchanges/bitget.spec.ts | 112 ++++++++++++++++++
.../trading/brokers/ccxt/exchanges/bitget.ts | 68 +++++++++++
.../domain/trading/brokers/ccxt/overrides.ts | 34 +++++-
.../domain/trading/brokers/presets.spec.ts | 6 +
services/uta/src/http/routes-trading.ts | 2 +-
13 files changed, 384 insertions(+), 19 deletions(-)
create mode 100644 services/uta/src/domain/trading/brokers/ccxt/exchanges/bitget.ccxt.spec.ts
create mode 100644 services/uta/src/domain/trading/brokers/ccxt/exchanges/bitget.spec.ts
create mode 100644 services/uta/src/domain/trading/brokers/ccxt/exchanges/bitget.ts
diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md
index b1a53ffbf..5589b326d 100644
--- a/CONTRIBUTORS.md
+++ b/CONTRIBUTORS.md
@@ -47,6 +47,7 @@ work left a mark on it, you belong here.
| | 
[@rudyll](https://github.com/rudyll) | 🤔 🎨 | [Richer contract rows — surfacing the instrument long-name + primary listing exchange on position/order rows (follow-up to #335)](https://github.com/TraderAlice/OpenAlice/issues/340), reimplemented via a cached catalog join |
| | 
[@jalilsedna](https://github.com/jalilsedna) | 🐛 🤔 | [IBKR forex contract resolution — traced the bare-conId order failure to the `SMART`/`USD` fallback diverging from quote resolution and proposed a shared canonical-contract lookup (#345)](https://github.com/TraderAlice/OpenAlice/pull/345), which led to the broader in-house fix across quote, place, modify, and close paths in [#655](https://github.com/TraderAlice/OpenAlice/pull/655) |
| | 
[@dbydd](https://github.com/dbydd) | 🐛 🤔 🎨 | [Pi global + Workspace configuration layering and model reasoning capabilities (#662)](https://github.com/TraderAlice/OpenAlice/issues/662) — an unusually complete two-part reproduction that drove the native project-overlay architecture, safe migration of legacy `.pi-agent` state, and explicit reasoning-capability round trips in [#670](https://github.com/TraderAlice/OpenAlice/pull/670) |
+| | 
[@enderzcx](https://github.com/enderzcx) | 🐛 🤔 | [Bitget Classic account-state blind spots (#951)](https://github.com/TraderAlice/OpenAlice/pull/951) — traced healthy-looking unscoped CCXT reads that omitted USDT-M funds and conditional-order namespaces, leading to the in-house Classic account model and routing fix |
---
diff --git a/README.md b/README.md
index fb815b61f..7428be771 100644
--- a/README.md
+++ b/README.md
@@ -178,6 +178,7 @@ suggestion, or implementation proposal changes the product, it gets credited.
+
**See the full list and what each person shaped**: [CONTRIBUTORS.md](./CONTRIBUTORS.md)
diff --git a/packages/uta-protocol/src/brokers/preset-catalog.ts b/packages/uta-protocol/src/brokers/preset-catalog.ts
index 2d89a68ed..b8918f688 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). OpenAlice currently supports Classic accounts; Bitget Unified Trading Account (v3) is not yet supported. Demo Trading routes orders to a simulated environment using the production domain.',
defaultName: 'bitget-main',
badge: 'BG',
badgeColor: 'text-primary',
diff --git a/packages/uta-protocol/src/types/broker.ts b/packages/uta-protocol/src/types/broker.ts
index e19359970..ee6b7b1ba 100644
--- a/packages/uta-protocol/src/types/broker.ts
+++ b/packages/uta-protocol/src/types/broker.ts
@@ -271,7 +271,8 @@ export interface AccountInfo {
* them end-to-end (every broker today except CCXT).
*
* The asymmetric case is CCXT separate-wallet venues (Binance: spot /
- * USDⓈ-M / COIN-M live behind distinct endpoints) and, in future, IBKR
+ * USDⓈ-M / COIN-M; Bitget Classic: spot / USDT-M live behind distinct
+ * endpoints) and, in future, IBKR
* linked / FA accounts under one login. There the SAME connection spans
* several trading compartments, so a READ can scope to one (or aggregate
* across all) and a WRITE must name its target — placing an order is
@@ -534,7 +535,7 @@ export interface IBroker {
* omits it is treated as having a single implicit 'default' sub-account, and
* the `subAccountId` selector is ignored for it (every broker today except
* CCXT separate-wallet venues). Implementations return >1 ONLY for genuinely
- * separate-wallet venues (CCXT Binance: spot / USDⓈ-M / COIN-M). Trading
+ * separate-wallet venues (CCXT Binance and Bitget Classic). Trading
* compartments only — funding / earn wallets are never enumerated.
*/
listSubAccounts?(): Promise
diff --git a/services/uta/src/domain/trading/UnifiedTradingAccount.ts b/services/uta/src/domain/trading/UnifiedTradingAccount.ts
index 126a81727..75c38c35d 100644
--- a/services/uta/src/domain/trading/UnifiedTradingAccount.ts
+++ b/services/uta/src/domain/trading/UnifiedTradingAccount.ts
@@ -649,7 +649,7 @@ export class UnifiedTradingAccount {
}
/** The sub-accounts (wallets) this connection spans. One element for ordinary
- * brokers; >1 only for separate-wallet venues (CCXT Binance: spot / futures). */
+ * brokers; >1 only for separate-wallet venues (CCXT Binance / Bitget Classic). */
async listSubAccounts(): Promise {
return this._ensureSubAccounts()
}
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..6b28b7597 100644
--- a/services/uta/src/domain/trading/brokers/ccxt/CcxtBroker.spec.ts
+++ b/services/uta/src/domain/trading/brokers/ccxt/CcxtBroker.spec.ts
@@ -40,6 +40,7 @@ vi.mock('ccxt', () => {
default: {
bybit: MockExchange,
binance: MockExchange,
+ bitget: MockExchange,
},
}
})
@@ -999,6 +1000,60 @@ describe('CcxtBroker — sub-accounts', () => {
])
})
+ it('Bitget Classic exposes separate spot and USDT-M wallets', async () => {
+ const acc = makeAccount({ exchange: 'bitget' })
+ expect(await acc.listSubAccounts()).toEqual([
+ { id: 'spot', label: 'Spot', kind: 'spot' },
+ { id: 'derivatives', label: 'USDT-M Futures', kind: 'derivatives' },
+ ])
+ })
+
+ it('Bitget Classic aggregates spot and explicit USDT-M account state', async () => {
+ const acc = makeAccount({ exchange: 'bitget' })
+ 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')
+ expect(info.realizedPnL).toBe('3')
+ })
+
+ it('fails a Bitget Classic account read when the USDT-M wallet is unreadable', async () => {
+ const acc = makeAccount({ exchange: 'bitget' })
+ 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 Classic account read when positions are unreadable', async () => {
+ const acc = makeAccount({ exchange: 'bitget' })
+ setInitialized(acc, {})
+ ;(acc as any).exchange.fetchBalance = vi.fn()
+ .mockResolvedValueOnce({ USDT: { total: 4.44 } })
+ .mockResolvedValueOnce({ 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('subAccountForContract routes spot vs derivative instruments (binance)', () => {
const acc = makeAccount({ exchange: 'binance' })
const spot = new Contract(); spot.secType = 'CRYPTO'
@@ -1390,6 +1445,31 @@ describe('CcxtBroker — getPositions', () => {
})
})
+// ==================== getOpenOrders ====================
+
+describe('CcxtBroker — getOpenOrders', () => {
+ it('propagates Bitget Classic namespace failures instead of reporting a false empty list', async () => {
+ const acc = makeAccount({ exchange: 'bitget' })
+ 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')
+ })
+
+ it('keeps permissive venues on the existing empty-list fallback', async () => {
+ const acc = makeAccount({ exchange: 'binance' })
+ setInitialized(acc, {})
+ ;(acc as any).exchange.fetchOpenOrders = vi.fn().mockRejectedValue(new Error('listing unsupported'))
+
+ await expect(acc.getOpenOrders()).resolves.toEqual([])
+ })
+})
+
// ==================== 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..7fd8ed877 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,
@@ -727,7 +728,7 @@ export class CcxtBroker implements IBroker {
// ---- Sub-accounts ----
/** The sub-account decomposition for this venue: the override's list for
- * separate-wallet venues (binance), else the single unified default. */
+ * separate-wallet venues (Binance / Bitget Classic), else the single unified default. */
private resolveSubAccounts(): CcxtSubAccountDef[] {
return this.overrides.subAccounts?.length ? this.overrides.subAccounts : [UNIFIED_SUBACCOUNT]
}
@@ -769,6 +770,15 @@ export class CcxtBroker implements IBroker {
// ---- Queries ----
+ /** Keep account-level PnL and position rows on the same venue-specific
+ * derivative route. */
+ 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.
*
@@ -885,7 +895,8 @@ export class CcxtBroker implements IBroker {
* `subAccountId` selector narrows which are fetched (omitted ⇒ every wallet).
* Unified venues (okx / bybit UTA — verified: spot/swap/contract all return the
* same pool) have no wallet types → one unscoped call. A per-wallet failure
- * (e.g. an un-activated COIN-M wallet → -2015) is skipped loudly, not fatal.
+ * (e.g. an un-activated COIN-M wallet → -2015) is skipped loudly unless the
+ * venue declares strict private reads because every wallet is authoritative.
* Also rolls up futures `totalInitialMargin` for the account's margin figure.
*/
private async gatherWalletBalances(subAccountId?: string): Promise<{ balances: Array>; initMargin: Decimal }> {
@@ -897,16 +908,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 +1005,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
+ // balance read when their optional PnL endpoint fails.
+ }
}
return {
@@ -1020,12 +1042,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),
])
@@ -1152,8 +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.
+ * orders without a symbol scope; permissive defaults degrade to [] with a
+ * once-per-instance warning. Verified strict adapters propagate incomplete
+ * namespace reads so a partial list cannot masquerade as authoritative.
*/
async getOpenOrders(): Promise {
if (this.keyless) return []
@@ -1172,6 +1192,7 @@ export class CcxtBroker implements IBroker {
}
return converted
} catch (err) {
+ if (this.overrides.strictOpenOrderReads) 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..d0f9dfb14
--- /dev/null
+++ b/services/uta/src/domain/trading/brokers/ccxt/exchanges/bitget.ccxt.spec.ts
@@ -0,0 +1,45 @@
+import { describe, expect, it, vi } from 'vitest'
+import ccxt from 'ccxt'
+
+describe('CCXT 4.5.38 Bitget Classic routing contract', () => {
+ it('defaults an unscoped balance read to the spot endpoint', async () => {
+ const exchange = new ccxt.bitget()
+ exchange.loadMarkets = vi.fn().mockResolvedValue({}) as typeof exchange.loadMarkets
+ const fetchSpotAssets = vi.fn().mockResolvedValue({ data: [] })
+ ;(exchange as any).privateSpotGetV2SpotAccountAssets = fetchSpotAssets
+
+ await exchange.fetchBalance()
+
+ expect(fetchSpotAssets).toHaveBeenCalledWith({})
+ })
+
+ it('routes an explicit USDT-M balance read to the contract account endpoint', async () => {
+ const exchange = new ccxt.bitget()
+ exchange.loadMarkets = vi.fn().mockResolvedValue({}) as typeof exchange.loadMarkets
+ const fetchContractAssets = vi.fn().mockResolvedValue({ data: [] })
+ ;(exchange as any).privateMixGetV2MixAccountAccounts = fetchContractAssets
+
+ await exchange.fetchBalance({ type: 'swap', productType: 'USDT-FUTURES' })
+
+ expect(fetchContractAssets).toHaveBeenCalledWith({ productType: 'USDT-FUTURES' })
+ })
+
+ it('routes TP/SL reads to the profit_loss plan namespace', async () => {
+ const exchange = new ccxt.bitget()
+ 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..98114802b
--- /dev/null
+++ b/services/uta/src/domain/trading/brokers/ccxt/exchanges/bitget.spec.ts
@@ -0,0 +1,112 @@
+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(): Exchange {
+ return {
+ fetchPositions: vi.fn().mockResolvedValue([]),
+ fetchOpenOrders: vi.fn().mockResolvedValue([]),
+ } as unknown as Exchange
+}
+
+describe('bitgetOverrides — Classic account reads', () => {
+ it('declares separate spot and USDT-M wallets with strict read semantics', () => {
+ expect(bitgetOverrides.subAccounts).toEqual([
+ { id: 'spot', label: 'Spot', kind: 'spot', walletTypes: ['spot'] },
+ { id: 'derivatives', label: 'USDT-M Futures', kind: 'derivatives', walletTypes: ['swap'] },
+ ])
+ expect(bitgetOverrides.strictPrivateReads).toBe(true)
+ expect(bitgetOverrides.strictOpenOrderReads).toBe(true)
+ })
+
+ it('pins swap balances to the USDT-FUTURES product', async () => {
+ const exchange = fakeExchange()
+ 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('preserves the spot balance route', async () => {
+ const exchange = fakeExchange()
+ const defaultImpl = vi.fn().mockResolvedValue({ USDT: { total: 100 } })
+
+ await bitgetOverrides.fetchBalance!(exchange, { type: 'spot' }, defaultImpl)
+
+ expect(defaultImpl).toHaveBeenCalledWith(exchange, { type: 'spot' })
+ })
+
+ it('pins positions to USDT-FUTURES', async () => {
+ const exchange = fakeExchange()
+
+ await bitgetOverrides.fetchPositions!(exchange, async () => [])
+
+ expect(exchange.fetchPositions).toHaveBeenCalledWith(undefined, {
+ productType: 'USDT-FUTURES',
+ })
+ })
+
+ it('sweeps every spot and USDT-M open-order namespace', async () => {
+ const exchange = fakeExchange()
+ const fetchOpenOrders = exchange.fetchOpenOrders as ReturnType
+ fetchOpenOrders.mockImplementation(async (_symbol, _since, _limit, params: Record) => [
+ 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('deduplicates an order repeated by overlapping namespaces', async () => {
+ const exchange = fakeExchange()
+ ;(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('does not collide equal ids from different symbols', async () => {
+ const exchange = fakeExchange()
+ const fetchOpenOrders = exchange.fetchOpenOrders as ReturnType
+ let call = 0
+ fetchOpenOrders.mockImplementation(async () => [
+ fakeOrder('same-id', call++ === 0 ? 'ETH/USDT' : 'BTC/USDT:USDT'),
+ ])
+
+ const result = await bitgetOverrides.fetchAllOpenOrders!(exchange, async () => [])
+
+ expect(result).toHaveLength(2)
+ })
+
+ it('throws when one namespace fails instead of returning a partial list', async () => {
+ const exchange = fakeExchange()
+ ;(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..e9c51328f
--- /dev/null
+++ b/services/uta/src/domain/trading/brokers/ccxt/exchanges/bitget.ts
@@ -0,0 +1,68 @@
+/**
+ * Bitget Classic-specific overrides for CcxtBroker.
+ *
+ * Classic accounts keep spot and futures funds behind separate v2 account
+ * endpoints. CCXT defaults Bitget to spot, so an unscoped balance or open-order
+ * read succeeds while silently omitting USDT-M funds and orders. This adapter
+ * deliberately supports Classic only; Bitget Unified Trading Account (v3) is
+ * a separate account family and must not be enabled accidentally through a
+ * transport option.
+ */
+
+import type { Exchange, Order as CcxtOrder, Position as CcxtPosition } from 'ccxt'
+import type { CcxtExchangeOverrides } from '../overrides.js'
+
+const USDT_FUTURES = 'USDT-FUTURES'
+
+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) continue
+ merged.set(`${order.symbol ?? ''}:${order.id}`, order)
+ }
+ }
+ return Array.from(merged.values())
+}
+
+export const bitgetOverrides: CcxtExchangeOverrides = {
+ // Every declared namespace contributes to the account truth. Returning the
+ // readable subset would turn a permissions or routing error into false equity
+ // or a false empty order list.
+ strictPrivateReads: true,
+ strictOpenOrderReads: true,
+
+ subAccounts: [
+ { 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 = params?.['type'] === 'swap'
+ ? { ...params, productType: USDT_FUTURES }
+ : params
+ return await defaultImpl(exchange, routedParams)
+ },
+
+ async fetchPositions(exchange: Exchange, _defaultImpl): Promise {
+ return await exchange.fetchPositions(undefined, { productType: USDT_FUTURES })
+ },
+
+ async fetchAllOpenOrders(exchange: Exchange, _defaultImpl): Promise {
+ // Classic Bitget splits regular, trigger, TP/SL, and trailing orders into
+ // separate namespaces. Keep this sequential to avoid bursting six private
+ // requests at the venue at once.
+ 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..abca409c1 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,24 @@ import { hyperliquidOverrides } from './exchanges/hyperliquid.js'
type DefaultImpl = (...args: TArgs) => Promise
export interface CcxtExchangeOverrides {
+ /** Fail account reads when one of the wallets or position namespaces this
+ * adapter claims to aggregate is unreadable. Use only where a partial read
+ * would look valid while hiding material funds or risk. */
+ strictPrivateReads?: boolean
+
+ /** Propagate an all-open-orders failure instead of degrading to an empty
+ * list. Verified multi-namespace adapters use this because a partial list
+ * is actively unsafe for external-order observation. */
+ strictOpenOrderReads?: boolean
+
+ /** Fetch one normalized balance wallet. Override when a venue needs routing
+ * parameters beyond the generic CCXT `type` selector. */
+ fetchBalance?(
+ exchange: Exchange,
+ params: Record | undefined,
+ defaultImpl: DefaultImpl<[Exchange, Record | undefined], Record>,
+ ): Promise>
+
/** Fetch a single order by ID (regular + conditional). */
fetchOrderById?(
exchange: Exchange,
@@ -106,8 +125,8 @@ export interface CcxtExchangeOverrides {
* (ANG-111). Leave undefined for UNIFIED-account venues (okx / bybit UTA),
* where a single fetchBalance() returns the whole account — those expose one
* implicit 'default' sub-account and never require a selector. A per-type
- * fetch failure (e.g. an un-activated COIN-M wallet → -2015) is skipped, not
- * fatal. */
+ * fetch failure (e.g. an un-activated COIN-M wallet → -2015) is normally
+ * skipped; adapters with `strictPrivateReads` propagate it instead. */
subAccounts?: CcxtSubAccountDef[]
}
@@ -126,6 +145,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 +228,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..2e746c60f 100644
--- a/services/uta/src/domain/trading/brokers/presets.spec.ts
+++ b/services/uta/src/domain/trading/brokers/presets.spec.ts
@@ -136,6 +136,12 @@ describe('preset → engine config translation', () => {
expect(cfg.demoTrading).toBe(true)
})
+ it('Bitget preset keeps Unified/v3 routing disabled', () => {
+ const cfg = BITGET_PRESET.toEngineConfig({ mode: 'live', apiKey: 'k', secret: 's', password: 'p' })
+ expect(cfg.options).toBeUndefined()
+ expect(BITGET_PRESET.hint).toContain('Classic accounts')
+ })
+
it('Alpaca mode=paper sets paper=true', () => {
const cfg = ALPACA_PRESET.toEngineConfig({ mode: 'paper', apiKey: 'k', apiSecret: 's' })
expect(cfg.paper).toBe(true)
diff --git a/services/uta/src/http/routes-trading.ts b/services/uta/src/http/routes-trading.ts
index 08ad8cb6c..ba36245b4 100644
--- a/services/uta/src/http/routes-trading.ts
+++ b/services/uta/src/http/routes-trading.ts
@@ -252,7 +252,7 @@ export function createTradingRoutes(ctx: UTAEngineContext) {
})
// Sub-accounts (wallets) — one element for ordinary brokers, >1 for
- // separate-wallet venues (CCXT Binance: spot / derivatives).
+ // separate-wallet venues (CCXT Binance / Bitget Classic).
app.get('/uta/:id/subaccounts', async (c) => {
const account = resolveAccount(ctx, c)
if (!account) return c.json({ error: 'Account not found' }, 404)