Skip to content
Merged
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
1 change: 1 addition & 0 deletions CONTRIBUTORS.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ work left a mark on it, you belong here.
| | <a href="https://github.com/rudyll"><img src="https://github.com/rudyll.png" width="40" height="40" alt="@rudyll" /></a><br>[@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 |
| | <a href="https://github.com/jalilsedna"><img src="https://github.com/jalilsedna.png" width="40" height="40" alt="@jalilsedna" /></a><br>[@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) |
| | <a href="https://github.com/dbydd"><img src="https://github.com/dbydd.png" width="40" height="40" alt="@dbydd" /></a><br>[@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) |
| | <a href="https://github.com/enderzcx"><img src="https://github.com/enderzcx.png" width="40" height="40" alt="@enderzcx" /></a><br>[@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 |

---

Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,7 @@ suggestion, or implementation proposal changes the product, it gets credited.
<a href="https://github.com/rudyll"><img src="https://github.com/rudyll.png" width="56" height="56" alt="@rudyll" /></a>
<a href="https://github.com/jalilsedna"><img src="https://github.com/jalilsedna.png" width="56" height="56" alt="@jalilsedna" /></a>
<a href="https://github.com/dbydd"><img src="https://github.com/dbydd.png" width="56" height="56" alt="@dbydd" /></a>
<a href="https://github.com/enderzcx"><img src="https://github.com/enderzcx.png" width="56" height="56" alt="@enderzcx" /></a>
</p>

**See the full list and what each person shaped**: [CONTRIBUTORS.md](./CONTRIBUTORS.md)
Expand Down
2 changes: 1 addition & 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). 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',
Expand Down
5 changes: 3 additions & 2 deletions packages/uta-protocol/src/types/broker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -534,7 +535,7 @@ export interface IBroker<TMeta = unknown> {
* 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<SubAccountRef[]>
Expand Down
2 changes: 1 addition & 1 deletion services/uta/src/domain/trading/UnifiedTradingAccount.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<SubAccountRef[]> {
return this._ensureSubAccounts()
}
Expand Down
80 changes: 80 additions & 0 deletions services/uta/src/domain/trading/brokers/ccxt/CcxtBroker.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ vi.mock('ccxt', () => {
default: {
bybit: MockExchange,
binance: MockExchange,
bitget: MockExchange,
},
}
})
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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<string, unknown>) => {
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', () => {
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 @@ -727,7 +728,7 @@ export class CcxtBroker implements IBroker<CcxtBrokerMeta> {
// ---- 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]
}
Expand Down Expand Up @@ -769,6 +770,15 @@ export class CcxtBroker implements IBroker<CcxtBrokerMeta> {

// ---- 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.
*
Expand Down Expand Up @@ -885,7 +895,8 @@ export class CcxtBroker implements IBroker<CcxtBrokerMeta> {
* `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<Record<string, unknown>>; initMargin: Decimal }> {
Expand All @@ -897,16 +908,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 +1005,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
// balance read when their optional PnL endpoint fails.
}
}

return {
Expand Down Expand Up @@ -1020,12 +1042,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 @@ -1152,8 +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.
* 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<OpenOrder[]> {
if (this.keyless) return []
Expand All @@ -1172,6 +1192,7 @@ export class CcxtBroker implements IBroker<CcxtBrokerMeta> {
}
return converted
} catch (err) {
if (this.overrides.strictOpenOrderReads) 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,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',
})
})
})
Loading
Loading