Skip to content

Commit 05ad8b1

Browse files
committed
feature: add one-cancels-the-other order group support
- 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
1 parent c6ef320 commit 05ad8b1

35 files changed

Lines changed: 2210 additions & 121 deletions
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
/*
2+
* QUANTCONNECT.COM - Democratizing Finance, Empowering Individuals.
3+
* Lean Algorithmic Trading Engine v2.0. Copyright 2014 QuantConnect Corporation.
4+
*
5+
* Licensed under the Apache License, Version 2.0 (the "License");
6+
* you may not use this file except in compliance with the License.
7+
* You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
8+
*
9+
* Unless required by applicable law or agreed to in writing, software
10+
* distributed under the License is distributed on an "AS IS" BASIS,
11+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
* See the License for the specific language governing permissions and
13+
* limitations under the License.
14+
*/
15+
16+
using System;
17+
using System.Collections.Generic;
18+
using System.Linq;
19+
using QuantConnect.Data;
20+
using QuantConnect.Interfaces;
21+
using QuantConnect.Orders;
22+
23+
namespace QuantConnect.Algorithm.CSharp
24+
{
25+
/// <summary>
26+
/// Regression algorithm for the cancel path of a one-cancels-the-other (OCO) order group: the group is
27+
/// placed with both legs far from the market, so neither can fill inside the test window, then one of the
28+
/// two tickets is explicitly canceled. Asserts that canceling one leg cancels the whole group, not just
29+
/// the leg that was canceled
30+
/// </summary>
31+
public class OneCancelsTheOtherOrderCancelRegressionAlgorithm : QCAlgorithm, IRegressionAlgorithmDefinition
32+
{
33+
private Symbol _spy;
34+
private List<OrderTicket> _tickets;
35+
private bool _canceled;
36+
37+
public override void Initialize()
38+
{
39+
SetStartDate(2019, 1, 1);
40+
SetEndDate(2019, 1, 31);
41+
42+
_spy = AddEquity("SPY", Resolution.Hour).Symbol;
43+
}
44+
45+
public override void OnData(Slice slice)
46+
{
47+
if (!Portfolio.Invested)
48+
{
49+
MarketOrder(_spy, 100);
50+
51+
// both legs sit far from the market: limit sell +30% and stop sell -30% should never be
52+
// reachable in this test window, so only the explicit cancel below can close the group
53+
_tickets = OneCancelsTheOtherOrder(_spy, -100,
54+
limitPrice: Math.Round(Securities[_spy].Price * 1.30m, 2),
55+
stopPrice: Math.Round(Securities[_spy].Price * 0.70m, 2));
56+
}
57+
else if (!_canceled && Time.Day > 5)
58+
{
59+
// cancel only one leg: the whole OCO group must cancel with it
60+
_tickets[0].Cancel();
61+
_canceled = true;
62+
}
63+
}
64+
65+
public override void OnOrderEvent(OrderEvent orderEvent)
66+
{
67+
if (_tickets == null || orderEvent.Status != OrderStatus.Filled)
68+
{
69+
return;
70+
}
71+
72+
// neither OCO leg's price should ever be reachable in this test window; a fill here means the
73+
// regression scenario itself is broken, not just the cancellation behavior being tested
74+
if (_tickets.Any(ticket => ticket.OrderId == orderEvent.OrderId))
75+
{
76+
throw new RegressionTestException(
77+
$"Unexpected fill for OCO leg {orderEvent.OrderId}: prices were set far from the market so the group should only close through the explicit cancel");
78+
}
79+
}
80+
81+
public override void OnEndOfAlgorithm()
82+
{
83+
if (!_canceled)
84+
{
85+
throw new RegressionTestException("Expected to have canceled one of the OCO legs before the end of the algorithm");
86+
}
87+
88+
if (_tickets == null || _tickets.Count != 2)
89+
{
90+
throw new RegressionTestException("Expected the OCO group to have exactly 2 legs");
91+
}
92+
93+
foreach (var ticket in _tickets)
94+
{
95+
if (ticket.Status != OrderStatus.Canceled)
96+
{
97+
throw new RegressionTestException(
98+
$"Expected every OCO leg to be Canceled, including the leg that was not explicitly canceled. Leg {ticket.OrderId} has status {ticket.Status}");
99+
}
100+
}
101+
102+
// canceling the OCO exit group must not touch the original market order fill
103+
if (!Portfolio.Invested)
104+
{
105+
throw new RegressionTestException("Expected the algorithm to still be invested: the market order fill is independent from the canceled OCO group");
106+
}
107+
}
108+
109+
/// <summary>
110+
/// This is used by the regression test system to indicate if the open source Lean repository has the required data to run this algorithm.
111+
/// </summary>
112+
public bool CanRunLocally { get; } = true;
113+
114+
/// <summary>
115+
/// This is used by the regression test system to indicate which languages this algorithm is written in.
116+
/// </summary>
117+
public List<Language> Languages { get; } = new() { Language.CSharp, Language.Python };
118+
119+
/// <summary>
120+
/// Data Points count of all timeslices of algorithm
121+
/// </summary>
122+
public long DataPoints => 302;
123+
124+
/// <summary>
125+
/// Data Points count of the algorithm history
126+
/// </summary>
127+
public int AlgorithmHistoryDataPoints => 0;
128+
129+
/// <summary>
130+
/// Final status of the algorithm
131+
/// </summary>
132+
public AlgorithmStatus AlgorithmStatus => AlgorithmStatus.Completed;
133+
134+
/// <summary>
135+
/// This is used by the regression test system to indicate what the expected statistics are from running the algorithm
136+
/// </summary>
137+
public Dictionary<string, string> ExpectedStatistics => new Dictionary<string, string>
138+
{
139+
{"Total Orders", "3"},
140+
{"Average Win", "0%"},
141+
{"Average Loss", "0%"},
142+
{"Compounding Annual Return", "29.303%"},
143+
{"Drawdown", "0.700%"},
144+
{"Expectancy", "0"},
145+
{"Start Equity", "100000"},
146+
{"End Equity", "102182.68"},
147+
{"Net Profit", "2.183%"},
148+
{"Sharpe Ratio", "4.501"},
149+
{"Sortino Ratio", "5.158"},
150+
{"Probabilistic Sharpe Ratio", "85.073%"},
151+
{"Loss Rate", "0%"},
152+
{"Win Rate", "0%"},
153+
{"Profit-Loss Ratio", "0"},
154+
{"Alpha", "-0.047"},
155+
{"Beta", "0.24"},
156+
{"Annual Standard Deviation", "0.038"},
157+
{"Annual Variance", "0.001"},
158+
{"Information Ratio", "-6.241"},
159+
{"Tracking Error", "0.117"},
160+
{"Treynor Ratio", "0.708"},
161+
{"Total Fees", "$1.00"},
162+
{"Estimated Strategy Capacity", "$470000000.00"},
163+
{"Lowest Capacity Asset", "SPY R735QTJ8XC9X"},
164+
{"Portfolio Turnover", "0.76%"},
165+
{"Drawdown Recovery", "12"},
166+
{"OrderListHash", "eed60d3f37058f4f436ee819cb6228d7"}
167+
};
168+
}
169+
}
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
/*
2+
* QUANTCONNECT.COM - Democratizing Finance, Empowering Individuals.
3+
* Lean Algorithmic Trading Engine v2.0. Copyright 2014 QuantConnect Corporation.
4+
*
5+
* Licensed under the Apache License, Version 2.0 (the "License");
6+
* you may not use this file except in compliance with the License.
7+
* You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
8+
*
9+
* Unless required by applicable law or agreed to in writing, software
10+
* distributed under the License is distributed on an "AS IS" BASIS,
11+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
* See the License for the specific language governing permissions and
13+
* limitations under the License.
14+
*/
15+
16+
using System;
17+
using System.Collections.Generic;
18+
using QuantConnect.Data;
19+
using QuantConnect.Interfaces;
20+
using QuantConnect.Orders;
21+
22+
namespace QuantConnect.Algorithm.CSharp
23+
{
24+
/// <summary>
25+
/// Regression algorithm for the winner path of a one-cancels-the-other (OCO) order group: buys SPY at
26+
/// market, then places a 2-leg OCO group (a take-profit limit leg 1% above the entry price and a stop-market
27+
/// leg 30% below it, which the January 2019 rally can never reach). The limit leg should fill and the group
28+
/// should cancel the stop leg in the same order-event batch
29+
/// </summary>
30+
public class OneCancelsTheOtherOrderRegressionAlgorithm : QCAlgorithm, IRegressionAlgorithmDefinition
31+
{
32+
private Symbol _spy;
33+
private List<OrderTicket> _tickets;
34+
35+
// tracks every order event this algorithm receives, relevant or not, so we can tell whether two
36+
// particular events were delivered back to back (same batch) or with something else in between
37+
private int _orderEventCount;
38+
39+
private int? _winnerOrderId;
40+
private DateTime _winnerFillUtcTime;
41+
private int _winnerFillEventCount;
42+
private bool _siblingCanceledInSameBatch;
43+
44+
public override void Initialize()
45+
{
46+
SetStartDate(2019, 1, 1);
47+
SetEndDate(2019, 1, 20);
48+
49+
_spy = AddEquity("SPY", Resolution.Hour).Symbol;
50+
}
51+
52+
public override void OnData(Slice slice)
53+
{
54+
// trade exactly once: once the winning leg closes the position, Portfolio.Invested goes back to
55+
// false and this would otherwise place a second, independent OCO group on top of the first
56+
if (_tickets == null && !Portfolio.Invested)
57+
{
58+
MarketOrder(_spy, 100);
59+
60+
// take profit +1% is reached by the January rally; the stop -30% can never fill
61+
_tickets = OneCancelsTheOtherOrder(_spy, -100,
62+
limitPrice: Math.Round(Securities[_spy].Price * 1.01m, 2),
63+
stopPrice: Math.Round(Securities[_spy].Price * 0.70m, 2));
64+
}
65+
}
66+
67+
public override void OnOrderEvent(OrderEvent orderEvent)
68+
{
69+
_orderEventCount++;
70+
71+
if (_tickets == null || (orderEvent.OrderId != _tickets[0].OrderId && orderEvent.OrderId != _tickets[1].OrderId))
72+
{
73+
// not one of our OCO legs (for example the entry market order)
74+
return;
75+
}
76+
77+
if (orderEvent.Status == OrderStatus.Filled)
78+
{
79+
if (_winnerOrderId.HasValue)
80+
{
81+
throw new RegressionTestException(
82+
$"Order {orderEvent.OrderId} filled after order {_winnerOrderId} had already won the OCO group. Only one leg should ever fill.");
83+
}
84+
85+
_winnerOrderId = orderEvent.OrderId;
86+
_winnerFillUtcTime = orderEvent.UtcTime;
87+
_winnerFillEventCount = _orderEventCount;
88+
}
89+
else if (orderEvent.Status == OrderStatus.Canceled)
90+
{
91+
if (!_winnerOrderId.HasValue)
92+
{
93+
throw new RegressionTestException($"Order {orderEvent.OrderId} was canceled before any leg of the group had filled.");
94+
}
95+
96+
// the sibling cancel must land in the same order-event batch as the winning fill: same
97+
// timestamp, and delivered as the very next order event this algorithm receives after the fill
98+
if (orderEvent.UtcTime != _winnerFillUtcTime || _orderEventCount != _winnerFillEventCount + 1)
99+
{
100+
throw new RegressionTestException(
101+
"Expected the losing leg's Canceled event to arrive in the same order-event batch as the winning Filled event.");
102+
}
103+
104+
_siblingCanceledInSameBatch = true;
105+
}
106+
}
107+
108+
public override void OnEndOfAlgorithm()
109+
{
110+
if (_tickets == null || _tickets.Count != 2)
111+
{
112+
throw new RegressionTestException("Expected the one-cancels-the-other order group to have been placed with 2 legs.");
113+
}
114+
115+
// limit leg won, stop leg was canceled by the group
116+
if (_tickets[0].Status != OrderStatus.Filled)
117+
{
118+
throw new RegressionTestException($"Expected the take-profit limit order to be filled, but it was {_tickets[0].Status}.");
119+
}
120+
121+
if (_tickets[1].Status != OrderStatus.Canceled)
122+
{
123+
throw new RegressionTestException($"Expected the stop-loss order to be canceled by the group, but it was {_tickets[1].Status}.");
124+
}
125+
126+
if (Portfolio.Invested)
127+
{
128+
throw new RegressionTestException("Expected no open position at the end of the algorithm: the winning limit leg should have closed it.");
129+
}
130+
131+
if (!_siblingCanceledInSameBatch)
132+
{
133+
throw new RegressionTestException("Expected the stop-loss leg's Canceled event to have arrived in the same order-event batch as the winning fill.");
134+
}
135+
}
136+
137+
/// <summary>
138+
/// This is used by the regression test system to indicate if the open source Lean repository has the required data to run this algorithm.
139+
/// </summary>
140+
public bool CanRunLocally { get; } = true;
141+
142+
/// <summary>
143+
/// This is used by the regression test system to indicate which languages this algorithm is written in.
144+
/// </summary>
145+
public List<Language> Languages { get; } = new() { Language.CSharp, Language.Python };
146+
147+
/// <summary>
148+
/// Data Points count of all timeslices of algorithm
149+
/// </summary>
150+
public long DataPoints => 190;
151+
152+
/// <summary>
153+
/// Data Points count of the algorithm history
154+
/// </summary>
155+
public int AlgorithmHistoryDataPoints => 0;
156+
157+
/// <summary>
158+
/// Final status of the algorithm
159+
/// </summary>
160+
public AlgorithmStatus AlgorithmStatus => AlgorithmStatus.Completed;
161+
162+
/// <summary>
163+
/// This is used by the regression test system to indicate what the expected statistics are from running the algorithm
164+
/// </summary>
165+
public Dictionary<string, string> ExpectedStatistics => new Dictionary<string, string>
166+
{
167+
{"Total Orders", "3"},
168+
{"Average Win", "0.24%"},
169+
{"Average Loss", "0%"},
170+
{"Compounding Annual Return", "4.986%"},
171+
{"Drawdown", "0.000%"},
172+
{"Expectancy", "0"},
173+
{"Start Equity", "100000"},
174+
{"End Equity", "100235.79"},
175+
{"Net Profit", "0.236%"},
176+
{"Sharpe Ratio", "0.613"},
177+
{"Sortino Ratio", "0"},
178+
{"Probabilistic Sharpe Ratio", "45.094%"},
179+
{"Loss Rate", "0%"},
180+
{"Win Rate", "100%"},
181+
{"Profit-Loss Ratio", "0"},
182+
{"Alpha", "0.011"},
183+
{"Beta", "-0.003"},
184+
{"Annual Standard Deviation", "0.009"},
185+
{"Annual Variance", "0"},
186+
{"Information Ratio", "-8.833"},
187+
{"Tracking Error", "0.18"},
188+
{"Treynor Ratio", "-1.72"},
189+
{"Total Fees", "$2.00"},
190+
{"Estimated Strategy Capacity", "$140000000.00"},
191+
{"Lowest Capacity Asset", "SPY R735QTJ8XC9X"},
192+
{"Portfolio Turnover", "2.64%"},
193+
{"Drawdown Recovery", "0"},
194+
{"OrderListHash", "d0a9c717f35803df04a8ab7f31697c5c"}
195+
};
196+
}
197+
}

0 commit comments

Comments
 (0)