diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 15a46de9d..176361cb8 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -49,6 +49,7 @@ work left a mark on it, you belong here. | | @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 | | | @roymeshulam
[@roymeshulam](https://github.com/roymeshulam) | 🐛 🤔 | [Telegram Connector startup hid the health endpoint (#639)](https://github.com/TraderAlice/OpenAlice/pull/639) — isolated adapter init blocking Guardian's probe and proposed listen-before-start plus keeping degraded `lastError`, reimplemented in-house in [#1078](https://github.com/TraderAlice/OpenAlice/pull/1078) | +| | @fanfpy
[@fanfpy](https://github.com/fanfpy) | 🐛 🤔 | [Longbridge submit/replace failed at the N-API Decimal boundary (#959)](https://github.com/TraderAlice/OpenAlice/issues/959) — traced `decimal.js` values being passed into the SDK's native Decimal fields and proposed converting at the write boundary ([#704](https://github.com/TraderAlice/OpenAlice/pull/704)), reimplemented in-house | --- diff --git a/services/uta/src/domain/trading/brokers/longbridge/LongbridgeBroker.spec.ts b/services/uta/src/domain/trading/brokers/longbridge/LongbridgeBroker.spec.ts index cdc2fff00..71f1f00eb 100644 --- a/services/uta/src/domain/trading/brokers/longbridge/LongbridgeBroker.spec.ts +++ b/services/uta/src/domain/trading/brokers/longbridge/LongbridgeBroker.spec.ts @@ -1,6 +1,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import Decimal from 'decimal.js' import { Contract, Order } from '@traderalice/ibkr' +import { Decimal as LongbridgeDecimal } from 'longbridge' import { LongbridgeBroker, ibkrOrderTypeToLb, ibkrTifToLb } from './LongbridgeBroker.js' import { makeContract, parseLbSymbol, resolveSymbol, mapLbOrderStatus } from './longbridge-contracts.js' import '../../contract-ext.js' @@ -8,6 +9,18 @@ import '../../contract-ext.js' // ==================== Longbridge SDK mock ==================== vi.mock('longbridge', () => { + class MockLongbridgeDecimal { + private readonly value: string + + constructor(value: string | number) { + this.value = String(value) + } + + toString(): string { + return this.value + } + } + // Numeric enum values mirror the const enum in node_modules/longbridge/index.d.ts. const OrderSide = { Unknown: 0, Buy: 1, Sell: 2 } as const const OrderType = { @@ -18,6 +31,7 @@ vi.mock('longbridge', () => { const Market = { Unknown: 0, US: 1, HK: 2, CN: 3, SG: 4, Crypto: 5 } as const return { + Decimal: MockLongbridgeDecimal, Config: { fromApikey: vi.fn(() => ({ __config: true })) }, TradeContext: { new: vi.fn(() => ({ @@ -44,6 +58,12 @@ vi.mock('longbridge', () => { } }) +function expectLongbridgeDecimal(value: unknown, expected: string): void { + expect(value).toBeInstanceOf(LongbridgeDecimal) + expect(value).not.toBeInstanceOf(Decimal) + expect(String(value)).toBe(expected) +} + // Helper: stamp mock contexts onto a freshly-constructed broker so we can // drive method outcomes without going through init(). function attachMockContexts(broker: LongbridgeBroker): { @@ -534,6 +554,48 @@ describe('LongbridgeBroker — getPositions()', () => { describe('LongbridgeBroker — placeOrder()', () => { beforeEach(() => vi.clearAllMocks()) + it('converts decimal.js submit fields to SDK Decimal instances', async () => { + const broker = makeBroker() + const { trade } = attachMockContexts(broker) + trade.submitOrder.mockResolvedValue({ orderId: 'decimal-submit' }) + const contract = makeContract('AAPL.US') + const order = new Order() + order.action = 'BUY' + order.orderType = 'TRAIL LIMIT' + order.totalQuantity = new Decimal('1.25') + order.lmtPrice = new Decimal('201.125') + order.auxPrice = new Decimal('199.875') + order.trailingPercent = new Decimal('1.5') + order.tif = 'DAY' + + await broker.placeOrder(contract, order) + + const sent = trade.submitOrder.mock.calls[0][0] + expectLongbridgeDecimal(sent.submittedQuantity, '1.25') + expectLongbridgeDecimal(sent.submittedPrice, '201.125') + expectLongbridgeDecimal(sent.triggerPrice, '199.875') + expectLongbridgeDecimal(sent.trailingPercent, '1.5') + }) + + it('preserves high-precision quantity without a JS number hop', async () => { + const broker = makeBroker() + const { trade } = attachMockContexts(broker) + trade.submitOrder.mockResolvedValue({ orderId: 'decimal-precise' }) + const contract = makeContract('AAPL.US') + const order = new Order() + order.action = 'BUY' + order.orderType = 'LMT' + order.totalQuantity = new Decimal('123.4567890123456789') + order.lmtPrice = new Decimal('201.1250000000000001') + order.tif = 'DAY' + + await broker.placeOrder(contract, order) + + const sent = trade.submitOrder.mock.calls[0][0] + expectLongbridgeDecimal(sent.submittedQuantity, '123.4567890123456789') + expectLongbridgeDecimal(sent.submittedPrice, '201.1250000000000001') + }) + it('translates HK MKT → ELO (HK quirk)', async () => { const b = makeBroker() const { trade } = attachMockContexts(b) @@ -702,6 +764,24 @@ describe('LongbridgeBroker — modifyOrder()', () => { const sent = trade.replaceOrder.mock.calls[0][0] expect(sent.orderId).toBe('ord-1') expect(sent.quantity.toString()).toBe('150') + expectLongbridgeDecimal(sent.quantity, '150') + }) + + it('converts decimal.js replacement fields to SDK Decimal instances', async () => { + const broker = makeBroker() + const { trade } = attachMockContexts(broker) + trade.replaceOrder.mockResolvedValue(undefined) + + await broker.modifyOrder('ord-1', { + totalQuantity: new Decimal('2.5'), + lmtPrice: new Decimal('202.25'), + auxPrice: new Decimal('198.75'), + }) + + const sent = trade.replaceOrder.mock.calls[0][0] + expectLongbridgeDecimal(sent.quantity, '2.5') + expectLongbridgeDecimal(sent.price, '202.25') + expectLongbridgeDecimal(sent.triggerPrice, '198.75') }) }) diff --git a/services/uta/src/domain/trading/brokers/longbridge/LongbridgeBroker.ts b/services/uta/src/domain/trading/brokers/longbridge/LongbridgeBroker.ts index e48e74be9..a48021cab 100644 --- a/services/uta/src/domain/trading/brokers/longbridge/LongbridgeBroker.ts +++ b/services/uta/src/domain/trading/brokers/longbridge/LongbridgeBroker.ts @@ -19,6 +19,7 @@ import { z } from 'zod' import Decimal from 'decimal.js' import { Contract, ContractDescription, ContractDetails, Order, UNSET_DECIMAL } from '@traderalice/ibkr' import { + Decimal as LongbridgeDecimal, Config, TradeContext, QuoteContext, @@ -68,6 +69,11 @@ import type { const DERIVATIVE_OPTION = 0 const DERIVATIVE_WARRANT = 1 +/** Longbridge N-API writes require the SDK Decimal class, not decimal.js. */ +function toLongbridgeDecimal(value: Decimal): LongbridgeDecimal { + return new LongbridgeDecimal(value.toString()) +} + // ==================== Order-type translation ==================== /** @@ -294,11 +300,11 @@ export class LongbridgeBroker implements IBroker { orderType: lbType, side, timeInForce: lbTif, - submittedQuantity: order.totalQuantity as unknown as never, // SDK accepts decimal.js or its own Decimal + submittedQuantity: toLongbridgeDecimal(order.totalQuantity), } - if (!order.lmtPrice.equals(UNSET_DECIMAL)) opts.submittedPrice = order.lmtPrice as unknown as never - if (!order.auxPrice.equals(UNSET_DECIMAL)) opts.triggerPrice = order.auxPrice as unknown as never - if (!order.trailingPercent.equals(UNSET_DECIMAL)) opts.trailingPercent = order.trailingPercent as unknown as never + if (!order.lmtPrice.equals(UNSET_DECIMAL)) opts.submittedPrice = toLongbridgeDecimal(order.lmtPrice) + if (!order.auxPrice.equals(UNSET_DECIMAL)) opts.triggerPrice = toLongbridgeDecimal(order.auxPrice) + if (!order.trailingPercent.equals(UNSET_DECIMAL)) opts.trailingPercent = toLongbridgeDecimal(order.trailingPercent) try { const resp = await this.tradeCtx.submitOrder(opts) @@ -318,13 +324,13 @@ export class LongbridgeBroker implements IBroker { } const opts: ReplaceOrderOptions = { orderId, - quantity: changes.totalQuantity as unknown as never, + quantity: toLongbridgeDecimal(changes.totalQuantity), } if (changes.lmtPrice != null && !changes.lmtPrice.equals(UNSET_DECIMAL)) { - opts.price = changes.lmtPrice as unknown as never + opts.price = toLongbridgeDecimal(changes.lmtPrice) } if (changes.auxPrice != null && !changes.auxPrice.equals(UNSET_DECIMAL)) { - opts.triggerPrice = changes.auxPrice as unknown as never + opts.triggerPrice = toLongbridgeDecimal(changes.auxPrice) } try { await this.tradeCtx.replaceOrder(opts)