diff --git a/Algorithm.CSharp/OrderSurfaceShortcutsRegressionAlgorithm.cs b/Algorithm.CSharp/OrderSurfaceShortcutsRegressionAlgorithm.cs new file mode 100644 index 000000000000..d9eb7c9eeea7 --- /dev/null +++ b/Algorithm.CSharp/OrderSurfaceShortcutsRegressionAlgorithm.cs @@ -0,0 +1,249 @@ +/* + * QUANTCONNECT.COM - Democratizing Finance, Empowering Individuals. + * Lean Algorithmic Trading Engine v2.0. Copyright 2014 QuantConnect Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. +*/ + +using System; +using System.Collections.Generic; +using System.Linq; + +using QuantConnect.Data; +using QuantConnect.Interfaces; +using QuantConnect.Orders; + +namespace QuantConnect.Algorithm.CSharp +{ + /// + /// Regression algorithm asserting the flat order surface shortcuts: the numeric OrderFee + /// surface (OrderFee.Amount, arithmetic and comparison operators, OrderEvent.OrderFeeAmount), + /// the flat combo group ids (Order.GroupOrderManagerId, OrderEvent.GroupId, ComboOrderTicket), + /// the tag-argument tolerance of MarketOrder/Liquidate and OrderTargetNotional + /// + public class OrderSurfaceShortcutsRegressionAlgorithm : QCAlgorithm, IRegressionAlgorithmDefinition + { + private Symbol _equitySymbol; + private Symbol _optionSymbol; + private OrderTicket _taggedTicket; + private OrderTicket _notionalTicket; + private ComboOrderTicket _comboTicket; + private readonly HashSet _comboFillGroupIds = new(); + private int _comboFillEventsCount; + + public override void Initialize() + { + SetStartDate(2015, 12, 24); + SetEndDate(2015, 12, 24); + SetCash(200000); + + var equity = AddEquity("GOOG", leverage: 4, fillForward: true); + _equitySymbol = equity.Symbol; + var option = AddOption(equity.Symbol, fillForward: true); + _optionSymbol = option.Symbol; + + option.SetFilter(u => u.StandardsOnly().Strikes(-2, +2).Expiration(0, 180)); + } + + public override void OnData(Slice slice) + { + if (_taggedTicket == null && IsMarketOpen(_equitySymbol)) + { + // tag in the tag argument slot; the Python version passes it in the third positional slot + _taggedTicket = MarketOrder(_equitySymbol, 1, tag: "tagged entry"); + + // target an absolute notional value instead of a portfolio percentage + _notionalTicket = OrderTargetNotional(_equitySymbol, 10000); + if (_notionalTicket == null) + { + throw new RegressionTestException("OrderTargetNotional was expected to place an order"); + } + + // a tag slipped into the symbol slot must fail pointing to the tag parameter + var liquidateFailed = false; + try + { + Liquidate("EOD close"); + } + catch (ArgumentException exception) + { + liquidateFailed = true; + if (!exception.Message.Contains("tag")) + { + throw new RegressionTestException( + $"Liquidate() with an unknown ticker was expected to point to the tag parameter but the error was: {exception.Message}"); + } + } + if (!liquidateFailed) + { + throw new RegressionTestException("Liquidate() with an unknown ticker was expected to fail"); + } + } + + if (_comboTicket == null && IsMarketOpen(_optionSymbol) && slice.OptionChains.TryGetValue(_optionSymbol, out var chain)) + { + var callContracts = chain.Where(contract => contract.Right == OptionRight.Call) + .GroupBy(x => x.Expiry) + .OrderBy(grouping => grouping.Key) + .First() + .OrderBy(x => x.Strike) + .ToList(); + if (callContracts.Count < 3) + { + return; + } + + var legs = new List() + { + Leg.Create(callContracts[0].Symbol, 1), + Leg.Create(callContracts[1].Symbol, -2), + Leg.Create(callContracts[2].Symbol, 1) + }; + _comboTicket = ComboMarketOrder(legs, 10); + + if (_comboTicket.Count != legs.Count || _comboTicket.Tickets.Count != legs.Count) + { + throw new RegressionTestException($"Expected {legs.Count} leg tickets, found {_comboTicket.Count}"); + } + if (_comboTicket.GroupOrderManagerId == null) + { + throw new RegressionTestException("The combo order ticket was expected to have a group order manager id"); + } + } + } + + public override void OnOrderEvent(OrderEvent orderEvent) + { + if (orderEvent.Status != OrderStatus.Filled) + { + return; + } + + var order = Transactions.GetOrderById(orderEvent.OrderId); + + // the fee amount shortcuts and operators must match the two-level Value.Amount + var feeAmount = orderEvent.OrderFee.Value.Amount; + if (orderEvent.OrderFeeAmount != feeAmount || orderEvent.OrderFee.Amount != feeAmount) + { + throw new RegressionTestException($"Order fee amount shortcuts do not match the fee amount {feeAmount}"); + } + if (orderEvent.OrderFee + orderEvent.OrderFee != 2 * feeAmount || (feeAmount != 0 && !(orderEvent.OrderFee > 0))) + { + throw new RegressionTestException($"Order fee operators do not match the fee amount {feeAmount}"); + } + + if (order.Type == OrderType.ComboMarket) + { + // Note: these fill events are received while the synchronous ComboMarketOrder() call is still + // in flight, so the combo ticket is checked against them in OnEndOfAlgorithm + _comboFillEventsCount++; + if (order.GroupOrderManagerId == null) + { + throw new RegressionTestException("Combo orders were expected to have a group order manager id"); + } + if (orderEvent.GroupId != order.GroupOrderManagerId) + { + throw new RegressionTestException($"Expected order event group id {order.GroupOrderManagerId}, found {orderEvent.GroupId}"); + } + _comboFillGroupIds.Add(order.GroupOrderManagerId); + } + else if (order.GroupOrderManagerId != null || orderEvent.GroupId != null) + { + throw new RegressionTestException("Non-combo orders were expected to have null group ids"); + } + } + + public override void OnEndOfAlgorithm() + { + if (_taggedTicket == null || _taggedTicket.Tag != "tagged entry") + { + throw new RegressionTestException("The market order tag was not set from the tag argument"); + } + if (_notionalTicket.Status != OrderStatus.Filled) + { + throw new RegressionTestException("The notional target order was expected to be filled"); + } + if (_comboTicket == null || _comboFillEventsCount != _comboTicket.Count) + { + throw new RegressionTestException("The combo order was expected to be placed and filled"); + } + if (!_comboTicket.Filled) + { + throw new RegressionTestException("The combo order ticket was expected to aggregate the leg fills"); + } + if (_comboFillGroupIds.Single() != _comboTicket.GroupOrderManagerId) + { + throw new RegressionTestException($"Expected all combo fills to have group id {_comboTicket.GroupOrderManagerId}, " + + $"found {string.Join(", ", _comboFillGroupIds)}"); + } + } + + /// + /// This is used by the regression test system to indicate if the open source Lean repository has the required data to run this algorithm. + /// + public bool CanRunLocally => true; + + /// + /// This is used by the regression test system to indicate which languages this algorithm is written in. + /// + public List Languages { get; } = new() { Language.CSharp, Language.Python }; + + /// + /// Data Points count of all timeslices of algorithm + /// + public long DataPoints => 15023; + + /// + /// Data Points count of the algorithm history + /// + public int AlgorithmHistoryDataPoints => 0; + + /// + /// Final status of the algorithm + /// + public AlgorithmStatus AlgorithmStatus => AlgorithmStatus.Completed; + + /// + /// This is used by the regression test system to indicate what the expected statistics are from running the algorithm + /// + public Dictionary ExpectedStatistics => new Dictionary + { + {"Total Orders", "5"}, + {"Average Win", "0%"}, + {"Average Loss", "0%"}, + {"Compounding Annual Return", "0%"}, + {"Drawdown", "0%"}, + {"Expectancy", "0"}, + {"Start Equity", "200000"}, + {"End Equity", "198005.36"}, + {"Net Profit", "0%"}, + {"Sharpe Ratio", "0"}, + {"Sortino Ratio", "0"}, + {"Probabilistic Sharpe Ratio", "0%"}, + {"Loss Rate", "0%"}, + {"Win Rate", "0%"}, + {"Profit-Loss Ratio", "0"}, + {"Alpha", "0"}, + {"Beta", "0"}, + {"Annual Standard Deviation", "0"}, + {"Annual Variance", "0"}, + {"Information Ratio", "0"}, + {"Tracking Error", "0"}, + {"Treynor Ratio", "0"}, + {"Total Fees", "$28.00"}, + {"Estimated Strategy Capacity", "$80000.00"}, + {"Lowest Capacity Asset", "GOOCV W78ZERHAT67A|GOOCV VP83T1ZUHROL"}, + {"Portfolio Turnover", "35.27%"}, + {"Drawdown Recovery", "0"}, + {"OrderListHash", "94d4e9ad7a0c13884b49d68165ce6766"} + }; + } +} diff --git a/Algorithm.Python/OrderSurfaceShortcutsRegressionAlgorithm.py b/Algorithm.Python/OrderSurfaceShortcutsRegressionAlgorithm.py new file mode 100644 index 000000000000..920005d040ea --- /dev/null +++ b/Algorithm.Python/OrderSurfaceShortcutsRegressionAlgorithm.py @@ -0,0 +1,127 @@ +# QUANTCONNECT.COM - Democratizing Finance, Empowering Individuals. +# Lean Algorithmic Trading Engine v2.0. Copyright 2014 QuantConnect Corporation. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from AlgorithmImports import * + +### +### Regression algorithm asserting the flat order surface shortcuts: the numeric OrderFee +### surface (order_fee.amount, arithmetic and comparison operators, order_event.order_fee_amount), +### the flat combo group ids (order.group_order_manager_id, order_event.group_id, ComboOrderTicket), +### the tag-argument tolerance of market_order/liquidate and order_target_notional +### +class OrderSurfaceShortcutsRegressionAlgorithm(QCAlgorithm): + + def initialize(self): + self.set_start_date(2015, 12, 24) + self.set_end_date(2015, 12, 24) + self.set_cash(200000) + + equity = self.add_equity("GOOG", leverage=4, fill_forward=True) + self._equity_symbol = equity.symbol + option = self.add_option(equity.symbol, fill_forward=True) + self._option_symbol = option.symbol + + option.set_filter(lambda u: u.standards_only().strikes(-2, 2).expiration(0, 180)) + + self._tagged_ticket = None + self._notional_ticket = None + self._combo_ticket = None + self._combo_fill_group_ids = set() + self._combo_fill_events_count = 0 + + def on_data(self, slice): + if self._tagged_ticket is None and self.is_market_open(self._equity_symbol): + # the tag in the third positional slot must be accepted as the tag argument + self._tagged_ticket = self.market_order(self._equity_symbol, 1, "tagged entry") + + # target an absolute notional value instead of a portfolio percentage + self._notional_ticket = self.order_target_notional(self._equity_symbol, 10000) + if self._notional_ticket is None: + raise AssertionError("order_target_notional was expected to place an order") + + # a tag slipped into the symbol slot must fail pointing to the tag parameter + liquidate_failed = False + try: + self.liquidate("EOD close") + except Exception as exception: + liquidate_failed = True + if "tag" not in str(exception): + raise AssertionError("liquidate() with an unknown ticker was expected to point to the " + f"tag parameter but the error was: {exception}") + if not liquidate_failed: + raise AssertionError("liquidate() with an unknown ticker was expected to fail") + + if self._combo_ticket is None and self.is_market_open(self._option_symbol): + chain = slice.option_chains.get(self._option_symbol) + if chain is None: + return + call_contracts = [contract for contract in chain if contract.right == OptionRight.CALL] + if not call_contracts: + return + first_expiry = min(contract.expiry for contract in call_contracts) + call_contracts = sorted((contract for contract in call_contracts if contract.expiry == first_expiry), + key=lambda contract: contract.strike) + if len(call_contracts) < 3: + return + + legs = [ + Leg.create(call_contracts[0].symbol, 1), + Leg.create(call_contracts[1].symbol, -2), + Leg.create(call_contracts[2].symbol, 1), + ] + self._combo_ticket = self.combo_market_order(legs, 10) + + if len(self._combo_ticket) != len(legs) or len(self._combo_ticket.tickets) != len(legs): + raise AssertionError(f"Expected {len(legs)} leg tickets, found {len(self._combo_ticket)}") + if self._combo_ticket.group_order_manager_id is None: + raise AssertionError("The combo order ticket was expected to have a group order manager id") + + def on_order_event(self, order_event): + if order_event.status != OrderStatus.FILLED: + return + + order = self.transactions.get_order_by_id(order_event.order_id) + + # the fee amount shortcuts and operators must match the two-level value.amount + fee_amount = order_event.order_fee.value.amount + if order_event.order_fee_amount != fee_amount or order_event.order_fee.amount != fee_amount: + raise AssertionError(f"Order fee amount shortcuts do not match the fee amount {fee_amount}") + fee = order_event.order_fee + if fee + fee != 2 * fee_amount or sum([fee, fee], 0) != 2 * fee_amount or (fee_amount != 0 and not fee > 0): + raise AssertionError(f"Order fee operators do not match the fee amount {fee_amount}") + + if order.type == OrderType.COMBO_MARKET: + # Note: these fill events are received while the synchronous combo_market_order() call is still + # in flight, so the combo ticket is checked against them in on_end_of_algorithm + self._combo_fill_events_count += 1 + if order.group_order_manager_id is None: + raise AssertionError("Combo orders were expected to have a group order manager id") + if order_event.group_id != order.group_order_manager_id: + raise AssertionError(f"Expected order event group id {order.group_order_manager_id}, " + f"found {order_event.group_id}") + self._combo_fill_group_ids.add(order.group_order_manager_id) + elif order.group_order_manager_id is not None or order_event.group_id is not None: + raise AssertionError("Non-combo orders were expected to have null group ids") + + def on_end_of_algorithm(self): + if self._tagged_ticket is None or self._tagged_ticket.tag != "tagged entry": + raise AssertionError("The market order tag was not set from the tag argument") + if self._notional_ticket.status != OrderStatus.FILLED: + raise AssertionError("The notional target order was expected to be filled") + if self._combo_ticket is None or self._combo_fill_events_count != len(self._combo_ticket): + raise AssertionError("The combo order was expected to be placed and filled") + if not self._combo_ticket.filled: + raise AssertionError("The combo order ticket was expected to aggregate the leg fills") + if self._combo_fill_group_ids != {self._combo_ticket.group_order_manager_id}: + raise AssertionError(f"Expected all combo fills to have group id {self._combo_ticket.group_order_manager_id}, " + f"found {self._combo_fill_group_ids}") diff --git a/Algorithm/QCAlgorithm.Python.cs b/Algorithm/QCAlgorithm.Python.cs index 9859cbc4094b..ca8122007820 100644 --- a/Algorithm/QCAlgorithm.Python.cs +++ b/Algorithm/QCAlgorithm.Python.cs @@ -1897,6 +1897,23 @@ public IndicatorHistory IndicatorHistory(PyObject indicator, IEnumerable } } + /// + /// Market order implementation accepting the tag in the third position: Send a market order and wait for it to be filled. + /// Absorbs the common 'market_order(symbol, quantity, tag)' call shape from Python, where the tag string + /// would otherwise bind to the 'asynchronous' flag and fail overload resolution + /// + /// Symbol of the MarketType Required. + /// Number of shares to request. + /// Place a custom order property or tag (e.g. indicator data). + /// The order properties to use. Defaults to + /// The order ticket instance. + [DocumentationAttribute(TradingAndOrders)] + public OrderTicket MarketOrder(PyObject symbol, decimal quantity, string tag, IOrderProperties orderProperties = null) + { + return MarketOrder(symbol.ConvertToSymbolEnumerable().Single(), quantity, asynchronous: false, tag: tag, + orderProperties: orderProperties); + } + /// /// Liquidate your portfolio holdings /// diff --git a/Algorithm/QCAlgorithm.Trading.cs b/Algorithm/QCAlgorithm.Trading.cs index ddba02f0249c..4cbe2c712a5f 100644 --- a/Algorithm/QCAlgorithm.Trading.cs +++ b/Algorithm/QCAlgorithm.Trading.cs @@ -870,11 +870,11 @@ public List Order(OptionStrategy strategy, int quantity, bool async /// Send the order asynchronously (false). Otherwise we'll block until it fills /// String tag for the order (optional) /// The order properties to use. Defaults to - /// Sequence of order tickets, one for each leg + /// The combo order ticket, a list of order tickets, one for each leg [DocumentationAttribute(TradingAndOrders)] - public List ComboMarketOrder(List legs, int quantity, bool asynchronous = false, string tag = "", IOrderProperties orderProperties = null) + public ComboOrderTicket ComboMarketOrder(List legs, int quantity, bool asynchronous = false, string tag = "", IOrderProperties orderProperties = null) { - return SubmitComboOrder(legs, quantity, 0, asynchronous, tag, orderProperties); + return new ComboOrderTicket(SubmitComboOrder(legs, quantity, 0, asynchronous, tag, orderProperties)); } /// @@ -885,10 +885,10 @@ public List ComboMarketOrder(List legs, int quantity, bool asy /// Send the order asynchronously (false). Otherwise we'll block until it is fully submitted /// String tag for the order (optional) /// The order properties to use. Defaults to - /// Sequence of order tickets, one for each leg + /// The combo order ticket, a list of order tickets, one for each leg /// If not every leg has a defined limit price [DocumentationAttribute(TradingAndOrders)] - public List ComboLegLimitOrder(List legs, int quantity, bool asynchronous = false, + public ComboOrderTicket ComboLegLimitOrder(List legs, int quantity, bool asynchronous = false, string tag = "", IOrderProperties orderProperties = null) { if (legs.Any(x => x.OrderPrice == null || x.OrderPrice == 0)) @@ -896,7 +896,7 @@ public List ComboLegLimitOrder(List legs, int quantity, bool a throw new ArgumentException("ComboLegLimitOrder requires a limit price for each leg"); } - return SubmitComboOrder(legs, quantity, 0, asynchronous, tag, orderProperties); + return new ComboOrderTicket(SubmitComboOrder(legs, quantity, 0, asynchronous, tag, orderProperties)); } /// @@ -909,10 +909,10 @@ public List ComboLegLimitOrder(List legs, int quantity, bool a /// Send the order asynchronously (false). Otherwise we'll block until it is fully submitted /// String tag for the order (optional) /// The order properties to use. Defaults to - /// Sequence of order tickets, one for each leg + /// The combo order ticket, a list of order tickets, one for each leg /// If the order type is neither ComboMarket, ComboLimit nor ComboLegLimit [DocumentationAttribute(TradingAndOrders)] - public List ComboLimitOrder(List legs, int quantity, decimal limitPrice, + public ComboOrderTicket ComboLimitOrder(List legs, int quantity, decimal limitPrice, bool asynchronous = false, string tag = "", IOrderProperties orderProperties = null) { if (limitPrice == 0) @@ -925,7 +925,7 @@ public List ComboLimitOrder(List legs, int quantity, decimal l throw new ArgumentException("ComboLimitOrder does not support limit prices for individual legs"); } - return SubmitComboOrder(legs, quantity, limitPrice, asynchronous, tag, orderProperties); + return new ComboOrderTicket(SubmitComboOrder(legs, quantity, limitPrice, asynchronous, tag, orderProperties)); } private List GenerateOptionStrategyOrders(OptionStrategy strategy, int strategyQuantity, bool asynchronous, string tag, IOrderProperties orderProperties) @@ -1418,6 +1418,28 @@ public List Liquidate(IEnumerable symbols, bool asynchronou return orderTickets; } + /// + /// Liquidate the holdings of the security referenced by the given ticker + /// + /// If the ticker is not a known symbol this fails pointing to the tag parameter, absorbing the + /// common 'Liquidate(tag)' call shape where the tag would otherwise silently bind to the symbol argument + /// The ticker of the asset to liquidate + /// Flag to indicate if the symbols should be liquidated asynchronously + /// Custom tag to know who is calling this + /// Order properties to use + [DocumentationAttribute(TradingAndOrders)] + public List Liquidate(string ticker, bool asynchronous = false, string tag = null, IOrderProperties orderProperties = null) + { + if (!SymbolCache.TryGetSymbol(ticker, out var symbol)) + { + throw new ArgumentException($"Liquidate(): '{ticker}' is not a recognized symbol. " + + "The first argument must be a Symbol, a ticker or a list of them. " + + $"To pass a custom tag use Liquidate(tag: \"{ticker}\")"); + } + + return Liquidate(symbol, asynchronous, tag, orderProperties); + } + /// /// Liquidate all holdings and cancel open orders. Called at the end of day for tick-strategies. /// @@ -1431,6 +1453,44 @@ public List Liquidate(Symbol symbolToLiquidate, string tag) return Liquidate(symbol: symbolToLiquidate, tag: tag).Select(x => x.OrderId).ToList(); } + /// + /// Sends a market order to adjust the holdings of the given symbol to the target notional value + /// in units of the account currency, rounding the target quantity down to the security's lot size. + /// Unlike , which targets a + /// percentage of the total portfolio value, this targets an absolute position value: for derivatives, the + /// contract multiplier is included, so the target quantity is targetNotional / (price x multiplier) + /// + /// Symbol of the asset to trade + /// The target notional value of the holdings, in units of the account currency + /// Send the order asynchronously (false). Otherwise we'll block until it fills + /// Place a custom order property or tag (e.g. indicator data). + /// The order properties to use. Defaults to + /// The order ticket instance, or null if the current holdings already match the target + [DocumentationAttribute(TradingAndOrders)] + public OrderTicket OrderTargetNotional(Symbol symbol, decimal targetNotional, bool asynchronous = false, string tag = "", + IOrderProperties orderProperties = null) + { + var security = GetSecurityForOrder(symbol); + + // the current notional value of a single unit, including the contract multiplier and currency conversion + var unitValue = security.Holdings.GetQuantityValue(1).InAccountCurrency; + if (unitValue == 0) + { + throw new InvalidOperationException($"OrderTargetNotional(): {symbol.Value}: unable to compute an order quantity for " + + $"the target notional {targetNotional}: the security has no market price yet. Warm up the algorithm or wait for " + + "data before placing the order."); + } + + var targetQuantity = OrderSizing.AdjustByLotSize(security, targetNotional / unitValue); + var quantity = targetQuantity - security.Holdings.Quantity; + if (quantity == 0) + { + return null; + } + + return MarketOrder(symbol, quantity, asynchronous, tag, orderProperties); + } + /// /// Maximum number of orders for the algorithm /// diff --git a/Common/Orders/ComboOrderTicket.cs b/Common/Orders/ComboOrderTicket.cs new file mode 100644 index 000000000000..024cb6832ec7 --- /dev/null +++ b/Common/Orders/ComboOrderTicket.cs @@ -0,0 +1,62 @@ +/* + * QUANTCONNECT.COM - Democratizing Finance, Empowering Individuals. + * Lean Algorithmic Trading Engine v2.0. Copyright 2014 QuantConnect Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. +*/ + +using System.Collections.Generic; +using System.Linq; + +namespace QuantConnect.Orders +{ + /// + /// The collection of leg order tickets resulting from a combo order submission, + /// with helpers to track the combo as a single unit instead of reassembling the + /// legs by group order manager id + /// + /// Deliberately a non-generic subclass: pythonnet converts + /// generic list instances into plain Python lists, which would strip these properties. + /// A non-generic subclass reaches Python as an object that still supports len(), + /// indexing and iteration + public class ComboOrderTicket : List + { + /// + /// The order tickets of the combo order legs + /// + public IReadOnlyList Tickets => this; + + /// + /// The unique id of the group of orders this combo order consists of, null if empty + /// + public int? GroupOrderManagerId => Count > 0 ? this[0].SubmitRequest?.GroupOrderManager?.Id : null; + + /// + /// True if every leg of the combo order has been completely filled + /// + public bool Filled => Count > 0 && this.All(ticket => ticket.Status == OrderStatus.Filled); + + /// + /// Creates a new empty instance + /// + public ComboOrderTicket() + { + } + + /// + /// Creates a new instance holding the given leg order tickets + /// + /// The order tickets of the combo order legs + public ComboOrderTicket(IEnumerable tickets) : base(tickets) + { + } + } +} diff --git a/Common/Orders/Fees/OrderFee.cs b/Common/Orders/Fees/OrderFee.cs index 0d3d63203029..d707bba2db21 100644 --- a/Common/Orders/Fees/OrderFee.cs +++ b/Common/Orders/Fees/OrderFee.cs @@ -13,6 +13,7 @@ * limitations under the License. */ +using Newtonsoft.Json; using ProtoBuf; using QuantConnect.Securities; @@ -30,6 +31,19 @@ public class OrderFee [ProtoMember(1)] public CashAmount Value { get; set; } + /// + /// Gets the order fee amount, shortcut for the of . + /// The two-level 'Value.Amount' is hard to discover, especially from Python ('order_fee.value.amount') + /// + [JsonIgnore] + public decimal Amount => Value.Amount; + + /// + /// Gets the order fee currency, shortcut for the of + /// + [JsonIgnore] + public string Currency => Value.Currency; + /// /// Initializes a new instance of the class /// @@ -67,6 +81,58 @@ public static implicit operator decimal(OrderFee m) return m.Value.Amount; } + // Numeric operators delegating to the fee amount. In C# these mirror what the implicit + // decimal conversion above already allowed, so semantics are unchanged. Their real purpose + // is Python: pythonnet maps C# operators to __add__/__radd__/__gt__/... so summing or + // comparing fees works instead of raising TypeError. Note float(fee) is still not supported + // (pythonnet does not wire the nb_float slot for CLR types), use the 'Amount' property instead. + + /// Adds two order fee amounts + public static decimal operator +(OrderFee a, OrderFee b) => a.Value.Amount + b.Value.Amount; + /// Adds a value to the order fee amount + public static decimal operator +(OrderFee fee, decimal value) => fee.Value.Amount + value; + /// Adds the order fee amount to a value + public static decimal operator +(decimal value, OrderFee fee) => value + fee.Value.Amount; + + /// Subtracts two order fee amounts + public static decimal operator -(OrderFee a, OrderFee b) => a.Value.Amount - b.Value.Amount; + /// Subtracts a value from the order fee amount + public static decimal operator -(OrderFee fee, decimal value) => fee.Value.Amount - value; + /// Subtracts the order fee amount from a value + public static decimal operator -(decimal value, OrderFee fee) => value - fee.Value.Amount; + + /// Multiplies two order fee amounts + public static decimal operator *(OrderFee a, OrderFee b) => a.Value.Amount * b.Value.Amount; + /// Multiplies the order fee amount by a value + public static decimal operator *(OrderFee fee, decimal value) => fee.Value.Amount * value; + /// Multiplies a value by the order fee amount + public static decimal operator *(decimal value, OrderFee fee) => value * fee.Value.Amount; + + /// Divides two order fee amounts + public static decimal operator /(OrderFee a, OrderFee b) => a.Value.Amount / b.Value.Amount; + /// Divides the order fee amount by a value + public static decimal operator /(OrderFee fee, decimal value) => fee.Value.Amount / value; + /// Divides a value by the order fee amount + public static decimal operator /(decimal value, OrderFee fee) => value / fee.Value.Amount; + + /// Determines whether one order fee amount is less than another + public static bool operator <(OrderFee a, OrderFee b) => a.Value.Amount < b.Value.Amount; + /// Determines whether one order fee amount is greater than another + public static bool operator >(OrderFee a, OrderFee b) => a.Value.Amount > b.Value.Amount; + /// Determines whether one order fee amount is less than or equal to another + public static bool operator <=(OrderFee a, OrderFee b) => a.Value.Amount <= b.Value.Amount; + /// Determines whether one order fee amount is greater than or equal to another + public static bool operator >=(OrderFee a, OrderFee b) => a.Value.Amount >= b.Value.Amount; + + /// Determines whether the order fee amount is less than the given value + public static bool operator <(OrderFee fee, decimal value) => fee.Value.Amount < value; + /// Determines whether the order fee amount is greater than the given value + public static bool operator >(OrderFee fee, decimal value) => fee.Value.Amount > value; + /// Determines whether the order fee amount is less than or equal to the given value + public static bool operator <=(OrderFee fee, decimal value) => fee.Value.Amount <= value; + /// Determines whether the order fee amount is greater than or equal to the given value + public static bool operator >=(OrderFee fee, decimal value) => fee.Value.Amount >= value; + /// /// Gets an instance of that represents zero. /// diff --git a/Common/Orders/Order.cs b/Common/Orders/Order.cs index dfddcba0655c..90dd9f2f830f 100644 --- a/Common/Orders/Order.cs +++ b/Common/Orders/Order.cs @@ -230,6 +230,13 @@ public bool IsMarketable [JsonProperty(PropertyName = "groupOrderManager", DefaultValueHandling = DefaultValueHandling.Ignore)] public GroupOrderManager GroupOrderManager { get; set; } + /// + /// The unique id of the group of orders this order belongs to, if this is a combo order, null otherwise. + /// Shortcut for .Id + /// + [JsonIgnore] + public int? GroupOrderManagerId => GroupOrderManager?.Id; + /// /// The adjustment mode used on the order fill price /// diff --git a/Common/Orders/OrderEvent.cs b/Common/Orders/OrderEvent.cs index 32bc2e2c7fc2..fce41eead2d9 100644 --- a/Common/Orders/OrderEvent.cs +++ b/Common/Orders/OrderEvent.cs @@ -74,6 +74,13 @@ public class OrderEvent [ProtoMember(6)] public OrderFee OrderFee { get; set; } + /// + /// The fee amount associated with the order, shortcut for .Value.Amount. + /// The two-level 'OrderFee.Value.Amount' is hard to discover, especially from Python + /// + [JsonIgnore] + public decimal OrderFeeAmount => OrderFee?.Value.Amount ?? 0m; + /// /// Fill price information about the order /// @@ -233,6 +240,12 @@ public bool? TrailingAsPercentage [JsonIgnore] public OrderTicket Ticket { get; set; } + /// + /// The unique id of the order group this event's order belongs to, if it is a combo order leg, null otherwise + /// + [JsonIgnore] + public int? GroupId => Ticket?.SubmitRequest?.GroupOrderManager?.Id; + /// /// Order Event empty constructor required for json converter /// diff --git a/Tests/Algorithm/AlgorithmTradingTests.cs b/Tests/Algorithm/AlgorithmTradingTests.cs index 831a9702e59a..0c452173bb6b 100644 --- a/Tests/Algorithm/AlgorithmTradingTests.cs +++ b/Tests/Algorithm/AlgorithmTradingTests.cs @@ -1591,6 +1591,86 @@ public void LiquidateIgnoresSymbolsNotAddedToTheAlgorithm(Language language, boo Assert.IsEmpty(liquidatedTickets); } + [Test] + public void LiquidateWithKnownTickerResolvesTheSymbol() + { + var algo = GetAlgorithm(out _, 1, 0); + + // MSFT was registered in the symbol cache when added to the algorithm + List liquidatedTickets = null; + Assert.DoesNotThrow(() => liquidatedTickets = algo.Liquidate("MSFT")); + + // no holdings, so nothing to liquidate + Assert.IsEmpty(liquidatedTickets); + } + + [Test] + public void LiquidateWithUnknownTickerFailsPointingToTheTagParameter() + { + var algo = GetAlgorithm(out _, 1, 0); + + // the common slip: the tag passed as the first positional argument + var exception = Assert.Throws(() => algo.Liquidate("EOD close")); + Assert.IsTrue(exception.Message.Contains("EOD close", StringComparison.InvariantCulture)); + Assert.IsTrue(exception.Message.Contains("tag", StringComparison.InvariantCulture)); + } + + [TestCase(10000, 0, 400)] + [TestCase(10000, 100, 300)] + [TestCase(-10000, 0, -400)] + [TestCase(-10000, 100, -500)] + [TestCase(10010, 0, 400, Description = "Rounds the target quantity down to the lot size")] + [TestCase(0, 100, -100, Description = "Zero notional closes the position")] + public void OrderTargetNotionalComputesTheQuantityFromTheNotionalValue(decimal targetNotional, decimal initialHoldings, + decimal expectedQuantity) + { + var algo = GetAlgorithm(out var msft, 1, 0); + msft.Exchange.SetMarketHours(new List() { MarketHoursSegment.OpenAllDay() }); + Update(msft, 25); + msft.Holdings.SetHoldings(25, initialHoldings); + + var ticket = algo.OrderTargetNotional(Symbols.MSFT, targetNotional); + + Assert.IsNotNull(ticket); + Assert.AreEqual(expectedQuantity, ticket.Quantity); + } + + [Test] + public void OrderTargetNotionalIncludesTheContractMultiplier() + { + var algo = GetAlgorithm(out _, 1, 0); + var es20h20 = algo.AddFutureContract( + QuantConnect.Symbol.CreateFuture(Futures.Indices.SP500EMini, Market.CME, new DateTime(2020, 3, 20)), + Resolution.Minute); + Update(es20h20, 2000); + + // ES has a contract multiplier of 50: one contract is worth 2000 * 50 = 100k + var ticket = algo.OrderTargetNotional(es20h20.Symbol, 500000); + + Assert.IsNotNull(ticket); + Assert.AreEqual(5, ticket.Quantity); + } + + [Test] + public void OrderTargetNotionalReturnsNullWhenHoldingsAlreadyMatchTheTarget() + { + var algo = GetAlgorithm(out var msft, 1, 0); + msft.Exchange.SetMarketHours(new List() { MarketHoursSegment.OpenAllDay() }); + Update(msft, 25); + msft.Holdings.SetHoldings(25, 400); + + Assert.IsNull(algo.OrderTargetNotional(Symbols.MSFT, 10000)); + } + + [Test] + public void OrderTargetNotionalFailsWithoutMarketPrice() + { + var algo = GetAlgorithm(out _, 1, 0); + + var exception = Assert.Throws(() => algo.OrderTargetNotional(Symbols.MSFT, 10000)); + Assert.IsTrue(exception.Message.Contains("no market price", StringComparison.InvariantCulture)); + } + [Test] public void MarketOrdersAreSupportedForFuturesOnExtendedMarketHours() { diff --git a/Tests/Common/Orders/ComboOrderTicketTests.cs b/Tests/Common/Orders/ComboOrderTicketTests.cs new file mode 100644 index 000000000000..0fb94b312738 --- /dev/null +++ b/Tests/Common/Orders/ComboOrderTicketTests.cs @@ -0,0 +1,83 @@ +/* + * QUANTCONNECT.COM - Democratizing Finance, Empowering Individuals. + * Lean Algorithmic Trading Engine v2.0. Copyright 2014 QuantConnect Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. +*/ + +using System; +using NUnit.Framework; +using QuantConnect.Orders; + +namespace QuantConnect.Tests.Common.Orders +{ + [TestFixture] + public class ComboOrderTicketTests + { + private int _orderId; + + [Test] + public void ExposesTheGroupOrderManagerIdOfItsLegs() + { + var groupOrderManager = new GroupOrderManager(33, 2, 10); + var comboTicket = new ComboOrderTicket(new[] + { + CreateLegTicket(groupOrderManager, Symbols.SPY_C_192_Feb19_2016, 10, OrderStatus.Submitted), + CreateLegTicket(groupOrderManager, Symbols.SPY_P_192_Feb19_2016, -10, OrderStatus.Submitted) + }); + + Assert.AreEqual(33, comboTicket.GroupOrderManagerId); + Assert.AreEqual(2, comboTicket.Tickets.Count); + Assert.AreSame(comboTicket, comboTicket.Tickets); + } + + [Test] + public void FilledOnlyWhenEveryLegIsFilled() + { + var groupOrderManager = new GroupOrderManager(1, 2, 10); + var firstLeg = CreateLegTicket(groupOrderManager, Symbols.SPY_C_192_Feb19_2016, 10, OrderStatus.Filled); + var secondLeg = CreateLegTicket(groupOrderManager, Symbols.SPY_P_192_Feb19_2016, -10, OrderStatus.PartiallyFilled); + + var comboTicket = new ComboOrderTicket(new[] { firstLeg, secondLeg }); + Assert.IsFalse(comboTicket.Filled); + + comboTicket = new ComboOrderTicket(new[] + { + CreateLegTicket(groupOrderManager, Symbols.SPY_C_192_Feb19_2016, 10, OrderStatus.Filled), + CreateLegTicket(groupOrderManager, Symbols.SPY_P_192_Feb19_2016, -10, OrderStatus.Filled) + }); + Assert.IsTrue(comboTicket.Filled); + } + + [Test] + public void EmptyTicketHasNoGroupIdAndIsNotFilled() + { + var comboTicket = new ComboOrderTicket(); + + Assert.IsNull(comboTicket.GroupOrderManagerId); + Assert.IsFalse(comboTicket.Filled); + } + + private OrderTicket CreateLegTicket(GroupOrderManager groupOrderManager, Symbol symbol, decimal quantity, OrderStatus status) + { + var request = new SubmitOrderRequest(OrderType.ComboMarket, symbol.SecurityType, symbol, quantity, 0, 0, + new DateTime(2016, 2, 16, 11, 53, 30), "", groupOrderManager: groupOrderManager); + request.SetOrderId(++_orderId); + + var ticket = new OrderTicket(null, request); + var order = Order.CreateOrder(request); + order.Status = status; + ticket.SetOrder(order); + + return ticket; + } + } +} diff --git a/Tests/Common/Orders/Fees/OrderFeeTests.cs b/Tests/Common/Orders/Fees/OrderFeeTests.cs new file mode 100644 index 000000000000..e0fdf8b6ee35 --- /dev/null +++ b/Tests/Common/Orders/Fees/OrderFeeTests.cs @@ -0,0 +1,93 @@ +/* + * QUANTCONNECT.COM - Democratizing Finance, Empowering Individuals. + * Lean Algorithmic Trading Engine v2.0. Copyright 2014 QuantConnect Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. +*/ + +using System.Linq; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using NUnit.Framework; +using QuantConnect.Orders.Fees; +using QuantConnect.Securities; + +namespace QuantConnect.Tests.Common.Orders.Fees +{ + [TestFixture] + public class OrderFeeTests + { + [Test] + public void FlatAmountAndCurrencyShortcuts() + { + var fee = new OrderFee(new CashAmount(12.34m, Currencies.EUR)); + + Assert.AreEqual(12.34m, fee.Amount); + Assert.AreEqual(Currencies.EUR, fee.Currency); + } + + [Test] + public void ArithmeticOperatorsDelegateToTheFeeAmount() + { + var fee = new OrderFee(new CashAmount(2m, Currencies.USD)); + var otherFee = new OrderFee(new CashAmount(3m, Currencies.USD)); + + Assert.AreEqual(5m, fee + otherFee); + Assert.AreEqual(3m, fee + 1m); + Assert.AreEqual(3m, 1m + fee); + + Assert.AreEqual(-1m, fee - otherFee); + Assert.AreEqual(1m, fee - 1m); + Assert.AreEqual(8m, 10m - fee); + + Assert.AreEqual(6m, fee * otherFee); + Assert.AreEqual(4m, fee * 2m); + Assert.AreEqual(4m, 2m * fee); + + Assert.AreEqual(1.5m, otherFee / fee); + Assert.AreEqual(1m, fee / 2m); + Assert.AreEqual(5m, 10m / fee); + } + + [Test] + public void ComparisonOperatorsDelegateToTheFeeAmount() + { + var fee = new OrderFee(new CashAmount(2m, Currencies.USD)); + var otherFee = new OrderFee(new CashAmount(3m, Currencies.USD)); + + Assert.IsTrue(fee < otherFee); + Assert.IsFalse(fee > otherFee); + Assert.IsTrue(fee <= otherFee); + Assert.IsFalse(fee >= otherFee); + + Assert.IsTrue(fee > 0m); + Assert.IsFalse(fee < 2m); + Assert.IsTrue(fee <= 2m); + Assert.IsTrue(fee >= 2m); + } + + [Test] + public void SerializationShapeIsUnchangedByTheShortcutProperties() + { + var fee = new OrderFee(new CashAmount(12.34m, Currencies.EUR)); + + var json = JsonConvert.SerializeObject(fee); + var jObject = JObject.Parse(json); + + // the flat Amount/Currency shortcuts are json-ignored, only 'Value' is serialized + CollectionAssert.AreEqual(new[] { "Value" }, jObject.Properties().Select(property => property.Name)); + + var deserialized = JsonConvert.DeserializeObject(json); + Assert.AreEqual(fee.Value.Amount, deserialized.Value.Amount); + Assert.AreEqual(fee.Value.Currency, deserialized.Value.Currency); + } + } +} diff --git a/Tests/Common/Orders/OrderEventTests.cs b/Tests/Common/Orders/OrderEventTests.cs index 170c6bf3081c..78163c0287fd 100644 --- a/Tests/Common/Orders/OrderEventTests.cs +++ b/Tests/Common/Orders/OrderEventTests.cs @@ -50,6 +50,35 @@ public void JsonIgnores() Assert.IsTrue(json.Contains("LimitPrice", StringComparison.InvariantCulture)); Assert.IsTrue(json.Contains("StopPrice", StringComparison.InvariantCulture)); Assert.IsTrue(json.Contains(value: "IsInTheMoney", StringComparison.InvariantCulture)); + + // the flat shortcut properties are not serialized + Assert.IsFalse(json.Contains("OrderFeeAmount", StringComparison.InvariantCulture)); + Assert.IsFalse(json.Contains("GroupId", StringComparison.InvariantCulture)); + } + + [Test] + public void OrderFeeAmountShortcut() + { + var order = new MarketOrder(Symbols.BTCUSD, 0.123m, DateTime.UtcNow); + var orderEvent = new OrderEvent(order, DateTime.UtcNow, new OrderFee(new CashAmount(88, Currencies.USD))); + + Assert.AreEqual(88m, orderEvent.OrderFeeAmount); + Assert.AreEqual(0m, new OrderEvent().OrderFeeAmount); + } + + [Test] + public void GroupIdComesFromTheTicketGroupOrderManager() + { + var groupOrderManager = new GroupOrderManager(11, 2, 10); + var request = new SubmitOrderRequest(OrderType.ComboMarket, SecurityType.Option, Symbols.SPY_C_192_Feb19_2016, + 10, 0, 0, DateTime.UtcNow, "", groupOrderManager: groupOrderManager); + var order = new ComboMarketOrder(Symbols.SPY_C_192_Feb19_2016, 10, DateTime.UtcNow, groupOrderManager); + + var orderEvent = new OrderEvent(order, DateTime.UtcNow, OrderFee.Zero); + Assert.IsNull(orderEvent.GroupId); + + orderEvent.Ticket = new OrderTicket(null, request); + Assert.AreEqual(11, orderEvent.GroupId); } [Test] diff --git a/Tests/Common/Orders/OrderTests.cs b/Tests/Common/Orders/OrderTests.cs index 4d5d65d81079..d4cd8a768fdd 100644 --- a/Tests/Common/Orders/OrderTests.cs +++ b/Tests/Common/Orders/OrderTests.cs @@ -44,6 +44,17 @@ public void GetValueTest(ValueTestParameters parameters) Assert.AreEqual(parameters.ExpectedValue, value); } + [Test] + public void GroupOrderManagerIdShortcut() + { + var groupOrderManager = new GroupOrderManager(7, 2, 10); + var comboOrder = new ComboMarketOrder(Symbols.SPY_C_192_Feb19_2016, 10, DateTime.UtcNow, groupOrderManager); + Assert.AreEqual(7, comboOrder.GroupOrderManagerId); + + var marketOrder = new MarketOrder(Symbols.SPY, 10, DateTime.UtcNow); + Assert.IsNull(marketOrder.GroupOrderManagerId); + } + [TestCase(OrderDirection.Sell, 300, 0.1, true, 270)] [TestCase(OrderDirection.Sell, 300, 30, false, 270)] [TestCase(OrderDirection.Buy, 300, 0.1, true, 330)]