From cad5f20a3d94e2dfc5e482adcce698945909706c Mon Sep 17 00:00:00 2001 From: JosueNina <36119850+JosueNina@users.noreply.github.com> Date: Thu, 23 Jan 2025 17:44:29 -0500 Subject: [PATCH] Refactor SetHoldings to return a List (#8550) * Refactor SetHoldings to return a List * Add expectedQuantities to the testCases * Update regression tests * Update name of regressionAlgorithm * Address review comments * Update unit test --- ...xceptSpecifiedSymbolRegressionAlgorithm.cs | 7 +- .../LiquidateRegressionAlgorithm.cs | 7 ++ ...dateUsingSetHoldingsRegressionAlgorithm.cs | 2 +- ...gReturnsOrderTicketsRegressionAlgorithm.cs | 116 ++++++++++++++++++ Algorithm/QCAlgorithm.Trading.cs | 46 ++++--- Tests/Algorithm/AlgorithmTradingTests.cs | 64 ++++++++++ 6 files changed, 224 insertions(+), 18 deletions(-) create mode 100644 Algorithm.CSharp/SetHoldingReturnsOrderTicketsRegressionAlgorithm.cs diff --git a/Algorithm.CSharp/LiquidateAllExceptSpecifiedSymbolRegressionAlgorithm.cs b/Algorithm.CSharp/LiquidateAllExceptSpecifiedSymbolRegressionAlgorithm.cs index 41dfaddb9bf9..573cb59e6985 100644 --- a/Algorithm.CSharp/LiquidateAllExceptSpecifiedSymbolRegressionAlgorithm.cs +++ b/Algorithm.CSharp/LiquidateAllExceptSpecifiedSymbolRegressionAlgorithm.cs @@ -35,7 +35,7 @@ public override void Rebalance() // Liquidate the remaining symbols in the portfolio, except for SPY var orderProperties = new OrderProperties { TimeInForce = TimeInForce.GoodTilCanceled }; - SetHoldings(Spy, 1, true, "LiquidatedTest", orderProperties); + OrderTickets.AddRange(SetHoldings(Spy, 1, true, "LiquidatedTest", orderProperties)); } public override void OnEndOfAlgorithm() @@ -64,6 +64,11 @@ public override void OnEndOfAlgorithm() throw new RegressionTestException($"Expected 1 non-canceled order, but found {nonCanceledOrdersCount}."); } + if (nonCanceledOrdersCount != OrderTickets.Count) + { + throw new RegressionTestException($"Expected {OrderTickets.Count} non-canceled orders, but found {nonCanceledOrdersCount}."); + } + // Verify all tags are "LiquidatedTest" var invalidTags = orders.Where(order => order.Tag != "LiquidatedTest").ToList(); if (invalidTags.Count != 0) diff --git a/Algorithm.CSharp/LiquidateRegressionAlgorithm.cs b/Algorithm.CSharp/LiquidateRegressionAlgorithm.cs index 59d9756a239c..229e7e7ff627 100644 --- a/Algorithm.CSharp/LiquidateRegressionAlgorithm.cs +++ b/Algorithm.CSharp/LiquidateRegressionAlgorithm.cs @@ -27,6 +27,7 @@ namespace QuantConnect.Algorithm.CSharp /// public class LiquidateRegressionAlgorithm : QCAlgorithm, IRegressionAlgorithmDefinition { + protected List OrderTickets { get; private set; } protected Symbol Spy { get; private set; } protected Symbol Ibm { get; private set; } public override void Initialize() @@ -35,6 +36,7 @@ public override void Initialize() SetEndDate(2018, 1, 10); Spy = AddEquity("SPY", Resolution.Daily).Symbol; Ibm = AddEquity("IBM", Resolution.Daily).Symbol; + OrderTickets = new List(); // Schedule Rebalance method to be called on specific dates Schedule.On(DateRules.On(2018, 1, 5), TimeRules.Midnight, Rebalance); @@ -70,6 +72,11 @@ public override void OnEndOfAlgorithm() throw new RegressionTestException($"There are {nonCanceledOrdersCount} orders that should have been cancelled"); } + if (OrderTickets.Count > 0) + { + throw new RegressionTestException("The number of order tickets must be zero because all orders were cancelled"); + } + // Check if there are any holdings left in the portfolio foreach (var kvp in Portfolio) { diff --git a/Algorithm.CSharp/LiquidateUsingSetHoldingsRegressionAlgorithm.cs b/Algorithm.CSharp/LiquidateUsingSetHoldingsRegressionAlgorithm.cs index d4166924bc4f..fef88a15bba0 100644 --- a/Algorithm.CSharp/LiquidateUsingSetHoldingsRegressionAlgorithm.cs +++ b/Algorithm.CSharp/LiquidateUsingSetHoldingsRegressionAlgorithm.cs @@ -28,7 +28,7 @@ public class LiquidateUsingSetHoldingsRegressionAlgorithm : LiquidateRegressionA public override void PerformLiquidation() { var properties = new OrderProperties { TimeInForce = TimeInForce.GoodTilCanceled }; - SetHoldings(new List(), true, "LiquidatedTest", properties); + OrderTickets.AddRange(SetHoldings(new List(), true, "LiquidatedTest", properties)); var orders = Transactions.GetOrders().ToList(); var orderTags = orders.Where(e => e.Tag == "LiquidatedTest").ToList(); if (orderTags.Count != orders.Count) diff --git a/Algorithm.CSharp/SetHoldingReturnsOrderTicketsRegressionAlgorithm.cs b/Algorithm.CSharp/SetHoldingReturnsOrderTicketsRegressionAlgorithm.cs new file mode 100644 index 000000000000..77d3467351d3 --- /dev/null +++ b/Algorithm.CSharp/SetHoldingReturnsOrderTicketsRegressionAlgorithm.cs @@ -0,0 +1,116 @@ +/* + * 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.Algorithm.Framework.Portfolio; +using QuantConnect.Data; +using QuantConnect.Interfaces; + +namespace QuantConnect.Algorithm.CSharp +{ + /// + /// Validates that SetHoldings returns the correct number of order tickets on each execution. + /// + public class SetHoldingReturnsOrderTicketsRegressionAlgorithm : QCAlgorithm, IRegressionAlgorithmDefinition + { + private Symbol _spy; + private Symbol _ibm; + public override void Initialize() + { + SetStartDate(2018, 1, 4); + SetEndDate(2018, 1, 10); + _spy = AddEquity("SPY", Resolution.Daily).Symbol; + _ibm = AddEquity("IBM", Resolution.Daily).Symbol; + } + + public override void OnData(Slice slice) + { + var tickets = SetHoldings(new List { new(_spy, 0.8m), new(_ibm, 0.2m) }); + + if (!Portfolio.Invested) + { + // Ensure exactly 2 tickets are created when the portfolio is not yet invested + if (tickets.Count != 2) + { + throw new RegressionTestException("Expected 2 tickets, got " + tickets.Count); + } + } + else if (tickets.Count != 0) + { + // Ensure no tickets are created when the portfolio is already invested + throw new RegressionTestException("Expected 0 tickets, got " + tickets.Count); + } + } + + /// + /// Final status of the algorithm + /// + public AlgorithmStatus AlgorithmStatus => AlgorithmStatus.Completed; + + /// + /// 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 => 53; + + /// + /// Data Points count of the algorithm history + /// + public int AlgorithmHistoryDataPoints => 0; + + /// + /// 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", "2"}, + {"Average Win", "0%"}, + {"Average Loss", "0%"}, + {"Compounding Annual Return", "43.490%"}, + {"Drawdown", "0.100%"}, + {"Expectancy", "0"}, + {"Start Equity", "100000"}, + {"End Equity", "100661.71"}, + {"Net Profit", "0.662%"}, + {"Sharpe Ratio", "12.329"}, + {"Sortino Ratio", "0"}, + {"Probabilistic Sharpe Ratio", "97.100%"}, + {"Loss Rate", "0%"}, + {"Win Rate", "0%"}, + {"Profit-Loss Ratio", "0"}, + {"Alpha", "0.108"}, + {"Beta", "0.424"}, + {"Annual Standard Deviation", "0.024"}, + {"Annual Variance", "0.001"}, + {"Information Ratio", "-5.097"}, + {"Tracking Error", "0.03"}, + {"Treynor Ratio", "0.707"}, + {"Total Fees", "$2.56"}, + {"Estimated Strategy Capacity", "$170000000.00"}, + {"Lowest Capacity Asset", "IBM R735QTJ8XC9X"}, + {"Portfolio Turnover", "14.24%"}, + {"OrderListHash", "587e1a69d3c83cbd9907f9f9586697e1"} + }; + } +} diff --git a/Algorithm/QCAlgorithm.Trading.cs b/Algorithm/QCAlgorithm.Trading.cs index dcd8262df3e9..afb19225dfd0 100644 --- a/Algorithm/QCAlgorithm.Trading.cs +++ b/Algorithm/QCAlgorithm.Trading.cs @@ -1326,23 +1326,28 @@ public void SetMaximumOrders(int max) /// True will liquidate existing holdings /// Tag the order with a short string. /// The order properties to use. Defaults to + /// A list of order tickets. /// [DocumentationAttribute(TradingAndOrders)] - public void SetHoldings(List targets, bool liquidateExistingHoldings = false, string tag = null, IOrderProperties orderProperties = null) + public List SetHoldings(List targets, bool liquidateExistingHoldings = false, string tag = null, IOrderProperties orderProperties = null) { + List orderTickets = null; //If they triggered a liquidate if (liquidateExistingHoldings) { - Liquidate(GetSymbolsToLiquidate(targets.Select(t => t.Symbol)), tag: tag, orderProperties: orderProperties); + orderTickets = Liquidate(GetSymbolsToLiquidate(targets.Select(t => t.Symbol)), tag: tag, orderProperties: orderProperties); } + orderTickets ??= new List(); foreach (var portfolioTarget in targets // we need to create targets with quantities for OrderTargetsByMarginImpact .Select(target => new PortfolioTarget(target.Symbol, CalculateOrderQuantity(target.Symbol, target.Quantity))) .OrderTargetsByMarginImpact(this, targetIsDelta: true)) { - SetHoldingsImpl(portfolioTarget.Symbol, portfolioTarget.Quantity, false, tag, orderProperties); + var tickets = SetHoldingsImpl(portfolioTarget.Symbol, portfolioTarget.Quantity, false, tag, orderProperties); + orderTickets.AddRange(tickets); } + return orderTickets; } /// @@ -1353,11 +1358,12 @@ public void SetHoldings(List targets, bool liquidateExistingHol /// liquidate existing holdings if necessary to hold this stock /// Tag the order with a short string. /// The order properties to use. Defaults to + /// A list of order tickets. /// [DocumentationAttribute(TradingAndOrders)] - public void SetHoldings(Symbol symbol, double percentage, bool liquidateExistingHoldings = false, string tag = null, IOrderProperties orderProperties = null) + public List SetHoldings(Symbol symbol, double percentage, bool liquidateExistingHoldings = false, string tag = null, IOrderProperties orderProperties = null) { - SetHoldings(symbol, percentage.SafeDecimalCast(), liquidateExistingHoldings, tag, orderProperties); + return SetHoldings(symbol, percentage.SafeDecimalCast(), liquidateExistingHoldings, tag, orderProperties); } /// @@ -1368,11 +1374,12 @@ public void SetHoldings(Symbol symbol, double percentage, bool liquidateExisting /// bool liquidate existing holdings if necessary to hold this stock /// Tag the order with a short string. /// The order properties to use. Defaults to + /// A list of order tickets. /// [DocumentationAttribute(TradingAndOrders)] - public void SetHoldings(Symbol symbol, float percentage, bool liquidateExistingHoldings = false, string tag = null, IOrderProperties orderProperties = null) + public List SetHoldings(Symbol symbol, float percentage, bool liquidateExistingHoldings = false, string tag = null, IOrderProperties orderProperties = null) { - SetHoldings(symbol, (decimal)percentage, liquidateExistingHoldings, tag, orderProperties); + return SetHoldings(symbol, (decimal)percentage, liquidateExistingHoldings, tag, orderProperties); } /// @@ -1383,11 +1390,12 @@ public void SetHoldings(Symbol symbol, float percentage, bool liquidateExistingH /// bool liquidate existing holdings if necessary to hold this stock /// Tag the order with a short string. /// The order properties to use. Defaults to + /// A list of order tickets. /// [DocumentationAttribute(TradingAndOrders)] - public void SetHoldings(Symbol symbol, int percentage, bool liquidateExistingHoldings = false, string tag = null, IOrderProperties orderProperties = null) + public List SetHoldings(Symbol symbol, int percentage, bool liquidateExistingHoldings = false, string tag = null, IOrderProperties orderProperties = null) { - SetHoldings(symbol, (decimal)percentage, liquidateExistingHoldings, tag, orderProperties); + return SetHoldings(symbol, (decimal)percentage, liquidateExistingHoldings, tag, orderProperties); } /// @@ -1401,24 +1409,27 @@ public void SetHoldings(Symbol symbol, int percentage, bool liquidateExistingHol /// bool flag to clean all existing holdings before setting new faction. /// Tag the order with a short string. /// The order properties to use. Defaults to + /// A list of order tickets. /// [DocumentationAttribute(TradingAndOrders)] - public void SetHoldings(Symbol symbol, decimal percentage, bool liquidateExistingHoldings = false, string tag = null, IOrderProperties orderProperties = null) + public List SetHoldings(Symbol symbol, decimal percentage, bool liquidateExistingHoldings = false, string tag = null, IOrderProperties orderProperties = null) { - SetHoldingsImpl(symbol, CalculateOrderQuantity(symbol, percentage), liquidateExistingHoldings, tag, orderProperties); + return SetHoldingsImpl(symbol, CalculateOrderQuantity(symbol, percentage), liquidateExistingHoldings, tag, orderProperties); } /// /// Set holdings implementation, which uses order quantities (delta) not percentage nor target final quantity /// - private void SetHoldingsImpl(Symbol symbol, decimal orderQuantity, bool liquidateExistingHoldings = false, string tag = null, IOrderProperties orderProperties = null) + private List SetHoldingsImpl(Symbol symbol, decimal orderQuantity, bool liquidateExistingHoldings = false, string tag = null, IOrderProperties orderProperties = null) { + List orderTickets = null; //If they triggered a liquidate if (liquidateExistingHoldings) { - Liquidate(GetSymbolsToLiquidate([symbol]), tag: tag, orderProperties: orderProperties); + orderTickets = Liquidate(GetSymbolsToLiquidate([symbol]), tag: tag, orderProperties: orderProperties); } + orderTickets ??= new List(); tag ??= ""; //Calculate total unfilled quantity for open market orders var marketOrdersQuantity = Transactions.GetOpenOrderTickets( @@ -1435,19 +1446,22 @@ private void SetHoldingsImpl(Symbol symbol, decimal orderQuantity, bool liquidat if (!Securities.TryGetValue(symbol, out security)) { Error($"{symbol} not found in portfolio. Request this data when initializing the algorithm."); - return; + return orderTickets; } //Check whether the exchange is open to send a market order. If not, send a market on open order instead + OrderTicket ticket; if (security.Exchange.ExchangeOpen) { - MarketOrder(symbol, quantity, false, tag, orderProperties); + ticket = MarketOrder(symbol, quantity, false, tag, orderProperties); } else { - MarketOnOpenOrder(symbol, quantity, tag, orderProperties); + ticket = MarketOnOpenOrder(symbol, quantity, tag, orderProperties); } + orderTickets.Add(ticket); } + return orderTickets; } /// diff --git a/Tests/Algorithm/AlgorithmTradingTests.cs b/Tests/Algorithm/AlgorithmTradingTests.cs index 1a3732a20327..a1ae494dd2ac 100644 --- a/Tests/Algorithm/AlgorithmTradingTests.cs +++ b/Tests/Algorithm/AlgorithmTradingTests.cs @@ -31,6 +31,7 @@ using QuantConnect.Data; using QuantConnect.Indicators; using Python.Runtime; +using QuantConnect.Algorithm.Framework.Portfolio; namespace QuantConnect.Tests.Algorithm { @@ -1320,6 +1321,60 @@ public void SetHoldings_Long_ToZero_RoundOff() // Assert.AreEqual(2500, actual); //} + [TestCaseSource(nameof(SetHoldingReturnsOrderTicketsTestCases))] + public void SetHoldingsReturnsOrderTicketsTest(List symbols, bool liquidateExistingHoldings, Dictionary expectedOrders, string tag) + { + // Initialize the algorithm and add equities to the portfolio + var algo = GetAlgorithm(out _, 1, 0); + var appl = algo.AddEquity("AAPL"); + var spy = algo.AddEquity("SPY"); + var ibm = algo.AddEquity("IBM"); + + // Update prices and set initial holdings for the equities + Update(appl, 100); + Update(spy, 200); + Update(ibm, 300); + appl.Holdings.SetHoldings(25, 3); + spy.Holdings.SetHoldings(25, 3); + ibm.Holdings.SetHoldings(25, 3); + + List orderTickets; + if (symbols.Count > 1) + { + // Handle multiple symbols by creating portfolio targets + var portfolioTargets = new List(); + foreach (var symbol in symbols) + { + portfolioTargets.Add(new PortfolioTarget(symbol, 0.5m)); + } + orderTickets = algo.SetHoldings(portfolioTargets, liquidateExistingHoldings, tag); + } + else + { + // Handle a single symbol or no symbols + if (symbols.Count != 0) + { + orderTickets = algo.SetHoldings(symbols.First(), 1, liquidateExistingHoldings, tag); + } + else + { + orderTickets = algo.SetHoldings(new List(), liquidateExistingHoldings, tag); + } + } + + // Assert that the number of tickets matches the expected count + Assert.AreEqual(expectedOrders.Count, orderTickets.Count); + + // Check each ticket: + // 1. Ensure the symbol is in the expectedOrders dictionary. + // 2. Verify the quantity matches the expected value for that symbol. + foreach (var ticket in orderTickets) + { + Assert.IsTrue(expectedOrders.ContainsKey(ticket.Symbol)); + Assert.AreEqual(expectedOrders[ticket.Symbol], ticket.Quantity); + } + } + [Test] public void OrderQuantityConversionTest() { @@ -1821,5 +1876,14 @@ private bool HasSufficientBuyingPowerForOrder(decimal orderQuantity, Security se new object[] { Language.CSharp, null, false, null }, new object[] { Language.Python, null, false, null } }; + private static object[] SetHoldingReturnsOrderTicketsTestCases = + { + new object[] { new List(), true, new Dictionary { { Symbols.AAPL, -3 }, { Symbols.IBM, -3 }, { Symbols.SPY, -3 } }, "(Empty, true)"}, + new object[] { new List(), false, new Dictionary(), "(Empty, false)" }, + new object[] { new List() { Symbols.IBM }, true, new Dictionary { { Symbols.AAPL, -3m }, { Symbols.IBM, 335m }, { Symbols.SPY, -3m } }, "(OneSymbol, true)" }, + new object[] { new List() { Symbols.IBM }, false, new Dictionary { { Symbols.IBM, 335m } }, "(OneSymbol, true)" }, + new object[] { new List() { Symbols.AAPL, Symbols.SPY }, true, new Dictionary { { Symbols.AAPL, 504m }, { Symbols.IBM, -3m }, { Symbols.SPY, 250m } }, "(MultipleSymbols, true)" }, + new object[] { new List() { Symbols.AAPL, Symbols.SPY }, false, new Dictionary { { Symbols.AAPL, 504m }, { Symbols.SPY, 250m } }, "(MultipleSymbols, false)" }, + }; } }