diff --git a/src/domain/trading/UnifiedTradingAccount.spec.ts b/src/domain/trading/UnifiedTradingAccount.spec.ts index d2ec209d..10884f53 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,98 +242,126 @@ 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') }) + + 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 ==================== @@ -345,8 +373,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 +388,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 +453,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 +469,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 +490,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 +509,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 +531,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 +546,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 +560,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 +688,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..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, @@ -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,32 @@ 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 + takeProfit?: { price: string } + stopLoss?: { price: string; limitPrice?: 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 } @@ -162,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': @@ -366,33 +347,38 @@ 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 - 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 { 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/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 aff1961c..68133d7f 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) @@ -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)', () => { @@ -96,9 +139,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..6aa5161e 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`) @@ -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 36d5f684..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 @@ -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) @@ -97,9 +97,45 @@ 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, 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 +167,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..f76a11f9 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) @@ -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)', () => { @@ -102,9 +133,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..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' @@ -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() @@ -165,12 +165,49 @@ 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', () => { 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 +218,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/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 3f174a21..3bd16caf 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, @@ -423,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 11dddb98..eb1ae51c 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 ---- @@ -551,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/ibkr/IbkrBroker.ts b/src/domain/trading/brokers/ibkr/IbkrBroker.ts index 79872840..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 @@ -188,6 +189,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/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..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 ==================== @@ -168,6 +170,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 +201,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.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 907e8021..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) } @@ -265,24 +302,40 @@ 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 +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)'), - 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'), + 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), }), @@ -292,13 +345,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),