diff --git a/Algorithm.CSharp/ContractDaysToExpiryRegressionAlgorithm.cs b/Algorithm.CSharp/ContractDaysToExpiryRegressionAlgorithm.cs new file mode 100644 index 000000000000..4afad3cfc586 --- /dev/null +++ b/Algorithm.CSharp/ContractDaysToExpiryRegressionAlgorithm.cs @@ -0,0 +1,140 @@ +/* + * 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 QuantConnect.Data; +using QuantConnect.Data.Market; +using QuantConnect.Interfaces; + +namespace QuantConnect.Algorithm.CSharp +{ + /// + /// Regression algorithm asserting the behavior of and , + /// the supported alternative to manual expiry math mixing datetime and date values. + /// The Python version also asserts that the reference argument accepts both datetime and date instances. + /// + public class ContractDaysToExpiryRegressionAlgorithm : QCAlgorithm, IRegressionAlgorithmDefinition + { + private Symbol _optionSymbol; + private int _contractsValidated; + + public override void Initialize() + { + SetStartDate(2015, 12, 24); + SetEndDate(2015, 12, 24); + SetCash(100000); + + var option = AddOption("GOOG"); + option.SetFilter(u => u.Strikes(-2, +2).Expiration(0, 180)); + _optionSymbol = option.Symbol; + } + + public override void OnData(Slice slice) + { + OptionChain chain; + if (!slice.OptionChains.TryGetValue(_optionSymbol, out chain)) + { + return; + } + + foreach (var contract in chain) + { + var expected = (contract.Expiry.Date - Time.Date).Days; + if (contract.DaysToExpiry() != expected) + { + throw new RegressionTestException($"Expected DaysToExpiry() to be {expected} but was {contract.DaysToExpiry()}"); + } + if (contract.DTE != expected) + { + throw new RegressionTestException($"Expected DTE to be {expected} but was {contract.DTE}"); + } + if (contract.DaysToExpiry(Time.AddDays(-10)) != expected + 10) + { + throw new RegressionTestException($"Expected DaysToExpiry(reference) to be {expected + 10} but was {contract.DaysToExpiry(Time.AddDays(-10))}"); + } + _contractsValidated++; + } + } + + public override void OnEndOfAlgorithm() + { + if (_contractsValidated == 0) + { + throw new RegressionTestException("No contracts were validated"); + } + } + + /// + /// 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, Language.Python }; + + /// + /// Data Points count of all timeslices of algorithm + /// + public long DataPoints => 37131; + + /// + /// 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", "0"}, + {"Average Win", "0%"}, + {"Average Loss", "0%"}, + {"Compounding Annual Return", "0%"}, + {"Drawdown", "0%"}, + {"Expectancy", "0"}, + {"Start Equity", "100000"}, + {"End Equity", "100000"}, + {"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", "$0.00"}, + {"Estimated Strategy Capacity", "$0"}, + {"Lowest Capacity Asset", ""}, + {"Portfolio Turnover", "0%"}, + {"Drawdown Recovery", "0"}, + {"OrderListHash", "d41d8cd98f00b204e9800998ecf8427e"} + }; + } +} diff --git a/Algorithm.Python/ContractDaysToExpiryRegressionAlgorithm.py b/Algorithm.Python/ContractDaysToExpiryRegressionAlgorithm.py new file mode 100644 index 000000000000..fb5f344476c5 --- /dev/null +++ b/Algorithm.Python/ContractDaysToExpiryRegressionAlgorithm.py @@ -0,0 +1,55 @@ +# 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 behavior of 'days_to_expiry' and 'dte' on option/future contracts, +### the supported alternative to manual expiry math mixing datetime and date values. +### It also asserts that the reference argument accepts both datetime and date instances. +### +class ContractDaysToExpiryRegressionAlgorithm(QCAlgorithm): + def initialize(self): + self.set_start_date(2015, 12, 24) + self.set_end_date(2015, 12, 24) + self.set_cash(100000) + + option = self.add_option("GOOG") + option.set_filter(lambda u: u.strikes(-2, +2).expiration(0, 180)) + self._option_symbol = option.symbol + self._contracts_validated = 0 + + def on_data(self, slice): + chain = slice.option_chains.get(self._option_symbol) + if not chain: + return + + for contract in chain: + # The manual shape, with the operand types correctly aligned + expected = (contract.expiry.date() - self.time.date()).days + if contract.days_to_expiry() != expected: + raise AssertionError(f"Expected days_to_expiry() to be {expected} but was {contract.days_to_expiry()}") + if contract.dte != expected: + raise AssertionError(f"Expected dte to be {expected} but was {contract.dte}") + # The reference argument accepts both datetime and date instances + if contract.days_to_expiry(self.time) != expected: + raise AssertionError(f"Expected days_to_expiry(datetime) to be {expected} but was {contract.days_to_expiry(self.time)}") + if contract.days_to_expiry(self.time.date()) != expected: + raise AssertionError(f"Expected days_to_expiry(date) to be {expected} but was {contract.days_to_expiry(self.time.date())}") + if contract.days_to_expiry(reference=self.time.date() - timedelta(days=10)) != expected + 10: + raise AssertionError(f"Expected days_to_expiry(reference=date) to be {expected + 10}") + self._contracts_validated += 1 + + def on_end_of_algorithm(self): + if self._contracts_validated == 0: + raise AssertionError("No contracts were validated") diff --git a/Common/AlgorithmImports.py b/Common/AlgorithmImports.py index 35cda517a1ca..33cf2ab5f3ae 100644 --- a/Common/AlgorithmImports.py +++ b/Common/AlgorithmImports.py @@ -95,6 +95,7 @@ pass from datetime import date, time, datetime, timedelta +from zoneinfo import ZoneInfo from typing import * import math import json diff --git a/Common/Data/Market/BaseContract.cs b/Common/Data/Market/BaseContract.cs index 19110435d8f0..a573b57ec014 100644 --- a/Common/Data/Market/BaseContract.cs +++ b/Common/Data/Market/BaseContract.cs @@ -48,6 +48,34 @@ public Symbol Symbol /// public DateTime Expiry => Symbol.ID.Date; + /// + /// Gets the number of whole days until the contract expires, based on the contract's current time. + /// Shorthand alias of + /// + [PandasIgnore] + public int DTE => DaysToExpiry(); + + /// + /// Gets the number of whole days between the contract's current time () and + /// + /// The number of whole days until the contract expires + public int DaysToExpiry() + { + return DaysToExpiry(Time); + } + + /// + /// Gets the number of whole days between the given reference and . + /// From Python, the reference accepts both datetime and date instances, avoiding the + /// TypeError users hit when manually mixing them, e.g. (contract.expiry - self.time.date()).days + /// + /// The date to measure from. Only the date part is used + /// The number of whole days from the given reference until the contract expires + public int DaysToExpiry(DateTime reference) + { + return (Expiry.Date - reference.Date).Days; + } + /// /// Gets the local date time this contract's data was last updated /// diff --git a/Common/Exceptions/GenericTypeParameterPythonExceptionInterpreter.cs b/Common/Exceptions/GenericTypeParameterPythonExceptionInterpreter.cs new file mode 100644 index 000000000000..f6b6d4f2a55c --- /dev/null +++ b/Common/Exceptions/GenericTypeParameterPythonExceptionInterpreter.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; +using Python.Runtime; +using QuantConnect.Util; + +namespace QuantConnect.Exceptions +{ + /// + /// Interprets the TypeError exceptions pythonnet raises when a .NET generic type or Array is indexed with + /// something that is not a .NET type, e.g. 'RollingWindow[datetime](10)' with Python's datetime type + /// + public class GenericTypeParameterPythonExceptionInterpreter : PythonExceptionInterpreter + { + /// + /// Determines the order that an instance of this class should be called + /// + public override int Order => 0; + + /// + /// Determines if this interpreter should be applied to the specified exception. + /// + /// The exception to check + /// True if the exception can be interpreted, false otherwise + public override bool CanInterpret(Exception exception) + { + // "type(s) expected" is raised for generic types, "type expected" for Array + return base.CanInterpret(exception) && + (exception.Message.Contains(Messages.GenericTypeParameterPythonExceptionInterpreter.TypesExpectedSubstring) || + exception.Message.Contains(Messages.GenericTypeParameterPythonExceptionInterpreter.TypeExpectedSubstring)); + } + + /// + /// Interprets the specified exception into a new exception + /// + /// The exception to be interpreted + /// An interpreter that should be applied to the inner exception. + /// The interpreted exception + public override Exception Interpret(Exception exception, IExceptionInterpreter innerInterpreter) + { + var pe = (PythonException)exception; + + var message = Messages.GenericTypeParameterPythonExceptionInterpreter.InvalidGenericTypeParameter; + message += PythonUtil.PythonExceptionStackParser(pe.StackTrace); + + return new Exception(message, pe); + } + } +} diff --git a/Common/Exceptions/TzInfoPythonExceptionInterpreter.cs b/Common/Exceptions/TzInfoPythonExceptionInterpreter.cs new file mode 100644 index 000000000000..36e1f2d23313 --- /dev/null +++ b/Common/Exceptions/TzInfoPythonExceptionInterpreter.cs @@ -0,0 +1,63 @@ +/* + * 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 Python.Runtime; +using QuantConnect.Util; + +namespace QuantConnect.Exceptions +{ + /// + /// Interprets TypeError exceptions caused by passing a Lean time zone (a NodaTime DateTimeZone, like the + /// values) where Python expects a tzinfo instance, e.g. 'datetime.now(TimeZones.NEW_YORK)' + /// + public class TzInfoPythonExceptionInterpreter : PythonExceptionInterpreter + { + /// + /// Determines the order that an instance of this class should be called + /// + public override int Order => 0; + + /// + /// Determines if this interpreter should be applied to the specified exception. + /// + /// The exception to check + /// True if the exception can be interpreted, false otherwise + public override bool CanInterpret(Exception exception) + { + // CPython raises "tzinfo argument must be None or of a tzinfo subclass, not type 'CachedDateTimeZone'". + // Only interpret it when the offending type is a NodaTime time zone, so the hint below is accurate + return base.CanInterpret(exception) && + exception.Message.Contains(Messages.TzInfoPythonExceptionInterpreter.TzInfoSubclassExpectedSubstring) && + exception.Message.Contains(Messages.TzInfoPythonExceptionInterpreter.DateTimeZoneTypeSubstring); + } + + /// + /// Interprets the specified exception into a new exception + /// + /// The exception to be interpreted + /// An interpreter that should be applied to the inner exception. + /// The interpreted exception + public override Exception Interpret(Exception exception, IExceptionInterpreter innerInterpreter) + { + var pe = (PythonException)exception; + + var message = Messages.TzInfoPythonExceptionInterpreter.LeanTimeZoneUsedAsTzInfo; + message += PythonUtil.PythonExceptionStackParser(pe.StackTrace); + + return new Exception(message, pe); + } + } +} diff --git a/Common/Exceptions/UnsupportedOperandPythonExceptionInterpreter.cs b/Common/Exceptions/UnsupportedOperandPythonExceptionInterpreter.cs index ee788db9a3e5..4080f8851583 100644 --- a/Common/Exceptions/UnsupportedOperandPythonExceptionInterpreter.cs +++ b/Common/Exceptions/UnsupportedOperandPythonExceptionInterpreter.cs @@ -14,6 +14,7 @@ */ using System; +using System.Text.RegularExpressions; using Python.Runtime; using QuantConnect.Util; @@ -36,8 +37,15 @@ public class UnsupportedOperandPythonExceptionInterpreter : PythonExceptionInter /// True if the exception can be interpreted, false otherwise public override bool CanInterpret(Exception exception) { - return base.CanInterpret(exception) && - exception.Message.Contains(Messages.UnsupportedOperandPythonExceptionInterpreter.UnsupportedOperandTypeExpectedSubstring); + if (!base.CanInterpret(exception)) + { + return false; + } + return exception.Message.Contains(Messages.UnsupportedOperandPythonExceptionInterpreter.UnsupportedOperandTypeExpectedSubstring) || + // "can't compare datetime.datetime to datetime.date", the ordering flavor of mixing datetimes and dates, + // e.g. 'self.time.date() <= some_stored_datetime' + (exception.Message.Contains(Messages.UnsupportedOperandPythonExceptionInterpreter.CannotCompareTypesSubstring) && + HasDatetimeAndDateOperands(exception.Message)); } /// @@ -50,11 +58,37 @@ public override Exception Interpret(Exception exception, IExceptionInterpreter i { var pe = (PythonException)exception; - var types = pe.Message.Split(':')[1].Trim(); - var message = Messages.UnsupportedOperandPythonExceptionInterpreter.InvalidObjectTypesForOperation(types); + string message; + if (pe.Message.Contains(Messages.UnsupportedOperandPythonExceptionInterpreter.UnsupportedOperandTypeExpectedSubstring)) + { + var types = pe.Message.Split(':')[1].Trim(); + message = Messages.UnsupportedOperandPythonExceptionInterpreter.InvalidObjectTypesForOperation(types); + } + else + { + // "can't compare {left} to {right}" + var match = Regex.Match(pe.Message, @"can't compare (?\S+) to (?\S+)"); + var types = match.Success + ? $"'{match.Groups["left"].Value}' and '{match.Groups["right"].Value}'" + : "'datetime.datetime' and 'datetime.date'"; + message = Messages.UnsupportedOperandPythonExceptionInterpreter.InvalidObjectTypesForComparison(types); + } + + if (HasDatetimeAndDateOperands(pe.Message)) + { + // The single most common shape of this error is expiry math like '(contract.id.date - self.time.date()).days', + // so point users at the supported alternatives + message += Messages.UnsupportedOperandPythonExceptionInterpreter.DatetimeAndDateOperandsHint; + } message += PythonUtil.PythonExceptionStackParser(pe.StackTrace); return new Exception(message, pe); } + + private static bool HasDatetimeAndDateOperands(string message) + { + // "datetime.date" is a prefix of "datetime.datetime", so require a word boundary after ".date" + return Regex.IsMatch(message, @"datetime\.datetime\b") && Regex.IsMatch(message, @"datetime\.date\b"); + } } } diff --git a/Common/Messages/Messages.Exceptions.cs b/Common/Messages/Messages.Exceptions.cs index 1cda211c8f05..815b0ccb84a8 100644 --- a/Common/Messages/Messages.Exceptions.cs +++ b/Common/Messages/Messages.Exceptions.cs @@ -194,6 +194,19 @@ public static class UnsupportedOperandPythonExceptionInterpreter /// public static string UnsupportedOperandTypeExpectedSubstring = "unsupported operand type"; + /// + /// Substring of the TypeError CPython raises when ordering datetime.datetime against datetime.date, + /// e.g. "can't compare datetime.datetime to datetime.date" + /// + public static string CannotCompareTypesSubstring = "can't compare"; + + /// + /// Additional guidance appended when the offending operands are datetime.datetime and datetime.date + /// + public static string DatetimeAndDateOperandsHint = + " When mixing datetime and date values, align the types first, e.g. 'self.time.date()'." + + " For expiry math, option and future contracts provide 'contract.days_to_expiry(reference)', which accepts both types, and 'contract.dte'."; + /// /// Returns a message for invalid object types for operation /// @@ -203,6 +216,65 @@ public static string InvalidObjectTypesForOperation(string types) return $@"Trying to perform a summation, subtraction, multiplication or division between { types} objects throws a TypeError exception. To prevent the exception, ensure that both values share the same type."; } + + /// + /// Returns a message for an invalid comparison between two object types + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static string InvalidObjectTypesForComparison(string types) + { + return $"Trying to compare {types} objects throws a TypeError exception. To prevent the exception, ensure that both values share the same type."; + } + } + + /// + /// Provides user-facing messages for the class and its consumers or related classes + /// + public static class TzInfoPythonExceptionInterpreter + { + /// + /// Substring of the TypeError CPython raises when a non-tzinfo object is passed as a tzinfo argument + /// + public static string TzInfoSubclassExpectedSubstring = "of a tzinfo subclass"; + + /// + /// Substring identifying NodaTime time zone types, e.g. 'CachedDateTimeZone', the type of the TimeZones values + /// + public static string DateTimeZoneTypeSubstring = "DateTimeZone"; + + /// + /// Message explaining that Lean time zones are not Python tzinfo instances and pointing at zoneinfo + /// + public static string LeanTimeZoneUsedAsTzInfo = + "Trying to use a Lean time zone like 'TimeZones.NEW_YORK' where Python expects a tzinfo instance throws a TypeError exception," + + " because Lean time zones are NodaTime DateTimeZone objects intended for Lean APIs." + + " Use Python's zoneinfo module instead, e.g. 'datetime.now(ZoneInfo(\"America/New_York\"))'." + + " 'ZoneInfo' is imported by AlgorithmImports, and 'ZoneInfo(str(TimeZones.NEW_YORK))' converts any Lean time zone."; + } + + /// + /// Provides user-facing messages for the class and its consumers or related classes + /// + public static class GenericTypeParameterPythonExceptionInterpreter + { + /// + /// The TypeError message pythonnet raises when a generic type is indexed with something that is not a .NET type + /// + public static string TypesExpectedSubstring = "type(s) expected"; + + /// + /// The TypeError message pythonnet raises when the Array type is indexed with something that is not a .NET type + /// + public static string TypeExpectedSubstring = "type expected"; + + /// + /// Message explaining that Python types cannot parameterize .NET generic types and pointing at the alternatives + /// + public static string InvalidGenericTypeParameter = + "Trying to parameterize a .NET generic type with a Python type throws a TypeError exception." + + " Python types like 'datetime' cannot be used as generic type parameters: use a .NET type, e.g. 'from System import DateTime'" + + " then 'RollingWindow[DateTime](10)', or one of the supported aliases int, float, bool and str." + + " The untyped 'RollingWindow(10)' also accepts values of any type."; } } } diff --git a/Tests/Common/Data/Market/FuturesContractTests.cs b/Tests/Common/Data/Market/FuturesContractTests.cs index 0c8dba318459..6b0c390348f3 100644 --- a/Tests/Common/Data/Market/FuturesContractTests.cs +++ b/Tests/Common/Data/Market/FuturesContractTests.cs @@ -111,6 +111,19 @@ public void PriceValueAndCloseAliasLastPrice() Assert.AreEqual(futureContract.LastPrice, futureContract.Close); } + [Test] + public void DaysToExpiryFromContractTimeAndExplicitReference() + { + // Future_CLF19_Jan2019 expires on 2018-12-19 + var futureContract = new FuturesContract(Symbols.Future_CLF19_Jan2019) { Time = new DateTime(2018, 12, 10, 17, 0, 0) }; + + Assert.AreEqual(9, futureContract.DaysToExpiry()); + Assert.AreEqual(futureContract.DaysToExpiry(), futureContract.DTE); + + Assert.AreEqual(1, futureContract.DaysToExpiry(new DateTime(2018, 12, 18))); + Assert.AreEqual(-1, futureContract.DaysToExpiry(new DateTime(2018, 12, 20, 23, 59, 59))); + } + [Test] public void OpenInterest() { diff --git a/Tests/Common/Data/Market/OptionContractTests.cs b/Tests/Common/Data/Market/OptionContractTests.cs index 024821ba548c..3bb6ccaa05f4 100644 --- a/Tests/Common/Data/Market/OptionContractTests.cs +++ b/Tests/Common/Data/Market/OptionContractTests.cs @@ -15,6 +15,7 @@ using System; using NUnit.Framework; +using Python.Runtime; using QuantConnect.Data; using QuantConnect.Data.Market; using QuantConnect.Securities; @@ -69,5 +70,46 @@ public void PriceValueAndCloseAliasLastPrice() Assert.AreEqual(contract.LastPrice, contract.Value); Assert.AreEqual(contract.LastPrice, contract.Close); } + + [Test] + public void DaysToExpiryFromContractTimeAndExplicitReference() + { + // SPY_C_192_Feb19_2016 expires on 2016-02-19 + var symbol = Symbols.SPY_C_192_Feb19_2016; + var contract = new OptionContract(CreateOption(symbol)) { Time = new DateTime(2016, 02, 16, 9, 30, 0) }; + + // Default reference is the contract's current time, only the date parts matter + Assert.AreEqual(3, contract.DaysToExpiry()); + Assert.AreEqual(contract.DaysToExpiry(), contract.DTE); + + Assert.AreEqual(30, contract.DaysToExpiry(new DateTime(2016, 01, 20))); + Assert.AreEqual(0, contract.DaysToExpiry(new DateTime(2016, 02, 19, 23, 59, 59))); + Assert.AreEqual(-2, contract.DaysToExpiry(new DateTime(2016, 02, 21))); + } + + [Test] + public void DaysToExpiryAcceptsPythonDateAndDatetimeReferences() + { + var symbol = Symbols.SPY_C_192_Feb19_2016; + var contract = new OptionContract(CreateOption(symbol)) { Time = new DateTime(2016, 02, 16, 9, 30, 0) }; + + using (Py.GIL()) + { + dynamic getDte = PyModule.FromString("OptionContractTests_DaysToExpiry", @" +from datetime import date, datetime + +def get_dte(contract): + return (contract.days_to_expiry(), contract.dte, contract.days_to_expiry(date(2016, 1, 20)), + contract.days_to_expiry(datetime(2016, 2, 19, 23, 59)), contract.days_to_expiry(reference=date(2016, 2, 21))) +").GetAttr("get_dte"); + + var result = getDte(contract); + Assert.AreEqual(3, (int)result[0]); + Assert.AreEqual(3, (int)result[1]); + Assert.AreEqual(30, (int)result[2]); + Assert.AreEqual(0, (int)result[3]); + Assert.AreEqual(-2, (int)result[4]); + } + } } } diff --git a/Tests/Common/Exceptions/GenericTypeParameterPythonExceptionInterpreterTests.cs b/Tests/Common/Exceptions/GenericTypeParameterPythonExceptionInterpreterTests.cs new file mode 100644 index 000000000000..4adf361e8d4f --- /dev/null +++ b/Tests/Common/Exceptions/GenericTypeParameterPythonExceptionInterpreterTests.cs @@ -0,0 +1,60 @@ +/* + * 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 NUnit.Framework; +using Python.Runtime; +using QuantConnect.Exceptions; +using System; +using System.Collections.Generic; + +namespace QuantConnect.Tests.Common.Exceptions +{ + [TestFixture] + public class GenericTypeParameterPythonExceptionInterpreterTests + { + private PythonException _pythonException; + + [OneTimeSetUp] + public void Setup() + { + // x = RollingWindow[datetime](10) + _pythonException = UnsupportedOperandPythonExceptionInterpreterTests.CreatePythonException("python_type_as_generic_type_parameter"); + } + + [Test] + [TestCase(typeof(Exception), ExpectedResult = false)] + [TestCase(typeof(KeyNotFoundException), ExpectedResult = false)] + [TestCase(typeof(DivideByZeroException), ExpectedResult = false)] + [TestCase(typeof(InvalidOperationException), ExpectedResult = false)] + [TestCase(typeof(PythonException), ExpectedResult = true)] + public bool CanInterpretReturnsTrueForOnlyGenericTypeParameterPythonExceptionType(Type exceptionType) + { + var exception = CreateExceptionFromType(exceptionType); + return new GenericTypeParameterPythonExceptionInterpreter().CanInterpret(exception); + } + + [Test] + public void InterpretedMessagePointsAtNetTypesAndUntypedRollingWindow() + { + var interpreted = new GenericTypeParameterPythonExceptionInterpreter().Interpret(_pythonException, NullExceptionInterpreter.Instance); + Assert.True(interpreted.Message.Contains("RollingWindow[DateTime](10)"), interpreted.Message); + Assert.True(interpreted.Message.Contains("RollingWindow(10)"), interpreted.Message); + // The stack trace should point at the offending line + Assert.True(interpreted.Message.Contains("RollingWindow[datetime](10)"), interpreted.Message); + } + + private Exception CreateExceptionFromType(Type type) => type == typeof(PythonException) ? _pythonException : (Exception)Activator.CreateInstance(type); + } +} diff --git a/Tests/Common/Exceptions/TzInfoPythonExceptionInterpreterTests.cs b/Tests/Common/Exceptions/TzInfoPythonExceptionInterpreterTests.cs new file mode 100644 index 000000000000..574d3f0aa0bf --- /dev/null +++ b/Tests/Common/Exceptions/TzInfoPythonExceptionInterpreterTests.cs @@ -0,0 +1,81 @@ +/* + * 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 NUnit.Framework; +using Python.Runtime; +using QuantConnect.Exceptions; +using System; +using System.Collections.Generic; + +namespace QuantConnect.Tests.Common.Exceptions +{ + [TestFixture] + public class TzInfoPythonExceptionInterpreterTests + { + private PythonException _pythonException; + + [OneTimeSetUp] + public void Setup() + { + // x = datetime.now(TimeZones.NEW_YORK) + _pythonException = UnsupportedOperandPythonExceptionInterpreterTests.CreatePythonException("lean_time_zone_as_tzinfo"); + } + + [Test] + [TestCase(typeof(Exception), ExpectedResult = false)] + [TestCase(typeof(KeyNotFoundException), ExpectedResult = false)] + [TestCase(typeof(DivideByZeroException), ExpectedResult = false)] + [TestCase(typeof(InvalidOperationException), ExpectedResult = false)] + [TestCase(typeof(PythonException), ExpectedResult = true)] + public bool CanInterpretReturnsTrueForOnlyTzInfoPythonExceptionType(Type exceptionType) + { + var exception = CreateExceptionFromType(exceptionType); + return new TzInfoPythonExceptionInterpreter().CanInterpret(exception); + } + + [Test] + public void InterpretedMessagePointsAtZoneInfo() + { + var interpreted = new TzInfoPythonExceptionInterpreter().Interpret(_pythonException, NullExceptionInterpreter.Instance); + Assert.True(interpreted.Message.Contains("zoneinfo"), interpreted.Message); + Assert.True(interpreted.Message.Contains("ZoneInfo(\"America/New_York\")"), interpreted.Message); + // The stack trace should point at the offending line + Assert.True(interpreted.Message.Contains("datetime.now(TimeZones.NEW_YORK)"), interpreted.Message); + } + + [Test] + public void DoesNotInterpretOtherTzInfoTypeErrors() + { + // A non-Lean type as tzinfo should not get the Lean-specific hint + PythonException exception = null; + using (Py.GIL()) + { + try + { + PythonEngine.Exec("from datetime import datetime\ndatetime.now('America/New_York')"); + } + catch (PythonException pythonException) + { + exception = pythonException; + } + } + + Assert.IsNotNull(exception); + Assert.False(new TzInfoPythonExceptionInterpreter().CanInterpret(exception)); + } + + private Exception CreateExceptionFromType(Type type) => type == typeof(PythonException) ? _pythonException : (Exception)Activator.CreateInstance(type); + } +} diff --git a/Tests/Common/Exceptions/UnsupportedOperandPythonExceptionInterpreterTests.cs b/Tests/Common/Exceptions/UnsupportedOperandPythonExceptionInterpreterTests.cs index 94211cfd8396..5a2eb9b2114e 100644 --- a/Tests/Common/Exceptions/UnsupportedOperandPythonExceptionInterpreterTests.cs +++ b/Tests/Common/Exceptions/UnsupportedOperandPythonExceptionInterpreterTests.cs @@ -83,6 +83,84 @@ public void VerifyMessageContainsStackTraceInformation() Assert.True(exception.Message.Contains("x = None + \"Pepe Grillo\"")); } + [Test] + public void NonDatetimeOperandsDoNotGetTheDatetimeHint() + { + var interpreted = new UnsupportedOperandPythonExceptionInterpreter() + .Interpret(_pythonException, NullExceptionInterpreter.Instance); + Assert.False(interpreted.Message.Contains("days_to_expiry")); + } + + [Test] + public void DatetimeAndDateSubtractionGetsTheDatetimeHint() + { + // (contract.id.date - self.time.date()).days, the most common fleet shape of this error + var exception = CreatePythonException("datetime_and_date_subtraction"); + var interpreter = new UnsupportedOperandPythonExceptionInterpreter(); + Assert.True(interpreter.CanInterpret(exception)); + + var interpreted = interpreter.Interpret(exception, NullExceptionInterpreter.Instance); + Assert.True(interpreted.Message.Contains("'datetime.datetime' and 'datetime.date'"), interpreted.Message); + Assert.True(interpreted.Message.Contains("days_to_expiry"), interpreted.Message); + Assert.True(interpreted.Message.Contains(".date()"), interpreted.Message); + } + + [Test] + public void DatetimeAndDateComparisonIsInterpretedWithTheDatetimeHint() + { + // "can't compare datetime.datetime to datetime.date", e.g. self.time.date() <= some_stored_datetime + var exception = CreatePythonException("datetime_and_date_comparison"); + var interpreter = new UnsupportedOperandPythonExceptionInterpreter(); + Assert.True(interpreter.CanInterpret(exception)); + + var interpreted = interpreter.Interpret(exception, NullExceptionInterpreter.Instance); + Assert.True(interpreted.Message.Contains("Trying to compare"), interpreted.Message); + Assert.True(interpreted.Message.Contains("'datetime.datetime'"), interpreted.Message); + Assert.True(interpreted.Message.Contains("'datetime.date'"), interpreted.Message); + Assert.True(interpreted.Message.Contains("days_to_expiry"), interpreted.Message); + } + + [Test] + public void OtherComparisonTypeErrorsAreNotInterpreted() + { + // "can't compare offset-naive and offset-aware datetimes" is not the datetime-vs-date shape + PythonException exception = null; + using (Py.GIL()) + { + try + { + PythonEngine.Exec("from datetime import datetime, timezone\ndatetime.now() < datetime.now(timezone.utc)"); + } + catch (PythonException pythonException) + { + exception = pythonException; + } + } + + Assert.IsNotNull(exception); + Assert.False(new UnsupportedOperandPythonExceptionInterpreter().CanInterpret(exception)); + } + + internal static PythonException CreatePythonException(string methodName) + { + using (Py.GIL()) + { + var module = Py.Import("Test_PythonExceptionInterpreter"); + dynamic algorithm = module.GetAttr("Test_PythonExceptionInterpreter").Invoke(); + + try + { + algorithm.InvokeMethod(methodName); + } + catch (PythonException pythonException) + { + return pythonException; + } + } + + throw new InvalidOperationException($"Expected '{methodName}' to throw a PythonException"); + } + private Exception CreateExceptionFromType(Type type) => type == typeof(PythonException) ? _pythonException : (Exception)Activator.CreateInstance(type); } } diff --git a/Tests/RegressionAlgorithms/Test_PythonExceptionInterpreter.py b/Tests/RegressionAlgorithms/Test_PythonExceptionInterpreter.py index de3b05ca8faf..3e17b4ff807b 100644 --- a/Tests/RegressionAlgorithms/Test_PythonExceptionInterpreter.py +++ b/Tests/RegressionAlgorithms/Test_PythonExceptionInterpreter.py @@ -32,6 +32,18 @@ def no_method_match_rsi(self): def unsupported_operand(self): x = None + "Pepe Grillo" + def datetime_and_date_subtraction(self): + x = datetime.now() - datetime.now().date() + + def datetime_and_date_comparison(self): + x = datetime.now() <= datetime.now().date() + + def lean_time_zone_as_tzinfo(self): + x = datetime.now(TimeZones.NEW_YORK) + + def python_type_as_generic_type_parameter(self): + x = RollingWindow[datetime](10) + def module_not_found(self): from MissingClrNamespace.Distributions import Normal