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
[@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
[@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
[@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
[@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. @rudyll @jalilsedna @dbydd + @enderzcx

**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)