Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,60 @@ describe('AlpacaBroker — placeOrder()', () => {
])
})

it('uses order_class "oto" (not "bracket") when only one exit leg is attached', async () => {
// Regression: a single stop_loss under order_class "bracket" is rejected
// 422 by Alpaca ("bracket orders require take_profit.limit_price"). One
// leg must go out as "oto", two legs as "bracket".
const createOrder = vi.fn().mockResolvedValue({ id: 'ord-oto', status: 'new' })
const acc = new AlpacaBroker({ apiKey: 'k', secretKey: 's', paper: true })
;(acc as any).client = { createOrder }
const contract = new Contract()
contract.aliceId = 'alpaca-paper|FCX'
contract.symbol = 'FCX'
contract.secType = 'STK'
contract.exchange = 'NYSE'
contract.currency = 'USD'

const order = new Order()
order.action = 'BUY'
order.orderType = 'LMT'
order.totalQuantity = new Decimal(90)
order.lmtPrice = new Decimal('65')

// Only a stop loss, no take profit — the case that 422'd in production.
const result = await acc.placeOrder(contract, order, { stopLoss: { price: '58' } })
expect(result.success).toBe(true)
const sent = createOrder.mock.calls[0][0]
expect(sent.order_class).toBe('oto')
expect(sent.stop_loss).toEqual({ stop_price: 58 })
expect(sent.take_profit).toBeUndefined()
})

it('uses order_class "bracket" when both exit legs are attached', async () => {
const createOrder = vi.fn().mockResolvedValue({ id: 'ord-br', status: 'new' })
const acc = new AlpacaBroker({ apiKey: 'k', secretKey: 's', paper: true })
;(acc as any).client = { createOrder }
const contract = new Contract()
contract.aliceId = 'alpaca-paper|AAPL'
contract.symbol = 'AAPL'
contract.secType = 'STK'
contract.exchange = 'NASDAQ'
contract.currency = 'USD'

const order = new Order()
order.action = 'BUY'
order.orderType = 'LMT'
order.totalQuantity = new Decimal(1)
order.lmtPrice = new Decimal('291.57')

const result = await acc.placeOrder(contract, order, {
takeProfit: { price: '297' },
stopLoss: { price: '285.5' },
})
expect(result.success).toBe(true)
expect(createOrder.mock.calls[0][0].order_class).toBe('bracket')
})

it('omits legs for simple (non-bracket) placements', async () => {
const acc = new AlpacaBroker({ apiKey: 'k', secretKey: 's', paper: true })
;(acc as any).client = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -294,9 +294,13 @@ export class AlpacaBroker implements IBroker {
if (!order.trailingPercent.equals(UNSET_DECIMAL)) alpacaOrder.trail_percent = order.trailingPercent.toFixed()
if (order.outsideRth) alpacaOrder.extended_hours = true

// Bracket order (TPSL)
// Attached exit legs (TPSL). Alpaca's `bracket` class REQUIRES both
// take_profit AND stop_loss — a single leg under `bracket` is rejected
// 422 ("bracket orders require take_profit.limit_price"). When only one
// leg is present, `oto` (one-triggers-other) is the correct class, which
// accepts either leg alone. So: two legs → bracket, one leg → oto.
if (tpsl?.takeProfit || tpsl?.stopLoss) {
alpacaOrder.order_class = 'bracket'
alpacaOrder.order_class = (tpsl.takeProfit && tpsl.stopLoss) ? 'bracket' : 'oto'
if (tpsl.takeProfit) {
alpacaOrder.take_profit = { limit_price: parseFloat(tpsl.takeProfit.price) }
}
Expand Down
Loading