From b1a4b433c3b885f0a2b85c148304d5efb29d7c74 Mon Sep 17 00:00:00 2001 From: Ame Date: Wed, 1 Apr 2026 09:26:54 +0800 Subject: [PATCH 1/5] refactor: align placeOrder/modifyOrder AI tool params with IBKR Order fields Rename AI tool and StagePlaceOrderParams/StageModifyOrderParams to use IBKR-native field names (action, orderType, totalQuantity, lmtPrice, auxPrice, tif, outsideRth, etc.), eliminating the toIbkrOrderType/toIbkrTif translation layer. Fixes semantic bug where trailingAmount was incorrectly mapped to trailStopPrice instead of auxPrice. Adds missing trailStopPrice merge in IbkrBroker.modifyOrder. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../trading/UnifiedTradingAccount.spec.ts | 96 +++++++++---------- src/domain/trading/UnifiedTradingAccount.ts | 83 ++++++---------- .../__test__/e2e/uta-alpaca.e2e.spec.ts | 16 ++-- .../__test__/e2e/uta-bybit.e2e.spec.ts | 6 +- .../__test__/e2e/uta-ccxt-bybit.e2e.spec.ts | 6 +- .../trading/__test__/e2e/uta-ibkr.e2e.spec.ts | 16 ++-- .../__test__/e2e/uta-lifecycle.e2e.spec.ts | 22 ++--- .../trading/__test__/uta-health.spec.ts | 4 +- src/domain/trading/brokers/ibkr/IbkrBroker.ts | 1 + src/tool/trading.ts | 38 ++++---- 10 files changed, 131 insertions(+), 157 deletions(-) diff --git a/src/domain/trading/UnifiedTradingAccount.spec.ts b/src/domain/trading/UnifiedTradingAccount.spec.ts index d2ec209d..05a770a8 100644 --- a/src/domain/trading/UnifiedTradingAccount.spec.ts +++ b/src/domain/trading/UnifiedTradingAccount.spec.ts @@ -189,7 +189,7 @@ describe('UTA — getState', () => { broker.setPositions([makePosition()]) // Push a limit order to create a pending entry in git history - uta.stagePlaceOrder({ aliceId: 'mock-paper|AAPL', symbol: 'AAPL', side: 'buy', type: 'limit', qty: 5, price: 145 }) + uta.stagePlaceOrder({ aliceId: 'mock-paper|AAPL', symbol: 'AAPL', action: 'BUY', orderType: 'LMT', totalQuantity: 5, lmtPrice: 145 }) uta.commit('limit buy') await uta.push() @@ -242,94 +242,88 @@ describe('UTA — stagePlaceOrder', () => { ({ uta } = createUTA()) }) - it('maps buy side to BUY action', () => { - uta.stagePlaceOrder({ aliceId: 'mock-paper|AAPL', side: 'buy', type: 'market', qty: 10 }) + it('sets BUY action', () => { + uta.stagePlaceOrder({ aliceId: 'mock-paper|AAPL', action: 'BUY', orderType: 'MKT', totalQuantity: 10 }) const { order } = getStagedPlaceOrder(uta) expect(order.action).toBe('BUY') }) - it('maps sell side to SELL action', () => { - uta.stagePlaceOrder({ aliceId: 'mock-paper|AAPL', side: 'sell', type: 'market', qty: 10 }) + it('sets SELL action', () => { + uta.stagePlaceOrder({ aliceId: 'mock-paper|AAPL', action: 'SELL', orderType: 'MKT', totalQuantity: 10 }) const { order } = getStagedPlaceOrder(uta) expect(order.action).toBe('SELL') }) - it('maps order types correctly', () => { - const cases: Array<[string, string]> = [ - ['market', 'MKT'], - ['limit', 'LMT'], - ['stop', 'STP'], - ['stop_limit', 'STP LMT'], - ['trailing_stop', 'TRAIL'], - ] - for (const [input, expected] of cases) { + it('passes order types through', () => { + const types = ['MKT', 'LMT', 'STP', 'STP LMT', 'TRAIL'] + for (const orderType of types) { const { uta: u } = createUTA() - u.stagePlaceOrder({ aliceId: 'mock-paper|X', side: 'buy', type: input, qty: 1 }) + u.stagePlaceOrder({ aliceId: 'mock-paper|X', action: 'BUY', orderType, totalQuantity: 1 }) const { order } = getStagedPlaceOrder(u) - expect(order.orderType).toBe(expected) + expect(order.orderType).toBe(orderType) } }) - it('maps qty to totalQuantity as Decimal', () => { - uta.stagePlaceOrder({ aliceId: 'mock-paper|AAPL', side: 'buy', type: 'market', qty: 42 }) + it('sets totalQuantity as Decimal', () => { + uta.stagePlaceOrder({ aliceId: 'mock-paper|AAPL', action: 'BUY', orderType: 'MKT', totalQuantity: 42 }) const { order } = getStagedPlaceOrder(uta) expect(order.totalQuantity).toBeInstanceOf(Decimal) expect(order.totalQuantity.toNumber()).toBe(42) }) - it('maps notional to cashQty', () => { - uta.stagePlaceOrder({ aliceId: 'mock-paper|AAPL', side: 'buy', type: 'market', notional: 5000 }) + it('sets cashQty', () => { + uta.stagePlaceOrder({ aliceId: 'mock-paper|AAPL', action: 'BUY', orderType: 'MKT', cashQty: 5000 }) const { order } = getStagedPlaceOrder(uta) expect(order.cashQty).toBe(5000) }) - it('maps price to lmtPrice and stopPrice to auxPrice', () => { - uta.stagePlaceOrder({ aliceId: 'mock-paper|AAPL', side: 'buy', type: 'stop_limit', qty: 10, price: 150, stopPrice: 145 }) + it('sets lmtPrice and auxPrice', () => { + uta.stagePlaceOrder({ aliceId: 'mock-paper|AAPL', action: 'BUY', orderType: 'STP LMT', totalQuantity: 10, lmtPrice: 150, auxPrice: 145 }) const { order } = getStagedPlaceOrder(uta) expect(order.lmtPrice).toBe(150) 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 }) + it('auxPrice sets trailing offset for TRAIL orders', () => { + uta.stagePlaceOrder({ aliceId: 'mock-paper|AAPL', action: 'SELL', orderType: 'TRAIL', totalQuantity: 10, auxPrice: 5 }) const { order } = getStagedPlaceOrder(uta) - expect(order.trailStopPrice).toBe(5) + expect(order.auxPrice).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 }) + it('TRAIL order with trailStopPrice and auxPrice', () => { + uta.stagePlaceOrder({ aliceId: 'mock-paper|AAPL', action: 'SELL', orderType: 'TRAIL', totalQuantity: 10, trailStopPrice: 145, auxPrice: 5 }) const { order } = getStagedPlaceOrder(uta) - expect(order.auxPrice).toBe(145) - expect(order.trailStopPrice).toBe(5) + expect(order.trailStopPrice).toBe(145) + expect(order.auxPrice).toBe(5) }) - it('maps trailingPercent to trailingPercent', () => { - uta.stagePlaceOrder({ aliceId: 'mock-paper|AAPL', side: 'sell', type: 'trailing_stop', qty: 10, trailingPercent: 2.5 }) + it('sets trailingPercent', () => { + uta.stagePlaceOrder({ aliceId: 'mock-paper|AAPL', action: 'SELL', orderType: 'TRAIL', totalQuantity: 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 }) + it('defaults tif to DAY', () => { + uta.stagePlaceOrder({ aliceId: 'mock-paper|AAPL', action: 'BUY', orderType: 'MKT', totalQuantity: 10 }) const { order } = getStagedPlaceOrder(uta) expect(order.tif).toBe('DAY') }) - it('allows overriding timeInForce', () => { - uta.stagePlaceOrder({ aliceId: 'mock-paper|AAPL', side: 'buy', type: 'limit', qty: 10, price: 150, timeInForce: 'gtc' }) + it('allows overriding tif', () => { + uta.stagePlaceOrder({ aliceId: 'mock-paper|AAPL', action: 'BUY', orderType: 'LMT', totalQuantity: 10, lmtPrice: 150, tif: 'GTC' }) const { order } = getStagedPlaceOrder(uta) expect(order.tif).toBe('GTC') }) - it('maps extendedHours to outsideRth', () => { - uta.stagePlaceOrder({ aliceId: 'mock-paper|AAPL', side: 'buy', type: 'limit', qty: 10, price: 150, extendedHours: true }) + it('sets outsideRth', () => { + uta.stagePlaceOrder({ aliceId: 'mock-paper|AAPL', action: 'BUY', orderType: 'LMT', totalQuantity: 10, lmtPrice: 150, outsideRth: true }) const { order } = getStagedPlaceOrder(uta) expect(order.outsideRth).toBe(true) }) it('sets aliceId and symbol on contract', () => { - uta.stagePlaceOrder({ aliceId: 'mock-paper|AAPL', symbol: 'AAPL', side: 'buy', type: 'market', qty: 10 }) + uta.stagePlaceOrder({ aliceId: 'mock-paper|AAPL', symbol: 'AAPL', action: 'BUY', orderType: 'MKT', totalQuantity: 10 }) const { contract } = getStagedPlaceOrder(uta) expect(contract.aliceId).toBe('mock-paper|AAPL') expect(contract.symbol).toBe('AAPL') @@ -345,8 +339,8 @@ describe('UTA — stageModifyOrder', () => { ({ uta } = createUTA()) }) - it('maps provided fields to Partial', () => { - uta.stageModifyOrder({ orderId: 'ord-1', qty: 20, price: 155, type: 'limit', timeInForce: 'gtc' }) + it('sets provided fields on Partial', () => { + uta.stageModifyOrder({ orderId: 'ord-1', totalQuantity: 20, lmtPrice: 155, orderType: 'LMT', tif: 'GTC' }) const staged = uta.status().staged expect(staged).toHaveLength(1) const op = staged[0] as Extract @@ -360,7 +354,7 @@ describe('UTA — stageModifyOrder', () => { }) it('omits fields not provided', () => { - uta.stageModifyOrder({ orderId: 'ord-1', price: 160 }) + uta.stageModifyOrder({ orderId: 'ord-1', lmtPrice: 160 }) const staged = uta.status().staged const op = staged[0] as Extract expect(op.changes.lmtPrice).toBe(160) @@ -425,15 +419,15 @@ describe('UTA — git flow', () => { }) it('push throws when not committed', async () => { - uta.stagePlaceOrder({ aliceId: 'mock-paper|AAPL', side: 'buy', type: 'market', qty: 10 }) + uta.stagePlaceOrder({ aliceId: 'mock-paper|AAPL', action: 'BUY', orderType: 'MKT', totalQuantity: 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: 'mock-paper|AAPL', side: 'buy', type: 'market', qty: 10 }) - u.stagePlaceOrder({ aliceId: 'mock-paper|MSFT', symbol: 'MSFT', side: 'buy', type: 'market', qty: 5 }) + u.stagePlaceOrder({ aliceId: 'mock-paper|AAPL', action: 'BUY', orderType: 'MKT', totalQuantity: 10 }) + u.stagePlaceOrder({ aliceId: 'mock-paper|MSFT', symbol: 'MSFT', action: 'BUY', orderType: 'MKT', totalQuantity: 5 }) u.commit('buy both') await u.push() @@ -441,7 +435,7 @@ describe('UTA — git flow', () => { }) it('clears staging area after push', async () => { - uta.stagePlaceOrder({ aliceId: 'mock-paper|AAPL', side: 'buy', type: 'market', qty: 10 }) + uta.stagePlaceOrder({ aliceId: 'mock-paper|AAPL', action: 'BUY', orderType: 'MKT', totalQuantity: 10 }) uta.commit('buy') await uta.push() @@ -462,7 +456,7 @@ describe('UTA — sync', () => { const { uta, broker } = createUTA() // Limit order → MockBroker keeps it pending naturally - uta.stagePlaceOrder({ aliceId: 'mock-paper|AAPL', symbol: 'AAPL', side: 'buy', type: 'limit', qty: 10, price: 150 }) + uta.stagePlaceOrder({ aliceId: 'mock-paper|AAPL', symbol: 'AAPL', action: 'BUY', orderType: 'LMT', totalQuantity: 10, lmtPrice: 150 }) uta.commit('limit buy') const pushResult = await uta.push() const orderId = pushResult.submitted[0]?.orderId @@ -481,7 +475,7 @@ describe('UTA — sync', () => { const { uta, broker } = createUTA() // Limit order → pending - uta.stagePlaceOrder({ aliceId: 'mock-paper|AAPL', symbol: 'AAPL', side: 'buy', type: 'limit', qty: 10, price: 150 }) + uta.stagePlaceOrder({ aliceId: 'mock-paper|AAPL', symbol: 'AAPL', action: 'BUY', orderType: 'LMT', totalQuantity: 10, lmtPrice: 150 }) uta.commit('limit buy') const pushResult = await uta.push() const orderId = pushResult.submitted[0]?.orderId @@ -503,7 +497,7 @@ describe('UTA — guards', () => { }) const spy = vi.spyOn(broker, 'placeOrder') - uta.stagePlaceOrder({ aliceId: 'mock-paper|TSLA', symbol: 'TSLA', side: 'buy', type: 'market', qty: 10 }) + uta.stagePlaceOrder({ aliceId: 'mock-paper|TSLA', symbol: 'TSLA', action: 'BUY', orderType: 'MKT', totalQuantity: 10 }) uta.commit('buy TSLA (should be blocked)') const result = await uta.push() @@ -518,7 +512,7 @@ describe('UTA — guards', () => { }) const spy = vi.spyOn(broker, 'placeOrder') - uta.stagePlaceOrder({ aliceId: 'mock-paper|AAPL', symbol: 'AAPL', side: 'buy', type: 'market', qty: 10 }) + uta.stagePlaceOrder({ aliceId: 'mock-paper|AAPL', symbol: 'AAPL', action: 'BUY', orderType: 'MKT', totalQuantity: 10 }) uta.commit('buy AAPL (allowed)') await uta.push() @@ -532,7 +526,7 @@ describe('UTA — constructor', () => { it('restores from savedState', async () => { // Create a UTA, push a commit, export state const { uta: original } = createUTA() - original.stagePlaceOrder({ aliceId: 'mock-paper|AAPL', symbol: 'AAPL', side: 'buy', type: 'market', qty: 10 }) + original.stagePlaceOrder({ aliceId: 'mock-paper|AAPL', symbol: 'AAPL', action: 'BUY', orderType: 'MKT', totalQuantity: 10 }) original.commit('initial buy') await original.push() @@ -660,7 +654,7 @@ describe('UTA — health tracking', () => { await expect(uta.getAccount()).rejects.toThrow() } - uta.stagePlaceOrder({ aliceId: 'mock-paper|AAPL', side: 'buy', type: 'market', qty: 10 }) + uta.stagePlaceOrder({ aliceId: 'mock-paper|AAPL', action: 'BUY', orderType: 'MKT', totalQuantity: 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 b585d91a..1148a6a5 100644 --- a/src/domain/trading/UnifiedTradingAccount.ts +++ b/src/domain/trading/UnifiedTradingAccount.ts @@ -30,27 +30,6 @@ import type { import { createGuardPipeline, resolveGuards } from './guards/index.js' import './contract-ext.js' -// ==================== IBKR field mapping ==================== - -/** Map human-readable order type → IBKR short code. */ -function toIbkrOrderType(type: string): string { - switch (type) { - case 'market': return 'MKT' - case 'limit': return 'LMT' - case 'stop': return 'STP' - case 'stop_limit': return 'STP LMT' - case 'trailing_stop': return 'TRAIL' - case 'trailing_stop_limit': return 'TRAIL LIMIT' - case 'moc': return 'MOC' - default: return type.toUpperCase() - } -} - -/** Map human-readable TIF → IBKR short code. */ -function toIbkrTif(tif: string): string { - return tif.toUpperCase() -} - // ==================== Options ==================== export interface UnifiedTradingAccountOptions { @@ -67,30 +46,30 @@ export interface UnifiedTradingAccountOptions { export interface StagePlaceOrderParams { aliceId: string symbol?: string - side: 'buy' | 'sell' - type: string - qty?: number - notional?: number - price?: number - stopPrice?: number - trailingAmount?: number + action: 'BUY' | 'SELL' + orderType: string + totalQuantity?: number + cashQty?: number + lmtPrice?: number + auxPrice?: number + trailStopPrice?: number trailingPercent?: number - timeInForce?: string + tif?: string goodTillDate?: string - extendedHours?: boolean + outsideRth?: boolean parentId?: string ocaGroup?: string } export interface StageModifyOrderParams { orderId: string - qty?: number - price?: number - stopPrice?: number - trailingAmount?: number + totalQuantity?: number + lmtPrice?: number + auxPrice?: number + trailStopPrice?: number trailingPercent?: number - type?: string - timeInForce?: string + orderType?: string + tif?: string goodTillDate?: string } @@ -366,18 +345,18 @@ export class UnifiedTradingAccount { if (params.symbol) contract.symbol = params.symbol const order = new Order() - order.action = params.side === 'buy' ? 'BUY' : 'SELL' - order.orderType = toIbkrOrderType(params.type) - order.tif = toIbkrTif(params.timeInForce ?? 'day') - - if (params.qty != null) order.totalQuantity = new Decimal(String(params.qty)) - 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.trailStopPrice = params.trailingAmount + order.action = params.action + order.orderType = params.orderType + order.tif = params.tif ?? 'DAY' + + if (params.totalQuantity != null) order.totalQuantity = new Decimal(String(params.totalQuantity)) + if (params.cashQty != null) order.cashQty = params.cashQty + if (params.lmtPrice != null) order.lmtPrice = params.lmtPrice + if (params.auxPrice != null) order.auxPrice = params.auxPrice + if (params.trailStopPrice != null) order.trailStopPrice = params.trailStopPrice if (params.trailingPercent != null) order.trailingPercent = params.trailingPercent if (params.goodTillDate != null) order.goodTillDate = params.goodTillDate - if (params.extendedHours) order.outsideRth = true + if (params.outsideRth) order.outsideRth = true if (params.parentId != null) order.parentId = parseInt(params.parentId, 10) || 0 if (params.ocaGroup != null) order.ocaGroup = params.ocaGroup @@ -386,13 +365,13 @@ export class UnifiedTradingAccount { stageModifyOrder(params: StageModifyOrderParams): AddResult { const changes: Partial = {} - 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.trailStopPrice = params.trailingAmount + if (params.totalQuantity != null) changes.totalQuantity = new Decimal(String(params.totalQuantity)) + if (params.lmtPrice != null) changes.lmtPrice = params.lmtPrice + if (params.auxPrice != null) changes.auxPrice = params.auxPrice + if (params.trailStopPrice != null) changes.trailStopPrice = params.trailStopPrice 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) + if (params.orderType != null) changes.orderType = params.orderType + if (params.tif != null) changes.tif = params.tif if (params.goodTillDate != null) changes.goodTillDate = params.goodTillDate return this.git.add({ action: 'modifyOrder', orderId: params.orderId, changes }) diff --git a/src/domain/trading/__test__/e2e/uta-alpaca.e2e.spec.ts b/src/domain/trading/__test__/e2e/uta-alpaca.e2e.spec.ts index aff1961c..706df957 100644 --- a/src/domain/trading/__test__/e2e/uta-alpaca.e2e.spec.ts +++ b/src/domain/trading/__test__/e2e/uta-alpaca.e2e.spec.ts @@ -43,11 +43,11 @@ describe('UTA — Alpaca order lifecycle', () => { const addResult = uta!.stagePlaceOrder({ aliceId, symbol: 'AAPL', - side: 'buy', - type: 'limit', - price: 1.00, - qty: 1, - timeInForce: 'gtc', + action: 'BUY', + orderType: 'LMT', + lmtPrice: 1.00, + totalQuantity: 1, + tif: 'GTC', }) expect(addResult.staged).toBe(true) @@ -96,9 +96,9 @@ describe('UTA — Alpaca fill flow (AAPL)', () => { const addResult = uta!.stagePlaceOrder({ aliceId, symbol: 'AAPL', - side: 'buy', - type: 'market', - qty: 1, + action: 'BUY', + orderType: 'MKT', + totalQuantity: 1, }) expect(addResult.staged).toBe(true) diff --git a/src/domain/trading/__test__/e2e/uta-bybit.e2e.spec.ts b/src/domain/trading/__test__/e2e/uta-bybit.e2e.spec.ts index 4f9123e1..1af3c6cb 100644 --- a/src/domain/trading/__test__/e2e/uta-bybit.e2e.spec.ts +++ b/src/domain/trading/__test__/e2e/uta-bybit.e2e.spec.ts @@ -52,9 +52,9 @@ describe('UTA — Bybit lifecycle (ETH perp)', () => { // === Stage + Commit + Push: buy 0.01 ETH === const addResult = uta!.stagePlaceOrder({ aliceId: ethAliceId, - side: 'buy', - type: 'market', - qty: 0.01, + action: 'BUY', + orderType: 'MKT', + totalQuantity: 0.01, }) expect(addResult.staged).toBe(true) console.log(` staged: ok`) diff --git a/src/domain/trading/__test__/e2e/uta-ccxt-bybit.e2e.spec.ts b/src/domain/trading/__test__/e2e/uta-ccxt-bybit.e2e.spec.ts index 36d5f684..b95829c4 100644 --- a/src/domain/trading/__test__/e2e/uta-ccxt-bybit.e2e.spec.ts +++ b/src/domain/trading/__test__/e2e/uta-ccxt-bybit.e2e.spec.ts @@ -48,7 +48,7 @@ describe('UTA — Bybit demo (ETH perp)', () => { console.log(` initial ETH qty=${initialQty}`) // Stage + Commit + Push: buy 0.01 ETH - uta!.stagePlaceOrder({ aliceId: ethAliceId, side: 'buy', type: 'market', qty: 0.01 }) + uta!.stagePlaceOrder({ aliceId: ethAliceId, action: 'BUY', orderType: 'MKT', totalQuantity: 0.01 }) uta!.commit('e2e: buy 0.01 ETH') const pushResult = await uta!.push() expect(pushResult.submitted).toHaveLength(1) @@ -99,7 +99,7 @@ describe('UTA — Bybit demo (ETH perp)', () => { it('reject records user-rejected commit and clears staging', async () => { // Stage + Commit (but don't push) - uta!.stagePlaceOrder({ aliceId: ethAliceId, side: 'buy', type: 'market', qty: 0.01 }) + uta!.stagePlaceOrder({ aliceId: ethAliceId, action: 'BUY', orderType: 'MKT', totalQuantity: 0.01 }) const commitResult = uta!.commit('e2e: buy to be rejected') expect(commitResult.prepared).toBe(true) @@ -131,7 +131,7 @@ describe('UTA — Bybit demo (ETH perp)', () => { }, 30_000) it('reject without reason still works', async () => { - uta!.stagePlaceOrder({ aliceId: ethAliceId, side: 'sell', type: 'limit', qty: 0.01, price: 99999 }) + uta!.stagePlaceOrder({ aliceId: ethAliceId, action: 'SELL', orderType: 'LMT', totalQuantity: 0.01, lmtPrice: 99999 }) uta!.commit('e2e: sell to be rejected silently') const result = await uta!.reject() diff --git a/src/domain/trading/__test__/e2e/uta-ibkr.e2e.spec.ts b/src/domain/trading/__test__/e2e/uta-ibkr.e2e.spec.ts index 22f9edd6..0c7b2fc7 100644 --- a/src/domain/trading/__test__/e2e/uta-ibkr.e2e.spec.ts +++ b/src/domain/trading/__test__/e2e/uta-ibkr.e2e.spec.ts @@ -47,11 +47,11 @@ describe('UTA — IBKR order lifecycle', () => { const addResult = uta!.stagePlaceOrder({ aliceId, symbol: 'AAPL', - side: 'buy', - type: 'limit', - price: 1.00, - qty: 1, - timeInForce: 'gtc', + action: 'BUY', + orderType: 'LMT', + lmtPrice: 1.00, + totalQuantity: 1, + tif: 'GTC', }) expect(addResult.staged).toBe(true) @@ -102,9 +102,9 @@ describe('UTA — IBKR fill flow (AAPL)', () => { const addResult = uta!.stagePlaceOrder({ aliceId, symbol: 'AAPL', - side: 'buy', - type: 'market', - qty: 1, + action: 'BUY', + orderType: 'MKT', + totalQuantity: 1, }) expect(addResult.staged).toBe(true) diff --git a/src/domain/trading/__test__/e2e/uta-lifecycle.e2e.spec.ts b/src/domain/trading/__test__/e2e/uta-lifecycle.e2e.spec.ts index 162784c8..0977d2ff 100644 --- a/src/domain/trading/__test__/e2e/uta-lifecycle.e2e.spec.ts +++ b/src/domain/trading/__test__/e2e/uta-lifecycle.e2e.spec.ts @@ -28,7 +28,7 @@ beforeEach(() => { describe('UTA — full trading lifecycle', () => { it('market buy: push returns submitted, position appears, cash decreases', async () => { - uta.stagePlaceOrder({ aliceId: 'mock-paper|AAPL', symbol: 'AAPL', side: 'buy', type: 'market', qty: 10 }) + uta.stagePlaceOrder({ aliceId: 'mock-paper|AAPL', symbol: 'AAPL', action: 'BUY', orderType: 'MKT', totalQuantity: 10 }) const commitResult = uta.commit('buy 10 AAPL') expect(commitResult.prepared).toBe(true) @@ -50,7 +50,7 @@ describe('UTA — full trading lifecycle', () => { }) it('market buy fills at push time — no sync needed', async () => { - uta.stagePlaceOrder({ aliceId: 'mock-paper|AAPL', symbol: 'AAPL', side: 'buy', type: 'market', qty: 10 }) + uta.stagePlaceOrder({ aliceId: 'mock-paper|AAPL', symbol: 'AAPL', action: 'BUY', orderType: 'MKT', totalQuantity: 10 }) uta.commit('buy AAPL') const pushResult = await uta.push() @@ -63,12 +63,12 @@ describe('UTA — full trading lifecycle', () => { }) it('getState reflects positions and pending orders', async () => { - uta.stagePlaceOrder({ aliceId: 'mock-paper|AAPL', symbol: 'AAPL', side: 'buy', type: 'market', qty: 10 }) + uta.stagePlaceOrder({ aliceId: 'mock-paper|AAPL', symbol: 'AAPL', action: 'BUY', orderType: 'MKT', totalQuantity: 10 }) uta.commit('buy AAPL') await uta.push() // Place a limit order (goes submitted) - uta.stagePlaceOrder({ aliceId: 'mock-paper|ETH', symbol: 'ETH', side: 'buy', type: 'limit', qty: 1, price: 1800 }) + uta.stagePlaceOrder({ aliceId: 'mock-paper|ETH', symbol: 'ETH', action: 'BUY', orderType: 'LMT', totalQuantity: 1, lmtPrice: 1800 }) uta.commit('limit buy ETH') const limitPush = await uta.push() expect(limitPush.submitted).toHaveLength(1) @@ -80,7 +80,7 @@ describe('UTA — full trading lifecycle', () => { }) it('limit order → submitted → fill → sync detects filled', async () => { - uta.stagePlaceOrder({ aliceId: 'mock-paper|AAPL', symbol: 'AAPL', side: 'buy', type: 'limit', qty: 5, price: 145 }) + uta.stagePlaceOrder({ aliceId: 'mock-paper|AAPL', symbol: 'AAPL', action: 'BUY', orderType: 'LMT', totalQuantity: 5, lmtPrice: 145 }) uta.commit('limit buy AAPL') const pushResult = await uta.push() expect(pushResult.submitted).toHaveLength(1) @@ -107,7 +107,7 @@ describe('UTA — full trading lifecycle', () => { }) it('partial close reduces position', async () => { - uta.stagePlaceOrder({ aliceId: 'mock-paper|AAPL', symbol: 'AAPL', side: 'buy', type: 'market', qty: 10 }) + uta.stagePlaceOrder({ aliceId: 'mock-paper|AAPL', symbol: 'AAPL', action: 'BUY', orderType: 'MKT', totalQuantity: 10 }) uta.commit('buy') await uta.push() @@ -122,7 +122,7 @@ describe('UTA — full trading lifecycle', () => { }) it('full close removes position + restores cash', async () => { - uta.stagePlaceOrder({ aliceId: 'mock-paper|AAPL', symbol: 'AAPL', side: 'buy', type: 'market', qty: 10 }) + uta.stagePlaceOrder({ aliceId: 'mock-paper|AAPL', symbol: 'AAPL', action: 'BUY', orderType: 'MKT', totalQuantity: 10 }) uta.commit('buy') await uta.push() @@ -136,7 +136,7 @@ describe('UTA — full trading lifecycle', () => { }) it('cancel pending order', async () => { - uta.stagePlaceOrder({ aliceId: 'mock-paper|AAPL', symbol: 'AAPL', side: 'buy', type: 'limit', qty: 5, price: 140 }) + uta.stagePlaceOrder({ aliceId: 'mock-paper|AAPL', symbol: 'AAPL', action: 'BUY', orderType: 'LMT', totalQuantity: 5, lmtPrice: 140 }) uta.commit('limit buy') const pushResult = await uta.push() const orderId = pushResult.submitted[0].orderId! @@ -150,7 +150,7 @@ describe('UTA — full trading lifecycle', () => { }) it('trading history records all commits', async () => { - uta.stagePlaceOrder({ aliceId: 'mock-paper|AAPL', symbol: 'AAPL', side: 'buy', type: 'market', qty: 10 }) + uta.stagePlaceOrder({ aliceId: 'mock-paper|AAPL', symbol: 'AAPL', action: 'BUY', orderType: 'MKT', totalQuantity: 10 }) uta.commit('buy AAPL') await uta.push() @@ -170,7 +170,7 @@ describe('UTA — full trading lifecycle', () => { describe('UTA — precision end-to-end', () => { it('fractional qty survives stage → push → position', async () => { broker.setQuote('ETH', 1920) - uta.stagePlaceOrder({ aliceId: 'mock-paper|ETH', symbol: 'ETH', side: 'buy', type: 'market', qty: 0.123456789 }) + uta.stagePlaceOrder({ aliceId: 'mock-paper|ETH', symbol: 'ETH', action: 'BUY', orderType: 'MKT', totalQuantity: 0.123456789 }) uta.commit('buy fractional ETH') const result = await uta.push() @@ -181,7 +181,7 @@ describe('UTA — precision end-to-end', () => { it('partial close precision: 1.0 - 0.3 = 0.7 exactly', async () => { broker.setQuote('ETH', 1920) - uta.stagePlaceOrder({ aliceId: 'mock-paper|ETH', symbol: 'ETH', side: 'buy', type: 'market', qty: 1.0 }) + uta.stagePlaceOrder({ aliceId: 'mock-paper|ETH', symbol: 'ETH', action: 'BUY', orderType: 'MKT', totalQuantity: 1.0 }) uta.commit('buy 1 ETH') await uta.push() diff --git a/src/domain/trading/__test__/uta-health.spec.ts b/src/domain/trading/__test__/uta-health.spec.ts index 4dbeaba7..33573772 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: 'mock-paper|AAPL', side: 'buy', type: 'market', qty: 10 }) + uta.stagePlaceOrder({ aliceId: 'mock-paper|AAPL', action: 'BUY', orderType: 'MKT', totalQuantity: 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: 'mock-paper|AAPL', side: 'buy', type: 'market', qty: 10 }) + const result = uta.stagePlaceOrder({ aliceId: 'mock-paper|AAPL', action: 'BUY', orderType: 'MKT', totalQuantity: 10 }) expect(result.staged).toBe(true) const commit = uta.commit('buy while offline') diff --git a/src/domain/trading/brokers/ibkr/IbkrBroker.ts b/src/domain/trading/brokers/ibkr/IbkrBroker.ts index 79872840..2097c8c2 100644 --- a/src/domain/trading/brokers/ibkr/IbkrBroker.ts +++ b/src/domain/trading/brokers/ibkr/IbkrBroker.ts @@ -188,6 +188,7 @@ export class IbkrBroker implements IBroker { if (changes.tif) mergedOrder.tif = changes.tif if (changes.orderType) mergedOrder.orderType = changes.orderType if (changes.trailingPercent != null) mergedOrder.trailingPercent = changes.trailingPercent + if (changes.trailStopPrice != null) mergedOrder.trailStopPrice = changes.trailStopPrice const numericId = parseInt(orderId, 10) const promise = this.bridge.requestOrder(numericId) diff --git a/src/tool/trading.ts b/src/tool/trading.ts index 907e8021..672a253d 100644 --- a/src/tool/trading.ts +++ b/src/tool/trading.ts @@ -269,19 +269,19 @@ NOTE: This stages the operation. Call tradingCommit + tradingPush to execute.`, inputSchema: z.object({ source: z.string().describe(sourceDesc(true)), 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'), - qty: z.number().positive().optional().describe('Number of shares'), - notional: z.number().positive().optional().describe('Dollar amount'), - price: z.number().positive().optional().describe('Limit price'), - stopPrice: z.number().positive().optional().describe('Stop trigger price'), - trailingAmount: z.number().positive().optional().describe('Trailing stop offset'), - trailingPercent: z.number().positive().optional().describe('Trailing stop percentage'), - timeInForce: z.enum(['day', 'gtc', 'ioc', 'fok', 'opg', 'gtd']).default('day').describe('Time in force'), - goodTillDate: z.string().optional().describe('Expiration date for GTD'), - extendedHours: z.boolean().optional().describe('Allow pre/after-hours'), - parentId: z.string().optional().describe('Parent order ID for bracket orders'), + symbol: z.string().optional().describe('Human-readable symbol (optional, for display only)'), + action: z.enum(['BUY', 'SELL']).describe('Order direction'), + orderType: z.enum(['MKT', 'LMT', 'STP', 'STP LMT', 'TRAIL', 'TRAIL LIMIT', 'MOC']).describe('Order type'), + totalQuantity: z.number().positive().optional().describe('Number of shares/contracts (mutually exclusive with cashQty)'), + cashQty: z.number().positive().optional().describe('Notional dollar amount (mutually exclusive with totalQuantity)'), + lmtPrice: z.number().positive().optional().describe('Limit price (required for LMT, STP LMT, TRAIL LIMIT)'), + auxPrice: z.number().positive().optional().describe('Stop trigger price for STP/STP LMT; trailing offset amount for TRAIL/TRAIL LIMIT'), + trailStopPrice: z.number().positive().optional().describe('Initial trailing stop price (TRAIL/TRAIL LIMIT only)'), + trailingPercent: z.number().positive().optional().describe('Trailing stop percentage offset (alternative to auxPrice for TRAIL)'), + tif: z.enum(['DAY', 'GTC', 'IOC', 'FOK', 'OPG', 'GTD']).default('DAY').describe('Time in force'), + goodTillDate: z.string().optional().describe('Expiration datetime for GTD orders'), + outsideRth: z.boolean().optional().describe('Allow execution outside regular trading hours'), + parentId: z.string().optional().describe('Parent order ID (bracket orders)'), ocaGroup: z.string().optional().describe('One-Cancels-All group name'), }), execute: ({ source, ...params }) => manager.resolveOne(source).stagePlaceOrder(params), @@ -292,13 +292,13 @@ NOTE: This stages the operation. Call tradingCommit + tradingPush to execute.`, inputSchema: z.object({ source: z.string().describe(sourceDesc(true)), orderId: z.string().describe('Order ID to modify'), - qty: z.number().positive().optional().describe('New quantity'), - price: z.number().positive().optional().describe('New limit price'), - stopPrice: z.number().positive().optional().describe('New stop trigger price'), - trailingAmount: z.number().positive().optional().describe('New trailing stop offset'), + totalQuantity: z.number().positive().optional().describe('New quantity'), + lmtPrice: z.number().positive().optional().describe('New limit price'), + auxPrice: z.number().positive().optional().describe('New stop trigger price or trailing offset (depends on order type)'), + trailStopPrice: z.number().positive().optional().describe('New initial trailing stop price'), trailingPercent: z.number().positive().optional().describe('New trailing stop percentage'), - type: z.enum(['market', 'limit', 'stop', 'stop_limit', 'trailing_stop', 'trailing_stop_limit', 'moc']).optional().describe('New order type'), - timeInForce: z.enum(['day', 'gtc', 'ioc', 'fok', 'opg', 'gtd']).optional().describe('New time in force'), + orderType: z.enum(['MKT', 'LMT', 'STP', 'STP LMT', 'TRAIL', 'TRAIL LIMIT', 'MOC']).optional().describe('New order type'), + tif: z.enum(['DAY', 'GTC', 'IOC', 'FOK', 'OPG', 'GTD']).optional().describe('New time in force'), goodTillDate: z.string().optional().describe('New expiration date'), }), execute: ({ source, ...params }) => manager.resolveOne(source).stageModifyOrder(params), From d056b9d78737abb47c062f224e697c9a712f98d2 Mon Sep 17 00:00:00 2001 From: Ame Date: Wed, 1 Apr 2026 09:30:24 +0800 Subject: [PATCH 2/5] docs: add orderType param guide to placeOrder tool description Co-Authored-By: Claude Opus 4.6 (1M context) --- src/tool/trading.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/tool/trading.ts b/src/tool/trading.ts index 672a253d..f0cc01a7 100644 --- a/src/tool/trading.ts +++ b/src/tool/trading.ts @@ -265,7 +265,15 @@ IMPORTANT: Check this BEFORE making new trading decisions.`, placeOrder: tool({ description: `Stage an order (will execute on tradingPush). BEFORE placing orders: check tradingLog, getPortfolio, verify strategy alignment. -NOTE: This stages the operation. Call tradingCommit + tradingPush to execute.`, +NOTE: This stages the operation. Call tradingCommit + tradingPush to execute. +Required params by orderType: + MKT: totalQuantity (or cashQty) + LMT: totalQuantity + lmtPrice + STP: totalQuantity + auxPrice (stop trigger) + STP LMT: totalQuantity + auxPrice (stop trigger) + lmtPrice + TRAIL: totalQuantity + auxPrice (trailing offset) or trailingPercent + TRAIL LIMIT: totalQuantity + auxPrice (trailing offset) + lmtPrice + MOC: totalQuantity`, inputSchema: z.object({ source: z.string().describe(sourceDesc(true)), aliceId: z.string().describe('Contract ID (format: accountId|nativeKey, from searchContracts)'), From ff3c1d0f18283ca7c4e7d562df6b67d7914269c0 Mon Sep 17 00:00:00 2001 From: Ame Date: Wed, 1 Apr 2026 10:15:29 +0800 Subject: [PATCH 3/5] feat: add single-level TPSL (take profit / stop loss) to placeOrder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New optional takeProfit/stopLoss params on placeOrder AI tool, with string prices to avoid precision loss. TpSlParams flows through the full pipeline: tool → StagePlaceOrderParams → Operation → dispatcher → IBroker.placeOrder(). Broker implementations: - CCXT: maps to createOrder params { takeProfit, stopLoss } - Alpaca: maps to order_class "bracket" with take_profit/stop_loss - IBKR: signature updated, params ignored (use parentId bracket path) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../trading/UnifiedTradingAccount.spec.ts | 34 ++++++++++++++++ src/domain/trading/UnifiedTradingAccount.ts | 13 +++++-- .../__test__/e2e/uta-lifecycle.e2e.spec.ts | 39 ++++++++++++++++++- .../trading/brokers/alpaca/AlpacaBroker.ts | 17 +++++++- src/domain/trading/brokers/ccxt/CcxtBroker.ts | 15 ++++++- src/domain/trading/brokers/ibkr/IbkrBroker.ts | 3 +- src/domain/trading/brokers/index.ts | 1 + src/domain/trading/brokers/mock/MockBroker.ts | 5 ++- src/domain/trading/brokers/types.ts | 9 ++++- src/domain/trading/git/types.ts | 4 +- src/domain/trading/index.ts | 1 + src/tool/trading.ts | 10 ++++- 12 files changed, 137 insertions(+), 14 deletions(-) diff --git a/src/domain/trading/UnifiedTradingAccount.spec.ts b/src/domain/trading/UnifiedTradingAccount.spec.ts index 05a770a8..10884f53 100644 --- a/src/domain/trading/UnifiedTradingAccount.spec.ts +++ b/src/domain/trading/UnifiedTradingAccount.spec.ts @@ -328,6 +328,40 @@ describe('UTA — stagePlaceOrder', () => { expect(contract.aliceId).toBe('mock-paper|AAPL') expect(contract.symbol).toBe('AAPL') }) + + it('sets tpsl with takeProfit only', () => { + uta.stagePlaceOrder({ aliceId: 'mock-paper|AAPL', action: 'BUY', orderType: 'MKT', totalQuantity: 10, takeProfit: { price: '160' } }) + const staged = uta.status().staged + const op = staged[0] as Extract + expect(op.tpsl).toEqual({ takeProfit: { price: '160' }, stopLoss: undefined }) + }) + + it('sets tpsl with stopLoss only', () => { + uta.stagePlaceOrder({ aliceId: 'mock-paper|AAPL', action: 'BUY', orderType: 'MKT', totalQuantity: 10, stopLoss: { price: '140' } }) + const staged = uta.status().staged + const op = staged[0] as Extract + expect(op.tpsl).toEqual({ takeProfit: undefined, stopLoss: { price: '140' } }) + }) + + it('sets tpsl with both TP and SL', () => { + uta.stagePlaceOrder({ + aliceId: 'mock-paper|AAPL', action: 'BUY', orderType: 'MKT', totalQuantity: 10, + takeProfit: { price: '160' }, stopLoss: { price: '140', limitPrice: '139.50' }, + }) + const staged = uta.status().staged + const op = staged[0] as Extract + expect(op.tpsl).toEqual({ + takeProfit: { price: '160' }, + stopLoss: { price: '140', limitPrice: '139.50' }, + }) + }) + + it('omits tpsl when neither TP nor SL provided', () => { + uta.stagePlaceOrder({ aliceId: 'mock-paper|AAPL', action: 'BUY', orderType: 'MKT', totalQuantity: 10 }) + const staged = uta.status().staged + const op = staged[0] as Extract + expect(op.tpsl).toBeUndefined() + }) }) // ==================== stageModifyOrder ==================== diff --git a/src/domain/trading/UnifiedTradingAccount.ts b/src/domain/trading/UnifiedTradingAccount.ts index 1148a6a5..f3f36121 100644 --- a/src/domain/trading/UnifiedTradingAccount.ts +++ b/src/domain/trading/UnifiedTradingAccount.ts @@ -9,7 +9,7 @@ import Decimal from 'decimal.js' import { Contract, Order, ContractDescription, ContractDetails, UNSET_DECIMAL } from '@traderalice/ibkr' -import { BrokerError, type IBroker, type AccountInfo, type Position, type OpenOrder, type PlaceOrderResult, type Quote, type MarketClock, type AccountCapabilities, type BrokerHealth, type BrokerHealthInfo } from './brokers/types.js' +import { BrokerError, type IBroker, type AccountInfo, type Position, type OpenOrder, type PlaceOrderResult, type Quote, type MarketClock, type AccountCapabilities, type BrokerHealth, type BrokerHealthInfo, type TpSlParams } from './brokers/types.js' import { TradingGit } from './git/TradingGit.js' import type { Operation, @@ -59,6 +59,8 @@ export interface StagePlaceOrderParams { outsideRth?: boolean parentId?: string ocaGroup?: string + takeProfit?: { price: string } + stopLoss?: { price: string; limitPrice?: string } } export interface StageModifyOrderParams { @@ -141,7 +143,7 @@ export class UnifiedTradingAccount { const dispatcher = async (op: Operation): Promise => { switch (op.action) { case 'placeOrder': - return broker.placeOrder(op.contract, op.order) + return broker.placeOrder(op.contract, op.order, op.tpsl) case 'modifyOrder': return broker.modifyOrder(op.orderId, op.changes) case 'closePosition': @@ -360,7 +362,12 @@ export class UnifiedTradingAccount { if (params.parentId != null) order.parentId = parseInt(params.parentId, 10) || 0 if (params.ocaGroup != null) order.ocaGroup = params.ocaGroup - return this.git.add({ action: 'placeOrder', contract, order }) + const tpsl: TpSlParams | undefined = + (params.takeProfit || params.stopLoss) + ? { takeProfit: params.takeProfit, stopLoss: params.stopLoss } + : undefined + + return this.git.add({ action: 'placeOrder', contract, order, tpsl }) } stageModifyOrder(params: StageModifyOrderParams): AddResult { diff --git a/src/domain/trading/__test__/e2e/uta-lifecycle.e2e.spec.ts b/src/domain/trading/__test__/e2e/uta-lifecycle.e2e.spec.ts index 0977d2ff..5fa4e1e3 100644 --- a/src/domain/trading/__test__/e2e/uta-lifecycle.e2e.spec.ts +++ b/src/domain/trading/__test__/e2e/uta-lifecycle.e2e.spec.ts @@ -9,7 +9,7 @@ * placeOrder returns submitted — fill confirmed via getOrder/sync. */ -import { describe, it, expect, beforeEach } from 'vitest' +import { describe, it, expect, beforeEach, vi } from 'vitest' import { UnifiedTradingAccount } from '../../UnifiedTradingAccount.js' import { MockBroker } from '../../brokers/mock/index.js' import '../../contract-ext.js' @@ -165,6 +165,43 @@ describe('UTA — full trading lifecycle', () => { }) }) +// ==================== TPSL end-to-end ==================== + +describe('UTA — TPSL end-to-end', () => { + it('tpsl params flow through to broker.placeOrder', async () => { + const spy = vi.spyOn(broker, 'placeOrder') + + uta.stagePlaceOrder({ + aliceId: 'mock-paper|AAPL', symbol: 'AAPL', action: 'BUY', orderType: 'MKT', totalQuantity: 10, + takeProfit: { price: '160' }, + stopLoss: { price: '140', limitPrice: '139.50' }, + }) + uta.commit('buy AAPL with TPSL') + const result = await uta.push() + + expect(result.submitted).toHaveLength(1) + expect(spy).toHaveBeenCalledTimes(1) + + // Verify tpsl reached the broker (3rd argument) + const tpslArg = spy.mock.calls[0][2] + expect(tpslArg).toEqual({ + takeProfit: { price: '160' }, + stopLoss: { price: '140', limitPrice: '139.50' }, + }) + }) + + it('order without tpsl passes undefined to broker', async () => { + const spy = vi.spyOn(broker, 'placeOrder') + + uta.stagePlaceOrder({ aliceId: 'mock-paper|AAPL', symbol: 'AAPL', action: 'BUY', orderType: 'MKT', totalQuantity: 10 }) + uta.commit('buy AAPL no TPSL') + await uta.push() + + const tpslArg = spy.mock.calls[0][2] + expect(tpslArg).toBeUndefined() + }) +}) + // ==================== Precision end-to-end ==================== describe('UTA — precision end-to-end', () => { diff --git a/src/domain/trading/brokers/alpaca/AlpacaBroker.ts b/src/domain/trading/brokers/alpaca/AlpacaBroker.ts index 3f174a21..32b41e3b 100644 --- a/src/domain/trading/brokers/alpaca/AlpacaBroker.ts +++ b/src/domain/trading/brokers/alpaca/AlpacaBroker.ts @@ -23,6 +23,7 @@ import { type Quote, type MarketClock, type BrokerConfigField, + type TpSlParams, } from '../types.js' import '../../contract-ext.js' import type { @@ -177,7 +178,7 @@ export class AlpacaBroker implements IBroker { // ---- Trading operations ---- - async placeOrder(contract: Contract, order: Order): Promise { + async placeOrder(contract: Contract, order: Order, tpsl?: TpSlParams): Promise { const symbol = resolveSymbol(contract) if (!symbol) { return { success: false, error: 'Cannot resolve contract to Alpaca symbol' } @@ -211,6 +212,20 @@ export class AlpacaBroker implements IBroker { if (order.trailingPercent !== UNSET_DOUBLE) alpacaOrder.trail_percent = order.trailingPercent if (order.outsideRth) alpacaOrder.extended_hours = true + // Bracket order (TPSL) + if (tpsl?.takeProfit || tpsl?.stopLoss) { + alpacaOrder.order_class = 'bracket' + if (tpsl.takeProfit) { + alpacaOrder.take_profit = { limit_price: parseFloat(tpsl.takeProfit.price) } + } + if (tpsl.stopLoss) { + alpacaOrder.stop_loss = { + stop_price: parseFloat(tpsl.stopLoss.price), + ...(tpsl.stopLoss.limitPrice && { limit_price: parseFloat(tpsl.stopLoss.limitPrice) }), + } + } + } + const result = await this.client.createOrder(alpacaOrder) as AlpacaOrderRaw return { success: true, diff --git a/src/domain/trading/brokers/ccxt/CcxtBroker.ts b/src/domain/trading/brokers/ccxt/CcxtBroker.ts index 11dddb98..7ee7d8b7 100644 --- a/src/domain/trading/brokers/ccxt/CcxtBroker.ts +++ b/src/domain/trading/brokers/ccxt/CcxtBroker.ts @@ -22,6 +22,7 @@ import { type Quote, type MarketClock, type BrokerConfigField, + type TpSlParams, } from '../types.js' import '../../contract-ext.js' import type { CcxtBrokerConfig, CcxtMarket, FundingRate, OrderBook, OrderBookLevel } from './ccxt-types.js' @@ -283,7 +284,7 @@ export class CcxtBroker implements IBroker { // ---- Trading operations ---- - async placeOrder(contract: Contract, order: Order, extraParams?: Record): Promise { + async placeOrder(contract: Contract, order: Order, tpsl?: TpSlParams, extraParams?: Record): Promise { this.ensureInit() @@ -314,6 +315,16 @@ export class CcxtBroker implements IBroker { try { const params: Record = { ...extraParams } + if (tpsl?.takeProfit) { + params.takeProfit = { triggerPrice: parseFloat(tpsl.takeProfit.price) } + } + if (tpsl?.stopLoss) { + params.stopLoss = { + triggerPrice: parseFloat(tpsl.stopLoss.price), + ...(tpsl.stopLoss.limitPrice && { price: parseFloat(tpsl.stopLoss.limitPrice) }), + } + } + const ccxtOrderType = ibkrOrderTypeToCcxt(order.orderType) const side = order.action.toLowerCase() as 'buy' | 'sell' @@ -412,7 +423,7 @@ export class CcxtBroker implements IBroker { order.orderType = 'MKT' order.totalQuantity = quantity ?? pos.quantity - return this.placeOrder(pos.contract, order, { reduceOnly: true }) + return this.placeOrder(pos.contract, order, undefined, { reduceOnly: true }) } // ---- Queries ---- diff --git a/src/domain/trading/brokers/ibkr/IbkrBroker.ts b/src/domain/trading/brokers/ibkr/IbkrBroker.ts index 2097c8c2..a5d0e8bd 100644 --- a/src/domain/trading/brokers/ibkr/IbkrBroker.ts +++ b/src/domain/trading/brokers/ibkr/IbkrBroker.ts @@ -33,6 +33,7 @@ import { type Quote, type MarketClock, type BrokerConfigField, + type TpSlParams, } from '../types.js' import '../../contract-ext.js' import { RequestBridge } from './request-bridge.js' @@ -148,7 +149,7 @@ export class IbkrBroker implements IBroker { // ==================== Trading operations ==================== - async placeOrder(contract: Contract, order: Order): Promise { + async placeOrder(contract: Contract, order: Order, _tpsl?: TpSlParams): Promise { // TWS requires exchange and currency on the contract. Upstream layers // (staging, AI tools) typically only populate symbol + secType. // Default to SMART routing. Currency defaults to USD — non-USD markets diff --git a/src/domain/trading/brokers/index.ts b/src/domain/trading/brokers/index.ts index 870e0978..dfb210d2 100644 --- a/src/domain/trading/brokers/index.ts +++ b/src/domain/trading/brokers/index.ts @@ -9,6 +9,7 @@ export type { MarketClock, AccountCapabilities, BrokerConfigField, + TpSlParams, } from './types.js' // Factory + Registry diff --git a/src/domain/trading/brokers/mock/MockBroker.ts b/src/domain/trading/brokers/mock/MockBroker.ts index ac76f5b2..f8233e8d 100644 --- a/src/domain/trading/brokers/mock/MockBroker.ts +++ b/src/domain/trading/brokers/mock/MockBroker.ts @@ -20,6 +20,7 @@ import type { OpenOrder, Quote, MarketClock, + TpSlParams, } from '../types.js' import '../../contract-ext.js' @@ -217,8 +218,8 @@ export class MockBroker implements IBroker { // ---- Trading operations ---- - async placeOrder(contract: Contract, order: Order, _extraParams?: Record): Promise { - this._record('placeOrder', [contract, order, _extraParams]) + async placeOrder(contract: Contract, order: Order, tpsl?: TpSlParams): Promise { + this._record('placeOrder', [contract, order, tpsl]) const orderId = `mock-ord-${this._nextOrderId++}` const isMarket = order.orderType === 'MKT' const side = order.action.toUpperCase() diff --git a/src/domain/trading/brokers/types.ts b/src/domain/trading/brokers/types.ts index a851860b..27e812ca 100644 --- a/src/domain/trading/brokers/types.ts +++ b/src/domain/trading/brokers/types.ts @@ -168,6 +168,13 @@ export interface BrokerConfigField { sensitive?: boolean } +// ==================== Take Profit / Stop Loss ==================== + +export interface TpSlParams { + takeProfit?: { price: string } + stopLoss?: { price: string; limitPrice?: string } +} + // ==================== IBroker ==================== export interface IBroker { @@ -192,7 +199,7 @@ export interface IBroker { // ---- Trading operations (IBKR Order as source of truth) ---- - placeOrder(contract: Contract, order: Order): Promise + placeOrder(contract: Contract, order: Order, tpsl?: TpSlParams): Promise modifyOrder(orderId: string, changes: Partial): Promise cancelOrder(orderId: string, orderCancel?: OrderCancel): Promise closePosition(contract: Contract, quantity?: Decimal): Promise diff --git a/src/domain/trading/git/types.ts b/src/domain/trading/git/types.ts index 0cf34ab0..f9f187f0 100644 --- a/src/domain/trading/git/types.ts +++ b/src/domain/trading/git/types.ts @@ -7,7 +7,7 @@ import type { Contract, Order, OrderCancel, Execution, OrderState } from '@traderalice/ibkr' import type Decimal from 'decimal.js' -import type { Position, OpenOrder } from '../brokers/types.js' +import type { Position, OpenOrder, TpSlParams } from '../brokers/types.js' import '../contract-ext.js' // ==================== Commit Hash ==================== @@ -20,7 +20,7 @@ export type CommitHash = string export type OperationAction = Operation['action'] export type Operation = - | { action: 'placeOrder'; contract: Contract; order: Order } + | { action: 'placeOrder'; contract: Contract; order: Order; tpsl?: TpSlParams } | { action: 'modifyOrder'; orderId: string; changes: Partial } | { action: 'closePosition'; contract: Contract; quantity?: Decimal } | { action: 'cancelOrder'; orderId: string; orderCancel?: OrderCancel } diff --git a/src/domain/trading/index.ts b/src/domain/trading/index.ts index 30996722..bf88abaa 100644 --- a/src/domain/trading/index.ts +++ b/src/domain/trading/index.ts @@ -24,6 +24,7 @@ export type { Quote, MarketClock, AccountCapabilities, + TpSlParams, } from './brokers/index.js' export { createBroker, diff --git a/src/tool/trading.ts b/src/tool/trading.ts index f0cc01a7..aa0fe4d6 100644 --- a/src/tool/trading.ts +++ b/src/tool/trading.ts @@ -273,7 +273,8 @@ Required params by orderType: STP LMT: totalQuantity + auxPrice (stop trigger) + lmtPrice TRAIL: totalQuantity + auxPrice (trailing offset) or trailingPercent TRAIL LIMIT: totalQuantity + auxPrice (trailing offset) + lmtPrice - MOC: totalQuantity`, + MOC: totalQuantity +Optional: attach takeProfit and/or stopLoss for automatic exit orders.`, inputSchema: z.object({ source: z.string().describe(sourceDesc(true)), aliceId: z.string().describe('Contract ID (format: accountId|nativeKey, from searchContracts)'), @@ -291,6 +292,13 @@ Required params by orderType: outsideRth: z.boolean().optional().describe('Allow execution outside regular trading hours'), parentId: z.string().optional().describe('Parent order ID (bracket orders)'), ocaGroup: z.string().optional().describe('One-Cancels-All group name'), + takeProfit: z.object({ + price: z.string().describe('Take profit price'), + }).optional().describe('Take profit order (single-level, full quantity)'), + stopLoss: z.object({ + price: z.string().describe('Stop loss trigger price'), + limitPrice: z.string().optional().describe('Limit price for stop-limit SL (omit for stop-market)'), + }).optional().describe('Stop loss order (single-level, full quantity)'), }), execute: ({ source, ...params }) => manager.resolveOne(source).stagePlaceOrder(params), }), From fd2236d892b87ec5ab1f822281cd0dbbb7b9887e Mon Sep 17 00:00:00 2001 From: Ame Date: Wed, 1 Apr 2026 14:09:37 +0800 Subject: [PATCH 4/5] feat: expose TPSL on fetched orders (OpenOrder.tpsl) Read takeProfitPrice/stopLossPrice from CCXT orders in convertCcxtOrder, and parse bracket order legs in Alpaca's mapOpenOrder. OpenOrder now carries optional tpsl field matching the TpSlParams shape used at placement time. Unit tests (TDD): 3 CCXT + 3 Alpaca extraction tests. E2E tests: TPSL round-trip on Bybit (3 files), IBKR pass-through, Alpaca bracket (market-hours gated). Co-Authored-By: Claude Opus 4.6 (1M context) --- .../__test__/e2e/ccxt-bybit.e2e.spec.ts | 46 +++++++++++++ .../__test__/e2e/uta-alpaca.e2e.spec.ts | 43 ++++++++++++ .../__test__/e2e/uta-bybit.e2e.spec.ts | 35 ++++++++++ .../__test__/e2e/uta-ccxt-bybit.e2e.spec.ts | 36 ++++++++++ .../trading/__test__/e2e/uta-ibkr.e2e.spec.ts | 31 +++++++++ .../brokers/alpaca/AlpacaBroker.spec.ts | 65 +++++++++++++++++++ .../trading/brokers/alpaca/AlpacaBroker.ts | 20 ++++++ .../trading/brokers/alpaca/alpaca-types.ts | 2 + .../trading/brokers/ccxt/CcxtBroker.spec.ts | 51 +++++++++++++++ src/domain/trading/brokers/ccxt/CcxtBroker.ts | 10 +++ src/domain/trading/brokers/types.ts | 2 + 11 files changed, 341 insertions(+) diff --git a/src/domain/trading/__test__/e2e/ccxt-bybit.e2e.spec.ts b/src/domain/trading/__test__/e2e/ccxt-bybit.e2e.spec.ts index 1ae4a649..f616bfae 100644 --- a/src/domain/trading/__test__/e2e/ccxt-bybit.e2e.spec.ts +++ b/src/domain/trading/__test__/e2e/ccxt-bybit.e2e.spec.ts @@ -137,6 +137,52 @@ describe('CcxtBroker — Bybit e2e', () => { await b().closePosition(ethPerp.contract, new Decimal('0.01')) }, 15_000) + it('places order with TPSL and reads back tpsl from getOrder', async ({ skip }) => { + const matches = await b().searchContracts('ETH') + const ethPerp = matches.find(m => m.contract.localSymbol?.includes('USDT:USDT')) + if (!ethPerp) return skip('ETH/USDT perp not found') + + const order = new Order() + order.action = 'BUY' + order.orderType = 'MKT' + order.totalQuantity = new Decimal('0.01') + + // Get current price to set reasonable TP/SL + const quote = await b().getQuote(ethPerp.contract) + const tpPrice = Math.round(quote.last * 1.5) // 50% above — won't trigger + const slPrice = Math.round(quote.last * 0.5) // 50% below — won't trigger + + const placed = await b().placeOrder(ethPerp.contract, order, { + takeProfit: { price: String(tpPrice) }, + stopLoss: { price: String(slPrice) }, + }) + expect(placed.success).toBe(true) + console.log(` placed with TPSL: orderId=${placed.orderId}, tp=${tpPrice}, sl=${slPrice}`) + + // Wait for exchange to register + await new Promise(r => setTimeout(r, 3000)) + + const detail = await b().getOrder(placed.orderId!) + expect(detail).not.toBeNull() + console.log(` getOrder tpsl:`, JSON.stringify(detail!.tpsl)) + + // CCXT should populate takeProfitPrice/stopLossPrice on the fetched order + if (detail!.tpsl) { + if (detail!.tpsl.takeProfit) { + expect(parseFloat(detail!.tpsl.takeProfit.price)).toBe(tpPrice) + } + if (detail!.tpsl.stopLoss) { + expect(parseFloat(detail!.tpsl.stopLoss.price)).toBe(slPrice) + } + } else { + // Some exchanges don't return TP/SL on the parent order — log for visibility + console.log(' NOTE: exchange did not return TPSL on fetched order (may be separate conditional orders)') + } + + // Clean up + await b().closePosition(ethPerp.contract, new Decimal('0.01')) + }, 30_000) + it('queries conditional/trigger order by ID (#90)', async ({ skip }) => { // Place a stop-loss trigger order far from market price, then verify getOrder can see it. // This is the core scenario from issue #90. diff --git a/src/domain/trading/__test__/e2e/uta-alpaca.e2e.spec.ts b/src/domain/trading/__test__/e2e/uta-alpaca.e2e.spec.ts index 706df957..68133d7f 100644 --- a/src/domain/trading/__test__/e2e/uta-alpaca.e2e.spec.ts +++ b/src/domain/trading/__test__/e2e/uta-alpaca.e2e.spec.ts @@ -75,6 +75,49 @@ describe('UTA — Alpaca order lifecycle', () => { }, 30_000) }) +// ==================== TPSL bracket order (market hours only) ==================== + +describe('UTA — Alpaca TPSL bracket', () => { + beforeEach(({ skip }) => { + if (!uta) skip('no Alpaca paper account') + if (!marketOpen) skip('market closed') + }) + + it('market buy with TPSL → getOrder returns bracket legs', async () => { + const nativeKey = broker!.getNativeKey({ symbol: 'AAPL' } as any) + const aliceId = `${uta!.id}|${nativeKey}` + + uta!.stagePlaceOrder({ + aliceId, symbol: 'AAPL', action: 'BUY', orderType: 'MKT', totalQuantity: 1, + takeProfit: { price: '999' }, + stopLoss: { price: '1' }, + }) + uta!.commit('e2e: buy AAPL with TPSL') + const pushResult = await uta!.push() + expect(pushResult.submitted).toHaveLength(1) + const orderId = pushResult.submitted[0].orderId! + console.log(` TPSL bracket: orderId=${orderId}`) + + await new Promise(r => setTimeout(r, 2000)) + + const detail = await broker!.getOrder(orderId) + expect(detail).not.toBeNull() + console.log(` fetched tpsl:`, JSON.stringify(detail!.tpsl)) + + if (detail!.tpsl) { + if (detail!.tpsl.takeProfit) expect(detail!.tpsl.takeProfit.price).toBe('999') + if (detail!.tpsl.stopLoss) expect(detail!.tpsl.stopLoss.price).toBe('1') + } else { + console.log(' NOTE: Alpaca did not return legs on fetched bracket order') + } + + // Clean up — cancel the bracket legs then close position + uta!.stageClosePosition({ aliceId, qty: 1 }) + uta!.commit('e2e: close TPSL AAPL') + await uta!.push() + }, 30_000) +}) + // ==================== Full fill flow (market hours only) ==================== describe('UTA — Alpaca fill flow (AAPL)', () => { diff --git a/src/domain/trading/__test__/e2e/uta-bybit.e2e.spec.ts b/src/domain/trading/__test__/e2e/uta-bybit.e2e.spec.ts index 1af3c6cb..6aa5161e 100644 --- a/src/domain/trading/__test__/e2e/uta-bybit.e2e.spec.ts +++ b/src/domain/trading/__test__/e2e/uta-bybit.e2e.spec.ts @@ -117,4 +117,39 @@ describe('UTA — Bybit lifecycle (ETH perp)', () => { console.log(` log: ${history.length} commits — [${history.map(h => h.message).join(', ')}]`) expect(history.length).toBeGreaterThanOrEqual(2) }, 60_000) + + it('buy with TPSL → tpsl visible on fetched order', async () => { + const quote = await broker!.getQuote(broker!.resolveNativeKey(ethAliceId.split('|')[1])) + const tpPrice = Math.round(quote.last * 1.5) + const slPrice = Math.round(quote.last * 0.5) + + uta!.stagePlaceOrder({ + aliceId: ethAliceId, action: 'BUY', orderType: 'MKT', totalQuantity: 0.01, + takeProfit: { price: String(tpPrice) }, + stopLoss: { price: String(slPrice) }, + }) + uta!.commit('e2e: buy ETH with TPSL') + const pushResult = await uta!.push() + expect(pushResult.submitted).toHaveLength(1) + const orderId = pushResult.submitted[0].orderId! + console.log(` TPSL: orderId=${orderId}, tp=${tpPrice}, sl=${slPrice}`) + + await new Promise(r => setTimeout(r, 3000)) + + const detail = await broker!.getOrder(orderId) + expect(detail).not.toBeNull() + console.log(` fetched tpsl:`, JSON.stringify(detail!.tpsl)) + + if (detail!.tpsl) { + if (detail!.tpsl.takeProfit) expect(parseFloat(detail!.tpsl.takeProfit.price)).toBe(tpPrice) + if (detail!.tpsl.stopLoss) expect(parseFloat(detail!.tpsl.stopLoss.price)).toBe(slPrice) + } else { + console.log(' NOTE: TPSL not returned on fetched order (separate conditional orders)') + } + + // Clean up + uta!.stageClosePosition({ aliceId: ethAliceId, qty: 0.01 }) + uta!.commit('e2e: close TPSL') + await uta!.push() + }, 60_000) }) diff --git a/src/domain/trading/__test__/e2e/uta-ccxt-bybit.e2e.spec.ts b/src/domain/trading/__test__/e2e/uta-ccxt-bybit.e2e.spec.ts index b95829c4..99d89e1f 100644 --- a/src/domain/trading/__test__/e2e/uta-ccxt-bybit.e2e.spec.ts +++ b/src/domain/trading/__test__/e2e/uta-ccxt-bybit.e2e.spec.ts @@ -97,6 +97,42 @@ describe('UTA — Bybit demo (ETH perp)', () => { console.log(` log: ${log.length} commits`) }, 60_000) + it('buy with TPSL → getOrder returns tpsl', async () => { + const quote = await broker!.getQuote(broker!.resolveNativeKey(ethAliceId.split('|')[1])) + const tpPrice = Math.round(quote.last * 1.5) + const slPrice = Math.round(quote.last * 0.5) + + uta!.stagePlaceOrder({ + aliceId: ethAliceId, action: 'BUY', orderType: 'MKT', totalQuantity: 0.01, + takeProfit: { price: String(tpPrice) }, + stopLoss: { price: String(slPrice) }, + }) + uta!.commit('e2e: buy 0.01 ETH with TPSL') + const pushResult = await uta!.push() + expect(pushResult.submitted).toHaveLength(1) + const orderId = pushResult.submitted[0].orderId! + console.log(` TPSL order: orderId=${orderId}, tp=${tpPrice}, sl=${slPrice}`) + + // Wait for exchange to settle + await new Promise(r => setTimeout(r, 3000)) + + const detail = await broker!.getOrder(orderId) + expect(detail).not.toBeNull() + console.log(` getOrder tpsl:`, JSON.stringify(detail!.tpsl)) + + if (detail!.tpsl) { + if (detail!.tpsl.takeProfit) expect(parseFloat(detail!.tpsl.takeProfit.price)).toBe(tpPrice) + if (detail!.tpsl.stopLoss) expect(parseFloat(detail!.tpsl.stopLoss.price)).toBe(slPrice) + } else { + console.log(' NOTE: exchange did not return TPSL on fetched order') + } + + // Clean up + uta!.stageClosePosition({ aliceId: ethAliceId, qty: 0.01 }) + uta!.commit('e2e: close TPSL position') + await uta!.push() + }, 60_000) + it('reject records user-rejected commit and clears staging', async () => { // Stage + Commit (but don't push) uta!.stagePlaceOrder({ aliceId: ethAliceId, action: 'BUY', orderType: 'MKT', totalQuantity: 0.01 }) diff --git a/src/domain/trading/__test__/e2e/uta-ibkr.e2e.spec.ts b/src/domain/trading/__test__/e2e/uta-ibkr.e2e.spec.ts index 0c7b2fc7..f76a11f9 100644 --- a/src/domain/trading/__test__/e2e/uta-ibkr.e2e.spec.ts +++ b/src/domain/trading/__test__/e2e/uta-ibkr.e2e.spec.ts @@ -79,6 +79,37 @@ describe('UTA — IBKR order lifecycle', () => { }, 30_000) }) +// ==================== TPSL param pass-through (any time) ==================== + +describe('UTA — IBKR TPSL pass-through', () => { + beforeEach(({ skip }) => { if (!uta) skip('no IBKR paper account') }) + + it('tpsl param does not break order placement', async () => { + const results = await broker!.searchContracts('AAPL') + const nativeKey = broker!.getNativeKey(results[0].contract) + const aliceId = `${uta!.id}|${nativeKey}` + + // Stage limit order with TPSL — IBKR ignores tpsl but it should not error + uta!.stagePlaceOrder({ + aliceId, symbol: 'AAPL', action: 'BUY', orderType: 'LMT', + lmtPrice: 1.00, totalQuantity: 1, tif: 'GTC', + takeProfit: { price: '300' }, + stopLoss: { price: '100' }, + }) + uta!.commit('e2e: IBKR limit with TPSL (ignored)') + const pushResult = await uta!.push() + console.log(` pushed with TPSL: submitted=${pushResult.submitted.length}, status=${pushResult.submitted[0]?.status}`) + expect(pushResult.submitted).toHaveLength(1) + expect(pushResult.rejected).toHaveLength(0) + + // Clean up + const orderId = pushResult.submitted[0].orderId! + uta!.stageCancelOrder({ orderId }) + uta!.commit('e2e: cancel TPSL order') + await uta!.push() + }, 30_000) +}) + // ==================== Full fill flow (market hours only) ==================== describe('UTA — IBKR fill flow (AAPL)', () => { diff --git a/src/domain/trading/brokers/alpaca/AlpacaBroker.spec.ts b/src/domain/trading/brokers/alpaca/AlpacaBroker.spec.ts index 266b5351..a68292e0 100644 --- a/src/domain/trading/brokers/alpaca/AlpacaBroker.spec.ts +++ b/src/domain/trading/brokers/alpaca/AlpacaBroker.spec.ts @@ -514,6 +514,71 @@ describe('AlpacaBroker — getOrder()', () => { // IBKR orderId is number — UUID can't fit, so it should be 0 expect(result!.order.orderId).toBe(0) }) + + it('extracts tpsl from bracket order legs', async () => { + const acc = new AlpacaBroker({ apiKey: 'k', secretKey: 's', paper: true }) + ;(acc as any).client = { + getOrder: vi.fn().mockResolvedValue({ + id: 'ord-bracket', symbol: 'AAPL', side: 'buy', qty: '10', notional: null, + type: 'market', limit_price: null, stop_price: null, + time_in_force: 'day', extended_hours: false, + status: 'filled', reject_reason: null, + order_class: 'bracket', + legs: [ + { id: 'tp-1', symbol: 'AAPL', side: 'sell', qty: '10', notional: null, + type: 'limit', limit_price: '160.00', stop_price: null, + time_in_force: 'gtc', extended_hours: false, status: 'new', reject_reason: null }, + { id: 'sl-1', symbol: 'AAPL', side: 'sell', qty: '10', notional: null, + type: 'stop', limit_price: null, stop_price: '140.00', + time_in_force: 'gtc', extended_hours: false, status: 'new', reject_reason: null }, + ], + }), + } + + const result = await acc.getOrder('ord-bracket') + expect(result!.tpsl).toEqual({ + takeProfit: { price: '160.00' }, + stopLoss: { price: '140.00' }, + }) + }) + + it('extracts stop-limit SL with limitPrice', async () => { + const acc = new AlpacaBroker({ apiKey: 'k', secretKey: 's', paper: true }) + ;(acc as any).client = { + getOrder: vi.fn().mockResolvedValue({ + id: 'ord-stp-lmt', symbol: 'AAPL', side: 'buy', qty: '10', notional: null, + type: 'market', limit_price: null, stop_price: null, + time_in_force: 'day', extended_hours: false, + status: 'filled', reject_reason: null, + order_class: 'bracket', + legs: [ + { id: 'sl-2', symbol: 'AAPL', side: 'sell', qty: '10', notional: null, + type: 'stop_limit', limit_price: '139.50', stop_price: '140.00', + time_in_force: 'gtc', extended_hours: false, status: 'new', reject_reason: null }, + ], + }), + } + + const result = await acc.getOrder('ord-stp-lmt') + expect(result!.tpsl).toEqual({ + stopLoss: { price: '140.00', limitPrice: '139.50' }, + }) + }) + + it('returns no tpsl for simple (non-bracket) orders', async () => { + const acc = new AlpacaBroker({ apiKey: 'k', secretKey: 's', paper: true }) + ;(acc as any).client = { + getOrder: vi.fn().mockResolvedValue({ + id: 'ord-simple', symbol: 'AAPL', side: 'buy', qty: '10', notional: null, + type: 'market', limit_price: null, stop_price: null, + time_in_force: 'day', extended_hours: false, + status: 'filled', reject_reason: null, + }), + } + + const result = await acc.getOrder('ord-simple') + expect(result!.tpsl).toBeUndefined() + }) }) // ==================== getQuote ==================== diff --git a/src/domain/trading/brokers/alpaca/AlpacaBroker.ts b/src/domain/trading/brokers/alpaca/AlpacaBroker.ts index 32b41e3b..3bd16caf 100644 --- a/src/domain/trading/brokers/alpaca/AlpacaBroker.ts +++ b/src/domain/trading/brokers/alpaca/AlpacaBroker.ts @@ -438,10 +438,30 @@ export class AlpacaBroker implements IBroker { // The real string ID is preserved through PlaceOrderResult.orderId and getOrder(string). order.orderId = 0 + const tpsl = this.extractTpSl(o) return { contract, order, orderState: makeOrderState(o.status, o.reject_reason ?? undefined), + ...(tpsl && { tpsl }), } } + + private extractTpSl(o: AlpacaOrderRaw): TpSlParams | undefined { + if (o.order_class !== 'bracket' || !o.legs?.length) return undefined + let takeProfit: TpSlParams['takeProfit'] + let stopLoss: TpSlParams['stopLoss'] + for (const leg of o.legs) { + if (leg.limit_price && !leg.stop_price) { + takeProfit = { price: leg.limit_price } + } else if (leg.stop_price) { + stopLoss = { + price: leg.stop_price, + ...(leg.limit_price && { limitPrice: leg.limit_price }), + } + } + } + if (!takeProfit && !stopLoss) return undefined + return { takeProfit, stopLoss } + } } diff --git a/src/domain/trading/brokers/alpaca/alpaca-types.ts b/src/domain/trading/brokers/alpaca/alpaca-types.ts index 71640fee..927be23c 100644 --- a/src/domain/trading/brokers/alpaca/alpaca-types.ts +++ b/src/domain/trading/brokers/alpaca/alpaca-types.ts @@ -46,6 +46,8 @@ export interface AlpacaOrderRaw { filled_at: string | null created_at: string reject_reason: string | null + order_class?: string + legs?: AlpacaOrderRaw[] } export interface AlpacaSnapshotRaw { diff --git a/src/domain/trading/brokers/ccxt/CcxtBroker.spec.ts b/src/domain/trading/brokers/ccxt/CcxtBroker.spec.ts index 06023a62..b7897dc7 100644 --- a/src/domain/trading/brokers/ccxt/CcxtBroker.spec.ts +++ b/src/domain/trading/brokers/ccxt/CcxtBroker.spec.ts @@ -352,6 +352,57 @@ describe('CcxtBroker — getOrder (bybit)', () => { const result = await acc.getOrder('ord-404') expect(result).toBeNull() }) + + it('extracts tpsl from CCXT order with takeProfitPrice/stopLossPrice', async () => { + const acc = makeAccount() + const market = makeSwapMarket('ETH', 'USDT', 'ETH/USDT:USDT') + setInitialized(acc, { 'ETH/USDT:USDT': market }) + + ;(acc as any).orderSymbolCache.set('ord-tp', 'ETH/USDT:USDT') + ;(acc as any).exchange.fetchOpenOrder = vi.fn().mockResolvedValue({ + id: 'ord-tp', symbol: 'ETH/USDT:USDT', side: 'buy', amount: 0.1, + type: 'limit', price: 1900, status: 'open', + takeProfitPrice: 2200, + stopLossPrice: 1800, + }) + + const result = await acc.getOrder('ord-tp') + expect(result!.tpsl).toEqual({ + takeProfit: { price: '2200' }, + stopLoss: { price: '1800' }, + }) + }) + + it('returns no tpsl when CCXT order has no TP/SL prices', async () => { + const acc = makeAccount() + const market = makeSwapMarket('ETH', 'USDT', 'ETH/USDT:USDT') + setInitialized(acc, { 'ETH/USDT:USDT': market }) + + ;(acc as any).orderSymbolCache.set('ord-plain', 'ETH/USDT:USDT') + ;(acc as any).exchange.fetchOpenOrder = vi.fn().mockResolvedValue({ + id: 'ord-plain', symbol: 'ETH/USDT:USDT', side: 'buy', amount: 0.1, + type: 'limit', price: 1900, status: 'open', + }) + + const result = await acc.getOrder('ord-plain') + expect(result!.tpsl).toBeUndefined() + }) + + it('extracts only takeProfit when stopLossPrice is absent', async () => { + const acc = makeAccount() + const market = makeSwapMarket('ETH', 'USDT', 'ETH/USDT:USDT') + setInitialized(acc, { 'ETH/USDT:USDT': market }) + + ;(acc as any).orderSymbolCache.set('ord-tp-only', 'ETH/USDT:USDT') + ;(acc as any).exchange.fetchOpenOrder = vi.fn().mockResolvedValue({ + id: 'ord-tp-only', symbol: 'ETH/USDT:USDT', side: 'buy', amount: 0.1, + type: 'limit', price: 1900, status: 'open', + takeProfitPrice: 2200, + }) + + const result = await acc.getOrder('ord-tp-only') + expect(result!.tpsl).toEqual({ takeProfit: { price: '2200' } }) + }) }) // ==================== getOrder — default path (binance etc) ==================== diff --git a/src/domain/trading/brokers/ccxt/CcxtBroker.ts b/src/domain/trading/brokers/ccxt/CcxtBroker.ts index 7ee7d8b7..eb1ae51c 100644 --- a/src/domain/trading/brokers/ccxt/CcxtBroker.ts +++ b/src/domain/trading/brokers/ccxt/CcxtBroker.ts @@ -562,10 +562,20 @@ export class CcxtBroker implements IBroker { if (o.price != null) order.lmtPrice = o.price order.orderId = parseInt(o.id, 10) || 0 + const tp = o.takeProfitPrice + const sl = o.stopLossPrice + const tpsl: TpSlParams | undefined = (tp != null || sl != null) + ? { + ...(tp != null && { takeProfit: { price: String(tp) } }), + ...(sl != null && { stopLoss: { price: String(sl) } }), + } + : undefined + return { contract, order, orderState: makeOrderState(o.status), + ...(tpsl && { tpsl }), } } diff --git a/src/domain/trading/brokers/types.ts b/src/domain/trading/brokers/types.ts index 27e812ca..b3ac1021 100644 --- a/src/domain/trading/brokers/types.ts +++ b/src/domain/trading/brokers/types.ts @@ -95,6 +95,8 @@ export interface OpenOrder { orderState: OrderState /** Average fill price — from orderStatus callback or broker-specific source. */ avgFillPrice?: number + /** Attached take-profit / stop-loss (CCXT: from order fields; Alpaca: from bracket legs). */ + tpsl?: TpSlParams } // ==================== Account info ==================== From 1bba6afb8959ae73daf79cde1dd744fc6c50af7d Mon Sep 17 00:00:00 2001 From: Ame Date: Wed, 1 Apr 2026 17:42:02 +0800 Subject: [PATCH 5/5] refactor: summarize getOrders output + add groupBy contract clustering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace raw IBKR OpenOrder spread (~200 fields, mostly UNSET_DOUBLE) with compact OrderSummary (~15 fields, UNSET values filtered out). Add optional groupBy: "contract" parameter to cluster orders by aliceId — critical for portfolios with many positions + TPSL orders. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/tool/trading.spec.ts | 138 ++++++++++++++++++++++++++++++++++++++- src/tool/trading.ts | 51 +++++++++++++-- 2 files changed, 181 insertions(+), 8 deletions(-) diff --git a/src/tool/trading.spec.ts b/src/tool/trading.spec.ts index 4ce66f19..4d95c0ad 100644 --- a/src/tool/trading.spec.ts +++ b/src/tool/trading.spec.ts @@ -1,5 +1,7 @@ import { describe, it, expect, beforeEach, vi } from 'vitest' -import { ContractDescription } from '@traderalice/ibkr' +import Decimal from 'decimal.js' +import { ContractDescription, Order, OrderState, UNSET_DOUBLE, UNSET_DECIMAL } from '@traderalice/ibkr' +import type { OpenOrder } from '../domain/trading/brokers/types.js' import { MockBroker, makeContract } from '../domain/trading/brokers/mock/index.js' import { AccountManager } from '../domain/trading/account-manager.js' import { UnifiedTradingAccount } from '../domain/trading/UnifiedTradingAccount.js' @@ -98,3 +100,137 @@ describe('createTradingTools — searchContracts', () => { expect(result).toHaveLength(2) }) }) + +// ==================== getOrders — summarization ==================== + +describe('createTradingTools — getOrders summarization', () => { + function makeOpenOrder(overrides?: Partial<{ action: string; orderType: string; qty: number; lmtPrice: number; status: string; symbol: string }>): OpenOrder { + const contract = makeContract({ symbol: overrides?.symbol ?? 'AAPL' }) + contract.aliceId = `mock-paper|${overrides?.symbol ?? 'AAPL'}` + const order = new Order() + order.action = overrides?.action ?? 'BUY' + order.orderType = overrides?.orderType ?? 'MKT' + order.totalQuantity = new Decimal(overrides?.qty ?? 10) + if (overrides?.lmtPrice != null) order.lmtPrice = overrides.lmtPrice + const orderState = new OrderState() + orderState.status = overrides?.status ?? 'Submitted' + return { contract, order, orderState } + } + + it('returns compact summaries without UNSET fields', async () => { + const broker = new MockBroker({ id: 'mock-paper' }) + broker.setQuote('AAPL', 150) + const mgr = makeManager(broker) + const uta = mgr.resolve('mock-paper')[0] + + uta.stagePlaceOrder({ aliceId: 'mock-paper|AAPL', action: 'BUY', orderType: 'MKT', totalQuantity: 10 }) + uta.commit('buy') + await uta.push() + + const tools = createTradingTools(mgr) + const ids = uta.getPendingOrderIds().map(p => p.orderId) + // getPendingOrderIds may be empty after market fill — test the tool output shape instead + const result = await (tools.getOrders.execute as Function)({ source: 'mock-paper' }) + + // Result should be an array of compact objects, not raw OpenOrder + if (Array.isArray(result) && result.length > 0) { + const first = result[0] + // Should have summarized fields + expect(first).toHaveProperty('source') + expect(first).toHaveProperty('action') + expect(first).toHaveProperty('orderType') + expect(first).toHaveProperty('totalQuantity') + expect(first).toHaveProperty('status') + // Should NOT have raw IBKR fields + expect(first).not.toHaveProperty('softDollarTier') + expect(first).not.toHaveProperty('transmit') + expect(first).not.toHaveProperty('blockOrder') + expect(first).not.toHaveProperty('sweepToFill') + } + }) + + it('filters UNSET values from order summary', async () => { + const broker = new MockBroker({ id: 'mock-paper' }) + const mgr = makeManager(broker) + const tools = createTradingTools(mgr) + + // Mock getOrders to return a raw OpenOrder with UNSET fields + const uta = mgr.resolve('mock-paper')[0] + vi.spyOn(uta, 'getPendingOrderIds').mockReturnValue([{ orderId: 'ord-1', symbol: 'AAPL' }]) + vi.spyOn(uta, 'getOrders').mockResolvedValue([makeOpenOrder()]) + + const result = await (tools.getOrders.execute as Function)({ source: 'mock-paper' }) + expect(Array.isArray(result)).toBe(true) + const order = result[0] + + // lmtPrice is UNSET_DOUBLE — should be absent + expect(order.lmtPrice).toBeUndefined() + // auxPrice is UNSET_DOUBLE — should be absent + expect(order.auxPrice).toBeUndefined() + // trailStopPrice is UNSET_DOUBLE — should be absent + expect(order.trailStopPrice).toBeUndefined() + // parentId is 0 — should be absent + expect(order.parentId).toBeUndefined() + // tpsl not set — should be absent + expect(order.tpsl).toBeUndefined() + }) + + it('includes non-UNSET optional fields', async () => { + const broker = new MockBroker({ id: 'mock-paper' }) + const mgr = makeManager(broker) + const tools = createTradingTools(mgr) + + const uta = mgr.resolve('mock-paper')[0] + vi.spyOn(uta, 'getPendingOrderIds').mockReturnValue([{ orderId: 'ord-2', symbol: 'AAPL' }]) + const openOrder = makeOpenOrder({ lmtPrice: 150, orderType: 'LMT' }) + openOrder.tpsl = { takeProfit: { price: '160' }, stopLoss: { price: '140' } } + vi.spyOn(uta, 'getOrders').mockResolvedValue([openOrder]) + + const result = await (tools.getOrders.execute as Function)({ source: 'mock-paper' }) + const order = result[0] + + expect(order.lmtPrice).toBe(150) + expect(order.tpsl).toEqual({ takeProfit: { price: '160' }, stopLoss: { price: '140' } }) + }) + + it('preserves string orderId from getPendingOrderIds', async () => { + const broker = new MockBroker({ id: 'mock-paper' }) + const mgr = makeManager(broker) + const tools = createTradingTools(mgr) + + const uta = mgr.resolve('mock-paper')[0] + vi.spyOn(uta, 'getPendingOrderIds').mockReturnValue([{ orderId: 'uuid-abc-123', symbol: 'AAPL' }]) + vi.spyOn(uta, 'getOrders').mockResolvedValue([makeOpenOrder()]) + + const result = await (tools.getOrders.execute as Function)({ source: 'mock-paper' }) + // Should use the string orderId, not order.orderId (which is 0) + expect(result[0].orderId).toBe('uuid-abc-123') + }) + + it('groupBy contract clusters orders by aliceId', async () => { + const broker = new MockBroker({ id: 'mock-paper' }) + const mgr = makeManager(broker) + const tools = createTradingTools(mgr) + + const uta = mgr.resolve('mock-paper')[0] + vi.spyOn(uta, 'getPendingOrderIds').mockReturnValue([ + { orderId: 'ord-1', symbol: 'AAPL' }, + { orderId: 'ord-2', symbol: 'AAPL' }, + { orderId: 'ord-3', symbol: 'ETH' }, + ]) + vi.spyOn(uta, 'getOrders').mockResolvedValue([ + makeOpenOrder({ symbol: 'AAPL', action: 'BUY' }), + makeOpenOrder({ symbol: 'AAPL', action: 'SELL', orderType: 'LMT', lmtPrice: 160 }), + makeOpenOrder({ symbol: 'ETH', action: 'BUY' }), + ]) + + const result = await (tools.getOrders.execute as Function)({ source: 'mock-paper', groupBy: 'contract' }) + + // Should be an object keyed by aliceId + expect(result).not.toBeInstanceOf(Array) + expect(result['mock-paper|AAPL']).toBeDefined() + expect(result['mock-paper|AAPL'].orders).toHaveLength(2) + expect(result['mock-paper|ETH']).toBeDefined() + expect(result['mock-paper|ETH'].orders).toHaveLength(1) + }) +}) diff --git a/src/tool/trading.ts b/src/tool/trading.ts index aa0fe4d6..87b70e2c 100644 --- a/src/tool/trading.ts +++ b/src/tool/trading.ts @@ -8,9 +8,9 @@ import { tool, type Tool } from 'ai' import { z } from 'zod' -import { Contract } from '@traderalice/ibkr' +import { Contract, UNSET_DOUBLE, UNSET_DECIMAL } from '@traderalice/ibkr' import type { AccountManager } from '@/domain/trading/account-manager.js' -import { BrokerError } from '@/domain/trading/brokers/types.js' +import { BrokerError, type OpenOrder } from '@/domain/trading/brokers/types.js' import '@/domain/trading/contract-ext.js' /** Classify a broker error into a structured response for AI consumption. */ @@ -26,6 +26,31 @@ function handleBrokerError(err: unknown): { error: string; code: string; transie } } +/** Summarize an OpenOrder into a compact object for AI consumption. */ +function summarizeOrder(o: OpenOrder, source: string, stringOrderId?: string) { + const order = o.order + return { + source, + orderId: stringOrderId ?? String(order.orderId), + aliceId: o.contract.aliceId ?? '', + symbol: o.contract.symbol || o.contract.localSymbol || '', + action: order.action, + orderType: order.orderType, + totalQuantity: order.totalQuantity.equals(UNSET_DECIMAL) ? '0' : order.totalQuantity.toString(), + status: o.orderState.status, + ...(order.lmtPrice !== UNSET_DOUBLE && { lmtPrice: order.lmtPrice }), + ...(order.auxPrice !== UNSET_DOUBLE && { auxPrice: order.auxPrice }), + ...(order.trailStopPrice !== UNSET_DOUBLE && { trailStopPrice: order.trailStopPrice }), + ...(order.trailingPercent !== UNSET_DOUBLE && { trailingPercent: order.trailingPercent }), + ...(order.tif && { tif: order.tif }), + ...(!order.filledQuantity.equals(UNSET_DECIMAL) && { filledQuantity: order.filledQuantity.toString() }), + ...(o.avgFillPrice != null && { avgFillPrice: o.avgFillPrice }), + ...(order.parentId !== 0 && { parentId: order.parentId }), + ...(order.ocaGroup && { ocaGroup: order.ocaGroup }), + ...(o.tpsl && { tpsl: o.tpsl }), + } +} + const sourceDesc = (required: boolean, extra?: string) => { const base = `Account source — matches account id (e.g. "alpaca-paper") or provider (e.g. "alpaca", "ccxt").` const req = required @@ -143,21 +168,33 @@ If this tool returns an error with transient=true, wait a few seconds and retry getOrders: tool({ description: `Query orders by ID. If no orderIds provided, queries all pending (submitted) orders. +Use groupBy: "contract" to group orders by contract/aliceId (useful with many positions + TPSL). If this tool returns an error with transient=true, wait a few seconds and retry once before reporting to the user.`, inputSchema: z.object({ source: z.string().optional().describe(sourceDesc(false)), orderIds: z.array(z.string()).optional().describe('Order IDs to query. If omitted, queries all pending orders.'), + groupBy: z.enum(['contract']).optional().describe('Group orders by contract (aliceId)'), }), - execute: async ({ source, orderIds }) => { + execute: async ({ source, orderIds, groupBy }) => { const targets = manager.resolve(source) if (targets.length === 0) return [] try { - const results = await Promise.all(targets.map(async (uta) => { + const summaries = (await Promise.all(targets.map(async (uta) => { const ids = orderIds ?? uta.getPendingOrderIds().map(p => p.orderId) const orders = await uta.getOrders(ids) - return orders.map((o) => ({ source: uta.id, ...o })) - })) - return results.flat() + return orders.map((o, i) => summarizeOrder(o, uta.id, ids[i])) + }))).flat() + + if (groupBy === 'contract') { + const grouped: Record[] }> = {} + for (const s of summaries) { + const key = s.aliceId || s.symbol + if (!grouped[key]) grouped[key] = { symbol: s.symbol, orders: [] } + grouped[key].orders.push(s) + } + return grouped + } + return summaries } catch (err) { return handleBrokerError(err) }