Skip to content
Open
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 @@ -49,6 +49,7 @@ work left a mark on it, you belong here.
| | <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 |
| | <a href="https://github.com/roymeshulam"><img src="https://github.com/roymeshulam.png" width="40" height="40" alt="@roymeshulam" /></a><br>[@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) |
| | <a href="https://github.com/fanfpy"><img src="https://github.com/fanfpy.png" width="40" height="40" alt="@fanfpy" /></a><br>[@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 |

---

Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,26 @@
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'

// ==================== 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 = {
Expand All @@ -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(() => ({
Expand All @@ -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): {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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')
})
})

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 ====================

/**
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down
Loading