Skip to content

feature: add one-cancels-the-other (OCO) order group - #9635

Open
Romazes wants to merge 21 commits into
QuantConnect:masterfrom
Romazes:feature-8253-oco-order
Open

feature: add one-cancels-the-other (OCO) order group#9635
Romazes wants to merge 21 commits into
QuantConnect:masterfrom
Romazes:feature-8253-oco-order

Conversation

@Romazes

@Romazes Romazes commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator

Description

Adds a one-cancels-the-other (OCO) order group. QCAlgorithm.OneCancelsTheOtherOrder places a take profit limit leg and a stop loss stop market leg together, both for the same quantity. Both legs rest in the market; the first one to execute ends the group.

C#:

MarketOrder(_spy, 100);
var price = Securities[_spy].Price;

// close the position at a profit or at a loss, whichever price comes first
var tickets = OneCancelsTheOtherOrder(_spy, -100, limitPrice: price * 1.01m, stopPrice: price * 0.95m);

Python:

self.market_order(self._spy, 100)
price = self.securities[self._spy].price

tickets = self.one_cancels_the_other_order(self._spy, -100, limit_price=price * 1.01, stop_price=price * 0.95)

The group can never trade more than the quantity it was given. When a leg executes, that quantity comes off every other open leg, and a leg left with nothing is canceled in the same order event batch. A full fill is just the case where the others reach zero. This also covers a partial fill: the stop selling 30 of 100 leaves the limit leg at 70, so the position keeps its target and the group never sells 130.

Main changes:

  • New GroupExecutionType enum (Combo, OneCancelsTheOther) and GroupOrderManager.ExecutionType, serialized as executionType. Groups serialized before this keep loading as Combo.
  • Order.CreateOrder and OrderJsonConverter attach the group manager to a plain Limit/StopMarket leg before its Id is set, so a leg restored from JSON stays in its group. Only combo order types carried the manager through those paths before.
  • QCAlgorithm.SubmitGroupOrder is the shared submitter for group types that are not the ratio combo: it builds the legs' requests, runs every pre-order check before submitting any leg, and submits in list order. The conditional (OTO) and bracket orders add their own thin wrapper over it.
  • BacktestingBrokerage simulates the group so backtesting and paper trading need no brokerage support. Legs are evaluated in a fixed order — stop legs first, then by id — and the first leg to execute any quantity ends the pass and reduces its siblings.
  • Buying power does not double count the legs. A same symbol group only has to afford its most expensive leg, since only one leg can execute; a group across symbols checks every leg on its own. CashBuyingPowerModel stops counting a sibling's quantity as reserved, and GetProjectedHoldings and Shortable count one leg per group.
  • HandleUpdateOrderRequest now validates buying power when a leg of a non-combo group is updated. Combo legs keep the old skip.
  • No brokerage gate. A brokerage maps the group in its own plugin, and AlpacaBrokerageModel.CanSubmitOrder checks Alpaca's own rules per leg: 2 legs, US equity, limit or stop market, both legs on the same side, day or good til canceled.

Related PR(s)

Both are drafts that depend on this one:

Related Issue

#8253

Motivation and Context

First step of the bracket order plan: OCO now, conditional (OTO) next, bracket on top of both. A user can protect a position with a take profit and a stop loss pair without canceling the other one by hand, and without the risk of both filling.

Requires Documentation Change

Yes. The new OneCancelsTheOtherOrder API needs a page, and the brokerage pages need to say which brokerages accept the group once the two plugin pull requests land.

How Has This Been Tested?

