From 259ecb972de4013b4a3b2434245696b56a283924 Mon Sep 17 00:00:00 2001 From: Ame Date: Thu, 26 Mar 2026 07:42:27 +0800 Subject: [PATCH 1/3] =?UTF-8?q?fix:=20IBKR=20account=20data=20=E2=80=94=20?= =?UTF-8?q?replace=20request-response=20with=20persistent=20subscription?= =?UTF-8?q?=20cache?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit reqAccountUpdates was being used in subscribe→collect→unsubscribe round-trips on every getAccount/getPositions call. When TWS was slow (e.g. after tradingPush), timeouts cascaded and the lock's derived Promise rejected without a handler, crashing the process. Now subscribes once on init(), maintains a double-buffered cache that updates on each accountDownloadEnd callback. getAccount/getPositions read from cache synchronously — no network round-trip, no lock, no timeout, no unhandled rejection. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/domain/trading/brokers/ibkr/IbkrBroker.ts | 19 ++- .../trading/brokers/ibkr/request-bridge.ts | 150 ++++++++++-------- 2 files changed, 90 insertions(+), 79 deletions(-) diff --git a/src/domain/trading/brokers/ibkr/IbkrBroker.ts b/src/domain/trading/brokers/ibkr/IbkrBroker.ts index ed37f74f..79872840 100644 --- a/src/domain/trading/brokers/ibkr/IbkrBroker.ts +++ b/src/domain/trading/brokers/ibkr/IbkrBroker.ts @@ -37,7 +37,7 @@ import { import '../../contract-ext.js' import { RequestBridge } from './request-bridge.js' import { resolveSymbol } from './ibkr-contracts.js' -import type { IbkrBrokerConfig, AccountDownloadResult } from './ibkr-types.js' +import type { IbkrBrokerConfig } from './ibkr-types.js' export class IbkrBroker implements IBroker { // ---- Self-registration ---- @@ -110,9 +110,10 @@ export class IbkrBroker implements IBroker { throw new BrokerError('CONFIG', 'No account detected from TWS/Gateway. Set accountId in config for multi-account setups.') } - // Verify connection by fetching account data + // Start persistent account subscription and wait for first download try { - await this.getAccount() + this.bridge.startAccountSubscription(this.accountId) + await this.bridge.waitForAccountReady() console.log(`IbkrBroker[${this.id}]: connected (account=${this.accountId}, host=${host}:${port}, clientId=${clientId})`) } catch (err) { throw BrokerError.from(err, 'NETWORK') @@ -120,6 +121,7 @@ export class IbkrBroker implements IBroker { } async close(): Promise { + this.bridge.stopAccountSubscription() this.client.disconnect() } @@ -259,7 +261,8 @@ export class IbkrBroker implements IBroker { * will be stale even though Blue Ocean ATS prices may be moving. */ async getAccount(): Promise { - const download = await this.downloadAccount() + const download = this.bridge.getAccountCache() + if (!download) throw new BrokerError('NETWORK', 'Account data not yet available') const totalCashValue = parseFloat(download.values.get('TotalCashValue') ?? '0') let totalMarketValue = 0 @@ -305,7 +308,8 @@ export class IbkrBroker implements IBroker { * snapshot mode and can see overnight session data. */ async getPositions(): Promise { - const download = await this.downloadAccount() + const download = this.bridge.getAccountCache() + if (!download) throw new BrokerError('NETWORK', 'Account data not yet available') return download.positions } @@ -435,9 +439,4 @@ export class IbkrBroker implements IBroker { return c } - // ==================== Internal ==================== - - private downloadAccount(): Promise { - return this.bridge.requestAccountDownload(this.accountId!) - } } diff --git a/src/domain/trading/brokers/ibkr/request-bridge.ts b/src/domain/trading/brokers/ibkr/request-bridge.ts index b3b8c08f..c988b9b3 100644 --- a/src/domain/trading/brokers/ibkr/request-bridge.ts +++ b/src/domain/trading/brokers/ibkr/request-bridge.ts @@ -6,7 +6,8 @@ * * A) reqId-based: symbolSamples, contractDetails, accountSummary, tickSnapshot * B) orderId-based: openOrder, orderStatus (for placeOrder/cancelOrder) - * C) Single-slot: accountDownload (updatePortfolio/updateAccountValue), openOrders batch + * C) Single-slot: openOrders batch, completedOrders batch + * D) Persistent subscription: account data (updatePortfolio/updateAccountValue) with cache */ import Decimal from 'decimal.js' @@ -35,7 +36,7 @@ import type { } from './ibkr-types.js' const DEFAULT_TIMEOUT_MS = 10_000 -const ACCOUNT_DOWNLOAD_TIMEOUT_MS = 20_000 +const ACCOUNT_READY_TIMEOUT_MS = 20_000 export class RequestBridge extends DefaultEWrapper { // ---- State ---- @@ -55,24 +56,6 @@ export class RequestBridge extends DefaultEWrapper { private orderPending = new Map>() // ---- Mode C: single-slot collectors ---- - private accountDownload: { - positions: Array<{ - contract: Contract - side: 'long' | 'short' - quantity: Decimal - avgCost: number - marketPrice: number - marketValue: number - unrealizedPnL: number - realizedPnL: number - }> - values: Map - resolve: (result: AccountDownloadResult) => void - reject: (err: Error) => void - timer: ReturnType - } | null = null - private accountDownloadLock: Promise | null = null - private openOrdersCollector: { orders: CollectedOpenOrder[] resolve: (orders: CollectedOpenOrder[]) => void @@ -87,6 +70,18 @@ export class RequestBridge extends DefaultEWrapper { timer: ReturnType } | null = null + // ---- Mode D: persistent account subscription cache ---- + private accountCache_: AccountDownloadResult | null = null + private accountCachePending_: { + positions: AccountDownloadResult['positions'] + values: Map + } | null = null + private accountReadyResolve_: (() => void) | null = null + private accountReadyReject_: ((err: Error) => void) | null = null + private accountReadyPromise_: Promise | null = null + private accountSubscribed_ = false + private accountCode_: string | null = null + // ---- Fill data cache (from orderStatus callbacks) ---- private fillData_ = new Map() @@ -185,37 +180,6 @@ export class RequestBridge extends DefaultEWrapper { // ---- Mode C: single-slot requests ---- - /** Request account download (positions + account values). Serial access via lock. */ - async requestAccountDownload(acctCode: string, timeoutMs = ACCOUNT_DOWNLOAD_TIMEOUT_MS): Promise { - // Queue behind any in-flight download - if (this.accountDownloadLock) { - await this.accountDownloadLock.catch(() => {}) - } - - const promise = new Promise((resolve, reject) => { - const timer = setTimeout(() => { - this.accountDownload = null - this.client_?.reqAccountUpdates(false, acctCode) - reject(new BrokerError('NETWORK', `Account download timed out after ${timeoutMs}ms`)) - }, timeoutMs) - - this.accountDownload = { - positions: [], - values: new Map(), - resolve, - reject, - timer, - } - }) - - this.accountDownloadLock = promise.finally(() => { - this.accountDownloadLock = null - }) - - this.client_!.reqAccountUpdates(true, acctCode) - return promise - } - /** Request all open orders (batch collector). */ requestOpenOrders(timeoutMs = DEFAULT_TIMEOUT_MS): Promise { return new Promise((resolve, reject) => { @@ -260,6 +224,46 @@ export class RequestBridge extends DefaultEWrapper { }) } + // ---- Mode D: persistent account subscription ---- + + /** Subscribe to account updates. Call once after connect. */ + startAccountSubscription(acctCode: string): void { + if (this.accountSubscribed_) return + this.accountSubscribed_ = true + this.accountCode_ = acctCode + this.accountCachePending_ = { positions: [], values: new Map() } + this.accountReadyPromise_ = new Promise((resolve, reject) => { + this.accountReadyResolve_ = resolve + this.accountReadyReject_ = reject + }) + this.client_!.reqAccountUpdates(true, acctCode) + } + + /** Wait for first account download to complete. */ + async waitForAccountReady(timeoutMs = ACCOUNT_READY_TIMEOUT_MS): Promise { + if (this.accountCache_) return + if (!this.accountReadyPromise_) { + throw new BrokerError('NETWORK', 'Account subscription not started') + } + const timeout = new Promise((_, reject) => + setTimeout(() => reject(new BrokerError('NETWORK', `Initial account download timed out after ${timeoutMs}ms`)), timeoutMs), + ) + await Promise.race([this.accountReadyPromise_, timeout]) + } + + /** Read the cached account data. Returns null if not yet loaded. */ + getAccountCache(): AccountDownloadResult | null { + return this.accountCache_ + } + + /** Stop the account subscription. */ + stopAccountSubscription(): void { + if (!this.accountSubscribed_ || !this.accountCode_) return + this.accountSubscribed_ = false + this.client_?.reqAccountUpdates(false, this.accountCode_) + this.accountCode_ = null + } + // ==================== Internal helpers ==================== private resolveRequest(reqId: number, value: unknown): void { @@ -321,11 +325,15 @@ export class RequestBridge extends DefaultEWrapper { } this.orderPending.clear() - if (this.accountDownload) { - clearTimeout(this.accountDownload.timer) - this.accountDownload.reject(error) - this.accountDownload = null + // Reject account subscription ready promise if still pending + if (this.accountReadyReject_) { + this.accountReadyReject_(error) + this.accountReadyResolve_ = null + this.accountReadyReject_ = null } + this.accountSubscribed_ = false + this.accountCache_ = null + this.accountCachePending_ = null if (this.openOrdersCollector) { clearTimeout(this.openOrdersCollector.timer) @@ -433,7 +441,7 @@ export class RequestBridge extends DefaultEWrapper { this.resolveRequest(reqId, this.collectors.get(reqId) ?? new Map()) } - // ---- Account download (updatePortfolio + updateAccountValue) ---- + // ---- Account subscription callbacks (persistent cache) ---- override updatePortfolio( contract: Contract, @@ -445,10 +453,10 @@ export class RequestBridge extends DefaultEWrapper { realizedPNL: number, _accountName: string, ): void { - if (!this.accountDownload) return - if (position.isZero()) return // no position + if (!this.accountCachePending_) return + if (position.isZero()) return - this.accountDownload.positions.push({ + this.accountCachePending_.positions.push({ contract, side: position.greaterThan(0) ? 'long' : 'short', quantity: position.abs(), @@ -461,23 +469,27 @@ export class RequestBridge extends DefaultEWrapper { } override updateAccountValue(key: string, val: string, _currency: string, _accountName: string): void { - this.accountDownload?.values.set(key, val) + this.accountCachePending_?.values.set(key, val) } override accountDownloadEnd(_accountName: string): void { - if (!this.accountDownload) return - clearTimeout(this.accountDownload.timer) + if (!this.accountCachePending_) return - const result: AccountDownloadResult = { - values: this.accountDownload.values, - positions: this.accountDownload.positions, + // Swap pending buffer into cache (atomic replace) + this.accountCache_ = { + values: this.accountCachePending_.values, + positions: this.accountCachePending_.positions, } - this.accountDownload.resolve(result) - this.accountDownload = null + // Reset pending buffer for next batch + this.accountCachePending_ = { positions: [], values: new Map() } - // Unsubscribe - this.client_?.reqAccountUpdates(false, _accountName) + // Resolve the initial-load promise (first call only) + if (this.accountReadyResolve_) { + this.accountReadyResolve_() + this.accountReadyResolve_ = null + this.accountReadyReject_ = null + } } // ---- Market data snapshot ---- From fb1db786699d0a9abf49d1df1720f7cbbc35eba2 Mon Sep 17 00:00:00 2001 From: Ame Date: Thu, 26 Mar 2026 08:03:03 +0800 Subject: [PATCH 2/3] fix: enforce aliceId pipe format (accountId|nativeKey) across codebase stagePlaceOrder/stageClosePosition now throw on invalid aliceId instead of silently creating an empty Contract (which caused TWS to reject with "invalid security type"). Updated all test fixtures from old hyphen format (mock-AAPL) to pipe format (mock-paper|AAPL). Tool descriptions now document the expected format explicitly. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../web/__tests__/chat-streaming.spec.ts | 2 +- .../trading/UnifiedTradingAccount.spec.ts | 48 +++++++++---------- src/domain/trading/UnifiedTradingAccount.ts | 14 +++--- .../__test__/e2e/alpaca-paper.e2e.spec.ts | 10 ++-- .../trading/__test__/uta-health.spec.ts | 4 +- src/domain/trading/account-manager.spec.ts | 8 ++-- .../brokers/alpaca/AlpacaBroker.spec.ts | 12 ++--- .../trading/brokers/mock/MockBroker.spec.ts | 42 ++++++++-------- src/domain/trading/brokers/mock/MockBroker.ts | 2 +- src/domain/trading/git/TradingGit.spec.ts | 6 +-- src/domain/trading/snapshot/snapshot.spec.ts | 4 +- src/tool/trading.ts | 8 ++-- 12 files changed, 81 insertions(+), 79 deletions(-) diff --git a/src/connectors/web/__tests__/chat-streaming.spec.ts b/src/connectors/web/__tests__/chat-streaming.spec.ts index 9ed4c180..70e909f9 100644 --- a/src/connectors/web/__tests__/chat-streaming.spec.ts +++ b/src/connectors/web/__tests__/chat-streaming.spec.ts @@ -236,7 +236,7 @@ describe('Web UI chat streaming', () => { const provider = new FakeProvider([ toolUseEvent('t1', 'getAccount', {}), toolResultEvent('t1', '{"cash": 100000}'), - toolUseEvent('t2', 'getQuote', { aliceId: 'alpaca-AAPL' }), + toolUseEvent('t2', 'getQuote', { aliceId: 'mock-paper|AAPL' }), toolResultEvent('t2', '{"last": 255.71}'), textEvent('Account has $100k, AAPL at $255.71'), doneEvent('Account has $100k, AAPL at $255.71'), diff --git a/src/domain/trading/UnifiedTradingAccount.spec.ts b/src/domain/trading/UnifiedTradingAccount.spec.ts index 0aac3fbf..24d883c8 100644 --- a/src/domain/trading/UnifiedTradingAccount.spec.ts +++ b/src/domain/trading/UnifiedTradingAccount.spec.ts @@ -57,7 +57,7 @@ describe('UTA — operation dispatch', () => { it('passes aliceId and extra contract fields', async () => { const spy = vi.spyOn(broker, 'placeOrder') const contract = makeContract({ - aliceId: 'alpaca-AAPL', + aliceId: 'mock-paper|AAPL', symbol: 'AAPL', secType: 'STK', currency: 'USD', @@ -74,7 +74,7 @@ describe('UTA — operation dispatch', () => { await uta.push() const [passedContract, passedOrder] = spy.mock.calls[0] - expect(passedContract.aliceId).toBe('alpaca-AAPL') + expect(passedContract.aliceId).toBe('mock-paper|AAPL') expect(passedContract.secType).toBe('STK') expect(passedContract.currency).toBe('USD') expect(passedContract.exchange).toBe('NASDAQ') @@ -243,13 +243,13 @@ describe('UTA — stagePlaceOrder', () => { }) it('maps buy side to BUY action', () => { - uta.stagePlaceOrder({ aliceId: 'a-AAPL', side: 'buy', type: 'market', qty: 10 }) + uta.stagePlaceOrder({ aliceId: 'mock-paper|AAPL', side: 'buy', type: 'market', qty: 10 }) const { order } = getStagedPlaceOrder(uta) expect(order.action).toBe('BUY') }) it('maps sell side to SELL action', () => { - uta.stagePlaceOrder({ aliceId: 'a-AAPL', side: 'sell', type: 'market', qty: 10 }) + uta.stagePlaceOrder({ aliceId: 'mock-paper|AAPL', side: 'sell', type: 'market', qty: 10 }) const { order } = getStagedPlaceOrder(uta) expect(order.action).toBe('SELL') }) @@ -264,54 +264,54 @@ describe('UTA — stagePlaceOrder', () => { ] for (const [input, expected] of cases) { const { uta: u } = createUTA() - u.stagePlaceOrder({ aliceId: 'a-X', side: 'buy', type: input, qty: 1 }) + u.stagePlaceOrder({ aliceId: 'mock-paper|X', side: 'buy', type: input, qty: 1 }) const { order } = getStagedPlaceOrder(u) expect(order.orderType).toBe(expected) } }) it('maps qty to totalQuantity as Decimal', () => { - uta.stagePlaceOrder({ aliceId: 'a-AAPL', side: 'buy', type: 'market', qty: 42 }) + uta.stagePlaceOrder({ aliceId: 'mock-paper|AAPL', side: 'buy', type: 'market', qty: 42 }) const { order } = getStagedPlaceOrder(uta) expect(order.totalQuantity).toBeInstanceOf(Decimal) expect(order.totalQuantity.toNumber()).toBe(42) }) it('maps notional to cashQty', () => { - uta.stagePlaceOrder({ aliceId: 'a-AAPL', side: 'buy', type: 'market', notional: 5000 }) + uta.stagePlaceOrder({ aliceId: 'mock-paper|AAPL', side: 'buy', type: 'market', notional: 5000 }) const { order } = getStagedPlaceOrder(uta) expect(order.cashQty).toBe(5000) }) it('maps price to lmtPrice and stopPrice to auxPrice', () => { - uta.stagePlaceOrder({ aliceId: 'a-AAPL', side: 'buy', type: 'stop_limit', qty: 10, price: 150, stopPrice: 145 }) + uta.stagePlaceOrder({ aliceId: 'mock-paper|AAPL', side: 'buy', type: 'stop_limit', qty: 10, price: 150, stopPrice: 145 }) const { order } = getStagedPlaceOrder(uta) expect(order.lmtPrice).toBe(150) expect(order.auxPrice).toBe(145) }) it('defaults timeInForce to DAY', () => { - uta.stagePlaceOrder({ aliceId: 'a-AAPL', side: 'buy', type: 'market', qty: 10 }) + uta.stagePlaceOrder({ aliceId: 'mock-paper|AAPL', side: 'buy', type: 'market', qty: 10 }) const { order } = getStagedPlaceOrder(uta) expect(order.tif).toBe('DAY') }) it('allows overriding timeInForce', () => { - uta.stagePlaceOrder({ aliceId: 'a-AAPL', side: 'buy', type: 'limit', qty: 10, price: 150, timeInForce: 'gtc' }) + uta.stagePlaceOrder({ aliceId: 'mock-paper|AAPL', side: 'buy', type: 'limit', qty: 10, price: 150, timeInForce: 'gtc' }) const { order } = getStagedPlaceOrder(uta) expect(order.tif).toBe('GTC') }) it('maps extendedHours to outsideRth', () => { - uta.stagePlaceOrder({ aliceId: 'a-AAPL', side: 'buy', type: 'limit', qty: 10, price: 150, extendedHours: true }) + uta.stagePlaceOrder({ aliceId: 'mock-paper|AAPL', side: 'buy', type: 'limit', qty: 10, price: 150, extendedHours: true }) const { order } = getStagedPlaceOrder(uta) expect(order.outsideRth).toBe(true) }) it('sets aliceId and symbol on contract', () => { - uta.stagePlaceOrder({ aliceId: 'alpaca-AAPL', symbol: 'AAPL', side: 'buy', type: 'market', qty: 10 }) + uta.stagePlaceOrder({ aliceId: 'mock-paper|AAPL', symbol: 'AAPL', side: 'buy', type: 'market', qty: 10 }) const { contract } = getStagedPlaceOrder(uta) - expect(contract.aliceId).toBe('alpaca-AAPL') + expect(contract.aliceId).toBe('mock-paper|AAPL') expect(contract.symbol).toBe('AAPL') }) }) @@ -360,17 +360,17 @@ describe('UTA — stageClosePosition', () => { }) it('stages with Decimal quantity when qty provided', () => { - uta.stageClosePosition({ aliceId: 'a-AAPL', qty: 5 }) + uta.stageClosePosition({ aliceId: 'mock-paper|AAPL', qty: 5 }) const staged = uta.status().staged const op = staged[0] as Extract expect(op.action).toBe('closePosition') - expect(op.contract.aliceId).toBe('a-AAPL') + expect(op.contract.aliceId).toBe('mock-paper|AAPL') expect(op.quantity).toBeInstanceOf(Decimal) expect(op.quantity!.toNumber()).toBe(5) }) it('stages with undefined quantity for full close', () => { - uta.stageClosePosition({ aliceId: 'a-AAPL' }) + uta.stageClosePosition({ aliceId: 'mock-paper|AAPL' }) const staged = uta.status().staged const op = staged[0] as Extract expect(op.quantity).toBeUndefined() @@ -405,15 +405,15 @@ describe('UTA — git flow', () => { }) it('push throws when not committed', async () => { - uta.stagePlaceOrder({ aliceId: 'a-AAPL', side: 'buy', type: 'market', qty: 10 }) + uta.stagePlaceOrder({ aliceId: 'mock-paper|AAPL', side: 'buy', type: 'market', qty: 10 }) await expect(uta.push()).rejects.toThrow('please commit first') }) it('executes multiple operations in a single push', async () => { const { uta: u, broker: b } = createUTA() const spy = vi.spyOn(b, 'placeOrder') - u.stagePlaceOrder({ aliceId: 'a-AAPL', side: 'buy', type: 'market', qty: 10 }) - u.stagePlaceOrder({ aliceId: 'a-MSFT', symbol: 'MSFT', side: 'buy', type: 'market', qty: 5 }) + u.stagePlaceOrder({ aliceId: 'mock-paper|AAPL', side: 'buy', type: 'market', qty: 10 }) + u.stagePlaceOrder({ aliceId: 'mock-paper|MSFT', symbol: 'MSFT', side: 'buy', type: 'market', qty: 5 }) u.commit('buy both') await u.push() @@ -421,7 +421,7 @@ describe('UTA — git flow', () => { }) it('clears staging area after push', async () => { - uta.stagePlaceOrder({ aliceId: 'a-AAPL', side: 'buy', type: 'market', qty: 10 }) + uta.stagePlaceOrder({ aliceId: 'mock-paper|AAPL', side: 'buy', type: 'market', qty: 10 }) uta.commit('buy') await uta.push() @@ -483,7 +483,7 @@ describe('UTA — guards', () => { }) const spy = vi.spyOn(broker, 'placeOrder') - uta.stagePlaceOrder({ aliceId: 'a-TSLA', symbol: 'TSLA', side: 'buy', type: 'market', qty: 10 }) + uta.stagePlaceOrder({ aliceId: 'mock-paper|TSLA', symbol: 'TSLA', side: 'buy', type: 'market', qty: 10 }) uta.commit('buy TSLA (should be blocked)') const result = await uta.push() @@ -498,7 +498,7 @@ describe('UTA — guards', () => { }) const spy = vi.spyOn(broker, 'placeOrder') - uta.stagePlaceOrder({ aliceId: 'a-AAPL', symbol: 'AAPL', side: 'buy', type: 'market', qty: 10 }) + uta.stagePlaceOrder({ aliceId: 'mock-paper|AAPL', symbol: 'AAPL', side: 'buy', type: 'market', qty: 10 }) uta.commit('buy AAPL (allowed)') await uta.push() @@ -512,7 +512,7 @@ describe('UTA — constructor', () => { it('restores from savedState', async () => { // Create a UTA, push a commit, export state const { uta: original } = createUTA() - original.stagePlaceOrder({ aliceId: 'a-AAPL', symbol: 'AAPL', side: 'buy', type: 'market', qty: 10 }) + original.stagePlaceOrder({ aliceId: 'mock-paper|AAPL', symbol: 'AAPL', side: 'buy', type: 'market', qty: 10 }) original.commit('initial buy') await original.push() @@ -640,7 +640,7 @@ describe('UTA — health tracking', () => { await expect(uta.getAccount()).rejects.toThrow() } - uta.stagePlaceOrder({ aliceId: 'a-AAPL', side: 'buy', type: 'market', qty: 10 }) + uta.stagePlaceOrder({ aliceId: 'mock-paper|AAPL', side: 'buy', type: 'market', qty: 10 }) uta.commit('buy AAPL') await expect(uta.push()).rejects.toThrow(/offline/) await uta.close() diff --git a/src/domain/trading/UnifiedTradingAccount.ts b/src/domain/trading/UnifiedTradingAccount.ts index 22aaad4b..9ea3e83d 100644 --- a/src/domain/trading/UnifiedTradingAccount.ts +++ b/src/domain/trading/UnifiedTradingAccount.ts @@ -358,9 +358,10 @@ export class UnifiedTradingAccount { stagePlaceOrder(params: StagePlaceOrderParams): AddResult { // Resolve aliceId → full contract via broker (fills secType, exchange, currency, conId, etc.) const parsed = UnifiedTradingAccount.parseAliceId(params.aliceId) - const contract = parsed - ? this.broker.resolveNativeKey(parsed.nativeKey) - : new Contract() + if (!parsed) { + throw new Error(`Invalid aliceId "${params.aliceId}". Use searchContracts to get a valid contract identifier (expected format: "accountId|nativeKey").`) + } + const contract = this.broker.resolveNativeKey(parsed.nativeKey) contract.aliceId = params.aliceId if (params.symbol) contract.symbol = params.symbol @@ -399,9 +400,10 @@ export class UnifiedTradingAccount { stageClosePosition(params: StageClosePositionParams): AddResult { const parsed = UnifiedTradingAccount.parseAliceId(params.aliceId) - const contract = parsed - ? this.broker.resolveNativeKey(parsed.nativeKey) - : new Contract() + if (!parsed) { + throw new Error(`Invalid aliceId "${params.aliceId}". Use searchContracts to get a valid contract identifier (expected format: "accountId|nativeKey").`) + } + const contract = this.broker.resolveNativeKey(parsed.nativeKey) contract.aliceId = params.aliceId if (params.symbol) contract.symbol = params.symbol diff --git a/src/domain/trading/__test__/e2e/alpaca-paper.e2e.spec.ts b/src/domain/trading/__test__/e2e/alpaca-paper.e2e.spec.ts index f9b5b23d..16c7ab75 100644 --- a/src/domain/trading/__test__/e2e/alpaca-paper.e2e.spec.ts +++ b/src/domain/trading/__test__/e2e/alpaca-paper.e2e.spec.ts @@ -118,7 +118,7 @@ describe('AlpacaBroker — fill + position (market hours)', () => { it('fetches AAPL quote with valid prices', async () => { const contract = new Contract() - contract.aliceId = 'alpaca-AAPL' + contract.aliceId = 'alpaca-paper|AAPL' contract.symbol = 'AAPL' const quote = await broker!.getQuote(contract) @@ -131,7 +131,7 @@ describe('AlpacaBroker — fill + position (market hours)', () => { it('places market buy 1 AAPL → success with UUID orderId', async () => { const contract = new Contract() - contract.aliceId = 'alpaca-AAPL' + contract.aliceId = 'alpaca-paper|AAPL' contract.symbol = 'AAPL' contract.secType = 'STK' @@ -151,7 +151,7 @@ describe('AlpacaBroker — fill + position (market hours)', () => { it('queries order by ID after place', async () => { const contract = new Contract() - contract.aliceId = 'alpaca-AAPL' + contract.aliceId = 'alpaca-paper|AAPL' contract.symbol = 'AAPL' contract.secType = 'STK' @@ -187,7 +187,7 @@ describe('AlpacaBroker — fill + position (market hours)', () => { it('closes AAPL position', async () => { const contract = new Contract() - contract.aliceId = 'alpaca-AAPL' + contract.aliceId = 'alpaca-paper|AAPL' contract.symbol = 'AAPL' const result = await broker!.closePosition(contract) @@ -197,7 +197,7 @@ describe('AlpacaBroker — fill + position (market hours)', () => { it('getOrders with known IDs', async () => { const contract = new Contract() - contract.aliceId = 'alpaca-AAPL' + contract.aliceId = 'alpaca-paper|AAPL' contract.symbol = 'AAPL' contract.secType = 'STK' diff --git a/src/domain/trading/__test__/uta-health.spec.ts b/src/domain/trading/__test__/uta-health.spec.ts index 77a891e2..4dbeaba7 100644 --- a/src/domain/trading/__test__/uta-health.spec.ts +++ b/src/domain/trading/__test__/uta-health.spec.ts @@ -215,7 +215,7 @@ describe('UTA health — offline behavior', () => { await expect(uta.getAccount()).rejects.toThrow() } - uta.stagePlaceOrder({ aliceId: 'a-AAPL', side: 'buy', type: 'market', qty: 10 }) + uta.stagePlaceOrder({ aliceId: 'mock-paper|AAPL', side: 'buy', type: 'market', qty: 10 }) uta.commit('buy AAPL') await expect(uta.push()).rejects.toThrow(/offline/) await uta.close() @@ -231,7 +231,7 @@ describe('UTA health — offline behavior', () => { } // Staging is a local operation — should work even when offline - const result = uta.stagePlaceOrder({ aliceId: 'a-AAPL', side: 'buy', type: 'market', qty: 10 }) + const result = uta.stagePlaceOrder({ aliceId: 'mock-paper|AAPL', side: 'buy', type: 'market', qty: 10 }) expect(result.staged).toBe(true) const commit = uta.commit('buy while offline') diff --git a/src/domain/trading/account-manager.spec.ts b/src/domain/trading/account-manager.spec.ts index 49fe8059..7f25a10f 100644 --- a/src/domain/trading/account-manager.spec.ts +++ b/src/domain/trading/account-manager.spec.ts @@ -127,12 +127,12 @@ describe('AccountManager', () => { it('searches all accounts by default', async () => { const a1 = new MockBroker({ id: 'a1' }) const desc1 = new ContractDescription() - desc1.contract = makeContract({ aliceId: 'a1-AAPL' }) + desc1.contract = makeContract({ aliceId: 'a1|AAPL' }) vi.spyOn(a1, 'searchContracts').mockResolvedValue([desc1]) const a2 = new MockBroker({ id: 'a2' }) const desc2 = new ContractDescription() - desc2.contract = makeContract({ aliceId: 'a2-AAPL' }) + desc2.contract = makeContract({ aliceId: 'a2|AAPL' }) vi.spyOn(a2, 'searchContracts').mockResolvedValue([desc2]) manager.add(makeUta(a1)) @@ -145,12 +145,12 @@ describe('AccountManager', () => { it('scopes search to specific accountId', async () => { const a1 = new MockBroker({ id: 'a1' }) const desc1 = new ContractDescription() - desc1.contract = makeContract({ aliceId: 'a1-AAPL' }) + desc1.contract = makeContract({ aliceId: 'a1|AAPL' }) vi.spyOn(a1, 'searchContracts').mockResolvedValue([desc1]) const a2 = new MockBroker({ id: 'a2' }) const desc2 = new ContractDescription() - desc2.contract = makeContract({ aliceId: 'a2-AAPL' }) + desc2.contract = makeContract({ aliceId: 'a2|AAPL' }) vi.spyOn(a2, 'searchContracts').mockResolvedValue([desc2]) manager.add(makeUta(a1)) diff --git a/src/domain/trading/brokers/alpaca/AlpacaBroker.spec.ts b/src/domain/trading/brokers/alpaca/AlpacaBroker.spec.ts index f08e1307..266b5351 100644 --- a/src/domain/trading/brokers/alpaca/AlpacaBroker.spec.ts +++ b/src/domain/trading/brokers/alpaca/AlpacaBroker.spec.ts @@ -104,7 +104,7 @@ describe('AlpacaBroker — placeOrder()', () => { }), } const contract = new Contract() - contract.aliceId = 'alpaca-AAPL' + contract.aliceId = 'alpaca-paper|AAPL' contract.symbol = 'AAPL' contract.secType = 'STK' contract.exchange = 'NASDAQ' @@ -148,7 +148,7 @@ describe('AlpacaBroker — precision', () => { createOrder: vi.fn().mockResolvedValue({ id: 'ord-p', status: 'new' }), } const contract = new Contract() - contract.aliceId = 'alpaca-AAPL' + contract.aliceId = 'alpaca-paper|AAPL' contract.symbol = 'AAPL' contract.secType = 'STK' contract.exchange = 'NASDAQ' @@ -199,7 +199,7 @@ describe('AlpacaBroker — getContractDetails()', () => { it('returns ContractDetails for a valid symbol', async () => { const acc = new AlpacaBroker({ apiKey: 'k', secretKey: 's', paper: true }) const query = new Contract() - query.aliceId = 'alpaca-AAPL' + query.aliceId = 'alpaca-paper|AAPL' query.symbol = 'AAPL' const details = await acc.getContractDetails(query) @@ -312,7 +312,7 @@ describe('AlpacaBroker — closePosition()', () => { } const contract = new Contract() - contract.aliceId = 'alpaca-AAPL' + contract.aliceId = 'alpaca-paper|AAPL' contract.symbol = 'AAPL' const result = await acc.closePosition(contract) @@ -341,7 +341,7 @@ describe('AlpacaBroker — closePosition()', () => { } const contract = new Contract() - contract.aliceId = 'alpaca-AAPL' + contract.aliceId = 'alpaca-paper|AAPL' contract.symbol = 'AAPL' const result = await acc.closePosition(contract, new Decimal(3)) @@ -532,7 +532,7 @@ describe('AlpacaBroker — getQuote()', () => { } const contract = new Contract() - contract.aliceId = 'alpaca-AAPL' + contract.aliceId = 'alpaca-paper|AAPL' contract.symbol = 'AAPL' const quote = await acc.getQuote(contract) diff --git a/src/domain/trading/brokers/mock/MockBroker.spec.ts b/src/domain/trading/brokers/mock/MockBroker.spec.ts index f74dde08..b3904e7f 100644 --- a/src/domain/trading/brokers/mock/MockBroker.spec.ts +++ b/src/domain/trading/brokers/mock/MockBroker.spec.ts @@ -22,7 +22,7 @@ beforeEach(() => { describe('precision', () => { it('placeOrder quantity survives Decimal round-trip', async () => { - const contract = makeContract({ aliceId: 'mock-ETH', symbol: 'ETH' }) + const contract = makeContract({ aliceId: 'mock-paper|ETH', symbol: 'ETH' }) const order = new Order() order.action = 'BUY' order.orderType = 'MKT' @@ -36,7 +36,7 @@ describe('precision', () => { }) it('position quantity matches placed order exactly', async () => { - const contract = makeContract({ aliceId: 'mock-ETH', symbol: 'ETH' }) + const contract = makeContract({ aliceId: 'mock-paper|ETH', symbol: 'ETH' }) const order = new Order() order.action = 'BUY' order.orderType = 'MKT' @@ -49,7 +49,7 @@ describe('precision', () => { }) it('closePosition removes position completely', async () => { - const contract = makeContract({ aliceId: 'mock-ETH', symbol: 'ETH' }) + const contract = makeContract({ aliceId: 'mock-paper|ETH', symbol: 'ETH' }) const order = new Order() order.action = 'BUY' order.orderType = 'MKT' @@ -64,7 +64,7 @@ describe('precision', () => { }) it('partial close leaves correct remainder via Decimal subtraction', async () => { - const contract = makeContract({ aliceId: 'mock-ETH', symbol: 'ETH' }) + const contract = makeContract({ aliceId: 'mock-paper|ETH', symbol: 'ETH' }) const buyOrder = new Order() buyOrder.action = 'BUY' buyOrder.orderType = 'MKT' @@ -85,7 +85,7 @@ describe('precision', () => { describe('placeOrder', () => { it('market order returns submitted (fill confirmed via getOrder)', async () => { broker.setQuote('AAPL', 150) - const contract = makeContract({ aliceId: 'mock-AAPL', symbol: 'AAPL' }) + const contract = makeContract({ aliceId: 'mock-paper|AAPL', symbol: 'AAPL' }) const order = new Order() order.action = 'BUY' order.orderType = 'MKT' @@ -102,7 +102,7 @@ describe('placeOrder', () => { }) it('limit order stays submitted, no execution', async () => { - const contract = makeContract({ aliceId: 'mock-AAPL', symbol: 'AAPL' }) + const contract = makeContract({ aliceId: 'mock-paper|AAPL', symbol: 'AAPL' }) const order = new Order() order.action = 'BUY' order.orderType = 'LMT' @@ -118,7 +118,7 @@ describe('placeOrder', () => { it('creates position on buy', async () => { broker.setQuote('AAPL', 150) - const contract = makeContract({ aliceId: 'mock-AAPL', symbol: 'AAPL' }) + const contract = makeContract({ aliceId: 'mock-paper|AAPL', symbol: 'AAPL' }) const order = new Order() order.action = 'BUY' order.orderType = 'MKT' @@ -134,7 +134,7 @@ describe('placeOrder', () => { it('updates existing position on additional buy (avg cost recalc)', async () => { broker.setQuote('AAPL', 150) - const contract = makeContract({ aliceId: 'mock-AAPL', symbol: 'AAPL' }) + const contract = makeContract({ aliceId: 'mock-paper|AAPL', symbol: 'AAPL' }) const order1 = new Order() order1.action = 'BUY' @@ -162,7 +162,7 @@ describe('placeOrder', () => { describe('closePosition', () => { it('closes full position', async () => { broker.setQuote('AAPL', 150) - const contract = makeContract({ aliceId: 'mock-AAPL', symbol: 'AAPL' }) + const contract = makeContract({ aliceId: 'mock-paper|AAPL', symbol: 'AAPL' }) const order = new Order() order.action = 'BUY' order.orderType = 'MKT' @@ -178,7 +178,7 @@ describe('closePosition', () => { it('partial close reduces quantity', async () => { broker.setQuote('AAPL', 150) - const contract = makeContract({ aliceId: 'mock-AAPL', symbol: 'AAPL' }) + const contract = makeContract({ aliceId: 'mock-paper|AAPL', symbol: 'AAPL' }) const order = new Order() order.action = 'BUY' order.orderType = 'MKT' @@ -193,7 +193,7 @@ describe('closePosition', () => { }) it('returns error when no position', async () => { - const contract = makeContract({ aliceId: 'mock-AAPL', symbol: 'AAPL' }) + const contract = makeContract({ aliceId: 'mock-paper|AAPL', symbol: 'AAPL' }) const result = await broker.closePosition(contract) expect(result.success).toBe(false) expect(result.error).toContain('No open position') @@ -204,7 +204,7 @@ describe('closePosition', () => { describe('cancelOrder', () => { it('cancels pending order', async () => { - const contract = makeContract({ aliceId: 'mock-AAPL', symbol: 'AAPL' }) + const contract = makeContract({ aliceId: 'mock-paper|AAPL', symbol: 'AAPL' }) const order = new Order() order.action = 'BUY' order.orderType = 'LMT' @@ -232,7 +232,7 @@ describe('cancelOrder', () => { describe('modifyOrder', () => { it('updates pending order qty/price', async () => { - const contract = makeContract({ aliceId: 'mock-AAPL', symbol: 'AAPL' }) + const contract = makeContract({ aliceId: 'mock-paper|AAPL', symbol: 'AAPL' }) const order = new Order() order.action = 'BUY' order.orderType = 'LMT' @@ -264,7 +264,7 @@ describe('modifyOrder', () => { describe('getOrder', () => { it('finds order by id', async () => { - const contract = makeContract({ aliceId: 'mock-AAPL', symbol: 'AAPL' }) + const contract = makeContract({ aliceId: 'mock-paper|AAPL', symbol: 'AAPL' }) const order = new Order() order.action = 'BUY' order.orderType = 'LMT' @@ -287,7 +287,7 @@ describe('getOrder', () => { describe('fillPendingOrder', () => { it('fills a pending limit order at specified price', async () => { - const contract = makeContract({ aliceId: 'mock-AAPL', symbol: 'AAPL' }) + const contract = makeContract({ aliceId: 'mock-paper|AAPL', symbol: 'AAPL' }) const order = new Order() order.action = 'BUY' order.orderType = 'LMT' @@ -319,7 +319,7 @@ describe('getAccount', () => { it('cash decreases after buy, equity includes unrealized PnL', async () => { broker.setQuote('AAPL', 150) - const contract = makeContract({ aliceId: 'mock-AAPL', symbol: 'AAPL' }) + const contract = makeContract({ aliceId: 'mock-paper|AAPL', symbol: 'AAPL' }) const order = new Order() order.action = 'BUY' order.orderType = 'MKT' @@ -342,7 +342,7 @@ describe('getAccount', () => { describe('call tracking', () => { it('records method calls with args', async () => { - const contract = makeContract({ aliceId: 'mock-AAPL', symbol: 'AAPL' }) + const contract = makeContract({ aliceId: 'mock-paper|AAPL', symbol: 'AAPL' }) await broker.getQuote(contract) expect(broker.callCount('getQuote')).toBe(1) expect(broker.lastCall('getQuote')!.args[0]).toBe(contract) @@ -350,7 +350,7 @@ describe('call tracking', () => { it('tracks multiple calls', async () => { broker.setQuote('AAPL', 150) - const contract = makeContract({ aliceId: 'mock-AAPL', symbol: 'AAPL' }) + const contract = makeContract({ aliceId: 'mock-paper|AAPL', symbol: 'AAPL' }) const order = new Order() order.action = 'BUY' order.orderType = 'MKT' @@ -397,13 +397,13 @@ describe('accountInfo constructor option', () => { describe('factory helpers', () => { it('makeContract creates a contract with defaults', () => { const c = makeContract() - expect(c.aliceId).toBe('mock-AAPL') + expect(c.aliceId).toBe('mock-paper|AAPL') expect(c.symbol).toBe('AAPL') }) it('makeContract accepts overrides', () => { - const c = makeContract({ aliceId: 'mock-ETH', symbol: 'ETH', secType: 'CRYPTO' }) - expect(c.aliceId).toBe('mock-ETH') + const c = makeContract({ aliceId: 'mock-paper|ETH', symbol: 'ETH', secType: 'CRYPTO' }) + expect(c.aliceId).toBe('mock-paper|ETH') expect(c.symbol).toBe('ETH') expect(c.secType).toBe('CRYPTO') }) diff --git a/src/domain/trading/brokers/mock/MockBroker.ts b/src/domain/trading/brokers/mock/MockBroker.ts index a77036e4..ac76f5b2 100644 --- a/src/domain/trading/brokers/mock/MockBroker.ts +++ b/src/domain/trading/brokers/mock/MockBroker.ts @@ -74,7 +74,7 @@ export const DEFAULT_CAPABILITIES: AccountCapabilities = { export function makeContract(overrides: Partial & { aliceId?: string } = {}): Contract { const c = new Contract() - c.aliceId = overrides.aliceId ?? 'mock-AAPL' + c.aliceId = overrides.aliceId ?? 'mock-paper|AAPL' c.symbol = overrides.symbol ?? 'AAPL' c.secType = overrides.secType ?? 'STK' c.exchange = overrides.exchange ?? 'MOCK' diff --git a/src/domain/trading/git/TradingGit.spec.ts b/src/domain/trading/git/TradingGit.spec.ts index e907ce2a..09722325 100644 --- a/src/domain/trading/git/TradingGit.spec.ts +++ b/src/domain/trading/git/TradingGit.spec.ts @@ -10,7 +10,7 @@ import '../contract-ext.js' function makeContract(overrides: { aliceId?: string; symbol?: string } = {}): Contract { const c = new Contract() - c.aliceId = overrides.aliceId ?? 'mock-AAPL' + c.aliceId = overrides.aliceId ?? 'mock-paper|AAPL' c.symbol = overrides.symbol ?? 'AAPL' c.secType = 'STK' c.exchange = 'NASDAQ' @@ -581,7 +581,7 @@ describe('TradingGit', () => { const stateWithPositions = makeGitState({ positions: [ { - contract: makeContract({ aliceId: 'mock-AAPL', symbol: 'AAPL' }), + contract: makeContract({ aliceId: 'mock-paper|AAPL', symbol: 'AAPL' }), side: 'long', quantity: new Decimal(10), avgCost: 150, @@ -611,7 +611,7 @@ describe('TradingGit', () => { const stateWithPositions = makeGitState({ positions: [ { - contract: makeContract({ aliceId: 'mock-AAPL', symbol: 'AAPL' }), + contract: makeContract({ aliceId: 'mock-paper|AAPL', symbol: 'AAPL' }), side: 'long', quantity: new Decimal(10), avgCost: 150, diff --git a/src/domain/trading/snapshot/snapshot.spec.ts b/src/domain/trading/snapshot/snapshot.spec.ts index 2f9b9e50..21f2521f 100644 --- a/src/domain/trading/snapshot/snapshot.spec.ts +++ b/src/domain/trading/snapshot/snapshot.spec.ts @@ -35,7 +35,7 @@ function tempPath(ext: string): string { } function makeSubmittedOrder(symbol = 'AAPL'): ReturnType { - const contract = makeContract({ symbol, aliceId: `mock-${symbol}` }) + const contract = makeContract({ symbol, aliceId: `mock-paper|${symbol}` }) const order = new Order() order.orderId = 42 order.action = 'BUY' @@ -93,7 +93,7 @@ describe('Snapshot Builder', () => { // #3 it('positions use aliceId, not full contract', async () => { - const pos = makePosition({ contract: makeContract({ symbol: 'TSLA', aliceId: 'mock-TSLA' }) }) + const pos = makePosition({ contract: makeContract({ symbol: 'TSLA', aliceId: 'mock-paper|TSLA' }) }) broker.setPositions([pos]) const snap = await buildSnapshot(uta, 'manual') expect(snap).not.toBeNull() diff --git a/src/tool/trading.ts b/src/tool/trading.ts index e4462dfc..907e8021 100644 --- a/src/tool/trading.ts +++ b/src/tool/trading.ts @@ -69,7 +69,7 @@ This is a BROKER-LEVEL search — it queries your connected trading accounts.`, inputSchema: z.object({ source: z.string().describe(sourceDesc(true)), symbol: z.string().optional().describe('Symbol to look up'), - aliceId: z.string().optional().describe('Alice contract ID for exact match'), + aliceId: z.string().optional().describe('Contract ID (format: accountId|nativeKey, from searchContracts)'), secType: z.string().optional().describe('Security type filter'), currency: z.string().optional().describe('Currency filter'), }), @@ -168,7 +168,7 @@ If this tool returns an error with transient=true, wait a few seconds and retry description: `Query the latest quote/price for a contract. If this tool returns an error with transient=true, wait a few seconds and retry once before reporting to the user.`, inputSchema: z.object({ - aliceId: z.string().describe('Contract identifier from searchContracts'), + aliceId: z.string().describe('Contract ID (format: accountId|nativeKey, from searchContracts)'), source: z.string().optional().describe(sourceDesc(false)), }), execute: async ({ aliceId, source }) => { @@ -268,7 +268,7 @@ BEFORE placing orders: check tradingLog, getPortfolio, verify strategy alignment NOTE: This stages the operation. Call tradingCommit + tradingPush to execute.`, inputSchema: z.object({ source: z.string().describe(sourceDesc(true)), - aliceId: z.string().describe('Contract identifier from searchContracts'), + aliceId: z.string().describe('Contract ID (format: accountId|nativeKey, from searchContracts)'), symbol: z.string().optional().describe('Human-readable symbol. Optional.'), side: z.enum(['buy', 'sell']).describe('Buy or sell'), type: z.enum(['market', 'limit', 'stop', 'stop_limit', 'trailing_stop', 'trailing_stop_limit', 'moc']).describe('Order type'), @@ -308,7 +308,7 @@ NOTE: This stages the operation. Call tradingCommit + tradingPush to execute.`, description: 'Stage a position close.\nNOTE: This stages the operation. Call tradingCommit + tradingPush to execute.', inputSchema: z.object({ source: z.string().describe(sourceDesc(true)), - aliceId: z.string().describe('Contract identifier'), + aliceId: z.string().describe('Contract ID (format: accountId|nativeKey, from searchContracts)'), symbol: z.string().optional().describe('Human-readable symbol. Optional.'), qty: z.number().positive().optional().describe('Number of shares to sell (default: sell all)'), }), From a60844575bbaa0be0aa75214f4f268778befc5d0 Mon Sep 17 00:00:00 2001 From: Ame Date: Thu, 26 Mar 2026 08:19:34 +0800 Subject: [PATCH 3/3] fix: trailingAmount maps to trailStopPrice instead of auxPrice trailingAmount and stopPrice were both assigned to order.auxPrice, causing the last write to overwrite the other. IBKR Order has a dedicated trailStopPrice field for trailing stop offsets. Added tests for trailing stop amount, percent, and the case where both stopPrice and trailingAmount are set simultaneously. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../trading/UnifiedTradingAccount.spec.ts | 20 +++++++++++++++++++ src/domain/trading/UnifiedTradingAccount.ts | 4 ++-- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/src/domain/trading/UnifiedTradingAccount.spec.ts b/src/domain/trading/UnifiedTradingAccount.spec.ts index 24d883c8..d2ec209d 100644 --- a/src/domain/trading/UnifiedTradingAccount.spec.ts +++ b/src/domain/trading/UnifiedTradingAccount.spec.ts @@ -290,6 +290,26 @@ describe('UTA — stagePlaceOrder', () => { expect(order.auxPrice).toBe(145) }) + it('maps trailingAmount to trailStopPrice (not auxPrice)', () => { + uta.stagePlaceOrder({ aliceId: 'mock-paper|AAPL', side: 'sell', type: 'trailing_stop', qty: 10, trailingAmount: 5 }) + const { order } = getStagedPlaceOrder(uta) + expect(order.trailStopPrice).toBe(5) + expect(order.orderType).toBe('TRAIL') + }) + + it('trailingAmount and stopPrice use separate fields', () => { + uta.stagePlaceOrder({ aliceId: 'mock-paper|AAPL', side: 'sell', type: 'trailing_stop', qty: 10, stopPrice: 145, trailingAmount: 5 }) + const { order } = getStagedPlaceOrder(uta) + expect(order.auxPrice).toBe(145) + expect(order.trailStopPrice).toBe(5) + }) + + it('maps trailingPercent to trailingPercent', () => { + uta.stagePlaceOrder({ aliceId: 'mock-paper|AAPL', side: 'sell', type: 'trailing_stop', qty: 10, trailingPercent: 2.5 }) + const { order } = getStagedPlaceOrder(uta) + expect(order.trailingPercent).toBe(2.5) + }) + it('defaults timeInForce to DAY', () => { uta.stagePlaceOrder({ aliceId: 'mock-paper|AAPL', side: 'buy', type: 'market', qty: 10 }) const { order } = getStagedPlaceOrder(uta) diff --git a/src/domain/trading/UnifiedTradingAccount.ts b/src/domain/trading/UnifiedTradingAccount.ts index 9ea3e83d..b585d91a 100644 --- a/src/domain/trading/UnifiedTradingAccount.ts +++ b/src/domain/trading/UnifiedTradingAccount.ts @@ -374,7 +374,7 @@ export class UnifiedTradingAccount { if (params.notional != null) order.cashQty = params.notional if (params.price != null) order.lmtPrice = params.price if (params.stopPrice != null) order.auxPrice = params.stopPrice - if (params.trailingAmount != null) order.auxPrice = params.trailingAmount + if (params.trailingAmount != null) order.trailStopPrice = params.trailingAmount if (params.trailingPercent != null) order.trailingPercent = params.trailingPercent if (params.goodTillDate != null) order.goodTillDate = params.goodTillDate if (params.extendedHours) order.outsideRth = true @@ -389,7 +389,7 @@ export class UnifiedTradingAccount { if (params.qty != null) changes.totalQuantity = new Decimal(String(params.qty)) if (params.price != null) changes.lmtPrice = params.price if (params.stopPrice != null) changes.auxPrice = params.stopPrice - if (params.trailingAmount != null) changes.auxPrice = params.trailingAmount + if (params.trailingAmount != null) changes.trailStopPrice = params.trailingAmount if (params.trailingPercent != null) changes.trailingPercent = params.trailingPercent if (params.type != null) changes.orderType = toIbkrOrderType(params.type) if (params.timeInForce != null) changes.tif = toIbkrTif(params.timeInForce)