diff --git a/Algorithm.CSharp/MarketImpactSlippageModelRegressionAlgorithm.cs b/Algorithm.CSharp/MarketImpactSlippageModelRegressionAlgorithm.cs index f67f2fd48245..b4148a2489b9 100644 --- a/Algorithm.CSharp/MarketImpactSlippageModelRegressionAlgorithm.cs +++ b/Algorithm.CSharp/MarketImpactSlippageModelRegressionAlgorithm.cs @@ -95,31 +95,31 @@ public override void OnOrderEvent(OrderEvent orderEvent) {"Total Orders", "9"}, {"Average Win", "0%"}, {"Average Loss", "-0.04%"}, - {"Compounding Annual Return", "-93.847%"}, - {"Drawdown", "4.200%"}, + {"Compounding Annual Return", "-94.178%"}, + {"Drawdown", "4.300%"}, {"Expectancy", "-1"}, {"Start Equity", "10000000"}, - {"End Equity", "9649801.20"}, - {"Net Profit", "-3.502%"}, - {"Sharpe Ratio", "-2.93"}, - {"Sortino Ratio", "-2.869"}, - {"Probabilistic Sharpe Ratio", "7.243%"}, + {"End Equity", "9642964.36"}, + {"Net Profit", "-3.570%"}, + {"Sharpe Ratio", "-2.896"}, + {"Sortino Ratio", "-2.829"}, + {"Probabilistic Sharpe Ratio", "7.047%"}, {"Loss Rate", "100%"}, {"Win Rate", "0%"}, {"Profit-Loss Ratio", "0"}, - {"Alpha", "-3.355"}, - {"Beta", "1.244"}, - {"Annual Standard Deviation", "0.306"}, - {"Annual Variance", "0.094"}, - {"Information Ratio", "-20.203"}, - {"Tracking Error", "0.142"}, - {"Treynor Ratio", "-0.722"}, - {"Total Fees", "$1859.00"}, + {"Alpha", "-3.395"}, + {"Beta", "1.262"}, + {"Annual Standard Deviation", "0.312"}, + {"Annual Variance", "0.097"}, + {"Information Ratio", "-19.58"}, + {"Tracking Error", "0.147"}, + {"Treynor Ratio", "-0.715"}, + {"Total Fees", "$1860.21"}, {"Estimated Strategy Capacity", "$470000000.00"}, {"Lowest Capacity Asset", "AAPL R735QTJ8XC9X"}, - {"Portfolio Turnover", "21.04%"}, + {"Portfolio Turnover", "21.06%"}, {"Drawdown Recovery", "0"}, - {"OrderListHash", "fc0626f660981cb698f6a9a5d5d1389a"} + {"OrderListHash", "6a2a541fbde8de8454e9ae3a20d1ed69"} }; } } diff --git a/Algorithm.CSharp/MarketOnOpenOrderSlippageRegressionAlgorithm.cs b/Algorithm.CSharp/MarketOnOpenOrderSlippageRegressionAlgorithm.cs new file mode 100644 index 000000000000..00131740aaa0 --- /dev/null +++ b/Algorithm.CSharp/MarketOnOpenOrderSlippageRegressionAlgorithm.cs @@ -0,0 +1,150 @@ +/* + * 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 QuantConnect.Data; +using QuantConnect.Data.Market; +using QuantConnect.Interfaces; +using QuantConnect.Orders; +using QuantConnect.Orders.Slippage; + +namespace QuantConnect.Algorithm.CSharp +{ + /// + /// Regression algorithm asserting that market on open orders using daily data are filled at the bar open + /// with the slippage referenced to that same open price, not to the bar close which is not known at the open. + /// See GH issue 9753 + /// + public class MarketOnOpenOrderSlippageRegressionAlgorithm : QCAlgorithm, IRegressionAlgorithmDefinition + { + private const decimal SlippagePercent = 0.01m; + private Symbol _symbol; + private int _fills; + + public override void Initialize() + { + SetStartDate(2013, 10, 07); + SetEndDate(2013, 10, 11); + SetCash(100000); + + var security = AddEquity("SPY", Resolution.Daily); + security.SetSlippageModel(new ConstantSlippageModel(SlippagePercent)); + _symbol = security.Symbol; + } + + public override void OnData(Slice slice) + { + if (!slice.Bars.ContainsKey(_symbol) || Transactions.GetOpenOrders(_symbol).Count > 0) + { + return; + } + + // alternate buys and sells so both directions are checked + MarketOnOpenOrder(_symbol, Portfolio[_symbol].Invested ? -100 : 100); + } + + public override void OnOrderEvent(OrderEvent orderEvent) + { + if (orderEvent.Status != OrderStatus.Filled) + { + return; + } + + // the fill happens when the daily bar arrives, so this is the bar the order was filled with + var bar = Securities[_symbol].Cache.GetData(); + if (bar.Open == bar.Close) + { + throw new RegressionTestException($"Expected the fill bar open and close to differ so the slippage reference can be asserted: {bar}"); + } + + var slippage = bar.Open * SlippagePercent; + var expectedFillPrice = orderEvent.Direction == OrderDirection.Buy ? bar.Open + slippage : bar.Open - slippage; + if (orderEvent.FillPrice != expectedFillPrice) + { + throw new RegressionTestException($"Expected {orderEvent.Direction} fill price {expectedFillPrice} (open {bar.Open} +/- {SlippagePercent:P} slippage) but was {orderEvent.FillPrice}. Bar: {bar}"); + } + + _fills++; + } + + public override void OnEndOfAlgorithm() + { + if (_fills < 2) + { + throw new RegressionTestException($"Expected at least a buy and a sell fill but got {_fills}"); + } + } + + /// + /// 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 { get; } = 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 }; + + /// + /// Data Points count of all timeslices of algorithm + /// + public long DataPoints => 48; + + /// + /// 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.29%"}, + {"Compounding Annual Return", "-36.910%"}, + {"Drawdown", "0.600%"}, + {"Expectancy", "-1"}, + {"Start Equity", "100000"}, + {"End Equity", "99412.82"}, + {"Net Profit", "-0.587%"}, + {"Sharpe Ratio", "-14.31"}, + {"Sortino Ratio", "-19.441"}, + {"Probabilistic Sharpe Ratio", "1.568%"}, + {"Loss Rate", "100%"}, + {"Win Rate", "0%"}, + {"Profit-Loss Ratio", "0"}, + {"Alpha", "-0.502"}, + {"Beta", "0.093"}, + {"Annual Standard Deviation", "0.022"}, + {"Annual Variance", "0"}, + {"Information Ratio", "-11.354"}, + {"Tracking Error", "0.202"}, + {"Treynor Ratio", "-3.402"}, + {"Total Fees", "$4.00"}, + {"Estimated Strategy Capacity", "$1300000000.00"}, + {"Lowest Capacity Asset", "SPY R735QTJ8XC9X"}, + {"Portfolio Turnover", "11.63%"}, + {"Drawdown Recovery", "0"}, + {"OrderListHash", "4bbdfd7aaf0f2e4fa6cc9226fbf3d9e8"} + }; + } +} diff --git a/Common/Orders/Slippage/AlphaStreamsSlippageModel.cs b/Common/Orders/Slippage/AlphaStreamsSlippageModel.cs index 5158247cee7e..4dd98e3a6f0a 100644 --- a/Common/Orders/Slippage/AlphaStreamsSlippageModel.cs +++ b/Common/Orders/Slippage/AlphaStreamsSlippageModel.cs @@ -13,6 +13,7 @@ * limitations under the License. */ +using QuantConnect.Data.Market; using QuantConnect.Securities; using System.Collections.Generic; @@ -40,7 +41,13 @@ public decimal GetSlippageApproximation(Security asset, Order order) return 0; } - return _slippagePercent * asset.GetLastData()?.Value ?? 0; + var lastData = asset.GetLastData(); + if (lastData == null) return 0; + + // Market on open orders fill at the bar open, which is the price we have to reference, not the bar close + var referencePrice = order.Type == OrderType.MarketOnOpen && lastData is IBar bar ? bar.Open : lastData.Value; + + return _slippagePercent * referencePrice; } } } \ No newline at end of file diff --git a/Common/Orders/Slippage/ConstantSlippageModel.cs b/Common/Orders/Slippage/ConstantSlippageModel.cs index 43e348060b68..ab77d7728784 100644 --- a/Common/Orders/Slippage/ConstantSlippageModel.cs +++ b/Common/Orders/Slippage/ConstantSlippageModel.cs @@ -14,6 +14,7 @@ */ using QuantConnect.Data; +using QuantConnect.Data.Market; using QuantConnect.Securities; namespace QuantConnect.Orders.Slippage @@ -41,7 +42,10 @@ public decimal GetSlippageApproximation(Security asset, Order order) var lastData = asset.GetLastData(); if (lastData == null) return 0; - return lastData.Value*_slippagePercent; + // Market on open orders fill at the bar open, which is the price we have to reference, not the bar close + var referencePrice = order.Type == OrderType.MarketOnOpen && lastData is IBar bar ? bar.Open : lastData.Value; + + return referencePrice * _slippagePercent; } } } diff --git a/Common/Orders/Slippage/MarketImpactSlippageModel.cs b/Common/Orders/Slippage/MarketImpactSlippageModel.cs index 394e05a69486..7de6a5de472e 100644 --- a/Common/Orders/Slippage/MarketImpactSlippageModel.cs +++ b/Common/Orders/Slippage/MarketImpactSlippageModel.cs @@ -127,8 +127,11 @@ public decimal GetSlippageApproximation(Security asset, Order order) // realized market impact var realizedImpact = temporaryImpact + permanentImpact * 0.5d; + // Market on open orders fill at the bar open, which is the price we have to reference, not the bar close + var referencePrice = order.Type == OrderType.MarketOnOpen && asset.GetLastData() is IBar bar ? bar.Open : asset.Price; + // estimate the slippage by temporary impact - return SlippageFromImpactEstimation(realizedImpact) * asset.Price; + return SlippageFromImpactEstimation(realizedImpact) * referencePrice; } /// diff --git a/Common/Orders/Slippage/VolumeShareSlippageModel.cs b/Common/Orders/Slippage/VolumeShareSlippageModel.cs index 2841182f8bfa..ff6552174f69 100644 --- a/Common/Orders/Slippage/VolumeShareSlippageModel.cs +++ b/Common/Orders/Slippage/VolumeShareSlippageModel.cs @@ -86,7 +86,10 @@ public decimal GetSlippageApproximation(Security asset, Order order) slippagePercent = volumeShare * volumeShare * _priceImpact; } - return slippagePercent * lastData.Value; + // Market on open orders fill at the bar open, which is the price we have to reference, not the bar close + var referencePrice = order.Type == OrderType.MarketOnOpen ? ((IBar)lastData).Open : lastData.Value; + + return slippagePercent * referencePrice; } } } diff --git a/Common/Orders/Slippage/VolumeShareSlippageModel.py b/Common/Orders/Slippage/VolumeShareSlippageModel.py index 3921c414a741..8cd3edb25fe8 100644 --- a/Common/Orders/Slippage/VolumeShareSlippageModel.py +++ b/Common/Orders/Slippage/VolumeShareSlippageModel.py @@ -58,4 +58,7 @@ def get_slippage_approximation(self, asset: Security, order: Order) -> float: slippage_percent = volume_share * volume_share * self.price_impact - return slippage_percent * last_data.Value; + # Market on open orders fill at the bar open, which is the price we have to reference, not the bar close + reference_price = last_data.open if order.type == OrderType.MARKET_ON_OPEN else last_data.value + + return slippage_percent * reference_price diff --git a/Tests/Common/Orders/Fills/EquityFillModelTests.cs b/Tests/Common/Orders/Fills/EquityFillModelTests.cs index 8dc4c81bbe7a..b7ce196777ee 100644 --- a/Tests/Common/Orders/Fills/EquityFillModelTests.cs +++ b/Tests/Common/Orders/Fills/EquityFillModelTests.cs @@ -22,6 +22,7 @@ using QuantConnect.Indicators; using QuantConnect.Orders; using QuantConnect.Orders.Fills; +using QuantConnect.Orders.Slippage; using QuantConnect.Securities; using QuantConnect.Securities.Forex; using QuantConnect.Tests.Common.Data; @@ -446,6 +447,75 @@ public void PerformsMarketOnOpenUsingOpenPriceWithMinuteSubscription(int quantit Assert.AreEqual(expected, fill.FillPrice); } + // The slippage is referenced to the bar open, so the fill price does not depend on the bar close + [TestCase(-100, 103.896, Resolution.Daily)] + [TestCase(100, 104.104, Resolution.Daily)] + [TestCase(-100, 103.896, Resolution.Hour)] + [TestCase(100, 104.104, Resolution.Hour)] + [TestCase(-100, 103.896, Resolution.Minute)] + [TestCase(100, 104.104, Resolution.Minute)] + public void PerformsMarketOnOpenUsingOpenPriceForConstantSlippage(int quantity, decimal expected, Resolution resolution) + { + const decimal open = 104m; + const decimal baselineClose = 105m; + const decimal mutatedClose = 103.5m; + const decimal slippagePercent = 0.001m; + + var reference = new DateTime(2015, 06, 05, 12, 0, 0); + var config = CreateTradeBarConfig(Symbols.SPY, resolution); + + var baselineEquity = CreateEquity(config); + var mutatedEquity = CreateEquity(config); + + baselineEquity.SetSlippageModel(new ConstantSlippageModel(slippagePercent)); + mutatedEquity.SetSlippageModel(new ConstantSlippageModel(slippagePercent)); + + var time = baselineEquity.Exchange.Hours.GetNextMarketOpen(reference, false); + TimeKeeper.SetUtcDateTime(time.ConvertToUtc(TimeZones.NewYork)); + + var period = resolution.ToTimeSpan(); + TradeBar GetTradeBar(decimal close) => new TradeBar( + time.RoundDown(period), + Symbols.SPY, + open, + 106m, + 100m, + close, + 100, + period); + + baselineEquity.SetMarketPrice(GetTradeBar(baselineClose)); + mutatedEquity.SetMarketPrice(GetTradeBar(mutatedClose)); + + var baselineOrder = new MarketOnOpenOrder(Symbols.SPY, quantity, reference); + var mutatedOrder = new MarketOnOpenOrder(Symbols.SPY, quantity, reference); + + var configProvider = new MockSubscriptionDataConfigProvider(config); + + var baselineFill = ((EquityFillModel)baselineEquity.FillModel) + .Fill(new FillModelParameters( + baselineEquity, + baselineOrder, + configProvider, + Time.OneHour, + null)) + .Single(); + + var mutatedFill = ((EquityFillModel)mutatedEquity.FillModel) + .Fill(new FillModelParameters( + mutatedEquity, + mutatedOrder, + configProvider, + Time.OneHour, + null)) + .Single(); + + Assert.AreEqual(quantity, baselineFill.FillQuantity); + Assert.AreEqual(quantity, mutatedFill.FillQuantity); + Assert.AreEqual(expected, baselineFill.FillPrice); + Assert.AreEqual(baselineFill.FillPrice, mutatedFill.FillPrice); + } + [TestCase(-100)] [TestCase(100)] public void PerformsMarketOnOpenUsingOpenPriceWithDailySubscription(int quantity) diff --git a/Tests/Common/Orders/Slippage/MarketImpactSlippageModelTest.cs b/Tests/Common/Orders/Slippage/MarketImpactSlippageModelTest.cs index 6a931d6a4688..81a69ca8bb0d 100644 --- a/Tests/Common/Orders/Slippage/MarketImpactSlippageModelTest.cs +++ b/Tests/Common/Orders/Slippage/MarketImpactSlippageModelTest.cs @@ -162,6 +162,23 @@ public void SlippageExpectationTests(decimal orderQuantity, int index, double ex Assert.AreEqual(expected, (double)slippage, 0.005d); } + // Market on open orders fill at the bar open, so the slippage is referenced to it instead of the close + [TestCase(10000)] + [TestCase(-10000)] + public void MarketOnOpenOrdersReferenceTheOpenPrice(decimal orderQuantity) + { + var asset = _securities[0]; + asset.SetMarketPrice(new TradeBar(_algorithm.Time, asset.Symbol, 90m, 110m, 80m, 100m, 1)); + var time = new DateTime(2015, 6, 10, 14, 00, 0); + + // fresh models so both use the same noise sequence + var marketSlippage = new MarketImpactSlippageModel(_algorithm).GetSlippageApproximation(asset, new MarketOrder(asset.Symbol, orderQuantity, time)); + var marketOnOpenSlippage = new MarketImpactSlippageModel(_algorithm).GetSlippageApproximation(asset, new MarketOnOpenOrder(asset.Symbol, orderQuantity, time)); + + Assert.AreEqual(0.5075d, (double)marketSlippage, 0.005d); + Assert.AreEqual((double)marketSlippage * 0.9d, (double)marketOnOpenSlippage, 0.0001d); + } + // Test on buy & sell orders [TestCase(1)] [TestCase(-1)] diff --git a/Tests/Common/Orders/Slippage/SlippageModelsTests.cs b/Tests/Common/Orders/Slippage/SlippageModelsTests.cs index b37dda8e0ccf..a15f5e4725a5 100644 --- a/Tests/Common/Orders/Slippage/SlippageModelsTests.cs +++ b/Tests/Common/Orders/Slippage/SlippageModelsTests.cs @@ -14,13 +14,17 @@ */ using NUnit.Framework; +using Python.Runtime; +using QuantConnect.Data; using QuantConnect.Data.Market; using QuantConnect.Orders; using QuantConnect.Orders.Slippage; +using QuantConnect.Python; using QuantConnect.Securities; using QuantConnect.Securities.Equity; using QuantConnect.Securities.Forex; using System; +using System.IO; namespace QuantConnect.Tests.Common.Orders.Slippage { @@ -161,5 +165,104 @@ public void AlphaStreamsSlippageModel_ForexTest() var actual = model.GetSlippageApproximation(_forex, _forexBuyOrder); Assert.AreEqual(expected, actual); } + + // Market on open orders fill at the bar open, so the slippage is referenced to it instead of the close. + // Any other order type keeps using the last price. Ticks have no open so the price is used + [TestCase(MarketDataType.TradeBar, OrderType.Market, 100)] + [TestCase(MarketDataType.TradeBar, OrderType.MarketOnOpen, 90)] + [TestCase(MarketDataType.TradeBar, OrderType.MarketOnClose, 100)] + [TestCase(MarketDataType.TradeBar, OrderType.Limit, 100)] + [TestCase(MarketDataType.QuoteBar, OrderType.Market, 100)] + [TestCase(MarketDataType.QuoteBar, OrderType.MarketOnOpen, 90)] + [TestCase(MarketDataType.Tick, OrderType.Market, 100)] + [TestCase(MarketDataType.Tick, OrderType.MarketOnOpen, 100)] + public void SlippageModelsReferenceTheOpenPriceForMarketOnOpenOrders(MarketDataType dataType, OrderType orderType, decimal expectedReferencePrice) + { + var security = CreateSecurityWithData(dataType); + var order = CreateOrder(orderType, security.Symbol); + + Assert.AreEqual(expectedReferencePrice * 0.5m, new ConstantSlippageModel(0.5m).GetSlippageApproximation(security, order)); + + if (security.Type == SecurityType.Equity) + { + Assert.AreEqual(expectedReferencePrice * 0.0001m, new AlphaStreamsSlippageModel().GetSlippageApproximation(security, order)); + } + + var volumeShareModel = new VolumeShareSlippageModel(); + if (dataType == MarketDataType.Tick) + { + Assert.Throws(() => volumeShareModel.GetSlippageApproximation(security, order)); + } + else + { + // order quantity is 1 and the bar volume is 100, below the volume limit + var volumeShare = 1m / 100m; + Assert.AreEqual(expectedReferencePrice * volumeShare * volumeShare * 0.1m, volumeShareModel.GetSlippageApproximation(security, order)); + } + } + + [TestCase(MarketDataType.TradeBar, OrderType.Market, 100)] + [TestCase(MarketDataType.TradeBar, OrderType.MarketOnOpen, 90)] + [TestCase(MarketDataType.QuoteBar, OrderType.Market, 100)] + [TestCase(MarketDataType.QuoteBar, OrderType.MarketOnOpen, 90)] + public void PythonVolumeShareSlippageModelReferencesTheOpenPriceForMarketOnOpenOrders(MarketDataType dataType, OrderType orderType, decimal expectedReferencePrice) + { + var security = CreateSecurityWithData(dataType); + var order = CreateOrder(orderType, security.Symbol); + + ISlippageModel model; + using (Py.GIL()) + { + var module = PyModule.FromString("VolumeShareSlippageModelTest", + File.ReadAllText("../../../Common/Orders/Slippage/VolumeShareSlippageModel.py")); + model = new SlippageModelPythonWrapper(module.GetAttr("VolumeShareSlippageModel").Invoke()); + } + + // order quantity is 1 and the bar volume is 100, below the volume limit + var volumeShare = 1m / 100m; + var expected = expectedReferencePrice * volumeShare * volumeShare * 0.1m; + Assert.AreEqual((double)expected, (double)model.GetSlippageApproximation(security, order), 1e-12); + } + + private static Order CreateOrder(OrderType orderType, Symbol symbol) + { + var time = new DateTime(2015, 6, 10, 9, 0, 0); + return orderType switch + { + OrderType.Market => new MarketOrder(symbol, 1, time), + OrderType.MarketOnOpen => new MarketOnOpenOrder(symbol, 1, time), + OrderType.MarketOnClose => new MarketOnCloseOrder(symbol, 1, time), + OrderType.Limit => new LimitOrder(symbol, 1, 100, time), + _ => throw new ArgumentOutOfRangeException(nameof(orderType)) + }; + } + + /// + /// Sets data whose open is 90 and close is 100 as the last data of a security and returns it. + /// Quote bars are not the default data type for equities, so forex is used for them + /// + private Security CreateSecurityWithData(MarketDataType dataType) + { + var time = new DateTime(2015, 6, 10, 9, 30, 0); + BaseData data; + switch (dataType) + { + case MarketDataType.TradeBar: + data = new TradeBar(time, Symbols.SPY, 90m, 110m, 80m, 100m, 100); + break; + case MarketDataType.QuoteBar: + data = new QuoteBar(time, Symbols.EURUSD, new Bar(89m, 109m, 79m, 99m), 100, new Bar(91m, 111m, 81m, 101m), 100); + break; + case MarketDataType.Tick: + data = new Tick(time, Symbols.SPY, 100m, 100m) { TickType = TickType.Trade, Quantity = 100 }; + break; + default: + throw new ArgumentOutOfRangeException(nameof(dataType)); + } + + Security security = data.Symbol == Symbols.SPY ? _equity : _forex; + security.SetMarketPrice(data); + return security; + } } }