Regression algorithms on SPY hourly, January 2019:

  • OneCancelsTheOtherOrderRegressionAlgorithm (C# and Python) — the take profit leg fills, the stop leg ends Canceled in the same batch, and the algorithm ends flat.
  • OneCancelsTheOtherOrderCancelRegressionAlgorithm (C# and Python) — canceling one leg cancels both, and the original market order fill is untouched.
  • OneCancelsTheOtherOrderPartialFillRegressionAlgorithm (C# only, it needs a custom fill model) — the stop leg executes 30 of 100 and the limit leg has to shrink to 70. It asserts the leg quantity, not just the total, because a group that stopped early would also end flat.
  • OneCancelsTheOtherOrderDemoAlgorithm — a manual aid for running the group against a live or paper brokerage. It does not implement IRegressionAlgorithmDefinition.

Unit tests:

  • BacktestingBrokerageTests: LegFillCancelsSiblingInSameEventBatch, StopLegWinsTieOverLimitLeg, CancelingOneLegCancelsWholeGroup, CancelOrderLeavesAlreadyClosedLegUntouched, TimeInForceExpiryOnAnyLegCancelsWholeGroup, PartialFillReducesSiblingsAndGroupStaysPending, GroupIsProcessedOnlyOnceExactlyPerScanDespiteTwoPendingEntries.
  • AlgorithmTradingTests: the two tickets come back limit first and share one manager; a zero quantity returns one invalid ticket; the tag and order properties reach both legs; Liquidate cancels every leg of an open group.
  • BrokerageTransactionHandlerTests: the legs buffer until the group is complete, a submit time failure invalidates every leg, and the update path validates buying power for OCO but not for combo.
  • SecurityPortfolioManagerTests and CashBuyingPowerModelTests: the most expensive leg rule, and a cash account holding exactly 1 BTC can place the sell pair through OneCancelsTheOtherOrder.
  • OrderJsonConverterTests and OrderTests: a plain leg keeps its group over two round trips, and old JSON without the field loads as Combo.
  • AlpacaBrokerageModelTests: a valid group passes, and each of the five per-leg rules rejects on its own.

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • Refactor (non-breaking change which improves implementation)
  • Performance (non-breaking change which improves performance)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Non-functional change (xml comments/documentation/etc)

Checklist:

  • My code follows the code style of this project.
  • I have read the CONTRIBUTING document.
  • I have added tests to cover my changes.
  • All new and existing tests passed.
  • My branch follows the naming convention bug-<issue#>-<description> or feature-<issue#>-<description>

@Romazes
Romazes marked this pull request as draft July 24, 2026 15:34
@Romazes
Romazes force-pushed the feature-8253-oco-order branch from 99e04e7 to 05ad8b1 Compare July 28, 2026 20:34
@Romazes Romazes self-assigned this Jul 29, 2026
Romazes added 20 commits August 20, 2026 22:39
- add ComboType to GroupOrderManager and the OneCancelsTheOtherOrder api
- gate live order groups behind BrokerageModel.SupportsGroupExecution
- simulate group fill and sibling cancel in the backtesting brokerage
- count one leg per group in buying power and open order aggregations
- carry the group manager through the order factory and json round trip
- end the group pass on any fill, partial as well as complete
- take the executed quantity off every other open leg instead of only
  canceling on a complete fill, and cancel a leg left with nothing to execute
- add a regression algorithm that fails without the fix
- assert the sibling reduction in the backtesting brokerage tests
- add a second group that buys 100 and is won by its stop market leg
- sell 200 in the first group so holdings reverse through zero
- open the position on its own bar and drop the price rounding the
  transaction handler already does
- find the legs by order type instead of by list index
- reject a group that is not exactly 2 legs, us equity, limit plus stop market, one side, day or gtc tif
- add the five brokerage messages for those rejections
- cover the valid group and every rejection reason, and make the group execution gate test able to fail
- move the enum to its own file Common/Orders/GroupExecutionType.cs
- rename GroupOrderManager.ComboType to ExecutionType, json name to executionType
- lean already rounds order prices before sending them
- mirror the change in the python twin of the cancel algorithm
- PreOrderChecksImpl already answers ZeroQuantity, so the group comes back as one invalid ticket
- assert the invalid ticket instead of the exception
- drop the per group skip so every open order gets its own cancel, like CancelOpenOrders
- a leg its siblings already canceled answers with an error response instead of throwing
- remove SupportsGroupExecution from IBrokerageModel, DefaultBrokerageModel, alpaca, ib and the python wrapper
- remove the live mode check from the transaction handler and every test that covered it
- restore PythonWrapper as it was: no default bodied interface method is left to skip
- places a market entry and a 2 leg oco exit, logs every order event
- manual live/paper testing aid, it does not implement IRegressionAlgorithmDefinition
- ProcessOneCancelsTheOtherGroup returns true when the group must be evaluated again, no ref parameter
- shorten the comments to the reason behind the code
- CompositeLogHandler and BrokerageTests go back to master, they are not part of the order group work
- restore the group manager comments to one line each in Order and OrderJsonConverter
- place the cash buying power oco test through OneCancelsTheOtherOrder instead of hand built requests
@Romazes
Romazes force-pushed the feature-8253-oco-order branch from 9df6d96 to cdfd68c Compare August 20, 2026 19:40
- one line each in CashBuyingPowerModel, SecurityPortfolioManager and BrokerageTransactionHandler
@Romazes
Romazes marked this pull request as ready for review August 24, 2026 16:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant