From 433e6523b9e3a9116d18b0b2967850fc80ca5845 Mon Sep 17 00:00:00 2001 From: Jhonathan Abreu Date: Fri, 4 Sep 2026 14:35:28 -0400 Subject: [PATCH 01/17] Share the option universe filters with OptionChain Turn OptionFilterUniverse into the generic BaseOptionFilterUniverse, renamed in place, so the same filters run over universe rows and chain contracts. OptionChain gets the universe filter vocabulary (strikes, expiration, calls_only, standards/weeklys, front/back month, greeks, IV, OI and where) through an internal OptionChainFilterUniverse, each call returning a new chain. - IChainContractData and IOptionContractData let OptionContract be filtered without being BaseData; IOptionContractFilters declares the shared surface and OptionChainTests asserts the chain mirrors every universe filter - Expirations count on the contract's last trading date, so Saturday and holiday expiries match expiration() and the strategy pickers on their last trading day - Chains built from universe data now carry the underlying price - OptionChainFiltersRegressionAlgorithm exercises the filters on option_chain() and slice chains in C# and Python --- .../OptionChainFiltersRegressionAlgorithm.cs | 195 ++++++++++ .../OptionChainFiltersRegressionAlgorithm.py | 96 +++++ Common/Data/Market/BaseChain.cs | 27 ++ Common/Data/Market/BaseContract.cs | 3 +- Common/Data/Market/OptionChain.cs | 307 ++++++++++++++- Common/Data/Market/OptionContract.cs | 7 +- .../Data/UniverseSelection/OptionUniverse.cs | 3 +- .../ContractSecurityFilterUniverse.cs | 18 +- Common/Securities/IChainContractData.cs | 31 ++ Common/Securities/IChainUniverseData.cs | 6 +- .../IDerivativeSecurityFilterUniverse.cs | 2 +- .../Securities/Option/IOptionContractData.cs | 41 ++ .../Option/IOptionContractFilters.cs | 148 ++++++++ .../Option/OptionChainFilterUniverse.cs | 73 ++++ .../Securities/Option/OptionFilterUniverse.cs | 292 ++++++++++----- Tests/Common/Data/Market/OptionChainTests.cs | 353 ++++++++++++++++++ Tests/Common/Securities/OptionFilterTests.cs | 5 +- 17 files changed, 1499 insertions(+), 108 deletions(-) create mode 100644 Algorithm.CSharp/OptionChainFiltersRegressionAlgorithm.cs create mode 100644 Algorithm.Python/OptionChainFiltersRegressionAlgorithm.py create mode 100644 Common/Securities/IChainContractData.cs create mode 100644 Common/Securities/Option/IOptionContractData.cs create mode 100644 Common/Securities/Option/IOptionContractFilters.cs create mode 100644 Common/Securities/Option/OptionChainFilterUniverse.cs create mode 100644 Tests/Common/Data/Market/OptionChainTests.cs diff --git a/Algorithm.CSharp/OptionChainFiltersRegressionAlgorithm.cs b/Algorithm.CSharp/OptionChainFiltersRegressionAlgorithm.cs new file mode 100644 index 000000000000..d73971b1fa41 --- /dev/null +++ b/Algorithm.CSharp/OptionChainFiltersRegressionAlgorithm.cs @@ -0,0 +1,195 @@ +/* + * 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.Data.Market; +using QuantConnect.Interfaces; +using QuantConnect.Securities.Option; + +namespace QuantConnect.Algorithm.CSharp +{ + /// + /// Regression algorithm demonstrating that option chains can be filtered with the same filters used for + /// option universe selection, both on chains from + /// and on the chains delivered in the slice + /// + public class OptionChainFiltersRegressionAlgorithm : QCAlgorithm, IRegressionAlgorithmDefinition + { + private Symbol _option; + private bool _traded; + + public override void Initialize() + { + SetStartDate(2015, 12, 24); + SetEndDate(2015, 12, 24); + SetCash(100000); + + var option = AddOption("GOOG"); + _option = option.Symbol; + // The same words select the universe and, below, narrow down the chains + option.SetFilter(universe => universe.CallsOnly().Expiration(1, 10).Strikes(-2, 2)); + + var chain = OptionChain(_option); + if (chain.Count == 0) + { + throw new RegressionTestException("Expected a non empty option chain"); + } + // The relative strikes filter needs the underlying price, chains built from universe data must carry it + if (chain.Underlying.Price == 0) + { + throw new RegressionTestException("Expected the chain to carry the underlying price"); + } + + var totalContracts = chain.Count; + var filtered = chain.CallsOnly().Expiration(1, 10).Strikes(-2, 2); + // GOOG closed at 748.54 on 2015-12-23 and the only expiration 1 to 10 days out is 2015-12-31, + // so the two strikes below the spot and the two at or above it are 745, 747.5, 750 and 752.5 + AssertContracts(filtered, OptionRight.Call, new DateTime(2015, 12, 31), new[] { 745m, 747.5m, 750m, 752.5m }); + if (chain.Count != totalContracts) + { + throw new RegressionTestException("Filters must not modify the source chain"); + } + + // Front month is the nearest expiration, 2015-12-24 itself + AssertContracts(chain.PutsOnly().FrontMonth(), OptionRight.Put, new DateTime(2015, 12, 24)); + + // Standard contracts expire on the third Friday, weeklys do not + var standards = chain.StandardsOnly().FrontMonth(); + if (standards.Count == 0 || standards.Any(x => x.Expiry != new DateTime(2016, 1, 15))) + { + throw new RegressionTestException("Expected the standard front month to expire on 2016-01-15"); + } + var weeklys = chain.WeeklysOnly(); + if (weeklys.Count == 0 || weeklys.Any(x => OptionSymbol.IsStandard(x.Symbol))) + { + throw new RegressionTestException("Expected only weekly contracts"); + } + + // Greeks filters use the greeks the chain carries + var deltas = chain.Delta(0.5m, 0.6m); + var expectedDeltas = chain.Count(x => x.Greeks.Delta >= 0.5m && x.Greeks.Delta <= 0.6m); + if (deltas.Count == 0 || deltas.Count != expectedDeltas || deltas.Any(x => x.Greeks.Delta < 0.5m || x.Greeks.Delta > 0.6m)) + { + throw new RegressionTestException("Delta filter mismatch"); + } + } + + public override void OnData(Slice slice) + { + if (_traded || !slice.OptionChains.TryGetValue(_option, out var chain)) + { + return; + } + + // The universe only selected calls expiring 1 to 10 days out, so the chain filters agree with it + if (chain.CallsOnly().Expiration(1, 10).Count != chain.Count || chain.PutsOnly().Count != 0) + { + throw new RegressionTestException("Slice chain filters disagree with the universe filter"); + } + + // Buy the call at the first strike at or above the underlying price + var contract = chain.Strikes(0, 0).FirstOrDefault(); + if (contract != null) + { + MarketOrder(contract.Symbol, 1); + _traded = true; + } + } + + public override void OnEndOfAlgorithm() + { + if (!_traded) + { + throw new RegressionTestException("Expected to trade a contract selected from the slice option chain"); + } + } + + private static void AssertContracts(OptionChain chain, OptionRight right, DateTime expiry, decimal[] strikes = null) + { + if (chain.Count == 0 || chain.Any(x => x.Right != right || x.Expiry != expiry)) + { + throw new RegressionTestException($"Expected only {right} contracts expiring on {expiry:yyyy-MM-dd}"); + } + if (strikes != null && !chain.Select(x => x.Strike).OrderBy(x => x).SequenceEqual(strikes)) + { + throw new RegressionTestException($"Unexpected strikes: {string.Join(", ", chain.Select(x => x.Strike))}"); + } + } + + /// + /// 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 virtual List Languages { get; } = new() { Language.CSharp, Language.Python }; + + /// + /// Data Points count of all timeslices of algorithm + /// + public long DataPoints => 7080; + + /// + /// Data Points count of the algorithm history + /// + public int AlgorithmHistoryDataPoints => 1; + + /// + /// 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", "1"}, + {"Average Win", "0%"}, + {"Average Loss", "0%"}, + {"Compounding Annual Return", "0%"}, + {"Drawdown", "0%"}, + {"Expectancy", "0"}, + {"Start Equity", "100000"}, + {"End Equity", "99764"}, + {"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", "$1.00"}, + {"Estimated Strategy Capacity", "$36000.00"}, + {"Lowest Capacity Asset", "GOOCV W6U7P9WYPQVA|GOOCV VP83T1ZUHROL"}, + {"Portfolio Turnover", "0.73%"}, + {"Drawdown Recovery", "0"}, + {"OrderListHash", "d505d8b11141dfd2d54b74ac20e39268"} + }; + } +} diff --git a/Algorithm.Python/OptionChainFiltersRegressionAlgorithm.py b/Algorithm.Python/OptionChainFiltersRegressionAlgorithm.py new file mode 100644 index 000000000000..3452d3235d80 --- /dev/null +++ b/Algorithm.Python/OptionChainFiltersRegressionAlgorithm.py @@ -0,0 +1,96 @@ +# 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 demonstrating that option chains can be filtered with the same filters used for +### option universe selection, both on chains from QCAlgorithm.option_chain() and on the chains delivered in the slice +### +class OptionChainFiltersRegressionAlgorithm(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") + self._option = option.symbol + # The same words select the universe and, below, narrow down the chains + option.set_filter(lambda universe: universe.calls_only().expiration(1, 10).strikes(-2, 2)) + + chain = self.option_chain(self._option) + if chain.count == 0: + raise AssertionError("Expected a non empty option chain") + # The relative strikes filter needs the underlying price, chains built from universe data must carry it + if chain.underlying.price == 0: + raise AssertionError("Expected the chain to carry the underlying price") + + total_contracts = chain.count + filtered = chain.calls_only().expiration(1, 10).strikes(-2, 2) + # GOOG closed at 748.54 on 2015-12-23 and the only expiration 1 to 10 days out is 2015-12-31, + # so the two strikes below the spot and the two at or above it are 745, 747.5, 750 and 752.5 + self._assert_contracts(filtered, OptionRight.CALL, datetime(2015, 12, 31), [745, 747.5, 750, 752.5]) + if chain.count != total_contracts: + raise AssertionError("Filters must not modify the source chain") + + # Front month is the nearest expiration, 2015-12-24 itself + self._assert_contracts(chain.puts_only().front_month(), OptionRight.PUT, datetime(2015, 12, 24)) + + # Standard contracts expire on the third Friday, weeklys do not + standards = chain.standards_only().front_month() + if standards.count == 0 or any(x.expiry != datetime(2016, 1, 15) for x in standards): + raise AssertionError("Expected the standard front month to expire on 2016-01-15") + weeklys = chain.weeklys_only() + if weeklys.count == 0 or any(OptionSymbol.is_standard(x.symbol) for x in weeklys): + raise AssertionError("Expected only weekly contracts") + + # Greeks filters use the greeks the chain carries + deltas = chain.delta(0.5, 0.6) + expected_deltas = sum(1 for x in chain if 0.5 <= x.greeks.delta <= 0.6) + if deltas.count == 0 or deltas.count != expected_deltas or any(not 0.5 <= x.greeks.delta <= 0.6 for x in deltas): + raise AssertionError("Delta filter mismatch") + + # where() takes a predicate, like the universe filter does + high_open_interest = chain.where(lambda x: x.open_interest > 1000) + if high_open_interest.count == 0 or high_open_interest.count != sum(1 for x in chain if x.open_interest > 1000): + raise AssertionError("where() filter mismatch") + + self._traded = False + + def on_data(self, slice): + if self._traded: + return + chain = slice.option_chains.get(self._option) + if not chain: + return + + # The universe only selected calls expiring 1 to 10 days out, so the chain filters agree with it + if chain.calls_only().expiration(1, 10).count != chain.count or chain.puts_only().count != 0: + raise AssertionError("Slice chain filters disagree with the universe filter") + + # Buy the call at the first strike at or above the underlying price + contract = next(iter(chain.strikes(0, 0)), None) + if contract is not None: + self.market_order(contract.symbol, 1) + self._traded = True + + def on_end_of_algorithm(self): + if not self._traded: + raise AssertionError("Expected to trade a contract selected from the slice option chain") + + def _assert_contracts(self, chain, right, expiry, strikes=None): + if chain.count == 0 or any(x.right != right or x.expiry != expiry for x in chain): + raise AssertionError(f"Expected only {right} contracts expiring on {expiry:%Y-%m-%d}") + if strikes is not None and sorted(x.strike for x in chain) != strikes: + raise AssertionError(f"Unexpected strikes: {[x.strike for x in chain]}") diff --git a/Common/Data/Market/BaseChain.cs b/Common/Data/Market/BaseChain.cs index 1341d16f51a8..6e4e3c1dbca5 100644 --- a/Common/Data/Market/BaseChain.cs +++ b/Common/Data/Market/BaseChain.cs @@ -180,6 +180,33 @@ protected BaseChain(BaseChain other) FilteredContracts = other.FilteredContracts; } + /// + /// Initializes a new instance of the class as a copy of the specified chain + /// containing only the given subset of its contracts. The underlying, ticks, trade bars and quote bars are shared with the source chain + /// + /// The chain to copy + /// The contracts to keep + protected BaseChain(BaseChain other, IEnumerable contracts) + : this(other.DataType, other._flatten) + { + Symbol = other.Symbol; + Time = other.Time; + Value = other.Value; + Underlying = other.Underlying; + Ticks = other.Ticks; + QuoteBars = other.QuoteBars; + TradeBars = other.TradeBars; + FilteredContracts = other.FilteredContracts; + Contracts = new(); +#pragma warning disable 0618 // DataDictionary.Time is deprecated, ignore until removed entirely + Contracts.Time = other.Contracts.Time; +#pragma warning restore 0618 + foreach (var contract in contracts) + { + Contracts[contract.Symbol] = contract; + } + } + /// /// Gets the auxiliary data with the specified type and symbol /// diff --git a/Common/Data/Market/BaseContract.cs b/Common/Data/Market/BaseContract.cs index 19110435d8f0..bc79859f6d70 100644 --- a/Common/Data/Market/BaseContract.cs +++ b/Common/Data/Market/BaseContract.cs @@ -14,6 +14,7 @@ */ using QuantConnect.Python; +using QuantConnect.Securities; using System; namespace QuantConnect.Data.Market @@ -21,7 +22,7 @@ namespace QuantConnect.Data.Market /// /// Defines a base for a single contract, like an option or future contract /// - public abstract class BaseContract : ISymbolProvider + public abstract class BaseContract : IChainContractData { /// /// Gets the contract's symbol diff --git a/Common/Data/Market/OptionChain.cs b/Common/Data/Market/OptionChain.cs index 562cd18e0a0f..af8c198c355b 100644 --- a/Common/Data/Market/OptionChain.cs +++ b/Common/Data/Market/OptionChain.cs @@ -15,6 +15,8 @@ using System; using System.Collections.Generic; +using System.Linq; +using Python.Runtime; using QuantConnect.Data.UniverseSelection; using QuantConnect.Securities; @@ -22,9 +24,12 @@ namespace QuantConnect.Data.Market { /// /// Represents an entire chain of option contracts for a single underlying security. - /// This type is + /// This type is . + /// The chain can be narrowed down with the same filters available for option universe selection + /// (see and ), e.g. chain.calls_only().expiration(0, 30).strikes(-2, 2). + /// Each filter returns a new chain, leaving this one untouched. /// - public class OptionChain : BaseChain + public class OptionChain : BaseChain, IOptionContractFilters { /// /// Initializes a new instance of the class @@ -49,9 +54,15 @@ public OptionChain(Symbol canonicalOptionSymbol, DateTime time, IEnumerable + /// Initializes a new instance of the class as a copy of the specified chain + /// containing only the given subset of its contracts + /// + private OptionChain(OptionChain other, IEnumerable contracts) + : base(other, contracts) + { + } + /// /// Return a new instance clone of this object, used in fill forward /// @@ -73,5 +93,286 @@ public override BaseData Clone() { return new OptionChain(this); } + + #region Filters + + /// + /// Selects the contracts with strikes in the given range relative to the underlying price, in number of strikes. + /// Same as + /// + /// The minimum strike relative to the underlying price, for example, -1 would filter out contracts further than 1 strike below market price + /// The maximum strike relative to the underlying price, for example, +1 would filter out contracts further than 1 strike above market price + /// A new chain with the filter applied + public OptionChain Strikes(int minStrike, int maxStrike) + { + return Filter(universe => universe.Strikes(minStrike, maxStrike)); + } + + /// + /// Selects the contracts expiring in the given range relative to the chain date. + /// Same as + /// + /// The minimum time until expiry to include, for example, TimeSpan.FromDays(10) + /// would exclude contracts expiring in less than 10 days + /// The maximum time until expiry to include, for example, TimeSpan.FromDays(10) + /// would exclude contracts expiring in more than 10 days + /// A new chain with the filter applied + public OptionChain Expiration(TimeSpan minExpiry, TimeSpan maxExpiry) + { + return Filter(universe => universe.Expiration(minExpiry, maxExpiry)); + } + + /// + /// Selects the contracts expiring in the given range of days relative to the chain date. + /// Same as + /// + /// The minimum time, expressed in days, until expiry to include, for example, 10 + /// would exclude contracts expiring in less than 10 days + /// The maximum time, expressed in days, until expiry to include, for example, 10 + /// would exclude contracts expiring in more than 10 days + /// A new chain with the filter applied + public OptionChain Expiration(int minExpiryDays, int maxExpiryDays) + { + return Filter(universe => universe.Expiration(minExpiryDays, maxExpiryDays)); + } + + /// + /// Selects the call contracts. Same as + /// + /// A new chain with the filter applied + public OptionChain CallsOnly() + { + return Filter(universe => universe.CallsOnly()); + } + + /// + /// Selects the put contracts. Same as + /// + /// A new chain with the filter applied + public OptionChain PutsOnly() + { + return Filter(universe => universe.PutsOnly()); + } + + /// + /// Selects the standard contracts, excluding weeklys. Same as + /// + /// A new chain with the filter applied + public OptionChain StandardsOnly() + { + return Filter(universe => universe.StandardsOnly()); + } + + /// + /// Selects the non standard weekly contracts. Same as + /// + /// A new chain with the filter applied + public OptionChain WeeklysOnly() + { + return Filter(universe => universe.WeeklysOnly()); + } + + /// + /// Selects the contracts of the nearest expiration. Same as + /// + /// A new chain with the filter applied + public OptionChain FrontMonth() + { + return Filter(universe => universe.FrontMonth()); + } + + /// + /// Selects the contracts of all expirations but the nearest one. Same as + /// + /// A new chain with the filter applied + public OptionChain BackMonths() + { + return Filter(universe => universe.BackMonths()); + } + + /// + /// Selects the contracts of the second nearest expiration. Same as + /// + /// A new chain with the filter applied + public OptionChain BackMonth() + { + return Filter(universe => universe.BackMonth()); + } + + /// + /// Selects the contracts with delta in the given range. Same as + /// + /// The minimum delta value + /// The maximum delta value + /// A new chain with the filter applied + public OptionChain Delta(decimal min, decimal max) + { + return Filter(universe => universe.Delta(min, max)); + } + + /// + /// Selects the contracts with delta in the given range. Alias for + /// + /// The minimum delta value + /// The maximum delta value + /// A new chain with the filter applied + public OptionChain D(decimal min, decimal max) + { + return Delta(min, max); + } + + /// + /// Selects the contracts with gamma in the given range. Same as + /// + /// The minimum gamma value + /// The maximum gamma value + /// A new chain with the filter applied + public OptionChain Gamma(decimal min, decimal max) + { + return Filter(universe => universe.Gamma(min, max)); + } + + /// + /// Selects the contracts with gamma in the given range. Alias for + /// + /// The minimum gamma value + /// The maximum gamma value + /// A new chain with the filter applied + public OptionChain G(decimal min, decimal max) + { + return Gamma(min, max); + } + + /// + /// Selects the contracts with theta in the given range. Same as + /// + /// The minimum theta value + /// The maximum theta value + /// A new chain with the filter applied + public OptionChain Theta(decimal min, decimal max) + { + return Filter(universe => universe.Theta(min, max)); + } + + /// + /// Selects the contracts with theta in the given range. Alias for + /// + /// The minimum theta value + /// The maximum theta value + /// A new chain with the filter applied + public OptionChain T(decimal min, decimal max) + { + return Theta(min, max); + } + + /// + /// Selects the contracts with vega in the given range. Same as + /// + /// The minimum vega value + /// The maximum vega value + /// A new chain with the filter applied + public OptionChain Vega(decimal min, decimal max) + { + return Filter(universe => universe.Vega(min, max)); + } + + /// + /// Selects the contracts with vega in the given range. Alias for + /// + /// The minimum vega value + /// The maximum vega value + /// A new chain with the filter applied + public OptionChain V(decimal min, decimal max) + { + return Vega(min, max); + } + + /// + /// Selects the contracts with rho in the given range. Same as + /// + /// The minimum rho value + /// The maximum rho value + /// A new chain with the filter applied + public OptionChain Rho(decimal min, decimal max) + { + return Filter(universe => universe.Rho(min, max)); + } + + /// + /// Selects the contracts with rho in the given range. Alias for + /// + /// The minimum rho value + /// The maximum rho value + /// A new chain with the filter applied + public OptionChain R(decimal min, decimal max) + { + return Rho(min, max); + } + + /// + /// Selects the contracts with implied volatility in the given range. Same as + /// + /// The minimum implied volatility value + /// The maximum implied volatility value + /// A new chain with the filter applied + public OptionChain ImpliedVolatility(decimal min, decimal max) + { + return Filter(universe => universe.ImpliedVolatility(min, max)); + } + + /// + /// Selects the contracts with implied volatility in the given range. Alias for + /// + /// The minimum implied volatility value + /// The maximum implied volatility value + /// A new chain with the filter applied + public OptionChain IV(decimal min, decimal max) + { + return ImpliedVolatility(min, max); + } + + /// + /// Selects the contracts with open interest in the given range. Same as + /// + /// The minimum open interest value + /// The maximum open interest value + /// A new chain with the filter applied + public OptionChain OpenInterest(long min, long max) + { + return Filter(universe => universe.OpenInterest(min, max)); + } + + /// + /// Selects the contracts with open interest in the given range. Alias for + /// + /// The minimum open interest value + /// The maximum open interest value + /// A new chain with the filter applied + public OptionChain OI(long min, long max) + { + return OpenInterest(min, max); + } + + /// + /// Selects the contracts matching the given predicate, e.g. chain.where(lambda contract: contract.open_interest > 100). + /// From C# use Linq's Where, which keeps this chain's type untouched + /// + /// Function determining which contracts are kept + /// A new chain with the filter applied + public OptionChain Where(PyObject predicate) + { + return new OptionChain(this, Contracts.Values.Where(predicate.SafeAs>())); + } + + /// + /// Applies the given universe filter to the contracts of this chain and returns the result as a new chain + /// + private OptionChain Filter(Func filter) + { + // the type filters (standards/weeklys) are only applied on demand, like the universe selection does after the user filter + return new OptionChain(this, filter(new OptionChainFilterUniverse(this)).ApplyTypesFilter()); + } + + #endregion } } diff --git a/Common/Data/Market/OptionContract.cs b/Common/Data/Market/OptionContract.cs index 3d44ec15f2c9..d07e3dacd04d 100644 --- a/Common/Data/Market/OptionContract.cs +++ b/Common/Data/Market/OptionContract.cs @@ -24,7 +24,7 @@ namespace QuantConnect.Data.Market /// /// Defines a single option contract at a specific expiration and strike price /// - public class OptionContract : BaseContract + public class OptionContract : BaseContract, IOptionContractData { private IOptionData _optionData = OptionPriceModelResultData.Null; private readonly SymbolProperties _symbolProperties; @@ -104,6 +104,11 @@ public class OptionContract : BaseContract /// public decimal UnderlyingLastPrice => _optionData.UnderlyingLastPrice; + /// + /// The option symbol properties + /// + internal SymbolProperties SymbolProperties => _symbolProperties; + /// /// Initializes a new instance of the class /// diff --git a/Common/Data/UniverseSelection/OptionUniverse.cs b/Common/Data/UniverseSelection/OptionUniverse.cs index b8909c81d705..7cd98dc2049e 100644 --- a/Common/Data/UniverseSelection/OptionUniverse.cs +++ b/Common/Data/UniverseSelection/OptionUniverse.cs @@ -18,6 +18,7 @@ using System.IO; using System.Runtime.CompilerServices; using QuantConnect.Data.Market; +using QuantConnect.Securities; using QuantConnect.Python; using QuantConnect.Util; @@ -26,7 +27,7 @@ namespace QuantConnect.Data.UniverseSelection /// /// Represents a universe of options data /// - public class OptionUniverse : BaseChainUniverseData + public class OptionUniverse : BaseChainUniverseData, IOptionContractData { /// /// Cache for the symbols to avoid creating them multiple times diff --git a/Common/Securities/ContractSecurityFilterUniverse.cs b/Common/Securities/ContractSecurityFilterUniverse.cs index 7a317eeb4a90..15cbec59c59f 100644 --- a/Common/Securities/ContractSecurityFilterUniverse.cs +++ b/Common/Securities/ContractSecurityFilterUniverse.cs @@ -28,7 +28,7 @@ namespace QuantConnect.Securities /// public abstract class ContractSecurityFilterUniverse : IDerivativeSecurityFilterUniverse where T : ContractSecurityFilterUniverse - where TData : IChainUniverseData + where TData : IChainContractData { private bool _alreadyAppliedTypeFilters; @@ -294,6 +294,16 @@ protected virtual DateTime AdjustExpirationReferenceDate(DateTime referenceDate) return referenceDate; } + /// + /// Gets the date the given contract stops trading, used by the expiration filters. Defaults to the contract expiration date + /// + /// The contract + /// The contract's last trading date + protected virtual DateTime GetLastTradingDate(TData contract) + { + return contract.ID.Date.Date; + } + /// /// Applies filter selecting options contracts based on a range of expiration dates relative to the current day /// @@ -317,7 +327,11 @@ public virtual T Expiration(TimeSpan minExpiry, TimeSpan maxExpiry) var maxExpiryToDate = referenceDate + maxExpiry; Data = Data - .Where(symbol => symbol.ID.Date.Date >= minExpiryToDate && symbol.ID.Date.Date <= maxExpiryToDate) + .Where(contract => + { + var expiry = GetLastTradingDate(contract); + return expiry >= minExpiryToDate && expiry <= maxExpiryToDate; + }) .ToList(); return (T)this; diff --git a/Common/Securities/IChainContractData.cs b/Common/Securities/IChainContractData.cs new file mode 100644 index 000000000000..83adc08f5819 --- /dev/null +++ b/Common/Securities/IChainContractData.cs @@ -0,0 +1,31 @@ +/* + * 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 QuantConnect.Data; + +namespace QuantConnect.Securities +{ + /// + /// The minimal contract data the contract filter universes work with, + /// implemented by both universe selection data and chain contracts + /// + public interface IChainContractData : ISymbolProvider + { + /// + /// The security identifier of the contract + /// + SecurityIdentifier ID { get; } + } +} diff --git a/Common/Securities/IChainUniverseData.cs b/Common/Securities/IChainUniverseData.cs index cba27e3268e3..cf415788e0eb 100644 --- a/Common/Securities/IChainUniverseData.cs +++ b/Common/Securities/IChainUniverseData.cs @@ -21,11 +21,7 @@ namespace QuantConnect.Securities /// /// Base interface intended for chain universe data to have some of their symbol properties accessible directly. /// - public interface IChainUniverseData : IBaseData + public interface IChainUniverseData : IBaseData, IChainContractData { - /// - /// Gets the security identifier. - /// - SecurityIdentifier ID { get; } } } diff --git a/Common/Securities/IDerivativeSecurityFilterUniverse.cs b/Common/Securities/IDerivativeSecurityFilterUniverse.cs index a3a61cddfce7..12606c8ae29c 100644 --- a/Common/Securities/IDerivativeSecurityFilterUniverse.cs +++ b/Common/Securities/IDerivativeSecurityFilterUniverse.cs @@ -22,7 +22,7 @@ namespace QuantConnect.Securities /// Represents derivative symbols universe used in filtering. /// public interface IDerivativeSecurityFilterUniverse : IEnumerable - where T : IChainUniverseData + where T : IChainContractData { /// /// The number of contracts in the universe diff --git a/Common/Securities/Option/IOptionContractData.cs b/Common/Securities/Option/IOptionContractData.cs new file mode 100644 index 000000000000..38c9eb1427bf --- /dev/null +++ b/Common/Securities/Option/IOptionContractData.cs @@ -0,0 +1,41 @@ +/* + * 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 QuantConnect.Data.Market; + +namespace QuantConnect.Securities +{ + /// + /// The option contract data the option filters work with, + /// implemented by both option universe selection data and option chain contracts + /// + public interface IOptionContractData : IChainContractData + { + /// + /// The greeks of the contract + /// + Greeks Greeks { get; } + + /// + /// The implied volatility of the contract + /// + decimal ImpliedVolatility { get; } + + /// + /// The open interest of the contract + /// + decimal OpenInterest { get; } + } +} diff --git a/Common/Securities/Option/IOptionContractFilters.cs b/Common/Securities/Option/IOptionContractFilters.cs new file mode 100644 index 000000000000..70a655e8384b --- /dev/null +++ b/Common/Securities/Option/IOptionContractFilters.cs @@ -0,0 +1,148 @@ +/* + * 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; + +namespace QuantConnect.Securities +{ + /// + /// The option contract filters shared by the option universe selection () + /// and the option chain (), so both offer the same filters with the same semantics. + /// Every filter added here must be implemented by both; OptionChainTests.ChainExposesEveryUniverseFilter verifies it + /// + /// The implementing type, returned by every filter for chaining + public interface IOptionContractFilters + { + /// + /// Selects the contracts with strikes in the given range relative to the underlying price, in number of strikes + /// + TSelf Strikes(int minStrike, int maxStrike); + + /// + /// Selects the contracts expiring in the given range relative to the current date + /// + TSelf Expiration(TimeSpan minExpiry, TimeSpan maxExpiry); + + /// + /// Selects the contracts expiring in the given range of days relative to the current date + /// + TSelf Expiration(int minExpiryDays, int maxExpiryDays); + + /// + /// Selects the call contracts + /// + TSelf CallsOnly(); + + /// + /// Selects the put contracts + /// + TSelf PutsOnly(); + + /// + /// Selects the standard contracts, excluding weeklys + /// + TSelf StandardsOnly(); + + /// + /// Selects the non standard weekly contracts + /// + TSelf WeeklysOnly(); + + /// + /// Selects the contracts of the nearest expiration + /// + TSelf FrontMonth(); + + /// + /// Selects the contracts of all expirations but the nearest one + /// + TSelf BackMonths(); + + /// + /// Selects the contracts of the second nearest expiration + /// + TSelf BackMonth(); + + /// + /// Selects the contracts with delta in the given range + /// + TSelf Delta(decimal min, decimal max); + + /// + /// Selects the contracts with delta in the given range. Alias for + /// + TSelf D(decimal min, decimal max); + + /// + /// Selects the contracts with gamma in the given range + /// + TSelf Gamma(decimal min, decimal max); + + /// + /// Selects the contracts with gamma in the given range. Alias for + /// + TSelf G(decimal min, decimal max); + + /// + /// Selects the contracts with theta in the given range + /// + TSelf Theta(decimal min, decimal max); + + /// + /// Selects the contracts with theta in the given range. Alias for + /// + TSelf T(decimal min, decimal max); + + /// + /// Selects the contracts with vega in the given range + /// + TSelf Vega(decimal min, decimal max); + + /// + /// Selects the contracts with vega in the given range. Alias for + /// + TSelf V(decimal min, decimal max); + + /// + /// Selects the contracts with rho in the given range + /// + TSelf Rho(decimal min, decimal max); + + /// + /// Selects the contracts with rho in the given range. Alias for + /// + TSelf R(decimal min, decimal max); + + /// + /// Selects the contracts with implied volatility in the given range + /// + TSelf ImpliedVolatility(decimal min, decimal max); + + /// + /// Selects the contracts with implied volatility in the given range. Alias for + /// + TSelf IV(decimal min, decimal max); + + /// + /// Selects the contracts with open interest in the given range + /// + TSelf OpenInterest(long min, long max); + + /// + /// Selects the contracts with open interest in the given range. Alias for + /// + TSelf OI(long min, long max); + } +} diff --git a/Common/Securities/Option/OptionChainFilterUniverse.cs b/Common/Securities/Option/OptionChainFilterUniverse.cs new file mode 100644 index 000000000000..112dfff1dc5a --- /dev/null +++ b/Common/Securities/Option/OptionChainFilterUniverse.cs @@ -0,0 +1,73 @@ +/* + * 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.Linq; +using QuantConnect.Data; +using QuantConnect.Data.Market; + +namespace QuantConnect.Securities +{ + /// + /// Option contracts filter over the contracts of an , so chains offer + /// the same filters as the option universe selection () + /// + internal class OptionChainFilterUniverse : BaseOptionFilterUniverse + { + private readonly Symbol _symbol; + private SecurityExchangeHours _exchangeHours; + + /// + /// The option exchange hours + /// + protected override SecurityExchangeHours ExchangeHours => + _exchangeHours ??= MarketHoursDatabase.FromDataFolder().GetExchangeHours(_symbol.ID.Market, _symbol, _symbol.SecurityType); + + /// + /// The option security type + /// + protected override SecurityType SecurityType => _symbol.SecurityType; + + /// + /// Initializes a new instance of the class over the contracts of the given chain + /// + /// The option chain to filter + public OptionChainFilterUniverse(OptionChain chain) + : base(chain.Contracts.Values.ToList(), GetUnderlying(chain), chain.Time, GetStrikeMultiplier(chain)) + { + _symbol = chain.Symbol; + } + + /// + /// Not supported: the chain filters only ever select contracts that are already in the chain + /// + protected override OptionContract CreateDataInstance(Symbol symbol) + { + throw new InvalidOperationException($"OptionChainFilterUniverse.CreateDataInstance(): {symbol} is not part of the chain"); + } + + private static BaseData GetUnderlying(OptionChain chain) + { + // A chain without underlying data carries an empty placeholder, which must not be used as a zero price + var underlying = chain.Underlying; + return underlying != null && underlying.Price != 0 ? underlying : null; + } + + private static decimal GetStrikeMultiplier(OptionChain chain) + { + return chain.Contracts.Values.FirstOrDefault()?.SymbolProperties?.StrikeMultiplier ?? 1; + } + } +} diff --git a/Common/Securities/Option/OptionFilterUniverse.cs b/Common/Securities/Option/OptionFilterUniverse.cs index 5cb946a97286..97af64709f02 100644 --- a/Common/Securities/Option/OptionFilterUniverse.cs +++ b/Common/Securities/Option/OptionFilterUniverse.cs @@ -28,23 +28,37 @@ namespace QuantConnect.Securities { /// - /// Represents options symbols universe used in filtering. + /// Base option contracts filter, shared by the option universe selection filter () + /// and the option chain filters () so both offer the same filters with the same semantics /// - public class OptionFilterUniverse : ContractSecurityFilterUniverse + /// The concrete filter universe type + /// The option contract data type + public abstract class BaseOptionFilterUniverse : ContractSecurityFilterUniverse, IOptionContractFilters + where TUniverse : BaseOptionFilterUniverse + where TData : IOptionContractData { - private Option.Option _option; - // Fields used in relative strikes filter private List _uniqueStrikes; private bool _refreshUniqueStrikes; private DateTime _lastExchangeDate; private readonly decimal _underlyingScaleFactor = 1; + private readonly Dictionary _lastTradingDates = new(); /// /// The underlying price data /// protected BaseData UnderlyingInternal { get; set; } + /// + /// The option exchange hours, used to resolve trading dates. Can be null, in which case no date adjustment is made + /// + protected abstract SecurityExchangeHours ExchangeHours { get; } + + /// + /// The option security type + /// + protected abstract SecurityType SecurityType { get; } + /// /// The underlying price data /// @@ -57,27 +71,29 @@ public BaseData Underlying } /// - /// Constructs OptionFilterUniverse + /// Constructs BaseOptionFilterUniverse /// By default, the filter includes both standard and weekly contracts. /// - /// The canonical option chain security - public OptionFilterUniverse(Option.Option option) + /// The option strike multiplier, see + protected BaseOptionFilterUniverse(decimal underlyingScaleFactor) { - _option = option; - _underlyingScaleFactor = option.SymbolProperties.StrikeMultiplier; + _underlyingScaleFactor = underlyingScaleFactor; } /// - /// Constructs OptionFilterUniverse + /// Constructs BaseOptionFilterUniverse /// - /// Used for testing only - public OptionFilterUniverse(Option.Option option, IReadOnlyList allData, BaseData underlying, decimal underlyingScaleFactor = 1) - : base(allData, underlying.EndTime) + /// All data for the option contracts + /// The current underlying last data point + /// The current local time + /// The option strike multiplier, see + protected BaseOptionFilterUniverse(IReadOnlyList allData, BaseData underlying, DateTime localTime, decimal underlyingScaleFactor = 1) + : base(allData, localTime) { - _option = option; UnderlyingInternal = underlying; _refreshUniqueStrikes = true; _underlyingScaleFactor = underlyingScaleFactor; + _lastExchangeDate = localTime.Date; } /// @@ -86,7 +102,7 @@ public OptionFilterUniverse(Option.Option option, IReadOnlyList /// All data for the option contracts /// The current underlying last data point /// The current local time - public void Refresh(IReadOnlyList allContractsData, BaseData underlying, DateTime localTime) + public void Refresh(IReadOnlyList allContractsData, BaseData underlying, DateTime localTime) { base.Refresh(allContractsData, localTime); @@ -112,19 +128,6 @@ protected override bool IsStandard(Symbol symbol) } } - /// - /// Creates a new instance of the data type for the given symbol - /// - /// A data instance for the given symbol - protected override OptionUniverse CreateDataInstance(Symbol symbol) - { - return new OptionUniverse() - { - Symbol = symbol, - Time = LocalTime - }; - } - /// /// Adjusts the date to the next trading day if the current date is not a trading day, so that expiration filter is properly applied. /// e.g. Selection for Mondays happen on Friday midnight (Saturday start), so if the minimum time to expiration is, say 0, @@ -135,25 +138,64 @@ protected override OptionUniverse CreateDataInstance(Symbol symbol) protected override DateTime AdjustExpirationReferenceDate(DateTime referenceDate) { // Check whether the reference time is a tradable date: - if (!_option.Exchange.Hours.IsDateOpen(referenceDate)) + if (ExchangeHours != null && !ExchangeHours.IsDateOpen(referenceDate)) { - referenceDate = _option.Exchange.Hours.GetNextTradingDay(referenceDate); + referenceDate = ExchangeHours.GetNextTradingDay(referenceDate); } return referenceDate; } + /// + /// Gets the last trading date of the given contract. Expirations falling on a non trading day, like the + /// Saturday expirations of equity options before February 2015, are moved back to the previous trading day + /// + /// The contract + /// The date the contract stops trading + protected override DateTime GetLastTradingDate(TData contract) + { + return GetLastTradingDate(contract.ID.Date); + } + + /// + /// Gets the last trading date for the given expiration date. Expirations falling on a non trading day, like the + /// Saturday expirations of equity options before February 2015, are moved back to the previous trading day + /// + /// The contract expiration date + /// The date the contract stops trading + protected DateTime GetLastTradingDate(DateTime expiry) + { + var date = expiry.Date; + if (ExchangeHours == null) + { + return date; + } + + if (!_lastTradingDates.TryGetValue(date, out var lastTradingDate)) + { + lastTradingDate = date; + // bounded so a closed exchange calendar can't make this loop forever + for (var i = 0; i < 7 && !ExchangeHours.IsDateOpen(lastTradingDate); i++) + { + lastTradingDate = lastTradingDate.AddDays(-1); + } + _lastTradingDates[date] = lastTradingDate; + } + + return lastTradingDate; + } + /// /// Applies filter selecting options contracts based on a range of strikes in relative terms /// /// The minimum strike relative to the underlying price, for example, -1 would filter out contracts further than 1 strike below market price /// The maximum strike relative to the underlying price, for example, +1 would filter out contracts further than 1 strike above market price /// Universe with filter applied - public OptionFilterUniverse Strikes(int minStrike, int maxStrike) + public TUniverse Strikes(int minStrike, int maxStrike) { if (UnderlyingInternal == null) { - return this; + return (TUniverse)this; } if (_refreshUniqueStrikes || _uniqueStrikes == null) @@ -240,14 +282,14 @@ public OptionFilterUniverse Strikes(int minStrike, int maxStrike) } ).ToList(); - return this; + return (TUniverse)this; } /// /// Sets universe of call options (if any) as a selection /// /// Universe with filter applied - public OptionFilterUniverse CallsOnly() + public TUniverse CallsOnly() { return Contracts(contracts => contracts.Where(x => x.Symbol.ID.OptionRight == OptionRight.Call)); } @@ -256,7 +298,7 @@ public OptionFilterUniverse CallsOnly() /// Sets universe of put options (if any) as a selection /// /// Universe with filter applied - public OptionFilterUniverse PutsOnly() + public TUniverse PutsOnly() { return Contracts(contracts => contracts.Where(x => x.Symbol.ID.OptionRight == OptionRight.Put)); } @@ -268,7 +310,7 @@ public OptionFilterUniverse PutsOnly() /// The desire strike price distance from the current underlying price /// Applicable to Naked Call, Covered Call, and Protective Call Option Strategy /// Universe with filter applied - public OptionFilterUniverse NakedCall(int minDaysTillExpiry = 30, decimal strikeFromAtm = 0) + public TUniverse NakedCall(int minDaysTillExpiry = 30, decimal strikeFromAtm = 0) { return SingleContract(OptionRight.Call, minDaysTillExpiry, strikeFromAtm); } @@ -280,12 +322,12 @@ public OptionFilterUniverse NakedCall(int minDaysTillExpiry = 30, decimal strike /// The desire strike price distance from the current underlying price /// Applicable to Naked Put, Covered Put, and Protective Put Option Strategy /// Universe with filter applied - public OptionFilterUniverse NakedPut(int minDaysTillExpiry = 30, decimal strikeFromAtm = 0) + public TUniverse NakedPut(int minDaysTillExpiry = 30, decimal strikeFromAtm = 0) { return SingleContract(OptionRight.Put, minDaysTillExpiry, strikeFromAtm); } - private OptionFilterUniverse SingleContract(OptionRight right, int minDaysTillExpiry = 30, decimal strikeFromAtm = 0) + private TUniverse SingleContract(OptionRight right, int minDaysTillExpiry = 30, decimal strikeFromAtm = 0) { // Select the expiry as the nearest to set days later var contractsForExpiry = GetContractsForExpiry(AllSymbols, minDaysTillExpiry); @@ -310,7 +352,7 @@ private OptionFilterUniverse SingleContract(OptionRight right, int minDaysTillEx /// The desire strike price distance from the current underlying price of the lower strike price /// Applicable to Bear Call Spread and Bull Call Spread Option Strategy /// Universe with filter applied - public OptionFilterUniverse CallSpread(int minDaysTillExpiry = 30, decimal higherStrikeFromAtm = 5, decimal? lowerStrikeFromAtm = null) + public TUniverse CallSpread(int minDaysTillExpiry = 30, decimal higherStrikeFromAtm = 5, decimal? lowerStrikeFromAtm = null) { return Spread(OptionRight.Call, minDaysTillExpiry, higherStrikeFromAtm, lowerStrikeFromAtm); } @@ -323,12 +365,12 @@ public OptionFilterUniverse CallSpread(int minDaysTillExpiry = 30, decimal highe /// The desire strike price distance from the current underlying price of the lower strike price /// Applicable to Bear Put Spread and Bull Put Spread Option Strategy /// Universe with filter applied - public OptionFilterUniverse PutSpread(int minDaysTillExpiry = 30, decimal higherStrikeFromAtm = 5, decimal? lowerStrikeFromAtm = null) + public TUniverse PutSpread(int minDaysTillExpiry = 30, decimal higherStrikeFromAtm = 5, decimal? lowerStrikeFromAtm = null) { return Spread(OptionRight.Put, minDaysTillExpiry, higherStrikeFromAtm, lowerStrikeFromAtm); } - private OptionFilterUniverse Spread(OptionRight right, int minDaysTillExpiry, decimal higherStrikeFromAtm, decimal? lowerStrikeFromAtm = null) + private TUniverse Spread(OptionRight right, int minDaysTillExpiry, decimal higherStrikeFromAtm, decimal? lowerStrikeFromAtm = null) { if (!lowerStrikeFromAtm.HasValue) { @@ -372,7 +414,7 @@ private OptionFilterUniverse Spread(OptionRight right, int minDaysTillExpiry, de /// The mininum days till expiry of the further conrtact from the current time, closest expiry will be selected /// Applicable to Long and Short Call Calendar Spread Option Strategy /// Universe with filter applied - public OptionFilterUniverse CallCalendarSpread(decimal strikeFromAtm = 0, int minNearDaysTillExpiry = 30, int minFarDaysTillExpiry = 60) + public TUniverse CallCalendarSpread(decimal strikeFromAtm = 0, int minNearDaysTillExpiry = 30, int minFarDaysTillExpiry = 60) { return CalendarSpread(OptionRight.Call, strikeFromAtm, minNearDaysTillExpiry, minFarDaysTillExpiry); } @@ -385,12 +427,12 @@ public OptionFilterUniverse CallCalendarSpread(decimal strikeFromAtm = 0, int mi /// The mininum days till expiry of the further conrtact from the current time, closest expiry will be selected /// Applicable to Long and Short Put Calendar Spread Option Strategy /// Universe with filter applied - public OptionFilterUniverse PutCalendarSpread(decimal strikeFromAtm = 0, int minNearDaysTillExpiry = 30, int minFarDaysTillExpiry = 60) + public TUniverse PutCalendarSpread(decimal strikeFromAtm = 0, int minNearDaysTillExpiry = 30, int minFarDaysTillExpiry = 60) { return CalendarSpread(OptionRight.Put, strikeFromAtm, minNearDaysTillExpiry, minFarDaysTillExpiry); } - private OptionFilterUniverse CalendarSpread(OptionRight right, decimal strikeFromAtm, int minNearDaysTillExpiry, int minFarDaysTillExpiry) + private TUniverse CalendarSpread(OptionRight right, decimal strikeFromAtm, int minNearDaysTillExpiry, int minFarDaysTillExpiry) { if (minFarDaysTillExpiry <= minNearDaysTillExpiry) { @@ -432,7 +474,7 @@ private OptionFilterUniverse CalendarSpread(OptionRight right, decimal strikeFro /// The desire strike price distance from the current underlying price of the OTM put. It must be negative. /// Applicable to Long and Short Strangle Option Strategy /// Universe with filter applied - public OptionFilterUniverse Strangle(int minDaysTillExpiry = 30, decimal callStrikeFromAtm = 5, decimal putStrikeFromAtm = -5) + public TUniverse Strangle(int minDaysTillExpiry = 30, decimal callStrikeFromAtm = 5, decimal putStrikeFromAtm = -5) { if (callStrikeFromAtm <= 0) { @@ -453,7 +495,7 @@ public OptionFilterUniverse Strangle(int minDaysTillExpiry = 30, decimal callStr /// The minimum days till expiry from the current time, closest expiry will be selected /// Applicable to Long and Short Straddle Option Strategy /// Universe with filter applied - public OptionFilterUniverse Straddle(int minDaysTillExpiry = 30) + public TUniverse Straddle(int minDaysTillExpiry = 30) { return CallPutSpread(minDaysTillExpiry, 0, 0); } @@ -466,7 +508,7 @@ public OptionFilterUniverse Straddle(int minDaysTillExpiry = 30) /// The desire strike price distance from the current underlying price of the put. /// Applicable to Protective Collar Option Strategy /// Universe with filter applied - public OptionFilterUniverse ProtectiveCollar(int minDaysTillExpiry = 30, decimal callStrikeFromAtm = 5, decimal putStrikeFromAtm = -5) + public TUniverse ProtectiveCollar(int minDaysTillExpiry = 30, decimal callStrikeFromAtm = 5, decimal putStrikeFromAtm = -5) { if (callStrikeFromAtm <= putStrikeFromAtm) { @@ -493,12 +535,12 @@ public OptionFilterUniverse ProtectiveCollar(int minDaysTillExpiry = 30, decimal /// The desire strike price distance from the current underlying price /// Applicable to Conversion and Reverse Conversion Option Strategy /// Universe with filter applied - public OptionFilterUniverse Conversion(int minDaysTillExpiry = 30, decimal strikeFromAtm = 5) + public TUniverse Conversion(int minDaysTillExpiry = 30, decimal strikeFromAtm = 5) { return CallPutSpread(minDaysTillExpiry, strikeFromAtm, strikeFromAtm); } - private OptionFilterUniverse CallPutSpread(int minDaysTillExpiry, decimal callStrikeFromAtm, decimal putStrikeFromAtm, bool otm = false) + private TUniverse CallPutSpread(int minDaysTillExpiry, decimal callStrikeFromAtm, decimal putStrikeFromAtm, bool otm = false) { // Select the expiry as the nearest to set days later var contracts = GetContractsForExpiry(AllSymbols, minDaysTillExpiry).ToList(); @@ -534,7 +576,7 @@ private OptionFilterUniverse CallPutSpread(int minDaysTillExpiry, decimal callSt /// The desire strike price distance of the ITM call and the OTM call from the current underlying price /// Applicable to Long and Short Call Butterfly Option Strategy /// Universe with filter applied - public OptionFilterUniverse CallButterfly(int minDaysTillExpiry = 30, decimal strikeSpread = 5) + public TUniverse CallButterfly(int minDaysTillExpiry = 30, decimal strikeSpread = 5) { return Butterfly(OptionRight.Call, minDaysTillExpiry, strikeSpread); } @@ -546,12 +588,12 @@ public OptionFilterUniverse CallButterfly(int minDaysTillExpiry = 30, decimal st /// The desire strike price distance of the ITM put and the OTM put from the current underlying price /// Applicable to Long and Short Put Butterfly Option Strategy /// Universe with filter applied - public OptionFilterUniverse PutButterfly(int minDaysTillExpiry = 30, decimal strikeSpread = 5) + public TUniverse PutButterfly(int minDaysTillExpiry = 30, decimal strikeSpread = 5) { return Butterfly(OptionRight.Put, minDaysTillExpiry, strikeSpread); } - private OptionFilterUniverse Butterfly(OptionRight right, int minDaysTillExpiry, decimal strikeSpread) + private TUniverse Butterfly(OptionRight right, int minDaysTillExpiry, decimal strikeSpread) { if (strikeSpread <= 0) { @@ -593,7 +635,7 @@ private OptionFilterUniverse Butterfly(OptionRight right, int minDaysTillExpiry, /// The desire strike price distance of the OTM call and the OTM put from the current underlying price /// Applicable to Long and Short Iron Butterfly Option Strategy /// Universe with filter applied - public OptionFilterUniverse IronButterfly(int minDaysTillExpiry = 30, decimal strikeSpread = 5) + public TUniverse IronButterfly(int minDaysTillExpiry = 30, decimal strikeSpread = 5) { if (strikeSpread <= 0) { @@ -641,7 +683,7 @@ public OptionFilterUniverse IronButterfly(int minDaysTillExpiry = 30, decimal st /// The desire strike price distance of the further-to-expiry call and the further-to-expiry put from the current underlying price /// Applicable to Long and Short Iron Condor Option Strategy /// Universe with filter applied - public OptionFilterUniverse IronCondor(int minDaysTillExpiry = 30, decimal nearStrikeSpread = 5, decimal farStrikeSpread = 10) + public TUniverse IronCondor(int minDaysTillExpiry = 30, decimal nearStrikeSpread = 5, decimal farStrikeSpread = 10) { if (nearStrikeSpread <= 0 || farStrikeSpread <= 0) { @@ -698,7 +740,7 @@ public OptionFilterUniverse IronCondor(int minDaysTillExpiry = 30, decimal nearS /// The desire strike price distance of the OTM call and the OTM put from the current underlying price /// Applicable to Long and Short Box Spread Option Strategy /// Universe with filter applied - public OptionFilterUniverse BoxSpread(int minDaysTillExpiry = 30, decimal strikeSpread = 5) + public TUniverse BoxSpread(int minDaysTillExpiry = 30, decimal strikeSpread = 5) { if (strikeSpread <= 0) { @@ -735,7 +777,7 @@ public OptionFilterUniverse BoxSpread(int minDaysTillExpiry = 30, decimal strike /// The mininum days till expiry of the further conrtact from the current time, closest expiry will be selected /// Applicable to Long and Short Jelly Roll Option Strategy /// Universe with filter applied - public OptionFilterUniverse JellyRoll(decimal strikeFromAtm = 0, int minNearDaysTillExpiry = 30, int minFarDaysTillExpiry = 60) + public TUniverse JellyRoll(decimal strikeFromAtm = 0, int minNearDaysTillExpiry = 30, int minFarDaysTillExpiry = 60) { if (minFarDaysTillExpiry <= minNearDaysTillExpiry) { @@ -786,7 +828,7 @@ public OptionFilterUniverse JellyRoll(decimal strikeFromAtm = 0, int minNearDays /// The desire strike price distance from the current underlying price of the lower strike price /// Applicable to Bear Call Ladder and Bull Call Ladder Option Strategy /// Universe with filter applied - public OptionFilterUniverse CallLadder(int minDaysTillExpiry, decimal higherStrikeFromAtm, decimal middleStrikeFromAtm, decimal lowerStrikeFromAtm) + public TUniverse CallLadder(int minDaysTillExpiry, decimal higherStrikeFromAtm, decimal middleStrikeFromAtm, decimal lowerStrikeFromAtm) { return Ladder(OptionRight.Call, minDaysTillExpiry, higherStrikeFromAtm, middleStrikeFromAtm, lowerStrikeFromAtm); } @@ -800,7 +842,7 @@ public OptionFilterUniverse CallLadder(int minDaysTillExpiry, decimal higherStri /// The desire strike price distance from the current underlying price of the lower strike price /// Applicable to Bear Put Ladder and Bull Put Ladder Option Strategy /// Universe with filter applied - public OptionFilterUniverse PutLadder(int minDaysTillExpiry, decimal higherStrikeFromAtm, decimal middleStrikeFromAtm, decimal lowerStrikeFromAtm) + public TUniverse PutLadder(int minDaysTillExpiry, decimal higherStrikeFromAtm, decimal middleStrikeFromAtm, decimal lowerStrikeFromAtm) { return Ladder(OptionRight.Put, minDaysTillExpiry, higherStrikeFromAtm, middleStrikeFromAtm, lowerStrikeFromAtm); } @@ -811,7 +853,7 @@ public OptionFilterUniverse PutLadder(int minDaysTillExpiry, decimal higherStrik /// The minimum Delta value /// The maximum Delta value /// Universe with filter applied - public OptionFilterUniverse Delta(decimal min, decimal max) + public TUniverse Delta(decimal min, decimal max) { ValidateSecurityTypeForSupportedFilters(nameof(Delta)); return this.Where(contractData => contractData.Greeks.Delta >= min && contractData.Greeks.Delta <= max); @@ -824,7 +866,7 @@ public OptionFilterUniverse Delta(decimal min, decimal max) /// The minimum Delta value /// The maximum Delta value /// Universe with filter applied - public OptionFilterUniverse D(decimal min, decimal max) + public TUniverse D(decimal min, decimal max) { return Delta(min, max); } @@ -835,7 +877,7 @@ public OptionFilterUniverse D(decimal min, decimal max) /// The minimum Gamma value /// The maximum Gamma value /// Universe with filter applied - public OptionFilterUniverse Gamma(decimal min, decimal max) + public TUniverse Gamma(decimal min, decimal max) { ValidateSecurityTypeForSupportedFilters(nameof(Gamma)); return this.Where(contractData => contractData.Greeks.Gamma >= min && contractData.Greeks.Gamma <= max); @@ -848,7 +890,7 @@ public OptionFilterUniverse Gamma(decimal min, decimal max) /// The minimum Gamma value /// The maximum Gamma value /// Universe with filter applied - public OptionFilterUniverse G(decimal min, decimal max) + public TUniverse G(decimal min, decimal max) { return Gamma(min, max); } @@ -859,7 +901,7 @@ public OptionFilterUniverse G(decimal min, decimal max) /// The minimum Theta value /// The maximum Theta value /// Universe with filter applied - public OptionFilterUniverse Theta(decimal min, decimal max) + public TUniverse Theta(decimal min, decimal max) { ValidateSecurityTypeForSupportedFilters(nameof(Theta)); return this.Where(contractData => contractData.Greeks.Theta >= min && contractData.Greeks.Theta <= max); @@ -872,7 +914,7 @@ public OptionFilterUniverse Theta(decimal min, decimal max) /// The minimum Theta value /// The maximum Theta value /// Universe with filter applied - public OptionFilterUniverse T(decimal min, decimal max) + public TUniverse T(decimal min, decimal max) { return Theta(min, max); } @@ -883,7 +925,7 @@ public OptionFilterUniverse T(decimal min, decimal max) /// The minimum Vega value /// The maximum Vega value /// Universe with filter applied - public OptionFilterUniverse Vega(decimal min, decimal max) + public TUniverse Vega(decimal min, decimal max) { ValidateSecurityTypeForSupportedFilters(nameof(Vega)); return this.Where(contractData => contractData.Greeks.Vega >= min && contractData.Greeks.Vega <= max); @@ -896,7 +938,7 @@ public OptionFilterUniverse Vega(decimal min, decimal max) /// The minimum Vega value /// The maximum Vega value /// Universe with filter applied - public OptionFilterUniverse V(decimal min, decimal max) + public TUniverse V(decimal min, decimal max) { return Vega(min, max); } @@ -907,7 +949,7 @@ public OptionFilterUniverse V(decimal min, decimal max) /// The minimum Rho value /// The maximum Rho value /// Universe with filter applied - public OptionFilterUniverse Rho(decimal min, decimal max) + public TUniverse Rho(decimal min, decimal max) { ValidateSecurityTypeForSupportedFilters(nameof(Rho)); return this.Where(contractData => contractData.Greeks.Rho >= min && contractData.Greeks.Rho <= max); @@ -920,7 +962,7 @@ public OptionFilterUniverse Rho(decimal min, decimal max) /// The minimum Rho value /// The maximum Rho value /// Universe with filter applied - public OptionFilterUniverse R(decimal min, decimal max) + public TUniverse R(decimal min, decimal max) { return Rho(min, max); } @@ -931,7 +973,7 @@ public OptionFilterUniverse R(decimal min, decimal max) /// The minimum implied volatility value /// The maximum implied volatility value /// Universe with filter applied - public OptionFilterUniverse ImpliedVolatility(decimal min, decimal max) + public TUniverse ImpliedVolatility(decimal min, decimal max) { ValidateSecurityTypeForSupportedFilters(nameof(ImpliedVolatility)); return this.Where(contractData => contractData.ImpliedVolatility >= min && contractData.ImpliedVolatility <= max); @@ -944,7 +986,7 @@ public OptionFilterUniverse ImpliedVolatility(decimal min, decimal max) /// The minimum implied volatility value /// The maximum implied volatility value /// Universe with filter applied - public OptionFilterUniverse IV(decimal min, decimal max) + public TUniverse IV(decimal min, decimal max) { return ImpliedVolatility(min, max); } @@ -955,7 +997,7 @@ public OptionFilterUniverse IV(decimal min, decimal max) /// The minimum open interest value /// The maximum open interest value /// Universe with filter applied - public OptionFilterUniverse OpenInterest(long min, long max) + public TUniverse OpenInterest(long min, long max) { ValidateSecurityTypeForSupportedFilters(nameof(OpenInterest)); return this.Where(contractData => contractData.OpenInterest >= min && contractData.OpenInterest <= max); @@ -968,25 +1010,12 @@ public OptionFilterUniverse OpenInterest(long min, long max) /// The minimum open interest value /// The maximum open interest value /// Universe with filter applied - public OptionFilterUniverse OI(long min, long max) + public TUniverse OI(long min, long max) { return OpenInterest(min, max); } - /// - /// Implicitly convert the universe to a list of symbols - /// - /// -#pragma warning disable CA1002 // Do not expose generic lists -#pragma warning disable CA2225 // Operator overloads have named alternates - public static implicit operator List(OptionFilterUniverse universe) - { - return universe.AllSymbols.ToList(); - } -#pragma warning restore CA2225 // Operator overloads have named alternates -#pragma warning restore CA1002 // Do not expose generic lists - - private OptionFilterUniverse Ladder(OptionRight right, int minDaysTillExpiry, decimal higherStrikeFromAtm, decimal middleStrikeFromAtm, decimal lowerStrikeFromAtm) + private TUniverse Ladder(OptionRight right, int minDaysTillExpiry, decimal higherStrikeFromAtm, decimal middleStrikeFromAtm, decimal lowerStrikeFromAtm) { if (higherStrikeFromAtm <= lowerStrikeFromAtm || higherStrikeFromAtm <= middleStrikeFromAtm || middleStrikeFromAtm <= lowerStrikeFromAtm) { @@ -1024,7 +1053,7 @@ private OptionFilterUniverse Ladder(OptionRight right, int minDaysTillExpiry, de private IEnumerable GetContractsForExpiry(IEnumerable symbols, int minDaysTillExpiry) { var leastExpiryAccepted = _lastExchangeDate.AddDays(minDaysTillExpiry); - return symbols.Where(x => x.ID.Date >= leastExpiryAccepted) + return symbols.Where(x => GetLastTradingDate(x.ID.Date) >= leastExpiryAccepted) .GroupBy(x => x.ID.Date) .OrderBy(x => x.Key) .FirstOrDefault() @@ -1035,19 +1064,19 @@ private IEnumerable GetContractsForExpiry(IEnumerable symbols, i /// /// Helper method that will select no contract /// - private OptionFilterUniverse Empty() + private TUniverse Empty() { - Data = Enumerable.Empty().ToList(); - return this; + Data = Enumerable.Empty().ToList(); + return (TUniverse)this; } /// /// Helper method that will select the given contract list /// - private OptionFilterUniverse SymbolList(List contracts) + private TUniverse SymbolList(List contracts) { AllSymbols = contracts; - return this; + return (TUniverse)this; } private decimal GetStrike(IEnumerable symbols, decimal strikeFromAtm) @@ -1058,16 +1087,93 @@ private decimal GetStrike(IEnumerable symbols, decimal strikeFromAtm) .First(); } + private TUniverse Where(Func predicate) + { + Data = Data.Where(predicate).ToList(); + return (TUniverse)this; + } + + private TUniverse WhereContains(List filterList) + { + Data = Data.Where(x => filterList.Contains(x.Symbol)).ToList(); + return (TUniverse)this; + } + [MethodImpl(MethodImplOptions.AggressiveInlining)] private void ValidateSecurityTypeForSupportedFilters(string filterName) { - if (_option.Symbol.SecurityType == SecurityType.FutureOption) + if (SecurityType == SecurityType.FutureOption) { throw new InvalidOperationException($"{filterName} filter is not supported for future options."); } } } + /// + /// Represents options symbols universe used in filtering. + /// + public class OptionFilterUniverse : BaseOptionFilterUniverse + { + private readonly Option.Option _option; + + /// + /// The option exchange hours + /// + protected override SecurityExchangeHours ExchangeHours => _option?.Exchange.Hours; + + /// + /// The option security type + /// + protected override SecurityType SecurityType => _option.Symbol.SecurityType; + + /// + /// Constructs OptionFilterUniverse + /// By default, the filter includes both standard and weekly contracts. + /// + /// The canonical option chain security + public OptionFilterUniverse(Option.Option option) + : base(option.SymbolProperties.StrikeMultiplier) + { + _option = option; + } + + /// + /// Constructs OptionFilterUniverse + /// + /// Used for testing only + public OptionFilterUniverse(Option.Option option, IReadOnlyList allData, BaseData underlying, decimal underlyingScaleFactor = 1) + : base(allData, underlying, underlying.EndTime, underlyingScaleFactor) + { + _option = option; + } + + /// + /// Creates a new instance of the data type for the given symbol + /// + /// A data instance for the given symbol + protected override OptionUniverse CreateDataInstance(Symbol symbol) + { + return new OptionUniverse() + { + Symbol = symbol, + Time = LocalTime + }; + } + + /// + /// Implicitly convert the universe to a list of symbols + /// + /// +#pragma warning disable CA1002 // Do not expose generic lists +#pragma warning disable CA2225 // Operator overloads have named alternates + public static implicit operator List(OptionFilterUniverse universe) + { + return universe.AllSymbols.ToList(); + } +#pragma warning restore CA2225 // Operator overloads have named alternates +#pragma warning restore CA1002 // Do not expose generic lists + } + /// /// Extensions for Linq support /// diff --git a/Tests/Common/Data/Market/OptionChainTests.cs b/Tests/Common/Data/Market/OptionChainTests.cs new file mode 100644 index 000000000000..9ab7aa9f83ed --- /dev/null +++ b/Tests/Common/Data/Market/OptionChainTests.cs @@ -0,0 +1,353 @@ +/* + * 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.Globalization; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Text; +using NUnit.Framework; +using Python.Runtime; +using QuantConnect.Data; +using QuantConnect.Data.Market; +using QuantConnect.Data.UniverseSelection; +using QuantConnect.Securities; +using QuantConnect.Securities.Option; +using static System.FormattableString; + +namespace QuantConnect.Tests.Common.Data.Market +{ + [TestFixture] + public class OptionChainTests + { + private static readonly DateTime Date = new(2016, 2, 26); + private static readonly Symbol Canonical = Symbol.CreateCanonicalOption(Symbols.SPY); + private const decimal UnderlyingPrice = 101m; + private static readonly DateTime[] Expiries = { new(2016, 3, 4), new(2016, 3, 18), new(2016, 4, 15), new(2016, 6, 17) }; + private static readonly decimal[] Strikes = { 90m, 95m, 97.5m, 100m, 102.5m, 105m, 110m }; + + private List _data; + private BaseData _underlying; + private SymbolProperties _symbolProperties; + private Option _option; + + [OneTimeSetUp] + public void OneTimeSetUp() + { + (_data, _underlying) = CreateUniverseData(Date, UnderlyingPrice, Expiries, Strikes); + _symbolProperties = SymbolPropertiesDatabase.FromDataFolder().GetSymbolProperties(QuantConnect.Market.USA, Canonical, SecurityType.Option, Currencies.USD); + _option = CreateOption(); + } + + private static IEnumerable FilterCases() + { + yield return Case("Strikes(-2, 2)", u => u.Strikes(-2, 2), c => c.Strikes(-2, 2)); + yield return Case("Strikes(0, 0)", u => u.Strikes(0, 0), c => c.Strikes(0, 0)); + yield return Case("Strikes(-1, 0)", u => u.Strikes(-1, 0), c => c.Strikes(-1, 0)); + yield return Case("Strikes(-100, -10)", u => u.Strikes(-100, -10), c => c.Strikes(-100, -10), empty: true); + yield return Case("Expiration(0, 10)", u => u.Expiration(0, 10), c => c.Expiration(0, 10)); + yield return Case("Expiration(10, 60)", u => u.Expiration(10, 60), c => c.Expiration(10, 60)); + yield return Case("Expiration(TimeSpan)", u => u.Expiration(TimeSpan.FromDays(30), TimeSpan.FromDays(200)), + c => c.Expiration(TimeSpan.FromDays(30), TimeSpan.FromDays(200))); + yield return Case("Expiration(500, 600)", u => u.Expiration(500, 600), c => c.Expiration(500, 600), empty: true); + yield return Case("CallsOnly", u => u.CallsOnly(), c => c.CallsOnly()); + yield return Case("PutsOnly", u => u.PutsOnly(), c => c.PutsOnly()); + yield return Case("StandardsOnly", u => u.StandardsOnly(), c => c.StandardsOnly()); + yield return Case("WeeklysOnly", u => u.WeeklysOnly(), c => c.WeeklysOnly()); + yield return Case("FrontMonth", u => u.FrontMonth(), c => c.FrontMonth()); + yield return Case("BackMonth", u => u.BackMonth(), c => c.BackMonth()); + yield return Case("BackMonths", u => u.BackMonths(), c => c.BackMonths()); + yield return Case("Delta", u => u.Delta(0.4m, 0.6m), c => c.Delta(0.4m, 0.6m)); + yield return Case("D", u => u.D(-0.6m, -0.4m), c => c.D(-0.6m, -0.4m)); + yield return Case("Delta(5, 6)", u => u.Delta(5, 6), c => c.Delta(5, 6), empty: true); + yield return Case("Gamma", u => u.Gamma(0.012m, 0.02m), c => c.Gamma(0.012m, 0.02m)); + yield return Case("G", u => u.G(0.012m, 0.02m), c => c.G(0.012m, 0.02m)); + // theta is annualized from the per day value in the file + yield return Case("Theta", u => u.Theta(-365, -219), c => c.Theta(-365, -219)); + yield return Case("T", u => u.T(-365, -219), c => c.T(-365, -219)); + yield return Case("Vega", u => u.Vega(6, 8), c => c.Vega(6, 8)); + yield return Case("V", u => u.V(6, 8), c => c.V(6, 8)); + yield return Case("Rho", u => u.Rho(2, 4), c => c.Rho(2, 4)); + yield return Case("R", u => u.R(2, 4), c => c.R(2, 4)); + yield return Case("ImpliedVolatility", u => u.ImpliedVolatility(0.16m, 0.18m), c => c.ImpliedVolatility(0.16m, 0.18m)); + yield return Case("IV", u => u.IV(0.16m, 0.18m), c => c.IV(0.16m, 0.18m)); + yield return Case("OpenInterest", u => u.OpenInterest(200, 500), c => c.OpenInterest(200, 500)); + yield return Case("OI", u => u.OI(200, 500), c => c.OI(200, 500)); + yield return Case("CallsOnly.Expiration.Strikes", u => u.CallsOnly().Expiration(0, 30).Strikes(-1, 1), + c => c.CallsOnly().Expiration(0, 30).Strikes(-1, 1)); + yield return Case("PutsOnly.FrontMonth.Strikes", u => u.PutsOnly().FrontMonth().Strikes(-2, 0), + c => c.PutsOnly().FrontMonth().Strikes(-2, 0)); + yield return Case("StandardsOnly.FrontMonth", u => u.StandardsOnly().FrontMonth(), c => c.StandardsOnly().FrontMonth()); + yield return Case("WeeklysOnly.CallsOnly", u => u.WeeklysOnly().CallsOnly(), c => c.WeeklysOnly().CallsOnly()); + yield return Case("Expiration.Delta.Strikes", u => u.Expiration(0, 30).Delta(0.4m, 0.6m).Strikes(-3, 3), + c => c.Expiration(0, 30).Delta(0.4m, 0.6m).Strikes(-3, 3)); + } + + [TestCaseSource(nameof(FilterCases))] + public void ChainFiltersMatchUniverseFilters(Func universeFilter, + Func chainFilter, bool expectEmpty) + { + // the universe selection applies the contract type filters after the user filter + var expected = universeFilter(CreateUniverse()).ApplyTypesFilter().AsEnumerable().Select(x => x.Symbol.Value).ToList(); + var actual = chainFilter(CreateChain()).Select(x => x.Symbol.Value).ToList(); + + Assert.AreEqual(expectEmpty, expected.Count == 0); + CollectionAssert.AreEquivalent(expected, actual); + } + + [Test] + public void ChainExposesEveryUniverseFilter() + { + // Universe filters the chain deliberately doesn't mirror: they manage the universe selection itself + var excluded = new HashSet + { + "Contracts", "Refresh", "IncludeWeeklys", "OnlyApplyFilterAtMarketOpen", + // Strategy filters are added to the chain separately + "NakedCall", "NakedPut", "CallSpread", "PutSpread", "CallCalendarSpread", "PutCalendarSpread", "Strangle", "Straddle", + "ProtectiveCollar", "Conversion", "CallButterfly", "PutButterfly", "IronButterfly", "IronCondor", "BoxSpread", "JellyRoll", + "CallLadder", "PutLadder", + }; + + var universeType = typeof(BaseOptionFilterUniverse<,>); + var chainMethods = typeof(OptionChain).GetMethods(BindingFlags.Public | BindingFlags.Instance); + var universeFilters = universeType.GetMethods(BindingFlags.Public | BindingFlags.Instance) + .Where(x => x.ReturnType.IsGenericParameter && !excluded.Contains(x.Name)) + .ToList(); + var missing = new List(); + foreach (var universeFilter in universeFilters) + { + var parameters = universeFilter.GetParameters().Select(x => (x.ParameterType, x.HasDefaultValue, x.DefaultValue)); + var chainFilter = chainMethods.FirstOrDefault(x => x.Name == universeFilter.Name && x.ReturnType == typeof(OptionChain) + && x.GetParameters().Select(p => (p.ParameterType, p.HasDefaultValue, p.DefaultValue)).SequenceEqual(parameters)); + if (chainFilter == null) + { + missing.Add(universeFilter.ToString()); + } + } + + Assert.IsEmpty(missing, "OptionChain is missing universe filters, add them to OptionChain and IOptionContractFilters: " + + string.Join(", ", missing)); + // The shared interface must declare every filter too, so both implementations stay in sync at compile time + Assert.AreEqual(typeof(IOptionContractFilters<>).GetMethods().Length, universeFilters.Count); + } + + [Test] + public void FilteredChainIsANewChainSharingTheSourceProperties() + { + var chain = CreateChain(); + var count = chain.Count; + + var filtered = chain.CallsOnly().FrontMonth(); + + Assert.AreNotSame(chain, filtered); + Assert.AreEqual(count, chain.Count); + Assert.AreEqual(Strikes.Length, filtered.Count); + Assert.IsTrue(filtered.All(x => x.Right == OptionRight.Call && x.Expiry == Expiries[0])); + Assert.IsTrue(filtered.ContainsKey(filtered.First().Symbol)); + Assert.AreEqual(chain.Symbol, filtered.Symbol); + Assert.AreEqual(chain.Time, filtered.Time); + Assert.AreSame(chain.Underlying, filtered.Underlying); + } + + [Test] + public void UnderlyingIsTakenFromTheContractsData() + { + var chain = CreateChain(); + + Assert.AreEqual(UnderlyingPrice, chain.Underlying.Price); + } + + [Test] + public void FiltersOnAnEmptyChainReturnAnEmptyChain() + { + var chain = new OptionChain(Canonical, Date); + + foreach (var testCase in FilterCases()) + { + var filter = (Func)testCase.Arguments[1]; + Assert.AreEqual(0, filter(chain).Count, testCase.TestName); + } + } + + [Test] + public void StrikesFilterIsSkippedWithoutUnderlyingPrice() + { + var contracts = _data.Select(x => new OptionUniverse(x) { Underlying = null }).ToList(); + var chain = new OptionChain(Canonical, Date, contracts, _symbolProperties); + + Assert.AreEqual(0, chain.Underlying.Price); + Assert.AreEqual(chain.Count, chain.Strikes(0, 0).Count); + } + + // Before February 2015 equity options expired on Saturdays, the day after their last trading date. + // Days to expiration are counted on the last trading date, so 2012-02-18 is 0 days out on Friday 2012-02-17, + // and the Saturday after Good Friday 2012-04-06 is 0 days out on Thursday 2012-04-05 + [TestCase("2012-02-17", 0, 0, "2012-02-18")] + [TestCase("2012-02-17", 1, 40, "2012-03-17")] + [TestCase("2012-04-05", 0, 0, "2012-04-07")] + [TestCase("2012-04-05", 1, 60, "2012-05-19")] + public void ExpirationFilterCountsSaturdayExpiriesOnTheirLastTradingDate(string date, int minDays, int maxDays, string expectedExpiry) + { + var (data, underlying) = CreateSaturdayExpiriesData(date); + var expected = DateTime.ParseExact(expectedExpiry, "yyyy-MM-dd", CultureInfo.InvariantCulture); + + var universe = CreateUniverse(data, underlying, underlying.Time).Expiration(minDays, maxDays).ToList(); + var chain = new OptionChain(Canonical, underlying.Time, data, _symbolProperties).Expiration(minDays, maxDays).ToList(); + + Assert.AreEqual(2 * Strikes.Length, universe.Count); + Assert.IsTrue(universe.All(x => x.ID.Date == expected)); + CollectionAssert.AreEquivalent(universe.Select(x => x.Symbol.Value), chain.Select(x => x.Symbol.Value)); + } + + [TestCase("2012-02-17", 0, "2012-02-18")] + [TestCase("2012-02-17", 1, "2012-03-17")] + [TestCase("2012-04-05", 0, "2012-04-07")] + [TestCase("2012-04-05", 1, "2012-05-19")] + public void StrategyFiltersCountSaturdayExpiriesOnTheirLastTradingDate(string date, int minDaysTillExpiry, string expectedExpiry) + { + var (data, underlying) = CreateSaturdayExpiriesData(date); + var expected = DateTime.ParseExact(expectedExpiry, "yyyy-MM-dd", CultureInfo.InvariantCulture); + + var selected = CreateUniverse(data, underlying, underlying.Time).NakedCall(minDaysTillExpiry, 0).ToList(); + + Assert.AreEqual(1, selected.Count); + Assert.AreEqual(expected, selected[0].ID.Date); + Assert.AreEqual(100m, selected[0].ID.StrikePrice); + } + + [Test] + public void FiltersAreAvailableFromPython() + { + var chain = CreateChain(); + var expectedFiltered = chain.CallsOnly().Expiration(0, 30).Strikes(-1, 1).Select(x => x.Symbol).ToList(); + var expectedWhere = chain.Where(x => x.Right == OptionRight.Put && x.Strike > 100).Select(x => x.Symbol).ToList(); + Assert.IsNotEmpty(expectedFiltered); + Assert.IsNotEmpty(expectedWhere); + + using (Py.GIL()) + { + using var module = PyModule.FromString(nameof(OptionChainTests), @" +from AlgorithmImports import * + +def filter_chain(chain): + return chain.calls_only().expiration(0, 30).strikes(-1, 1) + +def where_chain(chain): + return chain.where(lambda contract: contract.right == OptionRight.PUT and contract.strike > 100) +"); + using var pyChain = chain.ToPython(); + + using var filtered = module.GetAttr("filter_chain").Invoke(pyChain); + CollectionAssert.AreEqual(expectedFiltered, filtered.As().Select(x => x.Symbol).ToList()); + + using var where = module.GetAttr("where_chain").Invoke(pyChain); + CollectionAssert.AreEqual(expectedWhere, where.As().Select(x => x.Symbol).ToList()); + } + } + + private static TestCaseData Case(string name, Func universeFilter, + Func chainFilter, bool empty = false) + { + return new TestCaseData(universeFilter, chainFilter, empty).SetName("{m}(" + name + ")"); + } + + private OptionFilterUniverse CreateUniverse(List data = null, BaseData underlying = null, DateTime? date = null) + { + data ??= _data; + underlying ??= _underlying; + var universe = new OptionFilterUniverse(_option, data, underlying); + universe.Refresh(data, underlying, date ?? Date); + return universe; + } + + private OptionChain CreateChain() + { + return new OptionChain(Canonical, Date, _data, _symbolProperties); + } + + private (List, BaseData) CreateSaturdayExpiriesData(string date) + { + var expiries = new[] { new DateTime(2012, 2, 18), new DateTime(2012, 3, 17), new DateTime(2012, 4, 7), new DateTime(2012, 5, 19) }; + return CreateUniverseData(DateTime.ParseExact(date, "yyyy-MM-dd", CultureInfo.InvariantCulture), 100m, expiries, Strikes); + } + + private static Option CreateOption() + { + var exchangeHours = MarketHoursDatabase.FromDataFolder().GetExchangeHours(Canonical.ID.Market, Canonical, Canonical.SecurityType); + return new Option( + exchangeHours, + new SubscriptionDataConfig(typeof(TradeBar), Canonical, Resolution.Minute, TimeZones.NewYork, TimeZones.NewYork, true, false, false), + new Cash(Currencies.USD, 0, 1m), + new OptionSymbolProperties(SymbolProperties.GetDefault(Currencies.USD)), + ErrorCurrencyConverter.Instance, + RegisteredSecurityDataTypesProvider.Null); + } + + /// + /// Creates option universe data for every expiry/strike/right combination, with synthetic but monotonic + /// greeks, implied volatility and open interest so every range filter has a distinct answer + /// + private static (List, BaseData) CreateUniverseData(DateTime date, decimal spot, DateTime[] expiries, decimal[] strikes) + { + var csv = new StringBuilder(); + csv.AppendLine("#expiry,strike,right,open,high,low,close,volume,open_interest,implied_volatility,delta,gamma,vega,theta,rho"); + csv.AppendLine(Invariant($",,,{spot},{spot},{spot},{spot},1000,,,,,,,")); + var i = 0; + foreach (var expiry in expiries) + { + foreach (var strike in strikes) + { + foreach (var right in new[] { "C", "P" }) + { + var callDelta = Math.Clamp(0.5m + (spot - strike) / 20m, 0.05m, 0.95m); + var delta = right == "C" ? callDelta : callDelta - 1; + var price = 1 + i; + csv.AppendLine(Invariant($"{expiry:yyyyMMdd},{strike},{right},{price},{price},{price},{price},{i},{100 * (i + 1)},{0.15m + 0.01m * i},{delta},{0.01m + 0.001m * i},{5 + i},{-(0.5m + 0.1m * i)},{1 + i}")); + i++; + } + } + } + + var config = new SubscriptionDataConfig(typeof(OptionUniverse), Canonical, Resolution.Daily, TimeZones.NewYork, TimeZones.NewYork, true, true, false); + var data = new List(); + BaseData underlying = null; + var factory = new OptionUniverse(); + using var stream = new MemoryStream(Encoding.UTF8.GetBytes(csv.ToString())); + using var reader = new StreamReader(stream); + while (!reader.EndOfStream) + { + var line = (OptionUniverse)factory.Reader(config, reader, date, false); + if (line == null) + { + continue; + } + if (line.Symbol.HasUnderlying) + { + // the underlying row comes first in the file and is attached to each contract, like the universe collection does + line.Underlying = underlying; + data.Add(line); + } + else + { + underlying = line; + } + } + + return (data, underlying); + } + } +} diff --git a/Tests/Common/Securities/OptionFilterTests.cs b/Tests/Common/Securities/OptionFilterTests.cs index 8e89f0b55fcd..d0b5549691e8 100644 --- a/Tests/Common/Securities/OptionFilterTests.cs +++ b/Tests/Common/Securities/OptionFilterTests.cs @@ -318,12 +318,15 @@ public void FiltersExpiryRange() var data = symbols.Select(x => new OptionUniverse() { Symbol = x }); var filterUniverse = new OptionFilterUniverse(option, data.ToList(), underlying); var filtered = filter.Filter(filterUniverse).ToList(); - Assert.AreEqual(5, filtered.Count); + // 2016-02-26 is a Friday: the weekend expiries 8 and 9 days out count on their last trading date, Friday 7 days out + Assert.AreEqual(7, filtered.Count); Assert.AreEqual(symbols[3], filtered[0].Symbol); Assert.AreEqual(symbols[4], filtered[1].Symbol); Assert.AreEqual(symbols[5], filtered[2].Symbol); Assert.AreEqual(symbols[6], filtered[3].Symbol); Assert.AreEqual(symbols[7], filtered[4].Symbol); + Assert.AreEqual(symbols[8], filtered[5].Symbol); + Assert.AreEqual(symbols[9], filtered[6].Symbol); } [Test] From a04afb4cfde0f914d32f602db1712af067594985 Mon Sep 17 00:00:00 2001 From: Jhonathan Abreu Date: Fri, 4 Sep 2026 16:44:13 -0400 Subject: [PATCH 02/17] Build the option chain test data with the universe serializer Write the test universe file with OptionUniverse.ToCsv and CsvHeader and read it back with OptionUniverse.Reader, asserting the round trip, so the tests follow the file format instead of hard coding it. Drop the reflection parity test, the shared IOptionContractFilters interface keeps the chain and the universe in sync. --- Tests/Common/Data/Market/OptionChainTests.cs | 95 ++++++++++---------- 1 file changed, 49 insertions(+), 46 deletions(-) diff --git a/Tests/Common/Data/Market/OptionChainTests.cs b/Tests/Common/Data/Market/OptionChainTests.cs index 9ab7aa9f83ed..53dc9cb89020 100644 --- a/Tests/Common/Data/Market/OptionChainTests.cs +++ b/Tests/Common/Data/Market/OptionChainTests.cs @@ -18,7 +18,6 @@ using System.Globalization; using System.IO; using System.Linq; -using System.Reflection; using System.Text; using NUnit.Framework; using Python.Runtime; @@ -27,7 +26,6 @@ using QuantConnect.Data.UniverseSelection; using QuantConnect.Securities; using QuantConnect.Securities.Option; -using static System.FormattableString; namespace QuantConnect.Tests.Common.Data.Market { @@ -109,42 +107,6 @@ public void ChainFiltersMatchUniverseFilters(Func - { - "Contracts", "Refresh", "IncludeWeeklys", "OnlyApplyFilterAtMarketOpen", - // Strategy filters are added to the chain separately - "NakedCall", "NakedPut", "CallSpread", "PutSpread", "CallCalendarSpread", "PutCalendarSpread", "Strangle", "Straddle", - "ProtectiveCollar", "Conversion", "CallButterfly", "PutButterfly", "IronButterfly", "IronCondor", "BoxSpread", "JellyRoll", - "CallLadder", "PutLadder", - }; - - var universeType = typeof(BaseOptionFilterUniverse<,>); - var chainMethods = typeof(OptionChain).GetMethods(BindingFlags.Public | BindingFlags.Instance); - var universeFilters = universeType.GetMethods(BindingFlags.Public | BindingFlags.Instance) - .Where(x => x.ReturnType.IsGenericParameter && !excluded.Contains(x.Name)) - .ToList(); - var missing = new List(); - foreach (var universeFilter in universeFilters) - { - var parameters = universeFilter.GetParameters().Select(x => (x.ParameterType, x.HasDefaultValue, x.DefaultValue)); - var chainFilter = chainMethods.FirstOrDefault(x => x.Name == universeFilter.Name && x.ReturnType == typeof(OptionChain) - && x.GetParameters().Select(p => (p.ParameterType, p.HasDefaultValue, p.DefaultValue)).SequenceEqual(parameters)); - if (chainFilter == null) - { - missing.Add(universeFilter.ToString()); - } - } - - Assert.IsEmpty(missing, "OptionChain is missing universe filters, add them to OptionChain and IOptionContractFilters: " - + string.Join(", ", missing)); - // The shared interface must declare every filter too, so both implementations stay in sync at compile time - Assert.AreEqual(typeof(IOptionContractFilters<>).GetMethods().Length, universeFilters.Count); - } - [Test] public void FilteredChainIsANewChainSharingTheSourceProperties() { @@ -303,26 +265,54 @@ private static Option CreateOption() /// private static (List, BaseData) CreateUniverseData(DateTime date, decimal spot, DateTime[] expiries, decimal[] strikes) { - var csv = new StringBuilder(); - csv.AppendLine("#expiry,strike,right,open,high,low,close,volume,open_interest,implied_volatility,delta,gamma,vega,theta,rho"); - csv.AppendLine(Invariant($",,,{spot},{spot},{spot},{spot},1000,,,,,,,")); + var contracts = new List<(Symbol, decimal, decimal, Greeks)>(); var i = 0; foreach (var expiry in expiries) { foreach (var strike in strikes) { - foreach (var right in new[] { "C", "P" }) + foreach (var right in new[] { OptionRight.Call, OptionRight.Put }) { + var symbol = Symbol.CreateOption(Canonical.Underlying, Canonical.ID.Market, OptionStyle.American, right, strike, expiry); var callDelta = Math.Clamp(0.5m + (spot - strike) / 20m, 0.05m, 0.95m); - var delta = right == "C" ? callDelta : callDelta - 1; - var price = 1 + i; - csv.AppendLine(Invariant($"{expiry:yyyyMMdd},{strike},{right},{price},{price},{price},{price},{i},{100 * (i + 1)},{0.15m + 0.01m * i},{delta},{0.01m + 0.001m * i},{5 + i},{-(0.5m + 0.1m * i)},{1 + i}")); + var delta = right == OptionRight.Call ? callDelta : callDelta - 1; + var greeks = new Greeks(delta, 0.01m + 0.001m * i, 5 + i, -(0.5m + 0.1m * i) * 365m, 1 + i, 0); + contracts.Add((symbol, 100 * (i + 1), 0.15m + 0.01m * i, greeks)); i++; } } } - var config = new SubscriptionDataConfig(typeof(OptionUniverse), Canonical, Resolution.Daily, TimeZones.NewYork, TimeZones.NewYork, true, true, false); + return CreateUniverseData(Canonical, date, spot, contracts); + } + + /// + /// Creates option universe data by writing a universe file with the same code the data generator uses, + /// , and reading it back with , + /// so the tests follow the file format instead of hard coding it + /// + /// The canonical option symbol + /// The universe file date + /// The underlying price, no underlying row is written when null + /// The contract rows to write + internal static (List contracts, BaseData underlying) CreateUniverseData(Symbol canonical, DateTime date, decimal? spot, + IEnumerable<(Symbol symbol, decimal openInterest, decimal impliedVolatility, Greeks greeks)> contracts) + { + var rows = contracts.ToList(); + var csv = new StringBuilder(); + csv.AppendLine("#" + OptionUniverse.CsvHeader(canonical.SecurityType)); + if (spot.HasValue) + { + csv.AppendLine(OptionUniverse.ToCsv(canonical.Underlying, spot.Value, spot.Value, spot.Value, spot.Value, 1000, null, null, null)); + } + var i = 0; + foreach (var (symbol, openInterest, impliedVolatility, greeks) in rows) + { + var price = 1 + i++; + csv.AppendLine(OptionUniverse.ToCsv(symbol, price, price, price, price, i, openInterest, impliedVolatility, greeks)); + } + + var config = new SubscriptionDataConfig(typeof(OptionUniverse), canonical, Resolution.Daily, TimeZones.NewYork, TimeZones.NewYork, true, true, false); var data = new List(); BaseData underlying = null; var factory = new OptionUniverse(); @@ -347,6 +337,19 @@ private static (List, BaseData) CreateUniverseData(DateTime date } } + // Fail here if the serializer and the reader ever drift apart, rather than silently filtering the wrong values + Assert.AreEqual(rows.Count, data.Count); + for (var j = 0; j < rows.Count; j++) + { + Assert.AreEqual(rows[j].symbol, data[j].Symbol); + Assert.AreEqual(rows[j].openInterest, data[j].OpenInterest); + Assert.AreEqual(rows[j].impliedVolatility, data[j].ImpliedVolatility); + Assert.AreEqual(rows[j].greeks.Delta, data[j].Greeks.Delta); + Assert.AreEqual(rows[j].greeks.Theta, data[j].Greeks.Theta); + Assert.AreEqual(rows[j].greeks.Rho, data[j].Greeks.Rho); + } + Assert.AreEqual(spot ?? 0, underlying?.Price ?? 0); + return (data, underlying); } } From c6f53df010fb74ef5b92e34f7793f9bf48c332fc Mon Sep 17 00:00:00 2001 From: Jhonathan Abreu Date: Fri, 4 Sep 2026 16:44:41 -0400 Subject: [PATCH 03/17] Add the option strategy filters to OptionChain The universe strategy pickers, naked_call through put_ladder, select their legs straight from an option chain with the same arguments and validation, returning an empty chain when nothing matches or the underlying price is unknown. IOptionContractFilters declares them so the chain and the universe stay in sync. --- ...ChainStrategyFiltersRegressionAlgorithm.cs | 183 ++++++++++++++ ...ChainStrategyFiltersRegressionAlgorithm.py | 86 +++++++ Common/Data/Market/OptionChain.cs | 224 +++++++++++++++++- .../Option/IOptionContractFilters.cs | 90 +++++++ Tests/Common/Data/Market/OptionChainTests.cs | 73 ++++++ 5 files changed, 654 insertions(+), 2 deletions(-) create mode 100644 Algorithm.CSharp/OptionChainStrategyFiltersRegressionAlgorithm.cs create mode 100644 Algorithm.Python/OptionChainStrategyFiltersRegressionAlgorithm.py diff --git a/Algorithm.CSharp/OptionChainStrategyFiltersRegressionAlgorithm.cs b/Algorithm.CSharp/OptionChainStrategyFiltersRegressionAlgorithm.cs new file mode 100644 index 000000000000..2dfcfda1f2b0 --- /dev/null +++ b/Algorithm.CSharp/OptionChainStrategyFiltersRegressionAlgorithm.cs @@ -0,0 +1,183 @@ +/* + * 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.Data.Market; +using QuantConnect.Interfaces; +using QuantConnect.Securities.Option; + +namespace QuantConnect.Algorithm.CSharp +{ + /// + /// Regression algorithm demonstrating that the option strategy filters of the universe selection, like + /// or + /// , select the strategy legs + /// straight from an option chain too + /// + public class OptionChainStrategyFiltersRegressionAlgorithm : QCAlgorithm, IRegressionAlgorithmDefinition + { + private Symbol _option; + private bool _traded; + + public override void Initialize() + { + SetStartDate(2015, 12, 24); + SetEndDate(2015, 12, 24); + SetCash(100000); + + var option = AddOption("GOOG"); + _option = option.Symbol; + // The universe selects the straddle legs, the same filter picks them again from the slice chain below + option.SetFilter(universe => universe.Straddle(7)); + + var chain = OptionChain(_option); + var expiry = new DateTime(2015, 12, 31); + + // GOOG closed at 748.54 on 2015-12-23, the first expiry at least 7 days out is 2015-12-31 and the ATM strike is 747.50 + var straddle = chain.Straddle(7); + AssertLegs(straddle, expiry, (OptionRight.Call, 747.5m), (OptionRight.Put, 747.5m)); + + // Iron condor: near legs 5 away from the spot, far legs 10 away + var ironCondor = chain.IronCondor(7, 5, 10); + AssertLegs(ironCondor, expiry, (OptionRight.Put, 737.5m), (OptionRight.Put, 742.5m), (OptionRight.Call, 752.5m), (OptionRight.Call, 757.5m)); + + // Single contract and vertical spread pickers + AssertLegs(chain.NakedPut(7, -5), expiry, (OptionRight.Put, 742.5m)); + AssertLegs(chain.CallSpread(7, 5), expiry, (OptionRight.Call, 742.5m), (OptionRight.Call, 752.5m)); + + // Calendar spread: same strike, expiries at least 7 and 14 days out + var calendar = chain.CallCalendarSpread(0, 7, 14); + if (calendar.Count != 2 || calendar.Any(x => x.Right != OptionRight.Call || x.Strike != 747.5m) + || !calendar.Select(x => x.Expiry).OrderBy(x => x).SequenceEqual(new[] { expiry, new DateTime(2016, 1, 8) })) + { + throw new RegressionTestException($"Unexpected calendar spread legs: {string.Join(", ", calendar.Select(x => x.Symbol.Value))}"); + } + + // No match selects nothing instead of throwing + if (chain.Straddle(1000).Count != 0) + { + throw new RegressionTestException("Expected no legs for an expiry out of the chain"); + } + + // Invalid arguments are rejected like the universe filters do + try + { + chain.Strangle(7, -5, 5); + throw new RegressionTestException("Expected Strangle() to reject a negative call strike distance"); + } + catch (ArgumentException) + { + } + } + + public override void OnData(Slice slice) + { + if (_traded || !slice.OptionChains.TryGetValue(_option, out var chain)) + { + return; + } + + // The same filter that selected the universe picks the legs from the slice chain + var legs = chain.Straddle(7); + if (legs.Count == 2) + { + var leg = legs.First(); + Buy(OptionStrategies.Straddle(_option, leg.Strike, leg.Expiry), 1); + _traded = true; + } + } + + public override void OnEndOfAlgorithm() + { + if (!_traded) + { + throw new RegressionTestException("Expected to trade the straddle selected from the slice option chain"); + } + } + + private static void AssertLegs(OptionChain legs, DateTime expiry, params (OptionRight right, decimal strike)[] expected) + { + var actual = legs.Select(x => (x.Right, x.Strike)).OrderBy(x => x.Right).ThenBy(x => x.Strike).ToList(); + if (legs.Any(x => x.Expiry != expiry) || !actual.SequenceEqual(expected.OrderBy(x => x.right).ThenBy(x => x.strike))) + { + throw new RegressionTestException($"Unexpected legs: {string.Join(", ", legs.Select(x => x.Symbol.Value))}"); + } + } + + /// + /// 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 virtual List Languages { get; } = new() { Language.CSharp, Language.Python }; + + /// + /// Data Points count of all timeslices of algorithm + /// + public long DataPoints => 5886; + + /// + /// Data Points count of the algorithm history + /// + public int AlgorithmHistoryDataPoints => 1; + + /// + /// 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", "2"}, + {"Average Win", "0%"}, + {"Average Loss", "0%"}, + {"Compounding Annual Return", "0%"}, + {"Drawdown", "0%"}, + {"Expectancy", "0"}, + {"Start Equity", "100000"}, + {"End Equity", "99638"}, + {"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", "$2.00"}, + {"Estimated Strategy Capacity", "$23000.00"}, + {"Lowest Capacity Asset", "GOOCV 305Y7VNVZK3D2|GOOCV VP83T1ZUHROL"}, + {"Portfolio Turnover", "1.55%"}, + {"Drawdown Recovery", "0"}, + {"OrderListHash", "0918e55ec2074aaafad98475aa2fcc43"} + }; + } +} diff --git a/Algorithm.Python/OptionChainStrategyFiltersRegressionAlgorithm.py b/Algorithm.Python/OptionChainStrategyFiltersRegressionAlgorithm.py new file mode 100644 index 000000000000..7521e005ca88 --- /dev/null +++ b/Algorithm.Python/OptionChainStrategyFiltersRegressionAlgorithm.py @@ -0,0 +1,86 @@ +# 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 demonstrating that the option strategy filters of the universe selection, like straddle() +### or iron_condor(), select the strategy legs straight from an option chain too +### +class OptionChainStrategyFiltersRegressionAlgorithm(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") + self._option = option.symbol + # The universe selects the straddle legs, the same filter picks them again from the slice chain below + option.set_filter(lambda universe: universe.straddle(7)) + + chain = self.option_chain(self._option) + expiry = datetime(2015, 12, 31) + + # GOOG closed at 748.54 on 2015-12-23, the first expiry at least 7 days out is 2015-12-31 and the ATM strike is 747.50 + self._assert_legs(chain.straddle(7), expiry, [(OptionRight.CALL, 747.5), (OptionRight.PUT, 747.5)]) + + # Iron condor: near legs 5 away from the spot, far legs 10 away + self._assert_legs(chain.iron_condor(7, 5, 10), expiry, + [(OptionRight.CALL, 752.5), (OptionRight.CALL, 757.5), (OptionRight.PUT, 737.5), (OptionRight.PUT, 742.5)]) + + # Single contract and vertical spread pickers + self._assert_legs(chain.naked_put(7, -5), expiry, [(OptionRight.PUT, 742.5)]) + self._assert_legs(chain.call_spread(7, 5), expiry, [(OptionRight.CALL, 742.5), (OptionRight.CALL, 752.5)]) + + # Calendar spread: same strike, expiries at least 7 and 14 days out + calendar = chain.call_calendar_spread(0, 7, 14) + if (calendar.count != 2 or any(x.right != OptionRight.CALL or x.strike != 747.5 for x in calendar) + or sorted(x.expiry for x in calendar) != [expiry, datetime(2016, 1, 8)]): + raise AssertionError(f"Unexpected calendar spread legs: {[x.symbol.value for x in calendar]}") + + # No match selects nothing instead of raising + if chain.straddle(1000).count != 0: + raise AssertionError("Expected no legs for an expiry out of the chain") + + # Invalid arguments are rejected like the universe filters do + try: + chain.strangle(7, -5, 5) + raise AssertionError("Expected strangle() to reject a negative call strike distance") + except ArgumentException: + pass + + self._traded = False + + def on_data(self, slice): + if self._traded: + return + chain = slice.option_chains.get(self._option) + if not chain: + return + + # The same filter that selected the universe picks the legs from the slice chain + legs = chain.straddle(7) + if legs.count == 2: + leg = next(iter(legs)) + self.buy(OptionStrategies.straddle(self._option, leg.strike, leg.expiry), 1) + self._traded = True + + def on_end_of_algorithm(self): + if not self._traded: + raise AssertionError("Expected to trade the straddle selected from the slice option chain") + + def _assert_legs(self, legs, expiry, expected): + actual = sorted((x.right, x.strike) for x in legs) + if any(x.expiry != expiry for x in legs) or actual != sorted(expected): + raise AssertionError(f"Unexpected legs: {[x.symbol.value for x in legs]}") diff --git a/Common/Data/Market/OptionChain.cs b/Common/Data/Market/OptionChain.cs index af8c198c355b..92e1607c90b1 100644 --- a/Common/Data/Market/OptionChain.cs +++ b/Common/Data/Market/OptionChain.cs @@ -364,13 +364,233 @@ public OptionChain Where(PyObject predicate) return new OptionChain(this, Contracts.Values.Where(predicate.SafeAs>())); } + #endregion + + #region Strategy filters + + /// + /// Selects the single call contract with the closest match to the criteria given, for a naked, covered or protective call. Same as + /// + /// The minimum days till expiry from the current time, closest expiry will be selected + /// The desired strike price distance from the current underlying price + /// A new chain with the selected contracts, empty if there is no match + public OptionChain NakedCall(int minDaysTillExpiry = 30, decimal strikeFromAtm = 0) + { + return Filter(universe => universe.NakedCall(minDaysTillExpiry, strikeFromAtm), requiresUnderlyingPrice: true); + } + + /// + /// Selects the single put contract with the closest match to the criteria given, for a naked, covered or protective put. Same as + /// + /// The minimum days till expiry from the current time, closest expiry will be selected + /// The desired strike price distance from the current underlying price + /// A new chain with the selected contracts, empty if there is no match + public OptionChain NakedPut(int minDaysTillExpiry = 30, decimal strikeFromAtm = 0) + { + return Filter(universe => universe.NakedPut(minDaysTillExpiry, strikeFromAtm), requiresUnderlyingPrice: true); + } + + /// + /// Selects the 2 call contracts with the same expiry and different strikes closest to the criteria given, for a bull or bear call spread. Same as + /// + /// The minimum days till expiry from the current time, closest expiry will be selected + /// The desired strike price distance from the current underlying price of the higher strike price + /// The desired strike price distance from the current underlying price of the lower strike price + /// A new chain with the selected contracts, empty if there is no match + public OptionChain CallSpread(int minDaysTillExpiry = 30, decimal higherStrikeFromAtm = 5, decimal? lowerStrikeFromAtm = null) + { + return Filter(universe => universe.CallSpread(minDaysTillExpiry, higherStrikeFromAtm, lowerStrikeFromAtm), requiresUnderlyingPrice: true); + } + + /// + /// Selects the 2 put contracts with the same expiry and different strikes closest to the criteria given, for a bull or bear put spread. Same as + /// + /// The minimum days till expiry from the current time, closest expiry will be selected + /// The desired strike price distance from the current underlying price of the higher strike price + /// The desired strike price distance from the current underlying price of the lower strike price + /// A new chain with the selected contracts, empty if there is no match + public OptionChain PutSpread(int minDaysTillExpiry = 30, decimal higherStrikeFromAtm = 5, decimal? lowerStrikeFromAtm = null) + { + return Filter(universe => universe.PutSpread(minDaysTillExpiry, higherStrikeFromAtm, lowerStrikeFromAtm), requiresUnderlyingPrice: true); + } + + /// + /// Selects the 2 call contracts with the same strike and different expiries closest to the criteria given, for a call calendar spread. Same as + /// + /// The desired strike price distance from the current underlying price + /// The minimum days till expiry of the closer contract from the current time, closest expiry will be selected + /// The minimum days till expiry of the further contract from the current time, closest expiry will be selected + /// A new chain with the selected contracts, empty if there is no match + public OptionChain CallCalendarSpread(decimal strikeFromAtm = 0, int minNearDaysTillExpiry = 30, int minFarDaysTillExpiry = 60) + { + return Filter(universe => universe.CallCalendarSpread(strikeFromAtm, minNearDaysTillExpiry, minFarDaysTillExpiry), requiresUnderlyingPrice: true); + } + + /// + /// Selects the 2 put contracts with the same strike and different expiries closest to the criteria given, for a put calendar spread. Same as + /// + /// The desired strike price distance from the current underlying price + /// The minimum days till expiry of the closer contract from the current time, closest expiry will be selected + /// The minimum days till expiry of the further contract from the current time, closest expiry will be selected + /// A new chain with the selected contracts, empty if there is no match + public OptionChain PutCalendarSpread(decimal strikeFromAtm = 0, int minNearDaysTillExpiry = 30, int minFarDaysTillExpiry = 60) + { + return Filter(universe => universe.PutCalendarSpread(strikeFromAtm, minNearDaysTillExpiry, minFarDaysTillExpiry), requiresUnderlyingPrice: true); + } + + /// + /// Selects an OTM call and an OTM put with the same expiry closest to the criteria given, for a strangle. Same as + /// + /// The minimum days till expiry from the current time, closest expiry will be selected + /// The desired strike price distance from the current underlying price of the OTM call, must be positive + /// The desired strike price distance from the current underlying price of the OTM put, must be negative + /// A new chain with the selected contracts, empty if there is no match + public OptionChain Strangle(int minDaysTillExpiry = 30, decimal callStrikeFromAtm = 5, decimal putStrikeFromAtm = -5) + { + return Filter(universe => universe.Strangle(minDaysTillExpiry, callStrikeFromAtm, putStrikeFromAtm), requiresUnderlyingPrice: true); + } + + /// + /// Selects the ATM call and the ATM put with the same expiry closest to the criteria given, for a straddle. Same as + /// + /// The minimum days till expiry from the current time, closest expiry will be selected + /// A new chain with the selected contracts, empty if there is no match + public OptionChain Straddle(int minDaysTillExpiry = 30) + { + return Filter(universe => universe.Straddle(minDaysTillExpiry), requiresUnderlyingPrice: true); + } + + /// + /// Selects a call and a put with the same expiry and a lower put strike closest to the criteria given, for a protective collar. Same as + /// + /// The minimum days till expiry from the current time, closest expiry will be selected + /// The desired strike price distance from the current underlying price of the call + /// The desired strike price distance from the current underlying price of the put + /// A new chain with the selected contracts, empty if there is no match + public OptionChain ProtectiveCollar(int minDaysTillExpiry = 30, decimal callStrikeFromAtm = 5, decimal putStrikeFromAtm = -5) + { + return Filter(universe => universe.ProtectiveCollar(minDaysTillExpiry, callStrikeFromAtm, putStrikeFromAtm), requiresUnderlyingPrice: true); + } + + /// + /// Selects a call and a put with the same expiry and strike closest to the criteria given, for a conversion or reverse conversion. Same as + /// + /// The minimum days till expiry from the current time, closest expiry will be selected + /// The desired strike price distance from the current underlying price + /// A new chain with the selected contracts, empty if there is no match + public OptionChain Conversion(int minDaysTillExpiry = 30, decimal strikeFromAtm = 5) + { + return Filter(universe => universe.Conversion(minDaysTillExpiry, strikeFromAtm), requiresUnderlyingPrice: true); + } + + /// + /// Selects an ITM, an ATM and an OTM call with the same expiry and equal strike distance closest to the criteria given, for a call butterfly. Same as + /// + /// The minimum days till expiry from the current time, closest expiry will be selected + /// The desired strike price distance of the ITM and OTM calls from the current underlying price + /// A new chain with the selected contracts, empty if there is no match + public OptionChain CallButterfly(int minDaysTillExpiry = 30, decimal strikeSpread = 5) + { + return Filter(universe => universe.CallButterfly(minDaysTillExpiry, strikeSpread), requiresUnderlyingPrice: true); + } + + /// + /// Selects an ITM, an ATM and an OTM put with the same expiry and equal strike distance closest to the criteria given, for a put butterfly. Same as + /// + /// The minimum days till expiry from the current time, closest expiry will be selected + /// The desired strike price distance of the ITM and OTM puts from the current underlying price + /// A new chain with the selected contracts, empty if there is no match + public OptionChain PutButterfly(int minDaysTillExpiry = 30, decimal strikeSpread = 5) + { + return Filter(universe => universe.PutButterfly(minDaysTillExpiry, strikeSpread), requiresUnderlyingPrice: true); + } + + /// + /// Selects an OTM call, an ATM call, an ATM put and an OTM put with the same expiry and equal strike distance closest to the criteria given, for an iron butterfly. Same as + /// + /// The minimum days till expiry from the current time, closest expiry will be selected + /// The desired strike price distance of the OTM call and the OTM put from the current underlying price + /// A new chain with the selected contracts, empty if there is no match + public OptionChain IronButterfly(int minDaysTillExpiry = 30, decimal strikeSpread = 5) + { + return Filter(universe => universe.IronButterfly(minDaysTillExpiry, strikeSpread), requiresUnderlyingPrice: true); + } + + /// + /// Selects a far OTM call, a near OTM call, a near OTM put and a far OTM put with the same expiry closest to the criteria given, for an iron condor. Same as + /// + /// The minimum days till expiry from the current time, closest expiry will be selected + /// The desired strike price distance of the near call and near put from the current underlying price + /// The desired strike price distance of the far call and far put from the current underlying price + /// A new chain with the selected contracts, empty if there is no match + public OptionChain IronCondor(int minDaysTillExpiry = 30, decimal nearStrikeSpread = 5, decimal farStrikeSpread = 10) + { + return Filter(universe => universe.IronCondor(minDaysTillExpiry, nearStrikeSpread, farStrikeSpread), requiresUnderlyingPrice: true); + } + + /// + /// Selects an OTM call, an ITM call, an OTM put and an ITM put with the same expiry closest to the criteria given, for a box spread. Same as + /// + /// The minimum days till expiry from the current time, closest expiry will be selected + /// The desired strike price distance of the OTM call and the OTM put from the current underlying price + /// A new chain with the selected contracts, empty if there is no match + public OptionChain BoxSpread(int minDaysTillExpiry = 30, decimal strikeSpread = 5) + { + return Filter(universe => universe.BoxSpread(minDaysTillExpiry, strikeSpread), requiresUnderlyingPrice: true); + } + + /// + /// Selects 2 calls and 2 puts with the same strike and 2 expiries closest to the criteria given, for a jelly roll. Same as + /// + /// The desired strike price distance from the current underlying price + /// The minimum days till expiry of the closer contract from the current time, closest expiry will be selected + /// The minimum days till expiry of the further contract from the current time, closest expiry will be selected + /// A new chain with the selected contracts, empty if there is no match + public OptionChain JellyRoll(decimal strikeFromAtm = 0, int minNearDaysTillExpiry = 30, int minFarDaysTillExpiry = 60) + { + return Filter(universe => universe.JellyRoll(strikeFromAtm, minNearDaysTillExpiry, minFarDaysTillExpiry), requiresUnderlyingPrice: true); + } + + /// + /// Selects 3 calls with the same expiry and different strikes closest to the criteria given, for a bull or bear call ladder. Same as + /// + /// The minimum days till expiry from the current time, closest expiry will be selected + /// The desired strike price distance from the current underlying price of the higher strike price + /// The desired strike price distance from the current underlying price of the middle strike price + /// The desired strike price distance from the current underlying price of the lower strike price + /// A new chain with the selected contracts, empty if there is no match + public OptionChain CallLadder(int minDaysTillExpiry, decimal higherStrikeFromAtm, decimal middleStrikeFromAtm, decimal lowerStrikeFromAtm) + { + return Filter(universe => universe.CallLadder(minDaysTillExpiry, higherStrikeFromAtm, middleStrikeFromAtm, lowerStrikeFromAtm), requiresUnderlyingPrice: true); + } + + /// + /// Selects 3 puts with the same expiry and different strikes closest to the criteria given, for a bull or bear put ladder. Same as + /// + /// The minimum days till expiry from the current time, closest expiry will be selected + /// The desired strike price distance from the current underlying price of the higher strike price + /// The desired strike price distance from the current underlying price of the middle strike price + /// The desired strike price distance from the current underlying price of the lower strike price + /// A new chain with the selected contracts, empty if there is no match + public OptionChain PutLadder(int minDaysTillExpiry, decimal higherStrikeFromAtm, decimal middleStrikeFromAtm, decimal lowerStrikeFromAtm) + { + return Filter(universe => universe.PutLadder(minDaysTillExpiry, higherStrikeFromAtm, middleStrikeFromAtm, lowerStrikeFromAtm), requiresUnderlyingPrice: true); + } + /// /// Applies the given universe filter to the contracts of this chain and returns the result as a new chain /// - private OptionChain Filter(Func filter) + /// The universe filter to apply + /// True for filters selecting strikes relative to the underlying price, which select nothing without it + private OptionChain Filter(Func filter, bool requiresUnderlyingPrice = false) { + var universe = new OptionChainFilterUniverse(this); + if (requiresUnderlyingPrice && universe.Underlying == null) + { + return new OptionChain(this, Enumerable.Empty()); + } // the type filters (standards/weeklys) are only applied on demand, like the universe selection does after the user filter - return new OptionChain(this, filter(new OptionChainFilterUniverse(this)).ApplyTypesFilter()); + return new OptionChain(this, filter(universe).ApplyTypesFilter()); } #endregion diff --git a/Common/Securities/Option/IOptionContractFilters.cs b/Common/Securities/Option/IOptionContractFilters.cs index 70a655e8384b..6eac6a497f47 100644 --- a/Common/Securities/Option/IOptionContractFilters.cs +++ b/Common/Securities/Option/IOptionContractFilters.cs @@ -144,5 +144,95 @@ public interface IOptionContractFilters /// Selects the contracts with open interest in the given range. Alias for /// TSelf OI(long min, long max); + + /// + /// Selects the single call contract with the closest match to the criteria given + /// + TSelf NakedCall(int minDaysTillExpiry = 30, decimal strikeFromAtm = 0); + + /// + /// Selects the single put contract with the closest match to the criteria given + /// + TSelf NakedPut(int minDaysTillExpiry = 30, decimal strikeFromAtm = 0); + + /// + /// Selects the 2 call contracts with the same expiry and different strikes closest to the criteria given + /// + TSelf CallSpread(int minDaysTillExpiry = 30, decimal higherStrikeFromAtm = 5, decimal? lowerStrikeFromAtm = null); + + /// + /// Selects the 2 put contracts with the same expiry and different strikes closest to the criteria given + /// + TSelf PutSpread(int minDaysTillExpiry = 30, decimal higherStrikeFromAtm = 5, decimal? lowerStrikeFromAtm = null); + + /// + /// Selects the 2 call contracts with the same strike and different expiries closest to the criteria given + /// + TSelf CallCalendarSpread(decimal strikeFromAtm = 0, int minNearDaysTillExpiry = 30, int minFarDaysTillExpiry = 60); + + /// + /// Selects the 2 put contracts with the same strike and different expiries closest to the criteria given + /// + TSelf PutCalendarSpread(decimal strikeFromAtm = 0, int minNearDaysTillExpiry = 30, int minFarDaysTillExpiry = 60); + + /// + /// Selects an OTM call and an OTM put with the same expiry closest to the criteria given + /// + TSelf Strangle(int minDaysTillExpiry = 30, decimal callStrikeFromAtm = 5, decimal putStrikeFromAtm = -5); + + /// + /// Selects the ATM call and the ATM put with the same expiry closest to the criteria given + /// + TSelf Straddle(int minDaysTillExpiry = 30); + + /// + /// Selects a call and a put with the same expiry and a lower put strike closest to the criteria given + /// + TSelf ProtectiveCollar(int minDaysTillExpiry = 30, decimal callStrikeFromAtm = 5, decimal putStrikeFromAtm = -5); + + /// + /// Selects a call and a put with the same expiry and strike closest to the criteria given + /// + TSelf Conversion(int minDaysTillExpiry = 30, decimal strikeFromAtm = 5); + + /// + /// Selects an ITM, an ATM and an OTM call with the same expiry and equal strike distance closest to the criteria given + /// + TSelf CallButterfly(int minDaysTillExpiry = 30, decimal strikeSpread = 5); + + /// + /// Selects an ITM, an ATM and an OTM put with the same expiry and equal strike distance closest to the criteria given + /// + TSelf PutButterfly(int minDaysTillExpiry = 30, decimal strikeSpread = 5); + + /// + /// Selects an OTM call, an ATM call, an ATM put and an OTM put with the same expiry and equal strike distance closest to the criteria given + /// + TSelf IronButterfly(int minDaysTillExpiry = 30, decimal strikeSpread = 5); + + /// + /// Selects a far OTM call, a near OTM call, a near OTM put and a far OTM put with the same expiry closest to the criteria given + /// + TSelf IronCondor(int minDaysTillExpiry = 30, decimal nearStrikeSpread = 5, decimal farStrikeSpread = 10); + + /// + /// Selects an OTM call, an ITM call, an OTM put and an ITM put with the same expiry closest to the criteria given + /// + TSelf BoxSpread(int minDaysTillExpiry = 30, decimal strikeSpread = 5); + + /// + /// Selects 2 calls and 2 puts with the same strike and 2 expiries closest to the criteria given + /// + TSelf JellyRoll(decimal strikeFromAtm = 0, int minNearDaysTillExpiry = 30, int minFarDaysTillExpiry = 60); + + /// + /// Selects 3 calls with the same expiry and different strikes closest to the criteria given + /// + TSelf CallLadder(int minDaysTillExpiry, decimal higherStrikeFromAtm, decimal middleStrikeFromAtm, decimal lowerStrikeFromAtm); + + /// + /// Selects 3 puts with the same expiry and different strikes closest to the criteria given + /// + TSelf PutLadder(int minDaysTillExpiry, decimal higherStrikeFromAtm, decimal middleStrikeFromAtm, decimal lowerStrikeFromAtm); } } diff --git a/Tests/Common/Data/Market/OptionChainTests.cs b/Tests/Common/Data/Market/OptionChainTests.cs index 53dc9cb89020..2074c3c2abe3 100644 --- a/Tests/Common/Data/Market/OptionChainTests.cs +++ b/Tests/Common/Data/Market/OptionChainTests.cs @@ -93,6 +93,26 @@ private static IEnumerable FilterCases() yield return Case("WeeklysOnly.CallsOnly", u => u.WeeklysOnly().CallsOnly(), c => c.WeeklysOnly().CallsOnly()); yield return Case("Expiration.Delta.Strikes", u => u.Expiration(0, 30).Delta(0.4m, 0.6m).Strikes(-3, 3), c => c.Expiration(0, 30).Delta(0.4m, 0.6m).Strikes(-3, 3)); + yield return Case("NakedCall", u => u.NakedCall(10, 0), c => c.NakedCall(10, 0)); + yield return Case("NakedPut", u => u.NakedPut(10, -5), c => c.NakedPut(10, -5)); + yield return Case("NakedCall(1000)", u => u.NakedCall(1000, 0), c => c.NakedCall(1000, 0), empty: true); + yield return Case("CallSpread", u => u.CallSpread(10, 5), c => c.CallSpread(10, 5)); + yield return Case("PutSpread", u => u.PutSpread(10, 5, -5), c => c.PutSpread(10, 5, -5)); + yield return Case("CallCalendarSpread", u => u.CallCalendarSpread(0, 10, 40), c => c.CallCalendarSpread(0, 10, 40)); + yield return Case("PutCalendarSpread", u => u.PutCalendarSpread(0, 10, 40), c => c.PutCalendarSpread(0, 10, 40)); + yield return Case("Strangle", u => u.Strangle(10, 5, -5), c => c.Strangle(10, 5, -5)); + yield return Case("Straddle", u => u.Straddle(10), c => c.Straddle(10)); + yield return Case("ProtectiveCollar", u => u.ProtectiveCollar(10, 5, -5), c => c.ProtectiveCollar(10, 5, -5)); + yield return Case("Conversion", u => u.Conversion(10, 5), c => c.Conversion(10, 5)); + yield return Case("CallButterfly", u => u.CallButterfly(10, 5), c => c.CallButterfly(10, 5)); + yield return Case("PutButterfly", u => u.PutButterfly(10, 5), c => c.PutButterfly(10, 5)); + yield return Case("IronButterfly", u => u.IronButterfly(10, 5), c => c.IronButterfly(10, 5)); + yield return Case("IronCondor", u => u.IronCondor(10, 5, 10), c => c.IronCondor(10, 5, 10)); + yield return Case("BoxSpread", u => u.BoxSpread(10, 5), c => c.BoxSpread(10, 5)); + yield return Case("JellyRoll", u => u.JellyRoll(0, 10, 40), c => c.JellyRoll(0, 10, 40)); + yield return Case("CallLadder", u => u.CallLadder(10, 10, 5, -5), c => c.CallLadder(10, 10, 5, -5)); + yield return Case("PutLadder", u => u.PutLadder(10, 10, 5, -5), c => c.PutLadder(10, 10, 5, -5)); + yield return Case("StandardsOnly.IronCondor", u => u.StandardsOnly().IronCondor(10, 5, 10), c => c.StandardsOnly().IronCondor(10, 5, 10)); } [TestCaseSource(nameof(FilterCases))] @@ -221,6 +241,59 @@ def where_chain(chain): } } + [Test] + public void StrategyFiltersReturnAnEmptyChainWithoutUnderlyingPrice() + { + var contracts = _data.Select(x => new OptionUniverse(x) { Underlying = null }).ToList(); + var chain = new OptionChain(Canonical, Date, contracts, _symbolProperties); + + Assert.AreEqual(0, chain.Underlying.Price); + Assert.AreEqual(0, chain.Straddle(10).Count); + Assert.AreEqual(0, chain.IronCondor(10, 5, 10).Count); + Assert.AreEqual(0, chain.NakedCall(10, 0).Count); + } + + [Test] + public void StrategyFiltersValidateArgumentsLikeTheUniverseFilters() + { + var chain = CreateChain(); + + Assert.Throws(() => chain.Strangle(10, -5, 5)); + Assert.Throws(() => chain.CallSpread(10, 5, 10)); + Assert.Throws(() => chain.IronCondor(10, 10, 5)); + Assert.Throws(() => chain.CallCalendarSpread(0, 40, 10)); + } + + [Test] + public void StrategyFiltersAreAvailableFromPython() + { + var chain = CreateChain(); + var expectedIronCondor = chain.IronCondor(10, 5, 10).Select(x => x.Symbol.Value).ToList(); + var expectedNakedPut = chain.NakedPut(10, -5).Select(x => x.Symbol.Value).ToList(); + Assert.AreEqual(4, expectedIronCondor.Count); + Assert.AreEqual(1, expectedNakedPut.Count); + + using (Py.GIL()) + { + using var module = PyModule.FromString(nameof(OptionChainTests) + "Strategies", @" +from AlgorithmImports import * + +def iron_condor(chain): + return chain.iron_condor(10, 5, 10) + +def naked_put(chain): + return chain.naked_put(min_days_till_expiry=10, strike_from_atm=-5) +"); + using var pyChain = chain.ToPython(); + + using var ironCondor = module.GetAttr("iron_condor").Invoke(pyChain); + CollectionAssert.AreEquivalent(expectedIronCondor, ironCondor.As().Select(x => x.Symbol.Value).ToList()); + + using var nakedPut = module.GetAttr("naked_put").Invoke(pyChain); + CollectionAssert.AreEqual(expectedNakedPut, nakedPut.As().Select(x => x.Symbol.Value).ToList()); + } + } + private static TestCaseData Case(string name, Func universeFilter, Func chainFilter, bool empty = false) { From 812671bfd58718be186133a498c6fdff9a147457 Mon Sep 17 00:00:00 2001 From: Jhonathan Abreu Date: Fri, 4 Sep 2026 18:34:19 -0400 Subject: [PATCH 04/17] Split OptionChain filters into OptionChain.Filters.cs OptionChain is partial now: the class core keeps the constructors and Clone, the universe filters and strategy pickers live in their own file. --- Common/Data/Market/OptionChain.Filters.cs | 530 ++++++++++++++++++++++ Common/Data/Market/OptionChain.cs | 505 +-------------------- 2 files changed, 531 insertions(+), 504 deletions(-) create mode 100644 Common/Data/Market/OptionChain.Filters.cs diff --git a/Common/Data/Market/OptionChain.Filters.cs b/Common/Data/Market/OptionChain.Filters.cs new file mode 100644 index 000000000000..8a3f4c439415 --- /dev/null +++ b/Common/Data/Market/OptionChain.Filters.cs @@ -0,0 +1,530 @@ +/* + * 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.Linq; +using Python.Runtime; +using QuantConnect.Securities; + +namespace QuantConnect.Data.Market +{ + /// + /// The option chain filters, the same ones the option universe selection offers, see . + /// Each filter returns a new chain, leaving this one untouched + /// + public partial class OptionChain + { + #region Filters + + /// + /// Selects the contracts with strikes in the given range relative to the underlying price, in number of strikes. + /// Same as + /// + /// The minimum strike relative to the underlying price, for example, -1 would filter out contracts further than 1 strike below market price + /// The maximum strike relative to the underlying price, for example, +1 would filter out contracts further than 1 strike above market price + /// A new chain with the filter applied + public OptionChain Strikes(int minStrike, int maxStrike) + { + return Filter(universe => universe.Strikes(minStrike, maxStrike)); + } + + /// + /// Selects the contracts expiring in the given range relative to the chain date. + /// Same as + /// + /// The minimum time until expiry to include, for example, TimeSpan.FromDays(10) + /// would exclude contracts expiring in less than 10 days + /// The maximum time until expiry to include, for example, TimeSpan.FromDays(10) + /// would exclude contracts expiring in more than 10 days + /// A new chain with the filter applied + public OptionChain Expiration(TimeSpan minExpiry, TimeSpan maxExpiry) + { + return Filter(universe => universe.Expiration(minExpiry, maxExpiry)); + } + + /// + /// Selects the contracts expiring in the given range of days relative to the chain date. + /// Same as + /// + /// The minimum time, expressed in days, until expiry to include, for example, 10 + /// would exclude contracts expiring in less than 10 days + /// The maximum time, expressed in days, until expiry to include, for example, 10 + /// would exclude contracts expiring in more than 10 days + /// A new chain with the filter applied + public OptionChain Expiration(int minExpiryDays, int maxExpiryDays) + { + return Filter(universe => universe.Expiration(minExpiryDays, maxExpiryDays)); + } + + /// + /// Selects the call contracts. Same as + /// + /// A new chain with the filter applied + public OptionChain CallsOnly() + { + return Filter(universe => universe.CallsOnly()); + } + + /// + /// Selects the put contracts. Same as + /// + /// A new chain with the filter applied + public OptionChain PutsOnly() + { + return Filter(universe => universe.PutsOnly()); + } + + /// + /// Selects the standard contracts, excluding weeklys. Same as + /// + /// A new chain with the filter applied + public OptionChain StandardsOnly() + { + return Filter(universe => universe.StandardsOnly()); + } + + /// + /// Selects the non standard weekly contracts. Same as + /// + /// A new chain with the filter applied + public OptionChain WeeklysOnly() + { + return Filter(universe => universe.WeeklysOnly()); + } + + /// + /// Selects the contracts of the nearest expiration. Same as + /// + /// A new chain with the filter applied + public OptionChain FrontMonth() + { + return Filter(universe => universe.FrontMonth()); + } + + /// + /// Selects the contracts of all expirations but the nearest one. Same as + /// + /// A new chain with the filter applied + public OptionChain BackMonths() + { + return Filter(universe => universe.BackMonths()); + } + + /// + /// Selects the contracts of the second nearest expiration. Same as + /// + /// A new chain with the filter applied + public OptionChain BackMonth() + { + return Filter(universe => universe.BackMonth()); + } + + /// + /// Selects the contracts with delta in the given range. Same as + /// + /// The minimum delta value + /// The maximum delta value + /// A new chain with the filter applied + public OptionChain Delta(decimal min, decimal max) + { + return Filter(universe => universe.Delta(min, max)); + } + + /// + /// Selects the contracts with delta in the given range. Alias for + /// + /// The minimum delta value + /// The maximum delta value + /// A new chain with the filter applied + public OptionChain D(decimal min, decimal max) + { + return Delta(min, max); + } + + /// + /// Selects the contracts with gamma in the given range. Same as + /// + /// The minimum gamma value + /// The maximum gamma value + /// A new chain with the filter applied + public OptionChain Gamma(decimal min, decimal max) + { + return Filter(universe => universe.Gamma(min, max)); + } + + /// + /// Selects the contracts with gamma in the given range. Alias for + /// + /// The minimum gamma value + /// The maximum gamma value + /// A new chain with the filter applied + public OptionChain G(decimal min, decimal max) + { + return Gamma(min, max); + } + + /// + /// Selects the contracts with theta in the given range. Same as + /// + /// The minimum theta value + /// The maximum theta value + /// A new chain with the filter applied + public OptionChain Theta(decimal min, decimal max) + { + return Filter(universe => universe.Theta(min, max)); + } + + /// + /// Selects the contracts with theta in the given range. Alias for + /// + /// The minimum theta value + /// The maximum theta value + /// A new chain with the filter applied + public OptionChain T(decimal min, decimal max) + { + return Theta(min, max); + } + + /// + /// Selects the contracts with vega in the given range. Same as + /// + /// The minimum vega value + /// The maximum vega value + /// A new chain with the filter applied + public OptionChain Vega(decimal min, decimal max) + { + return Filter(universe => universe.Vega(min, max)); + } + + /// + /// Selects the contracts with vega in the given range. Alias for + /// + /// The minimum vega value + /// The maximum vega value + /// A new chain with the filter applied + public OptionChain V(decimal min, decimal max) + { + return Vega(min, max); + } + + /// + /// Selects the contracts with rho in the given range. Same as + /// + /// The minimum rho value + /// The maximum rho value + /// A new chain with the filter applied + public OptionChain Rho(decimal min, decimal max) + { + return Filter(universe => universe.Rho(min, max)); + } + + /// + /// Selects the contracts with rho in the given range. Alias for + /// + /// The minimum rho value + /// The maximum rho value + /// A new chain with the filter applied + public OptionChain R(decimal min, decimal max) + { + return Rho(min, max); + } + + /// + /// Selects the contracts with implied volatility in the given range. Same as + /// + /// The minimum implied volatility value + /// The maximum implied volatility value + /// A new chain with the filter applied + public OptionChain ImpliedVolatility(decimal min, decimal max) + { + return Filter(universe => universe.ImpliedVolatility(min, max)); + } + + /// + /// Selects the contracts with implied volatility in the given range. Alias for + /// + /// The minimum implied volatility value + /// The maximum implied volatility value + /// A new chain with the filter applied + public OptionChain IV(decimal min, decimal max) + { + return ImpliedVolatility(min, max); + } + + /// + /// Selects the contracts with open interest in the given range. Same as + /// + /// The minimum open interest value + /// The maximum open interest value + /// A new chain with the filter applied + public OptionChain OpenInterest(long min, long max) + { + return Filter(universe => universe.OpenInterest(min, max)); + } + + /// + /// Selects the contracts with open interest in the given range. Alias for + /// + /// The minimum open interest value + /// The maximum open interest value + /// A new chain with the filter applied + public OptionChain OI(long min, long max) + { + return OpenInterest(min, max); + } + + /// + /// Selects the contracts matching the given predicate, e.g. chain.where(lambda contract: contract.open_interest > 100). + /// From C# use Linq's Where, which keeps this chain's type untouched + /// + /// Function determining which contracts are kept + /// A new chain with the filter applied + public OptionChain Where(PyObject predicate) + { + return new OptionChain(this, Contracts.Values.Where(predicate.SafeAs>())); + } + + #endregion + + #region Strategy filters + + /// + /// Selects the single call contract with the closest match to the criteria given, for a naked, covered or protective call. Same as + /// + /// The minimum days till expiry from the current time, closest expiry will be selected + /// The desired strike price distance from the current underlying price + /// A new chain with the selected contracts, empty if there is no match + public OptionChain NakedCall(int minDaysTillExpiry = 30, decimal strikeFromAtm = 0) + { + return Filter(universe => universe.NakedCall(minDaysTillExpiry, strikeFromAtm), requiresUnderlyingPrice: true); + } + + /// + /// Selects the single put contract with the closest match to the criteria given, for a naked, covered or protective put. Same as + /// + /// The minimum days till expiry from the current time, closest expiry will be selected + /// The desired strike price distance from the current underlying price + /// A new chain with the selected contracts, empty if there is no match + public OptionChain NakedPut(int minDaysTillExpiry = 30, decimal strikeFromAtm = 0) + { + return Filter(universe => universe.NakedPut(minDaysTillExpiry, strikeFromAtm), requiresUnderlyingPrice: true); + } + + /// + /// Selects the 2 call contracts with the same expiry and different strikes closest to the criteria given, for a bull or bear call spread. Same as + /// + /// The minimum days till expiry from the current time, closest expiry will be selected + /// The desired strike price distance from the current underlying price of the higher strike price + /// The desired strike price distance from the current underlying price of the lower strike price + /// A new chain with the selected contracts, empty if there is no match + public OptionChain CallSpread(int minDaysTillExpiry = 30, decimal higherStrikeFromAtm = 5, decimal? lowerStrikeFromAtm = null) + { + return Filter(universe => universe.CallSpread(minDaysTillExpiry, higherStrikeFromAtm, lowerStrikeFromAtm), requiresUnderlyingPrice: true); + } + + /// + /// Selects the 2 put contracts with the same expiry and different strikes closest to the criteria given, for a bull or bear put spread. Same as + /// + /// The minimum days till expiry from the current time, closest expiry will be selected + /// The desired strike price distance from the current underlying price of the higher strike price + /// The desired strike price distance from the current underlying price of the lower strike price + /// A new chain with the selected contracts, empty if there is no match + public OptionChain PutSpread(int minDaysTillExpiry = 30, decimal higherStrikeFromAtm = 5, decimal? lowerStrikeFromAtm = null) + { + return Filter(universe => universe.PutSpread(minDaysTillExpiry, higherStrikeFromAtm, lowerStrikeFromAtm), requiresUnderlyingPrice: true); + } + + /// + /// Selects the 2 call contracts with the same strike and different expiries closest to the criteria given, for a call calendar spread. Same as + /// + /// The desired strike price distance from the current underlying price + /// The minimum days till expiry of the closer contract from the current time, closest expiry will be selected + /// The minimum days till expiry of the further contract from the current time, closest expiry will be selected + /// A new chain with the selected contracts, empty if there is no match + public OptionChain CallCalendarSpread(decimal strikeFromAtm = 0, int minNearDaysTillExpiry = 30, int minFarDaysTillExpiry = 60) + { + return Filter(universe => universe.CallCalendarSpread(strikeFromAtm, minNearDaysTillExpiry, minFarDaysTillExpiry), requiresUnderlyingPrice: true); + } + + /// + /// Selects the 2 put contracts with the same strike and different expiries closest to the criteria given, for a put calendar spread. Same as + /// + /// The desired strike price distance from the current underlying price + /// The minimum days till expiry of the closer contract from the current time, closest expiry will be selected + /// The minimum days till expiry of the further contract from the current time, closest expiry will be selected + /// A new chain with the selected contracts, empty if there is no match + public OptionChain PutCalendarSpread(decimal strikeFromAtm = 0, int minNearDaysTillExpiry = 30, int minFarDaysTillExpiry = 60) + { + return Filter(universe => universe.PutCalendarSpread(strikeFromAtm, minNearDaysTillExpiry, minFarDaysTillExpiry), requiresUnderlyingPrice: true); + } + + /// + /// Selects an OTM call and an OTM put with the same expiry closest to the criteria given, for a strangle. Same as + /// + /// The minimum days till expiry from the current time, closest expiry will be selected + /// The desired strike price distance from the current underlying price of the OTM call, must be positive + /// The desired strike price distance from the current underlying price of the OTM put, must be negative + /// A new chain with the selected contracts, empty if there is no match + public OptionChain Strangle(int minDaysTillExpiry = 30, decimal callStrikeFromAtm = 5, decimal putStrikeFromAtm = -5) + { + return Filter(universe => universe.Strangle(minDaysTillExpiry, callStrikeFromAtm, putStrikeFromAtm), requiresUnderlyingPrice: true); + } + + /// + /// Selects the ATM call and the ATM put with the same expiry closest to the criteria given, for a straddle. Same as + /// + /// The minimum days till expiry from the current time, closest expiry will be selected + /// A new chain with the selected contracts, empty if there is no match + public OptionChain Straddle(int minDaysTillExpiry = 30) + { + return Filter(universe => universe.Straddle(minDaysTillExpiry), requiresUnderlyingPrice: true); + } + + /// + /// Selects a call and a put with the same expiry and a lower put strike closest to the criteria given, for a protective collar. Same as + /// + /// The minimum days till expiry from the current time, closest expiry will be selected + /// The desired strike price distance from the current underlying price of the call + /// The desired strike price distance from the current underlying price of the put + /// A new chain with the selected contracts, empty if there is no match + public OptionChain ProtectiveCollar(int minDaysTillExpiry = 30, decimal callStrikeFromAtm = 5, decimal putStrikeFromAtm = -5) + { + return Filter(universe => universe.ProtectiveCollar(minDaysTillExpiry, callStrikeFromAtm, putStrikeFromAtm), requiresUnderlyingPrice: true); + } + + /// + /// Selects a call and a put with the same expiry and strike closest to the criteria given, for a conversion or reverse conversion. Same as + /// + /// The minimum days till expiry from the current time, closest expiry will be selected + /// The desired strike price distance from the current underlying price + /// A new chain with the selected contracts, empty if there is no match + public OptionChain Conversion(int minDaysTillExpiry = 30, decimal strikeFromAtm = 5) + { + return Filter(universe => universe.Conversion(minDaysTillExpiry, strikeFromAtm), requiresUnderlyingPrice: true); + } + + /// + /// Selects an ITM, an ATM and an OTM call with the same expiry and equal strike distance closest to the criteria given, for a call butterfly. Same as + /// + /// The minimum days till expiry from the current time, closest expiry will be selected + /// The desired strike price distance of the ITM and OTM calls from the current underlying price + /// A new chain with the selected contracts, empty if there is no match + public OptionChain CallButterfly(int minDaysTillExpiry = 30, decimal strikeSpread = 5) + { + return Filter(universe => universe.CallButterfly(minDaysTillExpiry, strikeSpread), requiresUnderlyingPrice: true); + } + + /// + /// Selects an ITM, an ATM and an OTM put with the same expiry and equal strike distance closest to the criteria given, for a put butterfly. Same as + /// + /// The minimum days till expiry from the current time, closest expiry will be selected + /// The desired strike price distance of the ITM and OTM puts from the current underlying price + /// A new chain with the selected contracts, empty if there is no match + public OptionChain PutButterfly(int minDaysTillExpiry = 30, decimal strikeSpread = 5) + { + return Filter(universe => universe.PutButterfly(minDaysTillExpiry, strikeSpread), requiresUnderlyingPrice: true); + } + + /// + /// Selects an OTM call, an ATM call, an ATM put and an OTM put with the same expiry and equal strike distance closest to the criteria given, for an iron butterfly. Same as + /// + /// The minimum days till expiry from the current time, closest expiry will be selected + /// The desired strike price distance of the OTM call and the OTM put from the current underlying price + /// A new chain with the selected contracts, empty if there is no match + public OptionChain IronButterfly(int minDaysTillExpiry = 30, decimal strikeSpread = 5) + { + return Filter(universe => universe.IronButterfly(minDaysTillExpiry, strikeSpread), requiresUnderlyingPrice: true); + } + + /// + /// Selects a far OTM call, a near OTM call, a near OTM put and a far OTM put with the same expiry closest to the criteria given, for an iron condor. Same as + /// + /// The minimum days till expiry from the current time, closest expiry will be selected + /// The desired strike price distance of the near call and near put from the current underlying price + /// The desired strike price distance of the far call and far put from the current underlying price + /// A new chain with the selected contracts, empty if there is no match + public OptionChain IronCondor(int minDaysTillExpiry = 30, decimal nearStrikeSpread = 5, decimal farStrikeSpread = 10) + { + return Filter(universe => universe.IronCondor(minDaysTillExpiry, nearStrikeSpread, farStrikeSpread), requiresUnderlyingPrice: true); + } + + /// + /// Selects an OTM call, an ITM call, an OTM put and an ITM put with the same expiry closest to the criteria given, for a box spread. Same as + /// + /// The minimum days till expiry from the current time, closest expiry will be selected + /// The desired strike price distance of the OTM call and the OTM put from the current underlying price + /// A new chain with the selected contracts, empty if there is no match + public OptionChain BoxSpread(int minDaysTillExpiry = 30, decimal strikeSpread = 5) + { + return Filter(universe => universe.BoxSpread(minDaysTillExpiry, strikeSpread), requiresUnderlyingPrice: true); + } + + /// + /// Selects 2 calls and 2 puts with the same strike and 2 expiries closest to the criteria given, for a jelly roll. Same as + /// + /// The desired strike price distance from the current underlying price + /// The minimum days till expiry of the closer contract from the current time, closest expiry will be selected + /// The minimum days till expiry of the further contract from the current time, closest expiry will be selected + /// A new chain with the selected contracts, empty if there is no match + public OptionChain JellyRoll(decimal strikeFromAtm = 0, int minNearDaysTillExpiry = 30, int minFarDaysTillExpiry = 60) + { + return Filter(universe => universe.JellyRoll(strikeFromAtm, minNearDaysTillExpiry, minFarDaysTillExpiry), requiresUnderlyingPrice: true); + } + + /// + /// Selects 3 calls with the same expiry and different strikes closest to the criteria given, for a bull or bear call ladder. Same as + /// + /// The minimum days till expiry from the current time, closest expiry will be selected + /// The desired strike price distance from the current underlying price of the higher strike price + /// The desired strike price distance from the current underlying price of the middle strike price + /// The desired strike price distance from the current underlying price of the lower strike price + /// A new chain with the selected contracts, empty if there is no match + public OptionChain CallLadder(int minDaysTillExpiry, decimal higherStrikeFromAtm, decimal middleStrikeFromAtm, decimal lowerStrikeFromAtm) + { + return Filter(universe => universe.CallLadder(minDaysTillExpiry, higherStrikeFromAtm, middleStrikeFromAtm, lowerStrikeFromAtm), requiresUnderlyingPrice: true); + } + + /// + /// Selects 3 puts with the same expiry and different strikes closest to the criteria given, for a bull or bear put ladder. Same as + /// + /// The minimum days till expiry from the current time, closest expiry will be selected + /// The desired strike price distance from the current underlying price of the higher strike price + /// The desired strike price distance from the current underlying price of the middle strike price + /// The desired strike price distance from the current underlying price of the lower strike price + /// A new chain with the selected contracts, empty if there is no match + public OptionChain PutLadder(int minDaysTillExpiry, decimal higherStrikeFromAtm, decimal middleStrikeFromAtm, decimal lowerStrikeFromAtm) + { + return Filter(universe => universe.PutLadder(minDaysTillExpiry, higherStrikeFromAtm, middleStrikeFromAtm, lowerStrikeFromAtm), requiresUnderlyingPrice: true); + } + + /// + /// Applies the given universe filter to the contracts of this chain and returns the result as a new chain + /// + /// The universe filter to apply + /// True for filters selecting strikes relative to the underlying price, which select nothing without it + private OptionChain Filter(Func filter, bool requiresUnderlyingPrice = false) + { + var universe = new OptionChainFilterUniverse(this); + if (requiresUnderlyingPrice && universe.Underlying == null) + { + return new OptionChain(this, Enumerable.Empty()); + } + // the type filters (standards/weeklys) are only applied on demand, like the universe selection does after the user filter + return new OptionChain(this, filter(universe).ApplyTypesFilter()); + } + + #endregion + } +} diff --git a/Common/Data/Market/OptionChain.cs b/Common/Data/Market/OptionChain.cs index 92e1607c90b1..20c1ec0a7126 100644 --- a/Common/Data/Market/OptionChain.cs +++ b/Common/Data/Market/OptionChain.cs @@ -15,8 +15,6 @@ using System; using System.Collections.Generic; -using System.Linq; -using Python.Runtime; using QuantConnect.Data.UniverseSelection; using QuantConnect.Securities; @@ -29,7 +27,7 @@ namespace QuantConnect.Data.Market /// (see and ), e.g. chain.calls_only().expiration(0, 30).strikes(-2, 2). /// Each filter returns a new chain, leaving this one untouched. /// - public class OptionChain : BaseChain, IOptionContractFilters + public partial class OptionChain : BaseChain, IOptionContractFilters { /// /// Initializes a new instance of the class @@ -93,506 +91,5 @@ public override BaseData Clone() { return new OptionChain(this); } - - #region Filters - - /// - /// Selects the contracts with strikes in the given range relative to the underlying price, in number of strikes. - /// Same as - /// - /// The minimum strike relative to the underlying price, for example, -1 would filter out contracts further than 1 strike below market price - /// The maximum strike relative to the underlying price, for example, +1 would filter out contracts further than 1 strike above market price - /// A new chain with the filter applied - public OptionChain Strikes(int minStrike, int maxStrike) - { - return Filter(universe => universe.Strikes(minStrike, maxStrike)); - } - - /// - /// Selects the contracts expiring in the given range relative to the chain date. - /// Same as - /// - /// The minimum time until expiry to include, for example, TimeSpan.FromDays(10) - /// would exclude contracts expiring in less than 10 days - /// The maximum time until expiry to include, for example, TimeSpan.FromDays(10) - /// would exclude contracts expiring in more than 10 days - /// A new chain with the filter applied - public OptionChain Expiration(TimeSpan minExpiry, TimeSpan maxExpiry) - { - return Filter(universe => universe.Expiration(minExpiry, maxExpiry)); - } - - /// - /// Selects the contracts expiring in the given range of days relative to the chain date. - /// Same as - /// - /// The minimum time, expressed in days, until expiry to include, for example, 10 - /// would exclude contracts expiring in less than 10 days - /// The maximum time, expressed in days, until expiry to include, for example, 10 - /// would exclude contracts expiring in more than 10 days - /// A new chain with the filter applied - public OptionChain Expiration(int minExpiryDays, int maxExpiryDays) - { - return Filter(universe => universe.Expiration(minExpiryDays, maxExpiryDays)); - } - - /// - /// Selects the call contracts. Same as - /// - /// A new chain with the filter applied - public OptionChain CallsOnly() - { - return Filter(universe => universe.CallsOnly()); - } - - /// - /// Selects the put contracts. Same as - /// - /// A new chain with the filter applied - public OptionChain PutsOnly() - { - return Filter(universe => universe.PutsOnly()); - } - - /// - /// Selects the standard contracts, excluding weeklys. Same as - /// - /// A new chain with the filter applied - public OptionChain StandardsOnly() - { - return Filter(universe => universe.StandardsOnly()); - } - - /// - /// Selects the non standard weekly contracts. Same as - /// - /// A new chain with the filter applied - public OptionChain WeeklysOnly() - { - return Filter(universe => universe.WeeklysOnly()); - } - - /// - /// Selects the contracts of the nearest expiration. Same as - /// - /// A new chain with the filter applied - public OptionChain FrontMonth() - { - return Filter(universe => universe.FrontMonth()); - } - - /// - /// Selects the contracts of all expirations but the nearest one. Same as - /// - /// A new chain with the filter applied - public OptionChain BackMonths() - { - return Filter(universe => universe.BackMonths()); - } - - /// - /// Selects the contracts of the second nearest expiration. Same as - /// - /// A new chain with the filter applied - public OptionChain BackMonth() - { - return Filter(universe => universe.BackMonth()); - } - - /// - /// Selects the contracts with delta in the given range. Same as - /// - /// The minimum delta value - /// The maximum delta value - /// A new chain with the filter applied - public OptionChain Delta(decimal min, decimal max) - { - return Filter(universe => universe.Delta(min, max)); - } - - /// - /// Selects the contracts with delta in the given range. Alias for - /// - /// The minimum delta value - /// The maximum delta value - /// A new chain with the filter applied - public OptionChain D(decimal min, decimal max) - { - return Delta(min, max); - } - - /// - /// Selects the contracts with gamma in the given range. Same as - /// - /// The minimum gamma value - /// The maximum gamma value - /// A new chain with the filter applied - public OptionChain Gamma(decimal min, decimal max) - { - return Filter(universe => universe.Gamma(min, max)); - } - - /// - /// Selects the contracts with gamma in the given range. Alias for - /// - /// The minimum gamma value - /// The maximum gamma value - /// A new chain with the filter applied - public OptionChain G(decimal min, decimal max) - { - return Gamma(min, max); - } - - /// - /// Selects the contracts with theta in the given range. Same as - /// - /// The minimum theta value - /// The maximum theta value - /// A new chain with the filter applied - public OptionChain Theta(decimal min, decimal max) - { - return Filter(universe => universe.Theta(min, max)); - } - - /// - /// Selects the contracts with theta in the given range. Alias for - /// - /// The minimum theta value - /// The maximum theta value - /// A new chain with the filter applied - public OptionChain T(decimal min, decimal max) - { - return Theta(min, max); - } - - /// - /// Selects the contracts with vega in the given range. Same as - /// - /// The minimum vega value - /// The maximum vega value - /// A new chain with the filter applied - public OptionChain Vega(decimal min, decimal max) - { - return Filter(universe => universe.Vega(min, max)); - } - - /// - /// Selects the contracts with vega in the given range. Alias for - /// - /// The minimum vega value - /// The maximum vega value - /// A new chain with the filter applied - public OptionChain V(decimal min, decimal max) - { - return Vega(min, max); - } - - /// - /// Selects the contracts with rho in the given range. Same as - /// - /// The minimum rho value - /// The maximum rho value - /// A new chain with the filter applied - public OptionChain Rho(decimal min, decimal max) - { - return Filter(universe => universe.Rho(min, max)); - } - - /// - /// Selects the contracts with rho in the given range. Alias for - /// - /// The minimum rho value - /// The maximum rho value - /// A new chain with the filter applied - public OptionChain R(decimal min, decimal max) - { - return Rho(min, max); - } - - /// - /// Selects the contracts with implied volatility in the given range. Same as - /// - /// The minimum implied volatility value - /// The maximum implied volatility value - /// A new chain with the filter applied - public OptionChain ImpliedVolatility(decimal min, decimal max) - { - return Filter(universe => universe.ImpliedVolatility(min, max)); - } - - /// - /// Selects the contracts with implied volatility in the given range. Alias for - /// - /// The minimum implied volatility value - /// The maximum implied volatility value - /// A new chain with the filter applied - public OptionChain IV(decimal min, decimal max) - { - return ImpliedVolatility(min, max); - } - - /// - /// Selects the contracts with open interest in the given range. Same as - /// - /// The minimum open interest value - /// The maximum open interest value - /// A new chain with the filter applied - public OptionChain OpenInterest(long min, long max) - { - return Filter(universe => universe.OpenInterest(min, max)); - } - - /// - /// Selects the contracts with open interest in the given range. Alias for - /// - /// The minimum open interest value - /// The maximum open interest value - /// A new chain with the filter applied - public OptionChain OI(long min, long max) - { - return OpenInterest(min, max); - } - - /// - /// Selects the contracts matching the given predicate, e.g. chain.where(lambda contract: contract.open_interest > 100). - /// From C# use Linq's Where, which keeps this chain's type untouched - /// - /// Function determining which contracts are kept - /// A new chain with the filter applied - public OptionChain Where(PyObject predicate) - { - return new OptionChain(this, Contracts.Values.Where(predicate.SafeAs>())); - } - - #endregion - - #region Strategy filters - - /// - /// Selects the single call contract with the closest match to the criteria given, for a naked, covered or protective call. Same as - /// - /// The minimum days till expiry from the current time, closest expiry will be selected - /// The desired strike price distance from the current underlying price - /// A new chain with the selected contracts, empty if there is no match - public OptionChain NakedCall(int minDaysTillExpiry = 30, decimal strikeFromAtm = 0) - { - return Filter(universe => universe.NakedCall(minDaysTillExpiry, strikeFromAtm), requiresUnderlyingPrice: true); - } - - /// - /// Selects the single put contract with the closest match to the criteria given, for a naked, covered or protective put. Same as - /// - /// The minimum days till expiry from the current time, closest expiry will be selected - /// The desired strike price distance from the current underlying price - /// A new chain with the selected contracts, empty if there is no match - public OptionChain NakedPut(int minDaysTillExpiry = 30, decimal strikeFromAtm = 0) - { - return Filter(universe => universe.NakedPut(minDaysTillExpiry, strikeFromAtm), requiresUnderlyingPrice: true); - } - - /// - /// Selects the 2 call contracts with the same expiry and different strikes closest to the criteria given, for a bull or bear call spread. Same as - /// - /// The minimum days till expiry from the current time, closest expiry will be selected - /// The desired strike price distance from the current underlying price of the higher strike price - /// The desired strike price distance from the current underlying price of the lower strike price - /// A new chain with the selected contracts, empty if there is no match - public OptionChain CallSpread(int minDaysTillExpiry = 30, decimal higherStrikeFromAtm = 5, decimal? lowerStrikeFromAtm = null) - { - return Filter(universe => universe.CallSpread(minDaysTillExpiry, higherStrikeFromAtm, lowerStrikeFromAtm), requiresUnderlyingPrice: true); - } - - /// - /// Selects the 2 put contracts with the same expiry and different strikes closest to the criteria given, for a bull or bear put spread. Same as - /// - /// The minimum days till expiry from the current time, closest expiry will be selected - /// The desired strike price distance from the current underlying price of the higher strike price - /// The desired strike price distance from the current underlying price of the lower strike price - /// A new chain with the selected contracts, empty if there is no match - public OptionChain PutSpread(int minDaysTillExpiry = 30, decimal higherStrikeFromAtm = 5, decimal? lowerStrikeFromAtm = null) - { - return Filter(universe => universe.PutSpread(minDaysTillExpiry, higherStrikeFromAtm, lowerStrikeFromAtm), requiresUnderlyingPrice: true); - } - - /// - /// Selects the 2 call contracts with the same strike and different expiries closest to the criteria given, for a call calendar spread. Same as - /// - /// The desired strike price distance from the current underlying price - /// The minimum days till expiry of the closer contract from the current time, closest expiry will be selected - /// The minimum days till expiry of the further contract from the current time, closest expiry will be selected - /// A new chain with the selected contracts, empty if there is no match - public OptionChain CallCalendarSpread(decimal strikeFromAtm = 0, int minNearDaysTillExpiry = 30, int minFarDaysTillExpiry = 60) - { - return Filter(universe => universe.CallCalendarSpread(strikeFromAtm, minNearDaysTillExpiry, minFarDaysTillExpiry), requiresUnderlyingPrice: true); - } - - /// - /// Selects the 2 put contracts with the same strike and different expiries closest to the criteria given, for a put calendar spread. Same as - /// - /// The desired strike price distance from the current underlying price - /// The minimum days till expiry of the closer contract from the current time, closest expiry will be selected - /// The minimum days till expiry of the further contract from the current time, closest expiry will be selected - /// A new chain with the selected contracts, empty if there is no match - public OptionChain PutCalendarSpread(decimal strikeFromAtm = 0, int minNearDaysTillExpiry = 30, int minFarDaysTillExpiry = 60) - { - return Filter(universe => universe.PutCalendarSpread(strikeFromAtm, minNearDaysTillExpiry, minFarDaysTillExpiry), requiresUnderlyingPrice: true); - } - - /// - /// Selects an OTM call and an OTM put with the same expiry closest to the criteria given, for a strangle. Same as - /// - /// The minimum days till expiry from the current time, closest expiry will be selected - /// The desired strike price distance from the current underlying price of the OTM call, must be positive - /// The desired strike price distance from the current underlying price of the OTM put, must be negative - /// A new chain with the selected contracts, empty if there is no match - public OptionChain Strangle(int minDaysTillExpiry = 30, decimal callStrikeFromAtm = 5, decimal putStrikeFromAtm = -5) - { - return Filter(universe => universe.Strangle(minDaysTillExpiry, callStrikeFromAtm, putStrikeFromAtm), requiresUnderlyingPrice: true); - } - - /// - /// Selects the ATM call and the ATM put with the same expiry closest to the criteria given, for a straddle. Same as - /// - /// The minimum days till expiry from the current time, closest expiry will be selected - /// A new chain with the selected contracts, empty if there is no match - public OptionChain Straddle(int minDaysTillExpiry = 30) - { - return Filter(universe => universe.Straddle(minDaysTillExpiry), requiresUnderlyingPrice: true); - } - - /// - /// Selects a call and a put with the same expiry and a lower put strike closest to the criteria given, for a protective collar. Same as - /// - /// The minimum days till expiry from the current time, closest expiry will be selected - /// The desired strike price distance from the current underlying price of the call - /// The desired strike price distance from the current underlying price of the put - /// A new chain with the selected contracts, empty if there is no match - public OptionChain ProtectiveCollar(int minDaysTillExpiry = 30, decimal callStrikeFromAtm = 5, decimal putStrikeFromAtm = -5) - { - return Filter(universe => universe.ProtectiveCollar(minDaysTillExpiry, callStrikeFromAtm, putStrikeFromAtm), requiresUnderlyingPrice: true); - } - - /// - /// Selects a call and a put with the same expiry and strike closest to the criteria given, for a conversion or reverse conversion. Same as - /// - /// The minimum days till expiry from the current time, closest expiry will be selected - /// The desired strike price distance from the current underlying price - /// A new chain with the selected contracts, empty if there is no match - public OptionChain Conversion(int minDaysTillExpiry = 30, decimal strikeFromAtm = 5) - { - return Filter(universe => universe.Conversion(minDaysTillExpiry, strikeFromAtm), requiresUnderlyingPrice: true); - } - - /// - /// Selects an ITM, an ATM and an OTM call with the same expiry and equal strike distance closest to the criteria given, for a call butterfly. Same as - /// - /// The minimum days till expiry from the current time, closest expiry will be selected - /// The desired strike price distance of the ITM and OTM calls from the current underlying price - /// A new chain with the selected contracts, empty if there is no match - public OptionChain CallButterfly(int minDaysTillExpiry = 30, decimal strikeSpread = 5) - { - return Filter(universe => universe.CallButterfly(minDaysTillExpiry, strikeSpread), requiresUnderlyingPrice: true); - } - - /// - /// Selects an ITM, an ATM and an OTM put with the same expiry and equal strike distance closest to the criteria given, for a put butterfly. Same as - /// - /// The minimum days till expiry from the current time, closest expiry will be selected - /// The desired strike price distance of the ITM and OTM puts from the current underlying price - /// A new chain with the selected contracts, empty if there is no match - public OptionChain PutButterfly(int minDaysTillExpiry = 30, decimal strikeSpread = 5) - { - return Filter(universe => universe.PutButterfly(minDaysTillExpiry, strikeSpread), requiresUnderlyingPrice: true); - } - - /// - /// Selects an OTM call, an ATM call, an ATM put and an OTM put with the same expiry and equal strike distance closest to the criteria given, for an iron butterfly. Same as - /// - /// The minimum days till expiry from the current time, closest expiry will be selected - /// The desired strike price distance of the OTM call and the OTM put from the current underlying price - /// A new chain with the selected contracts, empty if there is no match - public OptionChain IronButterfly(int minDaysTillExpiry = 30, decimal strikeSpread = 5) - { - return Filter(universe => universe.IronButterfly(minDaysTillExpiry, strikeSpread), requiresUnderlyingPrice: true); - } - - /// - /// Selects a far OTM call, a near OTM call, a near OTM put and a far OTM put with the same expiry closest to the criteria given, for an iron condor. Same as - /// - /// The minimum days till expiry from the current time, closest expiry will be selected - /// The desired strike price distance of the near call and near put from the current underlying price - /// The desired strike price distance of the far call and far put from the current underlying price - /// A new chain with the selected contracts, empty if there is no match - public OptionChain IronCondor(int minDaysTillExpiry = 30, decimal nearStrikeSpread = 5, decimal farStrikeSpread = 10) - { - return Filter(universe => universe.IronCondor(minDaysTillExpiry, nearStrikeSpread, farStrikeSpread), requiresUnderlyingPrice: true); - } - - /// - /// Selects an OTM call, an ITM call, an OTM put and an ITM put with the same expiry closest to the criteria given, for a box spread. Same as - /// - /// The minimum days till expiry from the current time, closest expiry will be selected - /// The desired strike price distance of the OTM call and the OTM put from the current underlying price - /// A new chain with the selected contracts, empty if there is no match - public OptionChain BoxSpread(int minDaysTillExpiry = 30, decimal strikeSpread = 5) - { - return Filter(universe => universe.BoxSpread(minDaysTillExpiry, strikeSpread), requiresUnderlyingPrice: true); - } - - /// - /// Selects 2 calls and 2 puts with the same strike and 2 expiries closest to the criteria given, for a jelly roll. Same as - /// - /// The desired strike price distance from the current underlying price - /// The minimum days till expiry of the closer contract from the current time, closest expiry will be selected - /// The minimum days till expiry of the further contract from the current time, closest expiry will be selected - /// A new chain with the selected contracts, empty if there is no match - public OptionChain JellyRoll(decimal strikeFromAtm = 0, int minNearDaysTillExpiry = 30, int minFarDaysTillExpiry = 60) - { - return Filter(universe => universe.JellyRoll(strikeFromAtm, minNearDaysTillExpiry, minFarDaysTillExpiry), requiresUnderlyingPrice: true); - } - - /// - /// Selects 3 calls with the same expiry and different strikes closest to the criteria given, for a bull or bear call ladder. Same as - /// - /// The minimum days till expiry from the current time, closest expiry will be selected - /// The desired strike price distance from the current underlying price of the higher strike price - /// The desired strike price distance from the current underlying price of the middle strike price - /// The desired strike price distance from the current underlying price of the lower strike price - /// A new chain with the selected contracts, empty if there is no match - public OptionChain CallLadder(int minDaysTillExpiry, decimal higherStrikeFromAtm, decimal middleStrikeFromAtm, decimal lowerStrikeFromAtm) - { - return Filter(universe => universe.CallLadder(minDaysTillExpiry, higherStrikeFromAtm, middleStrikeFromAtm, lowerStrikeFromAtm), requiresUnderlyingPrice: true); - } - - /// - /// Selects 3 puts with the same expiry and different strikes closest to the criteria given, for a bull or bear put ladder. Same as - /// - /// The minimum days till expiry from the current time, closest expiry will be selected - /// The desired strike price distance from the current underlying price of the higher strike price - /// The desired strike price distance from the current underlying price of the middle strike price - /// The desired strike price distance from the current underlying price of the lower strike price - /// A new chain with the selected contracts, empty if there is no match - public OptionChain PutLadder(int minDaysTillExpiry, decimal higherStrikeFromAtm, decimal middleStrikeFromAtm, decimal lowerStrikeFromAtm) - { - return Filter(universe => universe.PutLadder(minDaysTillExpiry, higherStrikeFromAtm, middleStrikeFromAtm, lowerStrikeFromAtm), requiresUnderlyingPrice: true); - } - - /// - /// Applies the given universe filter to the contracts of this chain and returns the result as a new chain - /// - /// The universe filter to apply - /// True for filters selecting strikes relative to the underlying price, which select nothing without it - private OptionChain Filter(Func filter, bool requiresUnderlyingPrice = false) - { - var universe = new OptionChainFilterUniverse(this); - if (requiresUnderlyingPrice && universe.Underlying == null) - { - return new OptionChain(this, Enumerable.Empty()); - } - // the type filters (standards/weeklys) are only applied on demand, like the universe selection does after the user filter - return new OptionChain(this, filter(universe).ApplyTypesFilter()); - } - - #endregion } } From 53098305a9add4ead85cb091163533c405b05482 Mon Sep 17 00:00:00 2001 From: Jhonathan Abreu Date: Tue, 8 Sep 2026 10:43:40 -0400 Subject: [PATCH 05/17] Filter option chains on the exchange date Slice chains are stamped with the algorithm time, so their filters could count days from the wrong date when the algorithm and the exchange time zones differ. BaseChain.ExchangeTime, set by the slice factory from the contract subscription time zone, is now the filters' reference date, as in the universe selection. --- Common/Data/Market/BaseChain.cs | 25 ++++++----- .../Option/OptionChainFilterUniverse.cs | 2 +- Engine/DataFeeds/TimeSliceFactory.cs | 14 ++++--- Tests/Common/Data/Market/OptionChainTests.cs | 22 ++++++++++ Tests/Engine/DataFeeds/TimeSliceTests.cs | 41 ++++++++++++++++--- 5 files changed, 81 insertions(+), 23 deletions(-) diff --git a/Common/Data/Market/BaseChain.cs b/Common/Data/Market/BaseChain.cs index 6e4e3c1dbca5..6b08aeed34cd 100644 --- a/Common/Data/Market/BaseChain.cs +++ b/Common/Data/Market/BaseChain.cs @@ -35,6 +35,7 @@ public class BaseChain : BaseData, IEnumerable private Dictionary>> _auxiliaryData; private readonly Lazy _dataframe; private readonly bool _flatten; + private DateTime? _exchangeTime; private Dictionary>> AuxiliaryData { @@ -59,6 +60,17 @@ public BaseData Underlying get; internal set; } + /// + /// Gets the chain time in the exchange time zone, the reference date for the contract filters. + /// Defaults to , which the engine stamps in the algorithm time zone + /// + [PandasIgnore] + public DateTime ExchangeTime + { + get => _exchangeTime ?? Time; + internal set => _exchangeTime = value; + } + /// /// Gets all ticks for every option contract in this chain, keyed by option symbol /// @@ -171,6 +183,7 @@ protected BaseChain(BaseChain other) { Symbol = other.Symbol; Time = other.Time; + _exchangeTime = other._exchangeTime; Value = other.Value; Underlying = other.Underlying; Ticks = other.Ticks; @@ -187,20 +200,10 @@ protected BaseChain(BaseChain other) /// The chain to copy /// The contracts to keep protected BaseChain(BaseChain other, IEnumerable contracts) - : this(other.DataType, other._flatten) + : this(other) { - Symbol = other.Symbol; - Time = other.Time; - Value = other.Value; - Underlying = other.Underlying; - Ticks = other.Ticks; - QuoteBars = other.QuoteBars; - TradeBars = other.TradeBars; - FilteredContracts = other.FilteredContracts; Contracts = new(); -#pragma warning disable 0618 // DataDictionary.Time is deprecated, ignore until removed entirely Contracts.Time = other.Contracts.Time; -#pragma warning restore 0618 foreach (var contract in contracts) { Contracts[contract.Symbol] = contract; diff --git a/Common/Securities/Option/OptionChainFilterUniverse.cs b/Common/Securities/Option/OptionChainFilterUniverse.cs index 112dfff1dc5a..0ea0fa9f4fd7 100644 --- a/Common/Securities/Option/OptionChainFilterUniverse.cs +++ b/Common/Securities/Option/OptionChainFilterUniverse.cs @@ -45,7 +45,7 @@ internal class OptionChainFilterUniverse : BaseOptionFilterUniverse /// The option chain to filter public OptionChainFilterUniverse(OptionChain chain) - : base(chain.Contracts.Values.ToList(), GetUnderlying(chain), chain.Time, GetStrikeMultiplier(chain)) + : base(chain.Contracts.Values.ToList(), GetUnderlying(chain), chain.ExchangeTime, GetStrikeMultiplier(chain)) { _symbol = chain.Symbol; } diff --git a/Engine/DataFeeds/TimeSliceFactory.cs b/Engine/DataFeeds/TimeSliceFactory.cs index 28208e25696a..ca4768700b53 100644 --- a/Engine/DataFeeds/TimeSliceFactory.cs +++ b/Engine/DataFeeds/TimeSliceFactory.cs @@ -282,7 +282,7 @@ public TimeSlice Create(DateTime utcDateTime, { optionChains[baseData.Symbol] = (OptionChain)baseData; } - else if (!HandleOptionData(algorithmTime, baseData, optionChains, packet.Security, sliceFuture, optionUnderlyingUpdates)) + else if (!HandleOptionData(utcDateTime, algorithmTime, baseData, optionChains, packet.Security, packet.Configuration, sliceFuture, optionUnderlyingUpdates)) { continue; } @@ -304,7 +304,7 @@ public TimeSlice Create(DateTime utcDateTime, { futuresChains[baseData.Symbol] = (FuturesChain)baseData; } - else if (!HandleFuturesData(algorithmTime, baseData, futuresChains, packet.Security, packet.Configuration)) + else if (!HandleFuturesData(utcDateTime, algorithmTime, baseData, futuresChains, packet.Security, packet.Configuration)) { continue; } @@ -419,7 +419,8 @@ private void UpdateEmptyCollections(DateTime algorithmTime) #pragma warning restore 0618 } - private bool HandleOptionData(DateTime algorithmTime, BaseData baseData, OptionChains optionChains, ISecurityPrice security, Lazy sliceFuture, IReadOnlyDictionary optionUnderlyingUpdates) + private bool HandleOptionData(DateTime utcDateTime, DateTime algorithmTime, BaseData baseData, OptionChains optionChains, ISecurityPrice security, + SubscriptionDataConfig configuration, Lazy sliceFuture, IReadOnlyDictionary optionUnderlyingUpdates) { var symbol = baseData.Symbol; @@ -427,7 +428,7 @@ private bool HandleOptionData(DateTime algorithmTime, BaseData baseData, OptionC var canonical = symbol.Canonical; if (!optionChains.TryGetValue(canonical, out chain)) { - chain = new OptionChain(canonical, algorithmTime); + chain = new OptionChain(canonical, algorithmTime) { ExchangeTime = utcDateTime.ConvertFromUtc(configuration.ExchangeTimeZone) }; optionChains[canonical] = chain; } @@ -490,7 +491,8 @@ private bool HandleOptionData(DateTime algorithmTime, BaseData baseData, OptionC } - private bool HandleFuturesData(DateTime algorithmTime, BaseData baseData, FuturesChains futuresChains, ISecurityPrice security, SubscriptionDataConfig configuration) + private bool HandleFuturesData(DateTime utcDateTime, DateTime algorithmTime, BaseData baseData, FuturesChains futuresChains, ISecurityPrice security, + SubscriptionDataConfig configuration) { var symbol = baseData.Symbol; @@ -504,7 +506,7 @@ private bool HandleFuturesData(DateTime algorithmTime, BaseData baseData, Future return false; } - chain = new FuturesChain(canonical, algorithmTime); + chain = new FuturesChain(canonical, algorithmTime) { ExchangeTime = utcDateTime.ConvertFromUtc(configuration.ExchangeTimeZone) }; futuresChains[canonical] = chain; } diff --git a/Tests/Common/Data/Market/OptionChainTests.cs b/Tests/Common/Data/Market/OptionChainTests.cs index 2074c3c2abe3..a66147d70cd5 100644 --- a/Tests/Common/Data/Market/OptionChainTests.cs +++ b/Tests/Common/Data/Market/OptionChainTests.cs @@ -165,6 +165,28 @@ public void FiltersOnAnEmptyChainReturnAnEmptyChain() } } + [Test] + public void FiltersUseTheExchangeTimeAsTheReferenceDate() + { + // The engine stamps slice chains in the algorithm time zone, which can already be the day after the exchange date + var exchangeDate = new DateTime(2016, 3, 4); + var (data, underlying) = CreateUniverseData(exchangeDate, UnderlyingPrice, new[] { exchangeDate, Expiries[1] }, Strikes); + var chain = new OptionChain(Canonical, exchangeDate, data, _symbolProperties) { Time = exchangeDate.AddDays(1) }; + var universe = CreateUniverse(data, underlying, exchangeDate).Expiration(0, 0).ToList(); + + Assert.AreEqual(2 * Strikes.Length, universe.Count); + Assert.AreEqual(0, chain.Expiration(0, 0).Count); + + chain.ExchangeTime = exchangeDate; + var filtered = chain.Expiration(0, 0); + + CollectionAssert.AreEquivalent(universe.Select(x => x.Symbol.Value), filtered.Select(x => x.Symbol.Value)); + Assert.AreEqual(chain.Time, filtered.Time); + Assert.AreEqual(exchangeDate, filtered.ExchangeTime); + CollectionAssert.AreEquivalent(universe.Where(x => x.ID.OptionRight == OptionRight.Call).Select(x => x.Symbol.Value), + chain.CallsOnly().Expiration(0, 0).Select(x => x.Symbol.Value)); + } + [Test] public void StrikesFilterIsSkippedWithoutUnderlyingPrice() { diff --git a/Tests/Engine/DataFeeds/TimeSliceTests.cs b/Tests/Engine/DataFeeds/TimeSliceTests.cs index 95ef83abccbf..faadb7e30116 100644 --- a/Tests/Engine/DataFeeds/TimeSliceTests.cs +++ b/Tests/Engine/DataFeeds/TimeSliceTests.cs @@ -178,6 +178,37 @@ public void OptionsDataHasVolume() } } + [TestCase(SecurityType.Option)] + [TestCase(SecurityType.Future)] + public void ChainExchangeTimeIsInTheExchangeTimeZone(SecurityType securityType) + { + var symbol = securityType == SecurityType.Option ? Symbols.SPY_C_192_Feb19_2016 : Symbols.Fut_SPY_Mar19_2016; + var config = new SubscriptionDataConfig(typeof(TradeBar), symbol, Resolution.Minute, TimeZones.NewYork, TimeZones.NewYork, true, true, false); + var security = GetSecurity(config); + // 15:00 in New York on 2016-02-18 is already 05:00 on 2016-02-19 in Tokyo + var utcTime = new DateTime(2016, 2, 18, 20, 0, 0); + var time = utcTime.ConvertFromUtc(TimeZones.NewYork); + + var packets = new List(); + if (security is Option option) + { + var underlying = option.Underlying; + packets.Add(new DataFeedPacket(underlying, underlying.SubscriptionDataConfig, + new List { new TradeBar(time, underlying.Symbol, 100, 100, 110, 106, 100) })); + } + packets.Add(new DataFeedPacket(security, config, new List { new TradeBar(time, symbol, 100, 100, 110, 106, 100) })); + + var slice = new TimeSliceFactory(TimeZones.Tokyo).Create(utcTime, packets, + SecurityChangesTests.CreateNonInternal(Enumerable.Empty(), Enumerable.Empty()), + new Dictionary()).Slice; + var (chainTime, exchangeTime) = securityType == SecurityType.Option + ? (slice.OptionChains.Values.Single().Time, slice.OptionChains.Values.Single().ExchangeTime) + : (slice.FutureChains.Values.Single().Time, slice.FutureChains.Values.Single().ExchangeTime); + + Assert.AreEqual(new DateTime(2016, 2, 19, 5, 0, 0), chainTime); + Assert.AreEqual(new DateTime(2016, 2, 18, 15, 0, 0), exchangeTime); + } + [Test] public void SuspiciousTicksAreNotAddedToConsolidatorUpdateData() { @@ -266,16 +297,16 @@ private Security GetSecurity(SubscriptionDataConfig config) if (config.Symbol.SecurityType == SecurityType.Option) { var option = new Option( - SecurityExchangeHours.AlwaysOpen(TimeZones.Utc), + SecurityExchangeHours.AlwaysOpen(config.ExchangeTimeZone), config, new Cash(Currencies.USD, 0, 1m), new OptionSymbolProperties(SymbolProperties.GetDefault(Currencies.USD)), ErrorCurrencyConverter.Instance, RegisteredSecurityDataTypesProvider.Null); var underlyingConfig = new SubscriptionDataConfig(typeof(TradeBar), config.Symbol.Underlying, Resolution.Second, - TimeZones.Utc, TimeZones.Utc, true, true, false); + config.ExchangeTimeZone, config.ExchangeTimeZone, true, true, false); var equity = new Equity( - SecurityExchangeHours.AlwaysOpen(TimeZones.Utc), + SecurityExchangeHours.AlwaysOpen(config.ExchangeTimeZone), underlyingConfig, new Cash(Currencies.USD, 0, 1m), SymbolProperties.GetDefault(Currencies.USD), @@ -289,7 +320,7 @@ private Security GetSecurity(SubscriptionDataConfig config) if (config.Symbol.SecurityType == SecurityType.Future) { return new Future( - SecurityExchangeHours.AlwaysOpen(TimeZones.Utc), + SecurityExchangeHours.AlwaysOpen(config.ExchangeTimeZone), config, new Cash(Currencies.USD, 0, 1m), SymbolProperties.GetDefault(Currencies.USD), @@ -298,7 +329,7 @@ private Security GetSecurity(SubscriptionDataConfig config) } return new Security( - SecurityExchangeHours.AlwaysOpen(TimeZones.Utc), + SecurityExchangeHours.AlwaysOpen(config.ExchangeTimeZone), config, new Cash(Currencies.USD, 0, 1m), SymbolProperties.GetDefault(Currencies.USD), From 806d31a35087c7653c2d03a47720678c0457e4c4 Mon Sep 17 00:00:00 2001 From: Jhonathan Abreu Date: Tue, 8 Sep 2026 11:19:32 -0400 Subject: [PATCH 06/17] Harden and simplify the shared option chain filters The strategy filters now return an empty selection when there is no underlying price, in the base shared by the universe and the chain, so the chain wrapper no longer needs a per-filter flag and argument validation runs on every chain. ProtectiveCollar returns empty instead of throwing when a leg is missing. Reuse the Contracts selector for the greeks, IV, OI and strategy filters and route the Linq extensions through it, replacing three copies of the same filter primitive. ApplyTypesFilter skips the pass when every contract type is accepted, and the chain filter universe shares the chain's cached contract list instead of copying it. Filtered chains share the auxiliary data with their source, the chain type filters document that they apply in any order, and a reflection test checks every universe filter is declared on the chain interface. --- Common/Data/Market/BaseChain.cs | 3 +- Common/Data/Market/OptionChain.Filters.cs | 49 ++++---- .../ContractSecurityFilterUniverse.cs | 7 ++ .../Option/IOptionContractFilters.cs | 2 +- .../Option/OptionChainFilterUniverse.cs | 9 +- .../Securities/Option/OptionFilterUniverse.cs | 117 +++++++++++------- Tests/Common/Data/Market/OptionChainTests.cs | 93 ++++++++++++-- .../Options/OptionFilterUniverseTests.cs | 12 ++ 8 files changed, 211 insertions(+), 81 deletions(-) diff --git a/Common/Data/Market/BaseChain.cs b/Common/Data/Market/BaseChain.cs index 6b08aeed34cd..24c6d3b332f9 100644 --- a/Common/Data/Market/BaseChain.cs +++ b/Common/Data/Market/BaseChain.cs @@ -191,11 +191,12 @@ protected BaseChain(BaseChain other) TradeBars = other.TradeBars; Contracts = other.Contracts; FilteredContracts = other.FilteredContracts; + _auxiliaryData = other._auxiliaryData; } /// /// Initializes a new instance of the class as a copy of the specified chain - /// containing only the given subset of its contracts. The underlying, ticks, trade bars and quote bars are shared with the source chain + /// containing only the given subset of its contracts. The underlying, ticks, trade bars, quote bars and auxiliary data are shared with the source chain /// /// The chain to copy /// The contracts to keep diff --git a/Common/Data/Market/OptionChain.Filters.cs b/Common/Data/Market/OptionChain.Filters.cs index 8a3f4c439415..3fdaac86b4ac 100644 --- a/Common/Data/Market/OptionChain.Filters.cs +++ b/Common/Data/Market/OptionChain.Filters.cs @@ -87,7 +87,8 @@ public OptionChain PutsOnly() } /// - /// Selects the standard contracts, excluding weeklys. Same as + /// Selects the standard contracts in the chain, excluding weeklys. Unlike , + /// it applies to the contracts already selected, so it can be combined with the expiry filters in any order /// /// A new chain with the filter applied public OptionChain StandardsOnly() @@ -96,7 +97,8 @@ public OptionChain StandardsOnly() } /// - /// Selects the non standard weekly contracts. Same as + /// Selects the non standard weekly contracts in the chain. Unlike , + /// it applies to the contracts already selected, so it can be combined with the expiry filters in any order /// /// A new chain with the filter applied public OptionChain WeeklysOnly() @@ -308,7 +310,7 @@ public OptionChain Where(PyObject predicate) /// A new chain with the selected contracts, empty if there is no match public OptionChain NakedCall(int minDaysTillExpiry = 30, decimal strikeFromAtm = 0) { - return Filter(universe => universe.NakedCall(minDaysTillExpiry, strikeFromAtm), requiresUnderlyingPrice: true); + return Filter(universe => universe.NakedCall(minDaysTillExpiry, strikeFromAtm)); } /// @@ -319,7 +321,7 @@ public OptionChain NakedCall(int minDaysTillExpiry = 30, decimal strikeFromAtm = /// A new chain with the selected contracts, empty if there is no match public OptionChain NakedPut(int minDaysTillExpiry = 30, decimal strikeFromAtm = 0) { - return Filter(universe => universe.NakedPut(minDaysTillExpiry, strikeFromAtm), requiresUnderlyingPrice: true); + return Filter(universe => universe.NakedPut(minDaysTillExpiry, strikeFromAtm)); } /// @@ -331,7 +333,7 @@ public OptionChain NakedPut(int minDaysTillExpiry = 30, decimal strikeFromAtm = /// A new chain with the selected contracts, empty if there is no match public OptionChain CallSpread(int minDaysTillExpiry = 30, decimal higherStrikeFromAtm = 5, decimal? lowerStrikeFromAtm = null) { - return Filter(universe => universe.CallSpread(minDaysTillExpiry, higherStrikeFromAtm, lowerStrikeFromAtm), requiresUnderlyingPrice: true); + return Filter(universe => universe.CallSpread(minDaysTillExpiry, higherStrikeFromAtm, lowerStrikeFromAtm)); } /// @@ -343,7 +345,7 @@ public OptionChain CallSpread(int minDaysTillExpiry = 30, decimal higherStrikeFr /// A new chain with the selected contracts, empty if there is no match public OptionChain PutSpread(int minDaysTillExpiry = 30, decimal higherStrikeFromAtm = 5, decimal? lowerStrikeFromAtm = null) { - return Filter(universe => universe.PutSpread(minDaysTillExpiry, higherStrikeFromAtm, lowerStrikeFromAtm), requiresUnderlyingPrice: true); + return Filter(universe => universe.PutSpread(minDaysTillExpiry, higherStrikeFromAtm, lowerStrikeFromAtm)); } /// @@ -355,7 +357,7 @@ public OptionChain PutSpread(int minDaysTillExpiry = 30, decimal higherStrikeFro /// A new chain with the selected contracts, empty if there is no match public OptionChain CallCalendarSpread(decimal strikeFromAtm = 0, int minNearDaysTillExpiry = 30, int minFarDaysTillExpiry = 60) { - return Filter(universe => universe.CallCalendarSpread(strikeFromAtm, minNearDaysTillExpiry, minFarDaysTillExpiry), requiresUnderlyingPrice: true); + return Filter(universe => universe.CallCalendarSpread(strikeFromAtm, minNearDaysTillExpiry, minFarDaysTillExpiry)); } /// @@ -367,7 +369,7 @@ public OptionChain CallCalendarSpread(decimal strikeFromAtm = 0, int minNearDays /// A new chain with the selected contracts, empty if there is no match public OptionChain PutCalendarSpread(decimal strikeFromAtm = 0, int minNearDaysTillExpiry = 30, int minFarDaysTillExpiry = 60) { - return Filter(universe => universe.PutCalendarSpread(strikeFromAtm, minNearDaysTillExpiry, minFarDaysTillExpiry), requiresUnderlyingPrice: true); + return Filter(universe => universe.PutCalendarSpread(strikeFromAtm, minNearDaysTillExpiry, minFarDaysTillExpiry)); } /// @@ -379,7 +381,7 @@ public OptionChain PutCalendarSpread(decimal strikeFromAtm = 0, int minNearDaysT /// A new chain with the selected contracts, empty if there is no match public OptionChain Strangle(int minDaysTillExpiry = 30, decimal callStrikeFromAtm = 5, decimal putStrikeFromAtm = -5) { - return Filter(universe => universe.Strangle(minDaysTillExpiry, callStrikeFromAtm, putStrikeFromAtm), requiresUnderlyingPrice: true); + return Filter(universe => universe.Strangle(minDaysTillExpiry, callStrikeFromAtm, putStrikeFromAtm)); } /// @@ -389,7 +391,7 @@ public OptionChain Strangle(int minDaysTillExpiry = 30, decimal callStrikeFromAt /// A new chain with the selected contracts, empty if there is no match public OptionChain Straddle(int minDaysTillExpiry = 30) { - return Filter(universe => universe.Straddle(minDaysTillExpiry), requiresUnderlyingPrice: true); + return Filter(universe => universe.Straddle(minDaysTillExpiry)); } /// @@ -401,7 +403,7 @@ public OptionChain Straddle(int minDaysTillExpiry = 30) /// A new chain with the selected contracts, empty if there is no match public OptionChain ProtectiveCollar(int minDaysTillExpiry = 30, decimal callStrikeFromAtm = 5, decimal putStrikeFromAtm = -5) { - return Filter(universe => universe.ProtectiveCollar(minDaysTillExpiry, callStrikeFromAtm, putStrikeFromAtm), requiresUnderlyingPrice: true); + return Filter(universe => universe.ProtectiveCollar(minDaysTillExpiry, callStrikeFromAtm, putStrikeFromAtm)); } /// @@ -412,7 +414,7 @@ public OptionChain ProtectiveCollar(int minDaysTillExpiry = 30, decimal callStri /// A new chain with the selected contracts, empty if there is no match public OptionChain Conversion(int minDaysTillExpiry = 30, decimal strikeFromAtm = 5) { - return Filter(universe => universe.Conversion(minDaysTillExpiry, strikeFromAtm), requiresUnderlyingPrice: true); + return Filter(universe => universe.Conversion(minDaysTillExpiry, strikeFromAtm)); } /// @@ -423,7 +425,7 @@ public OptionChain Conversion(int minDaysTillExpiry = 30, decimal strikeFromAtm /// A new chain with the selected contracts, empty if there is no match public OptionChain CallButterfly(int minDaysTillExpiry = 30, decimal strikeSpread = 5) { - return Filter(universe => universe.CallButterfly(minDaysTillExpiry, strikeSpread), requiresUnderlyingPrice: true); + return Filter(universe => universe.CallButterfly(minDaysTillExpiry, strikeSpread)); } /// @@ -434,7 +436,7 @@ public OptionChain CallButterfly(int minDaysTillExpiry = 30, decimal strikeSprea /// A new chain with the selected contracts, empty if there is no match public OptionChain PutButterfly(int minDaysTillExpiry = 30, decimal strikeSpread = 5) { - return Filter(universe => universe.PutButterfly(minDaysTillExpiry, strikeSpread), requiresUnderlyingPrice: true); + return Filter(universe => universe.PutButterfly(minDaysTillExpiry, strikeSpread)); } /// @@ -445,7 +447,7 @@ public OptionChain PutButterfly(int minDaysTillExpiry = 30, decimal strikeSpread /// A new chain with the selected contracts, empty if there is no match public OptionChain IronButterfly(int minDaysTillExpiry = 30, decimal strikeSpread = 5) { - return Filter(universe => universe.IronButterfly(minDaysTillExpiry, strikeSpread), requiresUnderlyingPrice: true); + return Filter(universe => universe.IronButterfly(minDaysTillExpiry, strikeSpread)); } /// @@ -457,7 +459,7 @@ public OptionChain IronButterfly(int minDaysTillExpiry = 30, decimal strikeSprea /// A new chain with the selected contracts, empty if there is no match public OptionChain IronCondor(int minDaysTillExpiry = 30, decimal nearStrikeSpread = 5, decimal farStrikeSpread = 10) { - return Filter(universe => universe.IronCondor(minDaysTillExpiry, nearStrikeSpread, farStrikeSpread), requiresUnderlyingPrice: true); + return Filter(universe => universe.IronCondor(minDaysTillExpiry, nearStrikeSpread, farStrikeSpread)); } /// @@ -468,7 +470,7 @@ public OptionChain IronCondor(int minDaysTillExpiry = 30, decimal nearStrikeSpre /// A new chain with the selected contracts, empty if there is no match public OptionChain BoxSpread(int minDaysTillExpiry = 30, decimal strikeSpread = 5) { - return Filter(universe => universe.BoxSpread(minDaysTillExpiry, strikeSpread), requiresUnderlyingPrice: true); + return Filter(universe => universe.BoxSpread(minDaysTillExpiry, strikeSpread)); } /// @@ -480,7 +482,7 @@ public OptionChain BoxSpread(int minDaysTillExpiry = 30, decimal strikeSpread = /// A new chain with the selected contracts, empty if there is no match public OptionChain JellyRoll(decimal strikeFromAtm = 0, int minNearDaysTillExpiry = 30, int minFarDaysTillExpiry = 60) { - return Filter(universe => universe.JellyRoll(strikeFromAtm, minNearDaysTillExpiry, minFarDaysTillExpiry), requiresUnderlyingPrice: true); + return Filter(universe => universe.JellyRoll(strikeFromAtm, minNearDaysTillExpiry, minFarDaysTillExpiry)); } /// @@ -493,7 +495,7 @@ public OptionChain JellyRoll(decimal strikeFromAtm = 0, int minNearDaysTillExpir /// A new chain with the selected contracts, empty if there is no match public OptionChain CallLadder(int minDaysTillExpiry, decimal higherStrikeFromAtm, decimal middleStrikeFromAtm, decimal lowerStrikeFromAtm) { - return Filter(universe => universe.CallLadder(minDaysTillExpiry, higherStrikeFromAtm, middleStrikeFromAtm, lowerStrikeFromAtm), requiresUnderlyingPrice: true); + return Filter(universe => universe.CallLadder(minDaysTillExpiry, higherStrikeFromAtm, middleStrikeFromAtm, lowerStrikeFromAtm)); } /// @@ -506,21 +508,16 @@ public OptionChain CallLadder(int minDaysTillExpiry, decimal higherStrikeFromAtm /// A new chain with the selected contracts, empty if there is no match public OptionChain PutLadder(int minDaysTillExpiry, decimal higherStrikeFromAtm, decimal middleStrikeFromAtm, decimal lowerStrikeFromAtm) { - return Filter(universe => universe.PutLadder(minDaysTillExpiry, higherStrikeFromAtm, middleStrikeFromAtm, lowerStrikeFromAtm), requiresUnderlyingPrice: true); + return Filter(universe => universe.PutLadder(minDaysTillExpiry, higherStrikeFromAtm, middleStrikeFromAtm, lowerStrikeFromAtm)); } /// /// Applies the given universe filter to the contracts of this chain and returns the result as a new chain /// /// The universe filter to apply - /// True for filters selecting strikes relative to the underlying price, which select nothing without it - private OptionChain Filter(Func filter, bool requiresUnderlyingPrice = false) + private OptionChain Filter(Func filter) { var universe = new OptionChainFilterUniverse(this); - if (requiresUnderlyingPrice && universe.Underlying == null) - { - return new OptionChain(this, Enumerable.Empty()); - } // the type filters (standards/weeklys) are only applied on demand, like the universe selection does after the user filter return new OptionChain(this, filter(universe).ApplyTypesFilter()); } diff --git a/Common/Securities/ContractSecurityFilterUniverse.cs b/Common/Securities/ContractSecurityFilterUniverse.cs index 15cbec59c59f..439865a2c840 100644 --- a/Common/Securities/ContractSecurityFilterUniverse.cs +++ b/Common/Securities/ContractSecurityFilterUniverse.cs @@ -153,6 +153,13 @@ internal T ApplyTypesFilter() return (T)this; } + // Every contract passes by default, so skip the pass and only pin the ordering rule for StandardsOnly() + if (Type == DefaultExpirationType) + { + _alreadyAppliedTypeFilters = true; + return (T)this; + } + // memoization map for ApplyTypesFilter() var memoizedMap = new Dictionary(); diff --git a/Common/Securities/Option/IOptionContractFilters.cs b/Common/Securities/Option/IOptionContractFilters.cs index 6eac6a497f47..095601441bc1 100644 --- a/Common/Securities/Option/IOptionContractFilters.cs +++ b/Common/Securities/Option/IOptionContractFilters.cs @@ -20,7 +20,7 @@ namespace QuantConnect.Securities /// /// The option contract filters shared by the option universe selection () /// and the option chain (), so both offer the same filters with the same semantics. - /// Every filter added here must be implemented by both; OptionChainTests.ChainExposesEveryUniverseFilter verifies it + /// OptionChainTests.ChainExposesEveryUniverseFilter checks that every universe filter is declared here /// /// The implementing type, returned by every filter for chaining public interface IOptionContractFilters diff --git a/Common/Securities/Option/OptionChainFilterUniverse.cs b/Common/Securities/Option/OptionChainFilterUniverse.cs index 0ea0fa9f4fd7..0b78a498da91 100644 --- a/Common/Securities/Option/OptionChainFilterUniverse.cs +++ b/Common/Securities/Option/OptionChainFilterUniverse.cs @@ -14,6 +14,7 @@ */ using System; +using System.Collections.Generic; using System.Linq; using QuantConnect.Data; using QuantConnect.Data.Market; @@ -45,7 +46,7 @@ internal class OptionChainFilterUniverse : BaseOptionFilterUniverse /// The option chain to filter public OptionChainFilterUniverse(OptionChain chain) - : base(chain.Contracts.Values.ToList(), GetUnderlying(chain), chain.ExchangeTime, GetStrikeMultiplier(chain)) + : base(GetContracts(chain), GetUnderlying(chain), chain.ExchangeTime, GetStrikeMultiplier(chain)) { _symbol = chain.Symbol; } @@ -58,6 +59,12 @@ protected override OptionContract CreateDataInstance(Symbol symbol) throw new InvalidOperationException($"OptionChainFilterUniverse.CreateDataInstance(): {symbol} is not part of the chain"); } + private static IReadOnlyList GetContracts(OptionChain chain) + { + // The dictionary caches its values as a list that is replaced, never mutated, so it is safe to share + return chain.Contracts.Values as IReadOnlyList ?? chain.Contracts.Values.ToList(); + } + private static BaseData GetUnderlying(OptionChain chain) { // A chain without underlying data carries an empty placeholder, which must not be used as a zero price diff --git a/Common/Securities/Option/OptionFilterUniverse.cs b/Common/Securities/Option/OptionFilterUniverse.cs index 97af64709f02..446aef4b1d73 100644 --- a/Common/Securities/Option/OptionFilterUniverse.cs +++ b/Common/Securities/Option/OptionFilterUniverse.cs @@ -329,6 +329,11 @@ public TUniverse NakedPut(int minDaysTillExpiry = 30, decimal strikeFromAtm = 0) private TUniverse SingleContract(OptionRight right, int minDaysTillExpiry = 30, decimal strikeFromAtm = 0) { + if (UnderlyingInternal == null) + { + return Empty(); + } + // Select the expiry as the nearest to set days later var contractsForExpiry = GetContractsForExpiry(AllSymbols, minDaysTillExpiry); var contracts = contractsForExpiry.Where(x => x.ID.OptionRight == right).ToList(); @@ -383,6 +388,11 @@ private TUniverse Spread(OptionRight right, int minDaysTillExpiry, decimal highe + $"{nameof(higherStrikeFromAtm)}, {nameof(lowerStrikeFromAtm)}"); } + if (UnderlyingInternal == null) + { + return Empty(); + } + // Select the expiry as the nearest to set days later var contractsForExpiry = GetContractsForExpiry(AllSymbols, minDaysTillExpiry); var contracts = contractsForExpiry.Where(x => x.ID.OptionRight == right).ToList(); @@ -445,6 +455,11 @@ private TUniverse CalendarSpread(OptionRight right, decimal strikeFromAtm, int m throw new ArgumentException("CalendarSpread(): near expiry argument must be positive."); } + if (UnderlyingInternal == null) + { + return Empty(); + } + // Select the set strike var strike = GetStrike(AllSymbols, strikeFromAtm); var contracts = AllSymbols.Where(x => x.ID.StrikePrice == strike && x.ID.OptionRight == right).ToList(); @@ -518,9 +533,9 @@ public TUniverse ProtectiveCollar(int minDaysTillExpiry = 30, decimal callStrike var filtered = CallPutSpread(minDaysTillExpiry, callStrikeFromAtm, putStrikeFromAtm); - var callStrike = filtered.Single(x => x.ID.OptionRight == OptionRight.Call).ID.StrikePrice; - var putStrike = filtered.Single(x => x.ID.OptionRight == OptionRight.Put).ID.StrikePrice; - if (callStrike <= putStrike) + var call = filtered.SingleOrDefault(x => x.ID.OptionRight == OptionRight.Call); + var put = filtered.SingleOrDefault(x => x.ID.OptionRight == OptionRight.Put); + if (call == null || put == null || call.ID.StrikePrice <= put.ID.StrikePrice) { return Empty(); } @@ -542,6 +557,11 @@ public TUniverse Conversion(int minDaysTillExpiry = 30, decimal strikeFromAtm = private TUniverse CallPutSpread(int minDaysTillExpiry, decimal callStrikeFromAtm, decimal putStrikeFromAtm, bool otm = false) { + if (UnderlyingInternal == null) + { + return Empty(); + } + // Select the expiry as the nearest to set days later var contracts = GetContractsForExpiry(AllSymbols, minDaysTillExpiry).ToList(); @@ -600,6 +620,11 @@ private TUniverse Butterfly(OptionRight right, int minDaysTillExpiry, decimal st throw new ArgumentException("ProtectiveCollar(): strikeSpread arguments must be positive"); } + if (UnderlyingInternal == null) + { + return Empty(); + } + // Select the expiry as the nearest to set days later var contractsForExpiry = GetContractsForExpiry(AllSymbols, minDaysTillExpiry); var contracts = contractsForExpiry.Where(x => x.ID.OptionRight == right).ToList(); @@ -618,9 +643,9 @@ private TUniverse Butterfly(OptionRight right, int minDaysTillExpiry, decimal st } // Select the contracts - var filtered = this.Where(x => + var filtered = Contracts(data => data.Where(x => x.ID.Date == contracts[0].ID.Date && x.ID.OptionRight == right && - (x.ID.StrikePrice == atmStrike || x.ID.StrikePrice == lowerStrike || x.ID.StrikePrice == upperStrike)); + (x.ID.StrikePrice == atmStrike || x.ID.StrikePrice == lowerStrike || x.ID.StrikePrice == upperStrike))); if (filtered.Count() != 3) { return Empty(); @@ -642,6 +667,11 @@ public TUniverse IronButterfly(int minDaysTillExpiry = 30, decimal strikeSpread throw new ArgumentException("IronButterfly(): strikeSpread arguments must be positive"); } + if (UnderlyingInternal == null) + { + return Empty(); + } + // Select the expiry as the nearest to set days later var contracts = GetContractsForExpiry(AllSymbols, minDaysTillExpiry).ToList(); var calls = contracts.Where(x => x.ID.OptionRight == OptionRight.Call && x.ID.StrikePrice > Underlying.Price).ToList(); @@ -661,12 +691,12 @@ public TUniverse IronButterfly(int minDaysTillExpiry = 30, decimal strikeSpread otmPutStrike = atmStrike * 2 - otmCallStrike; } - var filtered = this.Where(x => + var filtered = Contracts(data => data.Where(x => x.ID.Date == contracts[0].ID.Date && ( x.ID.StrikePrice == atmStrike || (x.ID.OptionRight == OptionRight.Call && x.ID.StrikePrice == otmCallStrike) || (x.ID.OptionRight == OptionRight.Put && x.ID.StrikePrice == otmPutStrike) - )); + ))); if (filtered.Count() != 4) { return Empty(); @@ -697,6 +727,11 @@ public TUniverse IronCondor(int minDaysTillExpiry = 30, decimal nearStrikeSpread + $"{nameof(nearStrikeSpread)}, {nameof(farStrikeSpread)}"); } + if (UnderlyingInternal == null) + { + return Empty(); + } + // Select the expiry as the nearest to set days later var contracts = GetContractsForExpiry(AllSymbols, minDaysTillExpiry).ToList(); var calls = contracts.Where(x => x.ID.OptionRight == OptionRight.Call && x.ID.StrikePrice > Underlying.Price).ToList(); @@ -718,13 +753,13 @@ public TUniverse IronCondor(int minDaysTillExpiry = 30, decimal nearStrikeSpread } // Select the contracts - var filtered = this.Where(x => + var filtered = Contracts(data => data.Where(x => x.ID.Date == contracts[0].ID.Date && ( (x.ID.OptionRight == OptionRight.Call && x.ID.StrikePrice == nearCallStrike) || (x.ID.OptionRight == OptionRight.Put && x.ID.StrikePrice == nearPutStrike) || (x.ID.OptionRight == OptionRight.Call && x.ID.StrikePrice == farCallStrike) || (x.ID.OptionRight == OptionRight.Put && x.ID.StrikePrice == farPutStrike) - )); + ))); if (filtered.Count() != 4) { return Empty(); @@ -747,6 +782,11 @@ public TUniverse BoxSpread(int minDaysTillExpiry = 30, decimal strikeSpread = 5) throw new ArgumentException($"BoxSpread(): strike arguments must be positive, {nameof(strikeSpread)}"); } + if (UnderlyingInternal == null) + { + return Empty(); + } + // Select the expiry as the nearest to set days later var contracts = GetContractsForExpiry(AllSymbols, minDaysTillExpiry).ToList(); if (contracts.Count == 0) @@ -759,9 +799,9 @@ public TUniverse BoxSpread(int minDaysTillExpiry = 30, decimal strikeSpread = 5) var lowerStrike = GetStrike(contracts.Where(x => x.ID.StrikePrice < higherStrike && x.ID.StrikePrice < Underlying.Price), -strikeSpread); // Select the contracts - var filtered = this.Where(x => + var filtered = Contracts(data => data.Where(x => (x.ID.StrikePrice == higherStrike || x.ID.StrikePrice == lowerStrike) && - x.ID.Date == contracts[0].ID.Date); + x.ID.Date == contracts[0].ID.Date)); if (filtered.Count() != 4) { return Empty(); @@ -790,6 +830,11 @@ public TUniverse JellyRoll(decimal strikeFromAtm = 0, int minNearDaysTillExpiry throw new ArgumentException("JellyRoll(): near expiry argument must be positive."); } + if (UnderlyingInternal == null) + { + return Empty(); + } + // Select the set strike var strike = AllSymbols.OrderBy(x => Math.Abs(Underlying.Price - x.ID.StrikePrice + strikeFromAtm)) .First().ID.StrikePrice; @@ -811,7 +856,7 @@ public TUniverse JellyRoll(decimal strikeFromAtm = 0, int minNearDaysTillExpiry } var farExpiry = farExpiryContract.ID.Date; - var filtered = this.Where(x => x.ID.StrikePrice == strike && (x.ID.Date == nearExpiry || x.ID.Date == farExpiry)); + var filtered = Contracts(data => data.Where(x => x.ID.StrikePrice == strike && (x.ID.Date == nearExpiry || x.ID.Date == farExpiry))); if (filtered.Count() != 4) { return Empty(); @@ -856,7 +901,7 @@ public TUniverse PutLadder(int minDaysTillExpiry, decimal higherStrikeFromAtm, d public TUniverse Delta(decimal min, decimal max) { ValidateSecurityTypeForSupportedFilters(nameof(Delta)); - return this.Where(contractData => contractData.Greeks.Delta >= min && contractData.Greeks.Delta <= max); + return Contracts(data => data.Where(contractData => contractData.Greeks.Delta >= min && contractData.Greeks.Delta <= max)); } /// @@ -880,7 +925,7 @@ public TUniverse D(decimal min, decimal max) public TUniverse Gamma(decimal min, decimal max) { ValidateSecurityTypeForSupportedFilters(nameof(Gamma)); - return this.Where(contractData => contractData.Greeks.Gamma >= min && contractData.Greeks.Gamma <= max); + return Contracts(data => data.Where(contractData => contractData.Greeks.Gamma >= min && contractData.Greeks.Gamma <= max)); } /// @@ -904,7 +949,7 @@ public TUniverse G(decimal min, decimal max) public TUniverse Theta(decimal min, decimal max) { ValidateSecurityTypeForSupportedFilters(nameof(Theta)); - return this.Where(contractData => contractData.Greeks.Theta >= min && contractData.Greeks.Theta <= max); + return Contracts(data => data.Where(contractData => contractData.Greeks.Theta >= min && contractData.Greeks.Theta <= max)); } /// @@ -928,7 +973,7 @@ public TUniverse T(decimal min, decimal max) public TUniverse Vega(decimal min, decimal max) { ValidateSecurityTypeForSupportedFilters(nameof(Vega)); - return this.Where(contractData => contractData.Greeks.Vega >= min && contractData.Greeks.Vega <= max); + return Contracts(data => data.Where(contractData => contractData.Greeks.Vega >= min && contractData.Greeks.Vega <= max)); } /// @@ -952,7 +997,7 @@ public TUniverse V(decimal min, decimal max) public TUniverse Rho(decimal min, decimal max) { ValidateSecurityTypeForSupportedFilters(nameof(Rho)); - return this.Where(contractData => contractData.Greeks.Rho >= min && contractData.Greeks.Rho <= max); + return Contracts(data => data.Where(contractData => contractData.Greeks.Rho >= min && contractData.Greeks.Rho <= max)); } /// @@ -976,7 +1021,7 @@ public TUniverse R(decimal min, decimal max) public TUniverse ImpliedVolatility(decimal min, decimal max) { ValidateSecurityTypeForSupportedFilters(nameof(ImpliedVolatility)); - return this.Where(contractData => contractData.ImpliedVolatility >= min && contractData.ImpliedVolatility <= max); + return Contracts(data => data.Where(contractData => contractData.ImpliedVolatility >= min && contractData.ImpliedVolatility <= max)); } /// @@ -1000,7 +1045,7 @@ public TUniverse IV(decimal min, decimal max) public TUniverse OpenInterest(long min, long max) { ValidateSecurityTypeForSupportedFilters(nameof(OpenInterest)); - return this.Where(contractData => contractData.OpenInterest >= min && contractData.OpenInterest <= max); + return Contracts(data => data.Where(contractData => contractData.OpenInterest >= min && contractData.OpenInterest <= max)); } /// @@ -1023,6 +1068,11 @@ private TUniverse Ladder(OptionRight right, int minDaysTillExpiry, decimal highe + $"{nameof(higherStrikeFromAtm)}, {nameof(middleStrikeFromAtm)}, {nameof(lowerStrikeFromAtm)}"); } + if (UnderlyingInternal == null) + { + return Empty(); + } + // Select the expiry as the nearest to set days later var contracts = GetContractsForExpiry(AllSymbols.Where(x => x.ID.OptionRight == right).ToList(), minDaysTillExpiry); @@ -1041,7 +1091,7 @@ private TUniverse Ladder(OptionRight right, int minDaysTillExpiry, decimal highe return Empty(); } - return this.WhereContains(new List { lowerStrikeContract, middleStrikeContract, higherStrikeContract }); + return Contracts(data => data.Where(x => x.Symbol == lowerStrikeContract || x.Symbol == middleStrikeContract || x.Symbol == higherStrikeContract)); } /// @@ -1087,18 +1137,6 @@ private decimal GetStrike(IEnumerable symbols, decimal strikeFromAtm) .First(); } - private TUniverse Where(Func predicate) - { - Data = Data.Where(predicate).ToList(); - return (TUniverse)this; - } - - private TUniverse WhereContains(List filterList) - { - Data = Data.Where(x => filterList.Contains(x.Symbol)).ToList(); - return (TUniverse)this; - } - [MethodImpl(MethodImplOptions.AggressiveInlining)] private void ValidateSecurityTypeForSupportedFilters(string filterName) { @@ -1187,8 +1225,7 @@ public static class OptionFilterUniverseEx /// Universe with filter applied public static OptionFilterUniverse Where(this OptionFilterUniverse universe, Func predicate) { - universe.Data = universe.Data.Where(predicate).ToList(); - return universe; + return universe.Contracts(data => data.Where(predicate)); } /// @@ -1199,8 +1236,7 @@ public static OptionFilterUniverse Where(this OptionFilterUniverse universe, Fun /// Universe with filter applied public static OptionFilterUniverse Where(this OptionFilterUniverse universe, PyObject predicate) { - universe.Data = universe.Data.Where(predicate.SafeAs>()).ToList(); - return universe; + return universe.Where(predicate.SafeAs>()); } /// @@ -1211,8 +1247,7 @@ public static OptionFilterUniverse Where(this OptionFilterUniverse universe, PyO /// Universe with filter applied public static OptionFilterUniverse Select(this OptionFilterUniverse universe, Func mapFunc) { - universe.AllSymbols = universe.Data.Select(mapFunc).ToList(); - return universe; + return universe.Contracts(data => data.Select(mapFunc)); } /// @@ -1234,8 +1269,7 @@ public static OptionFilterUniverse Select(this OptionFilterUniverse universe, Py /// Universe with filter applied public static OptionFilterUniverse SelectMany(this OptionFilterUniverse universe, Func> mapFunc) { - universe.AllSymbols = universe.Data.SelectMany(mapFunc).ToList(); - return universe; + return universe.Contracts(data => data.SelectMany(mapFunc)); } /// @@ -1257,8 +1291,7 @@ public static OptionFilterUniverse SelectMany(this OptionFilterUniverse universe /// Universe with filter applied public static OptionFilterUniverse WhereContains(this OptionFilterUniverse universe, List filterList) { - universe.Data = universe.Data.Where(x => filterList.Contains(x)).ToList(); - return universe; + return universe.Where(x => filterList.Contains(x.Symbol)); } /// diff --git a/Tests/Common/Data/Market/OptionChainTests.cs b/Tests/Common/Data/Market/OptionChainTests.cs index a66147d70cd5..35daf2e269bc 100644 --- a/Tests/Common/Data/Market/OptionChainTests.cs +++ b/Tests/Common/Data/Market/OptionChainTests.cs @@ -18,6 +18,7 @@ using System.Globalization; using System.IO; using System.Linq; +using System.Reflection; using System.Text; using NUnit.Framework; using Python.Runtime; @@ -127,6 +128,37 @@ public void ChainFiltersMatchUniverseFilters(Func x.ReturnType == typeof(OptionFilterUniverse) && x.Name != "Contracts" && !x.IsDefined(typeof(ObsoleteAttribute))) + .ToList(); + + Assert.IsNotEmpty(universeFilters); + foreach (var universeFilter in universeFilters) + { + var parameters = universeFilter.GetParameters().Select(x => x.ParameterType).ToArray(); + var chainFilter = typeof(IOptionContractFilters).GetMethod(universeFilter.Name, parameters); + Assert.IsNotNull(chainFilter, $"{universeFilter.Name}({string.Join(", ", parameters.Select(x => x.Name))}) is not an option chain filter"); + } + } + + [Test] + public void FilteredChainSharesTheAuxiliaryData() + { + var chain = CreateChain(); + var symbol = chain.First().Symbol; + chain.AddData(new TestAuxData { Symbol = symbol, Time = Date, Value = 1 }); + + var filtered = chain.PutsOnly(); + + Assert.IsNotNull(chain.GetAux(symbol)); + Assert.AreSame(chain.GetAux(symbol), filtered.GetAux(symbol)); + } + [Test] public void FilteredChainIsANewChainSharingTheSourceProperties() { @@ -263,27 +295,64 @@ def where_chain(chain): } } - [Test] - public void StrategyFiltersReturnAnEmptyChainWithoutUnderlyingPrice() + private static IEnumerable StrategyFilterCases() + { + yield return Case("NakedCall", u => u.NakedCall(10, 0), c => c.NakedCall(10, 0)); + yield return Case("CallSpread", u => u.CallSpread(10, 5, -5), c => c.CallSpread(10, 5, -5)); + yield return Case("CallCalendarSpread", u => u.CallCalendarSpread(0, 10, 40), c => c.CallCalendarSpread(0, 10, 40)); + yield return Case("Strangle", u => u.Strangle(10, 5, -5), c => c.Strangle(10, 5, -5)); + yield return Case("Straddle", u => u.Straddle(10), c => c.Straddle(10)); + yield return Case("ProtectiveCollar", u => u.ProtectiveCollar(10, 5, -5), c => c.ProtectiveCollar(10, 5, -5)); + yield return Case("Conversion", u => u.Conversion(10, 5), c => c.Conversion(10, 5)); + yield return Case("CallButterfly", u => u.CallButterfly(10, 5), c => c.CallButterfly(10, 5)); + yield return Case("IronButterfly", u => u.IronButterfly(10, 5), c => c.IronButterfly(10, 5)); + yield return Case("IronCondor", u => u.IronCondor(10, 5, 10), c => c.IronCondor(10, 5, 10)); + yield return Case("BoxSpread", u => u.BoxSpread(10, 5), c => c.BoxSpread(10, 5)); + yield return Case("JellyRoll", u => u.JellyRoll(0, 10, 40), c => c.JellyRoll(0, 10, 40)); + yield return Case("CallLadder", u => u.CallLadder(10, 5, 0, -5), c => c.CallLadder(10, 5, 0, -5)); + } + + [TestCaseSource(nameof(StrategyFilterCases))] + public void StrategyFiltersSelectNothingWithoutUnderlyingPrice(Func universeFilter, + Func chainFilter, bool _) { var contracts = _data.Select(x => new OptionUniverse(x) { Underlying = null }).ToList(); var chain = new OptionChain(Canonical, Date, contracts, _symbolProperties); + var universe = new OptionFilterUniverse(_option); + universe.Refresh(contracts, null, Date); Assert.AreEqual(0, chain.Underlying.Price); - Assert.AreEqual(0, chain.Straddle(10).Count); - Assert.AreEqual(0, chain.IronCondor(10, 5, 10).Count); - Assert.AreEqual(0, chain.NakedCall(10, 0).Count); + Assert.AreEqual(0, universeFilter(universe).Count); + Assert.AreEqual(0, chainFilter(chain).Count); } [Test] public void StrategyFiltersValidateArgumentsLikeTheUniverseFilters() { - var chain = CreateChain(); + var contracts = _data.Select(x => new OptionUniverse(x) { Underlying = null }).ToList(); + var chains = new[] { CreateChain(), new OptionChain(Canonical, Date, contracts, _symbolProperties) }; - Assert.Throws(() => chain.Strangle(10, -5, 5)); - Assert.Throws(() => chain.CallSpread(10, 5, 10)); - Assert.Throws(() => chain.IronCondor(10, 10, 5)); - Assert.Throws(() => chain.CallCalendarSpread(0, 40, 10)); + foreach (var chain in chains) + { + Assert.Throws(() => chain.Strangle(10, -5, 5)); + Assert.Throws(() => chain.CallSpread(10, 5, 10)); + Assert.Throws(() => chain.IronCondor(10, 10, 5)); + Assert.Throws(() => chain.CallCalendarSpread(0, 40, 10)); + } + } + + [Test] + public void TypeFiltersApplyToTheChainContractsInAnyOrder() + { + var chain = CreateChain(); + var expected = CreateUniverse().StandardsOnly().FrontMonth().ToList().Select(x => x.Symbol.Value).ToList(); + + // The front month, 2016-03-04, is a weekly and 2016-03-18 the first standard expiry + Assert.AreEqual(2 * Strikes.Length, expected.Count); + CollectionAssert.AreEquivalent(expected, chain.StandardsOnly().FrontMonth().Select(x => x.Symbol.Value)); + Assert.AreEqual(0, chain.FrontMonth().StandardsOnly().Count); + Assert.IsTrue(chain.FrontMonth().WeeklysOnly().All(x => x.Expiry == Expiries[0])); + Assert.Throws(() => CreateUniverse().FrontMonth().StandardsOnly()); } [Test] @@ -316,6 +385,10 @@ def naked_put(chain): } } + private class TestAuxData : BaseData + { + } + private static TestCaseData Case(string name, Func universeFilter, Func chainFilter, bool empty = false) { diff --git a/Tests/Common/Securities/Options/OptionFilterUniverseTests.cs b/Tests/Common/Securities/Options/OptionFilterUniverseTests.cs index 4a24e92ea195..bec1e639c4fd 100644 --- a/Tests/Common/Securities/Options/OptionFilterUniverseTests.cs +++ b/Tests/Common/Securities/Options/OptionFilterUniverseTests.cs @@ -230,6 +230,18 @@ public void OptionUnivereDataFiltersAreNotSupportedForFutureOptions() }); } + [Test] + public void TypeFiltersMustBeAppliedBeforeExpiryFilters() + { + var universe = new OptionFilterUniverse(GetOption(), _testOptionsData, _underlying); + var count = universe.Count; + + universe.FrontMonth(); + + Assert.Less(universe.Count, count); + Assert.Throws(() => universe.StandardsOnly()); + } + [Test] public void CountReturnsTheNumberOfContractsInTheUniverse() { From d632fc69648646f016682f56add616fe06f5f0c1 Mon Sep 17 00:00:00 2001 From: Jhonathan Abreu Date: Tue, 8 Sep 2026 12:26:00 -0400 Subject: [PATCH 07/17] Always run the contract type filter pass --- Common/Securities/ContractSecurityFilterUniverse.cs | 7 ------- 1 file changed, 7 deletions(-) diff --git a/Common/Securities/ContractSecurityFilterUniverse.cs b/Common/Securities/ContractSecurityFilterUniverse.cs index 439865a2c840..15cbec59c59f 100644 --- a/Common/Securities/ContractSecurityFilterUniverse.cs +++ b/Common/Securities/ContractSecurityFilterUniverse.cs @@ -153,13 +153,6 @@ internal T ApplyTypesFilter() return (T)this; } - // Every contract passes by default, so skip the pass and only pin the ordering rule for StandardsOnly() - if (Type == DefaultExpirationType) - { - _alreadyAppliedTypeFilters = true; - return (T)this; - } - // memoization map for ApplyTypesFilter() var memoizedMap = new Dictionary(); From baf60070f1dfefd50b9f9523dd443f66d9b85fee Mon Sep 17 00:00:00 2001 From: Jhonathan Abreu Date: Tue, 8 Sep 2026 16:24:52 -0400 Subject: [PATCH 08/17] Revert "Filter option chains on the exchange date" This reverts commit 53098305a9add4ead85cb091163533c405b05482. --- Common/Data/Market/BaseChain.cs | 25 +++++------ .../Option/OptionChainFilterUniverse.cs | 2 +- Engine/DataFeeds/TimeSliceFactory.cs | 14 +++---- Tests/Common/Data/Market/OptionChainTests.cs | 22 ---------- Tests/Engine/DataFeeds/TimeSliceTests.cs | 41 +++---------------- 5 files changed, 23 insertions(+), 81 deletions(-) diff --git a/Common/Data/Market/BaseChain.cs b/Common/Data/Market/BaseChain.cs index 24c6d3b332f9..4ad253d36a23 100644 --- a/Common/Data/Market/BaseChain.cs +++ b/Common/Data/Market/BaseChain.cs @@ -35,7 +35,6 @@ public class BaseChain : BaseData, IEnumerable private Dictionary>> _auxiliaryData; private readonly Lazy _dataframe; private readonly bool _flatten; - private DateTime? _exchangeTime; private Dictionary>> AuxiliaryData { @@ -60,17 +59,6 @@ public BaseData Underlying get; internal set; } - /// - /// Gets the chain time in the exchange time zone, the reference date for the contract filters. - /// Defaults to , which the engine stamps in the algorithm time zone - /// - [PandasIgnore] - public DateTime ExchangeTime - { - get => _exchangeTime ?? Time; - internal set => _exchangeTime = value; - } - /// /// Gets all ticks for every option contract in this chain, keyed by option symbol /// @@ -183,7 +171,6 @@ protected BaseChain(BaseChain other) { Symbol = other.Symbol; Time = other.Time; - _exchangeTime = other._exchangeTime; Value = other.Value; Underlying = other.Underlying; Ticks = other.Ticks; @@ -201,10 +188,20 @@ protected BaseChain(BaseChain other) /// The chain to copy /// The contracts to keep protected BaseChain(BaseChain other, IEnumerable contracts) - : this(other) + : this(other.DataType, other._flatten) { + Symbol = other.Symbol; + Time = other.Time; + Value = other.Value; + Underlying = other.Underlying; + Ticks = other.Ticks; + QuoteBars = other.QuoteBars; + TradeBars = other.TradeBars; + FilteredContracts = other.FilteredContracts; Contracts = new(); +#pragma warning disable 0618 // DataDictionary.Time is deprecated, ignore until removed entirely Contracts.Time = other.Contracts.Time; +#pragma warning restore 0618 foreach (var contract in contracts) { Contracts[contract.Symbol] = contract; diff --git a/Common/Securities/Option/OptionChainFilterUniverse.cs b/Common/Securities/Option/OptionChainFilterUniverse.cs index 0b78a498da91..0acea0c6bdcc 100644 --- a/Common/Securities/Option/OptionChainFilterUniverse.cs +++ b/Common/Securities/Option/OptionChainFilterUniverse.cs @@ -46,7 +46,7 @@ internal class OptionChainFilterUniverse : BaseOptionFilterUniverse /// The option chain to filter public OptionChainFilterUniverse(OptionChain chain) - : base(GetContracts(chain), GetUnderlying(chain), chain.ExchangeTime, GetStrikeMultiplier(chain)) + : base(GetContracts(chain), GetUnderlying(chain), chain.Time, GetStrikeMultiplier(chain)) { _symbol = chain.Symbol; } diff --git a/Engine/DataFeeds/TimeSliceFactory.cs b/Engine/DataFeeds/TimeSliceFactory.cs index ca4768700b53..28208e25696a 100644 --- a/Engine/DataFeeds/TimeSliceFactory.cs +++ b/Engine/DataFeeds/TimeSliceFactory.cs @@ -282,7 +282,7 @@ public TimeSlice Create(DateTime utcDateTime, { optionChains[baseData.Symbol] = (OptionChain)baseData; } - else if (!HandleOptionData(utcDateTime, algorithmTime, baseData, optionChains, packet.Security, packet.Configuration, sliceFuture, optionUnderlyingUpdates)) + else if (!HandleOptionData(algorithmTime, baseData, optionChains, packet.Security, sliceFuture, optionUnderlyingUpdates)) { continue; } @@ -304,7 +304,7 @@ public TimeSlice Create(DateTime utcDateTime, { futuresChains[baseData.Symbol] = (FuturesChain)baseData; } - else if (!HandleFuturesData(utcDateTime, algorithmTime, baseData, futuresChains, packet.Security, packet.Configuration)) + else if (!HandleFuturesData(algorithmTime, baseData, futuresChains, packet.Security, packet.Configuration)) { continue; } @@ -419,8 +419,7 @@ private void UpdateEmptyCollections(DateTime algorithmTime) #pragma warning restore 0618 } - private bool HandleOptionData(DateTime utcDateTime, DateTime algorithmTime, BaseData baseData, OptionChains optionChains, ISecurityPrice security, - SubscriptionDataConfig configuration, Lazy sliceFuture, IReadOnlyDictionary optionUnderlyingUpdates) + private bool HandleOptionData(DateTime algorithmTime, BaseData baseData, OptionChains optionChains, ISecurityPrice security, Lazy sliceFuture, IReadOnlyDictionary optionUnderlyingUpdates) { var symbol = baseData.Symbol; @@ -428,7 +427,7 @@ private bool HandleOptionData(DateTime utcDateTime, DateTime algorithmTime, Base var canonical = symbol.Canonical; if (!optionChains.TryGetValue(canonical, out chain)) { - chain = new OptionChain(canonical, algorithmTime) { ExchangeTime = utcDateTime.ConvertFromUtc(configuration.ExchangeTimeZone) }; + chain = new OptionChain(canonical, algorithmTime); optionChains[canonical] = chain; } @@ -491,8 +490,7 @@ private bool HandleOptionData(DateTime utcDateTime, DateTime algorithmTime, Base } - private bool HandleFuturesData(DateTime utcDateTime, DateTime algorithmTime, BaseData baseData, FuturesChains futuresChains, ISecurityPrice security, - SubscriptionDataConfig configuration) + private bool HandleFuturesData(DateTime algorithmTime, BaseData baseData, FuturesChains futuresChains, ISecurityPrice security, SubscriptionDataConfig configuration) { var symbol = baseData.Symbol; @@ -506,7 +504,7 @@ private bool HandleFuturesData(DateTime utcDateTime, DateTime algorithmTime, Bas return false; } - chain = new FuturesChain(canonical, algorithmTime) { ExchangeTime = utcDateTime.ConvertFromUtc(configuration.ExchangeTimeZone) }; + chain = new FuturesChain(canonical, algorithmTime); futuresChains[canonical] = chain; } diff --git a/Tests/Common/Data/Market/OptionChainTests.cs b/Tests/Common/Data/Market/OptionChainTests.cs index 35daf2e269bc..9b4f463384fb 100644 --- a/Tests/Common/Data/Market/OptionChainTests.cs +++ b/Tests/Common/Data/Market/OptionChainTests.cs @@ -197,28 +197,6 @@ public void FiltersOnAnEmptyChainReturnAnEmptyChain() } } - [Test] - public void FiltersUseTheExchangeTimeAsTheReferenceDate() - { - // The engine stamps slice chains in the algorithm time zone, which can already be the day after the exchange date - var exchangeDate = new DateTime(2016, 3, 4); - var (data, underlying) = CreateUniverseData(exchangeDate, UnderlyingPrice, new[] { exchangeDate, Expiries[1] }, Strikes); - var chain = new OptionChain(Canonical, exchangeDate, data, _symbolProperties) { Time = exchangeDate.AddDays(1) }; - var universe = CreateUniverse(data, underlying, exchangeDate).Expiration(0, 0).ToList(); - - Assert.AreEqual(2 * Strikes.Length, universe.Count); - Assert.AreEqual(0, chain.Expiration(0, 0).Count); - - chain.ExchangeTime = exchangeDate; - var filtered = chain.Expiration(0, 0); - - CollectionAssert.AreEquivalent(universe.Select(x => x.Symbol.Value), filtered.Select(x => x.Symbol.Value)); - Assert.AreEqual(chain.Time, filtered.Time); - Assert.AreEqual(exchangeDate, filtered.ExchangeTime); - CollectionAssert.AreEquivalent(universe.Where(x => x.ID.OptionRight == OptionRight.Call).Select(x => x.Symbol.Value), - chain.CallsOnly().Expiration(0, 0).Select(x => x.Symbol.Value)); - } - [Test] public void StrikesFilterIsSkippedWithoutUnderlyingPrice() { diff --git a/Tests/Engine/DataFeeds/TimeSliceTests.cs b/Tests/Engine/DataFeeds/TimeSliceTests.cs index faadb7e30116..95ef83abccbf 100644 --- a/Tests/Engine/DataFeeds/TimeSliceTests.cs +++ b/Tests/Engine/DataFeeds/TimeSliceTests.cs @@ -178,37 +178,6 @@ public void OptionsDataHasVolume() } } - [TestCase(SecurityType.Option)] - [TestCase(SecurityType.Future)] - public void ChainExchangeTimeIsInTheExchangeTimeZone(SecurityType securityType) - { - var symbol = securityType == SecurityType.Option ? Symbols.SPY_C_192_Feb19_2016 : Symbols.Fut_SPY_Mar19_2016; - var config = new SubscriptionDataConfig(typeof(TradeBar), symbol, Resolution.Minute, TimeZones.NewYork, TimeZones.NewYork, true, true, false); - var security = GetSecurity(config); - // 15:00 in New York on 2016-02-18 is already 05:00 on 2016-02-19 in Tokyo - var utcTime = new DateTime(2016, 2, 18, 20, 0, 0); - var time = utcTime.ConvertFromUtc(TimeZones.NewYork); - - var packets = new List(); - if (security is Option option) - { - var underlying = option.Underlying; - packets.Add(new DataFeedPacket(underlying, underlying.SubscriptionDataConfig, - new List { new TradeBar(time, underlying.Symbol, 100, 100, 110, 106, 100) })); - } - packets.Add(new DataFeedPacket(security, config, new List { new TradeBar(time, symbol, 100, 100, 110, 106, 100) })); - - var slice = new TimeSliceFactory(TimeZones.Tokyo).Create(utcTime, packets, - SecurityChangesTests.CreateNonInternal(Enumerable.Empty(), Enumerable.Empty()), - new Dictionary()).Slice; - var (chainTime, exchangeTime) = securityType == SecurityType.Option - ? (slice.OptionChains.Values.Single().Time, slice.OptionChains.Values.Single().ExchangeTime) - : (slice.FutureChains.Values.Single().Time, slice.FutureChains.Values.Single().ExchangeTime); - - Assert.AreEqual(new DateTime(2016, 2, 19, 5, 0, 0), chainTime); - Assert.AreEqual(new DateTime(2016, 2, 18, 15, 0, 0), exchangeTime); - } - [Test] public void SuspiciousTicksAreNotAddedToConsolidatorUpdateData() { @@ -297,16 +266,16 @@ private Security GetSecurity(SubscriptionDataConfig config) if (config.Symbol.SecurityType == SecurityType.Option) { var option = new Option( - SecurityExchangeHours.AlwaysOpen(config.ExchangeTimeZone), + SecurityExchangeHours.AlwaysOpen(TimeZones.Utc), config, new Cash(Currencies.USD, 0, 1m), new OptionSymbolProperties(SymbolProperties.GetDefault(Currencies.USD)), ErrorCurrencyConverter.Instance, RegisteredSecurityDataTypesProvider.Null); var underlyingConfig = new SubscriptionDataConfig(typeof(TradeBar), config.Symbol.Underlying, Resolution.Second, - config.ExchangeTimeZone, config.ExchangeTimeZone, true, true, false); + TimeZones.Utc, TimeZones.Utc, true, true, false); var equity = new Equity( - SecurityExchangeHours.AlwaysOpen(config.ExchangeTimeZone), + SecurityExchangeHours.AlwaysOpen(TimeZones.Utc), underlyingConfig, new Cash(Currencies.USD, 0, 1m), SymbolProperties.GetDefault(Currencies.USD), @@ -320,7 +289,7 @@ private Security GetSecurity(SubscriptionDataConfig config) if (config.Symbol.SecurityType == SecurityType.Future) { return new Future( - SecurityExchangeHours.AlwaysOpen(config.ExchangeTimeZone), + SecurityExchangeHours.AlwaysOpen(TimeZones.Utc), config, new Cash(Currencies.USD, 0, 1m), SymbolProperties.GetDefault(Currencies.USD), @@ -329,7 +298,7 @@ private Security GetSecurity(SubscriptionDataConfig config) } return new Security( - SecurityExchangeHours.AlwaysOpen(config.ExchangeTimeZone), + SecurityExchangeHours.AlwaysOpen(TimeZones.Utc), config, new Cash(Currencies.USD, 0, 1m), SymbolProperties.GetDefault(Currencies.USD), From 9d1cb00becb392657e22be6222b81efb3a02178a Mon Sep 17 00:00:00 2001 From: Jhonathan Abreu Date: Tue, 8 Sep 2026 16:30:05 -0400 Subject: [PATCH 09/17] Keep the auxiliary data on filtered chains The subset constructor delegates to the copy constructor, which the revert had undone, so filtered chains share the source's auxiliary data again. --- Common/Data/Market/BaseChain.cs | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/Common/Data/Market/BaseChain.cs b/Common/Data/Market/BaseChain.cs index 4ad253d36a23..648c89c901f8 100644 --- a/Common/Data/Market/BaseChain.cs +++ b/Common/Data/Market/BaseChain.cs @@ -188,20 +188,10 @@ protected BaseChain(BaseChain other) /// The chain to copy /// The contracts to keep protected BaseChain(BaseChain other, IEnumerable contracts) - : this(other.DataType, other._flatten) + : this(other) { - Symbol = other.Symbol; - Time = other.Time; - Value = other.Value; - Underlying = other.Underlying; - Ticks = other.Ticks; - QuoteBars = other.QuoteBars; - TradeBars = other.TradeBars; - FilteredContracts = other.FilteredContracts; Contracts = new(); -#pragma warning disable 0618 // DataDictionary.Time is deprecated, ignore until removed entirely Contracts.Time = other.Contracts.Time; -#pragma warning restore 0618 foreach (var contract in contracts) { Contracts[contract.Symbol] = contract; From 0f899059864845362f70433e2afe4e6be1d6ae48 Mon Sep 17 00:00:00 2001 From: Jhonathan Abreu Date: Tue, 8 Sep 2026 16:39:45 -0400 Subject: [PATCH 10/17] Reapply "Filter option chains on the exchange date" This reverts commit baf60070f1dfefd50b9f9523dd443f66d9b85fee. --- Common/Data/Market/BaseChain.cs | 13 ++++++ .../Option/OptionChainFilterUniverse.cs | 2 +- Engine/DataFeeds/TimeSliceFactory.cs | 14 ++++--- Tests/Common/Data/Market/OptionChainTests.cs | 22 ++++++++++ Tests/Engine/DataFeeds/TimeSliceTests.cs | 41 ++++++++++++++++--- 5 files changed, 80 insertions(+), 12 deletions(-) diff --git a/Common/Data/Market/BaseChain.cs b/Common/Data/Market/BaseChain.cs index 648c89c901f8..24c6d3b332f9 100644 --- a/Common/Data/Market/BaseChain.cs +++ b/Common/Data/Market/BaseChain.cs @@ -35,6 +35,7 @@ public class BaseChain : BaseData, IEnumerable private Dictionary>> _auxiliaryData; private readonly Lazy _dataframe; private readonly bool _flatten; + private DateTime? _exchangeTime; private Dictionary>> AuxiliaryData { @@ -59,6 +60,17 @@ public BaseData Underlying get; internal set; } + /// + /// Gets the chain time in the exchange time zone, the reference date for the contract filters. + /// Defaults to , which the engine stamps in the algorithm time zone + /// + [PandasIgnore] + public DateTime ExchangeTime + { + get => _exchangeTime ?? Time; + internal set => _exchangeTime = value; + } + /// /// Gets all ticks for every option contract in this chain, keyed by option symbol /// @@ -171,6 +183,7 @@ protected BaseChain(BaseChain other) { Symbol = other.Symbol; Time = other.Time; + _exchangeTime = other._exchangeTime; Value = other.Value; Underlying = other.Underlying; Ticks = other.Ticks; diff --git a/Common/Securities/Option/OptionChainFilterUniverse.cs b/Common/Securities/Option/OptionChainFilterUniverse.cs index 0acea0c6bdcc..0b78a498da91 100644 --- a/Common/Securities/Option/OptionChainFilterUniverse.cs +++ b/Common/Securities/Option/OptionChainFilterUniverse.cs @@ -46,7 +46,7 @@ internal class OptionChainFilterUniverse : BaseOptionFilterUniverse /// The option chain to filter public OptionChainFilterUniverse(OptionChain chain) - : base(GetContracts(chain), GetUnderlying(chain), chain.Time, GetStrikeMultiplier(chain)) + : base(GetContracts(chain), GetUnderlying(chain), chain.ExchangeTime, GetStrikeMultiplier(chain)) { _symbol = chain.Symbol; } diff --git a/Engine/DataFeeds/TimeSliceFactory.cs b/Engine/DataFeeds/TimeSliceFactory.cs index 28208e25696a..ca4768700b53 100644 --- a/Engine/DataFeeds/TimeSliceFactory.cs +++ b/Engine/DataFeeds/TimeSliceFactory.cs @@ -282,7 +282,7 @@ public TimeSlice Create(DateTime utcDateTime, { optionChains[baseData.Symbol] = (OptionChain)baseData; } - else if (!HandleOptionData(algorithmTime, baseData, optionChains, packet.Security, sliceFuture, optionUnderlyingUpdates)) + else if (!HandleOptionData(utcDateTime, algorithmTime, baseData, optionChains, packet.Security, packet.Configuration, sliceFuture, optionUnderlyingUpdates)) { continue; } @@ -304,7 +304,7 @@ public TimeSlice Create(DateTime utcDateTime, { futuresChains[baseData.Symbol] = (FuturesChain)baseData; } - else if (!HandleFuturesData(algorithmTime, baseData, futuresChains, packet.Security, packet.Configuration)) + else if (!HandleFuturesData(utcDateTime, algorithmTime, baseData, futuresChains, packet.Security, packet.Configuration)) { continue; } @@ -419,7 +419,8 @@ private void UpdateEmptyCollections(DateTime algorithmTime) #pragma warning restore 0618 } - private bool HandleOptionData(DateTime algorithmTime, BaseData baseData, OptionChains optionChains, ISecurityPrice security, Lazy sliceFuture, IReadOnlyDictionary optionUnderlyingUpdates) + private bool HandleOptionData(DateTime utcDateTime, DateTime algorithmTime, BaseData baseData, OptionChains optionChains, ISecurityPrice security, + SubscriptionDataConfig configuration, Lazy sliceFuture, IReadOnlyDictionary optionUnderlyingUpdates) { var symbol = baseData.Symbol; @@ -427,7 +428,7 @@ private bool HandleOptionData(DateTime algorithmTime, BaseData baseData, OptionC var canonical = symbol.Canonical; if (!optionChains.TryGetValue(canonical, out chain)) { - chain = new OptionChain(canonical, algorithmTime); + chain = new OptionChain(canonical, algorithmTime) { ExchangeTime = utcDateTime.ConvertFromUtc(configuration.ExchangeTimeZone) }; optionChains[canonical] = chain; } @@ -490,7 +491,8 @@ private bool HandleOptionData(DateTime algorithmTime, BaseData baseData, OptionC } - private bool HandleFuturesData(DateTime algorithmTime, BaseData baseData, FuturesChains futuresChains, ISecurityPrice security, SubscriptionDataConfig configuration) + private bool HandleFuturesData(DateTime utcDateTime, DateTime algorithmTime, BaseData baseData, FuturesChains futuresChains, ISecurityPrice security, + SubscriptionDataConfig configuration) { var symbol = baseData.Symbol; @@ -504,7 +506,7 @@ private bool HandleFuturesData(DateTime algorithmTime, BaseData baseData, Future return false; } - chain = new FuturesChain(canonical, algorithmTime); + chain = new FuturesChain(canonical, algorithmTime) { ExchangeTime = utcDateTime.ConvertFromUtc(configuration.ExchangeTimeZone) }; futuresChains[canonical] = chain; } diff --git a/Tests/Common/Data/Market/OptionChainTests.cs b/Tests/Common/Data/Market/OptionChainTests.cs index 9b4f463384fb..35daf2e269bc 100644 --- a/Tests/Common/Data/Market/OptionChainTests.cs +++ b/Tests/Common/Data/Market/OptionChainTests.cs @@ -197,6 +197,28 @@ public void FiltersOnAnEmptyChainReturnAnEmptyChain() } } + [Test] + public void FiltersUseTheExchangeTimeAsTheReferenceDate() + { + // The engine stamps slice chains in the algorithm time zone, which can already be the day after the exchange date + var exchangeDate = new DateTime(2016, 3, 4); + var (data, underlying) = CreateUniverseData(exchangeDate, UnderlyingPrice, new[] { exchangeDate, Expiries[1] }, Strikes); + var chain = new OptionChain(Canonical, exchangeDate, data, _symbolProperties) { Time = exchangeDate.AddDays(1) }; + var universe = CreateUniverse(data, underlying, exchangeDate).Expiration(0, 0).ToList(); + + Assert.AreEqual(2 * Strikes.Length, universe.Count); + Assert.AreEqual(0, chain.Expiration(0, 0).Count); + + chain.ExchangeTime = exchangeDate; + var filtered = chain.Expiration(0, 0); + + CollectionAssert.AreEquivalent(universe.Select(x => x.Symbol.Value), filtered.Select(x => x.Symbol.Value)); + Assert.AreEqual(chain.Time, filtered.Time); + Assert.AreEqual(exchangeDate, filtered.ExchangeTime); + CollectionAssert.AreEquivalent(universe.Where(x => x.ID.OptionRight == OptionRight.Call).Select(x => x.Symbol.Value), + chain.CallsOnly().Expiration(0, 0).Select(x => x.Symbol.Value)); + } + [Test] public void StrikesFilterIsSkippedWithoutUnderlyingPrice() { diff --git a/Tests/Engine/DataFeeds/TimeSliceTests.cs b/Tests/Engine/DataFeeds/TimeSliceTests.cs index 95ef83abccbf..faadb7e30116 100644 --- a/Tests/Engine/DataFeeds/TimeSliceTests.cs +++ b/Tests/Engine/DataFeeds/TimeSliceTests.cs @@ -178,6 +178,37 @@ public void OptionsDataHasVolume() } } + [TestCase(SecurityType.Option)] + [TestCase(SecurityType.Future)] + public void ChainExchangeTimeIsInTheExchangeTimeZone(SecurityType securityType) + { + var symbol = securityType == SecurityType.Option ? Symbols.SPY_C_192_Feb19_2016 : Symbols.Fut_SPY_Mar19_2016; + var config = new SubscriptionDataConfig(typeof(TradeBar), symbol, Resolution.Minute, TimeZones.NewYork, TimeZones.NewYork, true, true, false); + var security = GetSecurity(config); + // 15:00 in New York on 2016-02-18 is already 05:00 on 2016-02-19 in Tokyo + var utcTime = new DateTime(2016, 2, 18, 20, 0, 0); + var time = utcTime.ConvertFromUtc(TimeZones.NewYork); + + var packets = new List(); + if (security is Option option) + { + var underlying = option.Underlying; + packets.Add(new DataFeedPacket(underlying, underlying.SubscriptionDataConfig, + new List { new TradeBar(time, underlying.Symbol, 100, 100, 110, 106, 100) })); + } + packets.Add(new DataFeedPacket(security, config, new List { new TradeBar(time, symbol, 100, 100, 110, 106, 100) })); + + var slice = new TimeSliceFactory(TimeZones.Tokyo).Create(utcTime, packets, + SecurityChangesTests.CreateNonInternal(Enumerable.Empty(), Enumerable.Empty()), + new Dictionary()).Slice; + var (chainTime, exchangeTime) = securityType == SecurityType.Option + ? (slice.OptionChains.Values.Single().Time, slice.OptionChains.Values.Single().ExchangeTime) + : (slice.FutureChains.Values.Single().Time, slice.FutureChains.Values.Single().ExchangeTime); + + Assert.AreEqual(new DateTime(2016, 2, 19, 5, 0, 0), chainTime); + Assert.AreEqual(new DateTime(2016, 2, 18, 15, 0, 0), exchangeTime); + } + [Test] public void SuspiciousTicksAreNotAddedToConsolidatorUpdateData() { @@ -266,16 +297,16 @@ private Security GetSecurity(SubscriptionDataConfig config) if (config.Symbol.SecurityType == SecurityType.Option) { var option = new Option( - SecurityExchangeHours.AlwaysOpen(TimeZones.Utc), + SecurityExchangeHours.AlwaysOpen(config.ExchangeTimeZone), config, new Cash(Currencies.USD, 0, 1m), new OptionSymbolProperties(SymbolProperties.GetDefault(Currencies.USD)), ErrorCurrencyConverter.Instance, RegisteredSecurityDataTypesProvider.Null); var underlyingConfig = new SubscriptionDataConfig(typeof(TradeBar), config.Symbol.Underlying, Resolution.Second, - TimeZones.Utc, TimeZones.Utc, true, true, false); + config.ExchangeTimeZone, config.ExchangeTimeZone, true, true, false); var equity = new Equity( - SecurityExchangeHours.AlwaysOpen(TimeZones.Utc), + SecurityExchangeHours.AlwaysOpen(config.ExchangeTimeZone), underlyingConfig, new Cash(Currencies.USD, 0, 1m), SymbolProperties.GetDefault(Currencies.USD), @@ -289,7 +320,7 @@ private Security GetSecurity(SubscriptionDataConfig config) if (config.Symbol.SecurityType == SecurityType.Future) { return new Future( - SecurityExchangeHours.AlwaysOpen(TimeZones.Utc), + SecurityExchangeHours.AlwaysOpen(config.ExchangeTimeZone), config, new Cash(Currencies.USD, 0, 1m), SymbolProperties.GetDefault(Currencies.USD), @@ -298,7 +329,7 @@ private Security GetSecurity(SubscriptionDataConfig config) } return new Security( - SecurityExchangeHours.AlwaysOpen(TimeZones.Utc), + SecurityExchangeHours.AlwaysOpen(config.ExchangeTimeZone), config, new Cash(Currencies.USD, 0, 1m), SymbolProperties.GetDefault(Currencies.USD), From b38596888da2fe19e39be99952edd91459192d44 Mon Sep 17 00:00:00 2001 From: Jhonathan Abreu Date: Tue, 8 Sep 2026 16:42:36 -0400 Subject: [PATCH 11/17] Count expirations on the listed expiration date The expiration filters and the strategy pickers compare the contract's listed date again, as before the shared engine. Counting Saturday and holiday expiries on their last trading day moves to its own change. --- .../ContractSecurityFilterUniverse.cs | 16 +------ .../Securities/Option/OptionFilterUniverse.cs | 42 +----------------- Tests/Common/Data/Market/OptionChainTests.cs | 43 ------------------- Tests/Common/Securities/OptionFilterTests.cs | 5 +-- 4 files changed, 3 insertions(+), 103 deletions(-) diff --git a/Common/Securities/ContractSecurityFilterUniverse.cs b/Common/Securities/ContractSecurityFilterUniverse.cs index 15cbec59c59f..247c5f3d0687 100644 --- a/Common/Securities/ContractSecurityFilterUniverse.cs +++ b/Common/Securities/ContractSecurityFilterUniverse.cs @@ -294,16 +294,6 @@ protected virtual DateTime AdjustExpirationReferenceDate(DateTime referenceDate) return referenceDate; } - /// - /// Gets the date the given contract stops trading, used by the expiration filters. Defaults to the contract expiration date - /// - /// The contract - /// The contract's last trading date - protected virtual DateTime GetLastTradingDate(TData contract) - { - return contract.ID.Date.Date; - } - /// /// Applies filter selecting options contracts based on a range of expiration dates relative to the current day /// @@ -327,11 +317,7 @@ public virtual T Expiration(TimeSpan minExpiry, TimeSpan maxExpiry) var maxExpiryToDate = referenceDate + maxExpiry; Data = Data - .Where(contract => - { - var expiry = GetLastTradingDate(contract); - return expiry >= minExpiryToDate && expiry <= maxExpiryToDate; - }) + .Where(symbol => symbol.ID.Date.Date >= minExpiryToDate && symbol.ID.Date.Date <= maxExpiryToDate) .ToList(); return (T)this; diff --git a/Common/Securities/Option/OptionFilterUniverse.cs b/Common/Securities/Option/OptionFilterUniverse.cs index 446aef4b1d73..f6acb1ba47ae 100644 --- a/Common/Securities/Option/OptionFilterUniverse.cs +++ b/Common/Securities/Option/OptionFilterUniverse.cs @@ -42,7 +42,6 @@ public abstract class BaseOptionFilterUniverse : ContractSecur private bool _refreshUniqueStrikes; private DateTime _lastExchangeDate; private readonly decimal _underlyingScaleFactor = 1; - private readonly Dictionary _lastTradingDates = new(); /// /// The underlying price data @@ -146,45 +145,6 @@ protected override DateTime AdjustExpirationReferenceDate(DateTime referenceDate return referenceDate; } - /// - /// Gets the last trading date of the given contract. Expirations falling on a non trading day, like the - /// Saturday expirations of equity options before February 2015, are moved back to the previous trading day - /// - /// The contract - /// The date the contract stops trading - protected override DateTime GetLastTradingDate(TData contract) - { - return GetLastTradingDate(contract.ID.Date); - } - - /// - /// Gets the last trading date for the given expiration date. Expirations falling on a non trading day, like the - /// Saturday expirations of equity options before February 2015, are moved back to the previous trading day - /// - /// The contract expiration date - /// The date the contract stops trading - protected DateTime GetLastTradingDate(DateTime expiry) - { - var date = expiry.Date; - if (ExchangeHours == null) - { - return date; - } - - if (!_lastTradingDates.TryGetValue(date, out var lastTradingDate)) - { - lastTradingDate = date; - // bounded so a closed exchange calendar can't make this loop forever - for (var i = 0; i < 7 && !ExchangeHours.IsDateOpen(lastTradingDate); i++) - { - lastTradingDate = lastTradingDate.AddDays(-1); - } - _lastTradingDates[date] = lastTradingDate; - } - - return lastTradingDate; - } - /// /// Applies filter selecting options contracts based on a range of strikes in relative terms /// @@ -1103,7 +1063,7 @@ private TUniverse Ladder(OptionRight right, int minDaysTillExpiry, decimal highe private IEnumerable GetContractsForExpiry(IEnumerable symbols, int minDaysTillExpiry) { var leastExpiryAccepted = _lastExchangeDate.AddDays(minDaysTillExpiry); - return symbols.Where(x => GetLastTradingDate(x.ID.Date) >= leastExpiryAccepted) + return symbols.Where(x => x.ID.Date >= leastExpiryAccepted) .GroupBy(x => x.ID.Date) .OrderBy(x => x.Key) .FirstOrDefault() diff --git a/Tests/Common/Data/Market/OptionChainTests.cs b/Tests/Common/Data/Market/OptionChainTests.cs index 35daf2e269bc..f117c4dbd962 100644 --- a/Tests/Common/Data/Market/OptionChainTests.cs +++ b/Tests/Common/Data/Market/OptionChainTests.cs @@ -15,7 +15,6 @@ using System; using System.Collections.Generic; -using System.Globalization; using System.IO; using System.Linq; using System.Reflection; @@ -229,42 +228,6 @@ public void StrikesFilterIsSkippedWithoutUnderlyingPrice() Assert.AreEqual(chain.Count, chain.Strikes(0, 0).Count); } - // Before February 2015 equity options expired on Saturdays, the day after their last trading date. - // Days to expiration are counted on the last trading date, so 2012-02-18 is 0 days out on Friday 2012-02-17, - // and the Saturday after Good Friday 2012-04-06 is 0 days out on Thursday 2012-04-05 - [TestCase("2012-02-17", 0, 0, "2012-02-18")] - [TestCase("2012-02-17", 1, 40, "2012-03-17")] - [TestCase("2012-04-05", 0, 0, "2012-04-07")] - [TestCase("2012-04-05", 1, 60, "2012-05-19")] - public void ExpirationFilterCountsSaturdayExpiriesOnTheirLastTradingDate(string date, int minDays, int maxDays, string expectedExpiry) - { - var (data, underlying) = CreateSaturdayExpiriesData(date); - var expected = DateTime.ParseExact(expectedExpiry, "yyyy-MM-dd", CultureInfo.InvariantCulture); - - var universe = CreateUniverse(data, underlying, underlying.Time).Expiration(minDays, maxDays).ToList(); - var chain = new OptionChain(Canonical, underlying.Time, data, _symbolProperties).Expiration(minDays, maxDays).ToList(); - - Assert.AreEqual(2 * Strikes.Length, universe.Count); - Assert.IsTrue(universe.All(x => x.ID.Date == expected)); - CollectionAssert.AreEquivalent(universe.Select(x => x.Symbol.Value), chain.Select(x => x.Symbol.Value)); - } - - [TestCase("2012-02-17", 0, "2012-02-18")] - [TestCase("2012-02-17", 1, "2012-03-17")] - [TestCase("2012-04-05", 0, "2012-04-07")] - [TestCase("2012-04-05", 1, "2012-05-19")] - public void StrategyFiltersCountSaturdayExpiriesOnTheirLastTradingDate(string date, int minDaysTillExpiry, string expectedExpiry) - { - var (data, underlying) = CreateSaturdayExpiriesData(date); - var expected = DateTime.ParseExact(expectedExpiry, "yyyy-MM-dd", CultureInfo.InvariantCulture); - - var selected = CreateUniverse(data, underlying, underlying.Time).NakedCall(minDaysTillExpiry, 0).ToList(); - - Assert.AreEqual(1, selected.Count); - Assert.AreEqual(expected, selected[0].ID.Date); - Assert.AreEqual(100m, selected[0].ID.StrikePrice); - } - [Test] public void FiltersAreAvailableFromPython() { @@ -409,12 +372,6 @@ private OptionChain CreateChain() return new OptionChain(Canonical, Date, _data, _symbolProperties); } - private (List, BaseData) CreateSaturdayExpiriesData(string date) - { - var expiries = new[] { new DateTime(2012, 2, 18), new DateTime(2012, 3, 17), new DateTime(2012, 4, 7), new DateTime(2012, 5, 19) }; - return CreateUniverseData(DateTime.ParseExact(date, "yyyy-MM-dd", CultureInfo.InvariantCulture), 100m, expiries, Strikes); - } - private static Option CreateOption() { var exchangeHours = MarketHoursDatabase.FromDataFolder().GetExchangeHours(Canonical.ID.Market, Canonical, Canonical.SecurityType); diff --git a/Tests/Common/Securities/OptionFilterTests.cs b/Tests/Common/Securities/OptionFilterTests.cs index d0b5549691e8..8e89f0b55fcd 100644 --- a/Tests/Common/Securities/OptionFilterTests.cs +++ b/Tests/Common/Securities/OptionFilterTests.cs @@ -318,15 +318,12 @@ public void FiltersExpiryRange() var data = symbols.Select(x => new OptionUniverse() { Symbol = x }); var filterUniverse = new OptionFilterUniverse(option, data.ToList(), underlying); var filtered = filter.Filter(filterUniverse).ToList(); - // 2016-02-26 is a Friday: the weekend expiries 8 and 9 days out count on their last trading date, Friday 7 days out - Assert.AreEqual(7, filtered.Count); + Assert.AreEqual(5, filtered.Count); Assert.AreEqual(symbols[3], filtered[0].Symbol); Assert.AreEqual(symbols[4], filtered[1].Symbol); Assert.AreEqual(symbols[5], filtered[2].Symbol); Assert.AreEqual(symbols[6], filtered[3].Symbol); Assert.AreEqual(symbols[7], filtered[4].Symbol); - Assert.AreEqual(symbols[8], filtered[5].Symbol); - Assert.AreEqual(symbols[9], filtered[6].Symbol); } [Test] From 66a958615b90362e99171281459b09be4e2f0754 Mon Sep 17 00:00:00 2001 From: Jhonathan Abreu Date: Tue, 8 Sep 2026 17:53:44 -0400 Subject: [PATCH 12/17] Drop the contract data interfaces from the shared option filters The contract filter base only needs ISymbolProvider, and the option base asks its subclasses for greeks, implied volatility and open interest through three abstract accessors, so the universe data and the chain contracts implement nothing new. ApplyTypesFilter skips its pass when every contract type is accepted. --- Common/Data/Market/BaseContract.cs | 3 +- Common/Data/Market/OptionContract.cs | 2 +- .../Data/UniverseSelection/OptionUniverse.cs | 3 +- .../ContractSecurityFilterUniverse.cs | 22 +++-- Common/Securities/IChainContractData.cs | 31 ------ Common/Securities/IChainUniverseData.cs | 6 +- .../IDerivativeSecurityFilterUniverse.cs | 3 +- .../Securities/Option/IOptionContractData.cs | 41 -------- .../Option/OptionChainFilterUniverse.cs | 15 +++ .../Securities/Option/OptionFilterUniverse.cs | 95 ++++++++++++++----- 10 files changed, 109 insertions(+), 112 deletions(-) delete mode 100644 Common/Securities/IChainContractData.cs delete mode 100644 Common/Securities/Option/IOptionContractData.cs diff --git a/Common/Data/Market/BaseContract.cs b/Common/Data/Market/BaseContract.cs index bc79859f6d70..19110435d8f0 100644 --- a/Common/Data/Market/BaseContract.cs +++ b/Common/Data/Market/BaseContract.cs @@ -14,7 +14,6 @@ */ using QuantConnect.Python; -using QuantConnect.Securities; using System; namespace QuantConnect.Data.Market @@ -22,7 +21,7 @@ namespace QuantConnect.Data.Market /// /// Defines a base for a single contract, like an option or future contract /// - public abstract class BaseContract : IChainContractData + public abstract class BaseContract : ISymbolProvider { /// /// Gets the contract's symbol diff --git a/Common/Data/Market/OptionContract.cs b/Common/Data/Market/OptionContract.cs index d07e3dacd04d..4006c4c573aa 100644 --- a/Common/Data/Market/OptionContract.cs +++ b/Common/Data/Market/OptionContract.cs @@ -24,7 +24,7 @@ namespace QuantConnect.Data.Market /// /// Defines a single option contract at a specific expiration and strike price /// - public class OptionContract : BaseContract, IOptionContractData + public class OptionContract : BaseContract { private IOptionData _optionData = OptionPriceModelResultData.Null; private readonly SymbolProperties _symbolProperties; diff --git a/Common/Data/UniverseSelection/OptionUniverse.cs b/Common/Data/UniverseSelection/OptionUniverse.cs index 7cd98dc2049e..b8909c81d705 100644 --- a/Common/Data/UniverseSelection/OptionUniverse.cs +++ b/Common/Data/UniverseSelection/OptionUniverse.cs @@ -18,7 +18,6 @@ using System.IO; using System.Runtime.CompilerServices; using QuantConnect.Data.Market; -using QuantConnect.Securities; using QuantConnect.Python; using QuantConnect.Util; @@ -27,7 +26,7 @@ namespace QuantConnect.Data.UniverseSelection /// /// Represents a universe of options data /// - public class OptionUniverse : BaseChainUniverseData, IOptionContractData + public class OptionUniverse : BaseChainUniverseData { /// /// Cache for the symbols to avoid creating them multiple times diff --git a/Common/Securities/ContractSecurityFilterUniverse.cs b/Common/Securities/ContractSecurityFilterUniverse.cs index 247c5f3d0687..ec1be57ef2f4 100644 --- a/Common/Securities/ContractSecurityFilterUniverse.cs +++ b/Common/Securities/ContractSecurityFilterUniverse.cs @@ -19,6 +19,7 @@ using Python.Runtime; using System.Collections; using System.Collections.Generic; +using QuantConnect.Data; namespace QuantConnect.Securities { @@ -28,7 +29,7 @@ namespace QuantConnect.Securities /// public abstract class ContractSecurityFilterUniverse : IDerivativeSecurityFilterUniverse where T : ContractSecurityFilterUniverse - where TData : IChainContractData + where TData : ISymbolProvider { private bool _alreadyAppliedTypeFilters; @@ -153,12 +154,19 @@ internal T ApplyTypesFilter() return (T)this; } + // Every contract passes by default, so skip the pass and only pin the ordering rule for StandardsOnly() + if (Type == DefaultExpirationType) + { + _alreadyAppliedTypeFilters = true; + return (T)this; + } + // memoization map for ApplyTypesFilter() var memoizedMap = new Dictionary(); Func memoizedIsStandardType = data => { - var dt = data.ID.Date; + var dt = data.Symbol.ID.Date; bool result; if (memoizedMap.TryGetValue(dt, out result)) @@ -252,9 +260,9 @@ public T WeeklysOnly() public virtual T FrontMonth() { ApplyTypesFilter(); - var ordered = Data.OrderBy(x => x.ID.Date).ToList(); + var ordered = Data.OrderBy(x => x.Symbol.ID.Date).ToList(); if (ordered.Count == 0) return (T)this; - var frontMonth = ordered.TakeWhile(x => ordered[0].ID.Date == x.ID.Date); + var frontMonth = ordered.TakeWhile(x => ordered[0].Symbol.ID.Date == x.Symbol.ID.Date); Data = frontMonth.ToList(); return (T)this; @@ -267,9 +275,9 @@ public virtual T FrontMonth() public virtual T BackMonths() { ApplyTypesFilter(); - var ordered = Data.OrderBy(x => x.ID.Date).ToList(); + var ordered = Data.OrderBy(x => x.Symbol.ID.Date).ToList(); if (ordered.Count == 0) return (T)this; - var backMonths = ordered.SkipWhile(x => ordered[0].ID.Date == x.ID.Date); + var backMonths = ordered.SkipWhile(x => ordered[0].Symbol.ID.Date == x.Symbol.ID.Date); Data = backMonths.ToList(); return (T)this; @@ -317,7 +325,7 @@ public virtual T Expiration(TimeSpan minExpiry, TimeSpan maxExpiry) var maxExpiryToDate = referenceDate + maxExpiry; Data = Data - .Where(symbol => symbol.ID.Date.Date >= minExpiryToDate && symbol.ID.Date.Date <= maxExpiryToDate) + .Where(data => data.Symbol.ID.Date.Date >= minExpiryToDate && data.Symbol.ID.Date.Date <= maxExpiryToDate) .ToList(); return (T)this; diff --git a/Common/Securities/IChainContractData.cs b/Common/Securities/IChainContractData.cs deleted file mode 100644 index 83adc08f5819..000000000000 --- a/Common/Securities/IChainContractData.cs +++ /dev/null @@ -1,31 +0,0 @@ -/* - * 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 QuantConnect.Data; - -namespace QuantConnect.Securities -{ - /// - /// The minimal contract data the contract filter universes work with, - /// implemented by both universe selection data and chain contracts - /// - public interface IChainContractData : ISymbolProvider - { - /// - /// The security identifier of the contract - /// - SecurityIdentifier ID { get; } - } -} diff --git a/Common/Securities/IChainUniverseData.cs b/Common/Securities/IChainUniverseData.cs index cf415788e0eb..cba27e3268e3 100644 --- a/Common/Securities/IChainUniverseData.cs +++ b/Common/Securities/IChainUniverseData.cs @@ -21,7 +21,11 @@ namespace QuantConnect.Securities /// /// Base interface intended for chain universe data to have some of their symbol properties accessible directly. /// - public interface IChainUniverseData : IBaseData, IChainContractData + public interface IChainUniverseData : IBaseData { + /// + /// Gets the security identifier. + /// + SecurityIdentifier ID { get; } } } diff --git a/Common/Securities/IDerivativeSecurityFilterUniverse.cs b/Common/Securities/IDerivativeSecurityFilterUniverse.cs index 12606c8ae29c..32f28d178d14 100644 --- a/Common/Securities/IDerivativeSecurityFilterUniverse.cs +++ b/Common/Securities/IDerivativeSecurityFilterUniverse.cs @@ -15,6 +15,7 @@ */ using System.Collections.Generic; +using QuantConnect.Data; namespace QuantConnect.Securities { @@ -22,7 +23,7 @@ namespace QuantConnect.Securities /// Represents derivative symbols universe used in filtering. /// public interface IDerivativeSecurityFilterUniverse : IEnumerable - where T : IChainContractData + where T : ISymbolProvider { /// /// The number of contracts in the universe diff --git a/Common/Securities/Option/IOptionContractData.cs b/Common/Securities/Option/IOptionContractData.cs deleted file mode 100644 index 38c9eb1427bf..000000000000 --- a/Common/Securities/Option/IOptionContractData.cs +++ /dev/null @@ -1,41 +0,0 @@ -/* - * 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 QuantConnect.Data.Market; - -namespace QuantConnect.Securities -{ - /// - /// The option contract data the option filters work with, - /// implemented by both option universe selection data and option chain contracts - /// - public interface IOptionContractData : IChainContractData - { - /// - /// The greeks of the contract - /// - Greeks Greeks { get; } - - /// - /// The implied volatility of the contract - /// - decimal ImpliedVolatility { get; } - - /// - /// The open interest of the contract - /// - decimal OpenInterest { get; } - } -} diff --git a/Common/Securities/Option/OptionChainFilterUniverse.cs b/Common/Securities/Option/OptionChainFilterUniverse.cs index 0b78a498da91..37d68d575813 100644 --- a/Common/Securities/Option/OptionChainFilterUniverse.cs +++ b/Common/Securities/Option/OptionChainFilterUniverse.cs @@ -59,6 +59,21 @@ protected override OptionContract CreateDataInstance(Symbol symbol) throw new InvalidOperationException($"OptionChainFilterUniverse.CreateDataInstance(): {symbol} is not part of the chain"); } + /// + /// Gets the greeks of the given contract + /// + protected override Greeks GetGreeks(OptionContract contract) => contract.Greeks; + + /// + /// Gets the implied volatility of the given contract + /// + protected override decimal GetImpliedVolatility(OptionContract contract) => contract.ImpliedVolatility; + + /// + /// Gets the open interest of the given contract + /// + protected override decimal GetOpenInterest(OptionContract contract) => contract.OpenInterest; + private static IReadOnlyList GetContracts(OptionChain chain) { // The dictionary caches its values as a list that is replaced, never mutated, so it is safe to share diff --git a/Common/Securities/Option/OptionFilterUniverse.cs b/Common/Securities/Option/OptionFilterUniverse.cs index f6acb1ba47ae..f7050a692203 100644 --- a/Common/Securities/Option/OptionFilterUniverse.cs +++ b/Common/Securities/Option/OptionFilterUniverse.cs @@ -20,6 +20,7 @@ using System.Runtime.CompilerServices; using Python.Runtime; using QuantConnect.Data; +using QuantConnect.Data.Market; using QuantConnect.Data.UniverseSelection; using QuantConnect.Securities.FutureOption; using QuantConnect.Securities.IndexOption; @@ -35,7 +36,7 @@ namespace QuantConnect.Securities /// The option contract data type public abstract class BaseOptionFilterUniverse : ContractSecurityFilterUniverse, IOptionContractFilters where TUniverse : BaseOptionFilterUniverse - where TData : IOptionContractData + where TData : ISymbolProvider { // Fields used in relative strikes filter private List _uniqueStrikes; @@ -58,6 +59,21 @@ public abstract class BaseOptionFilterUniverse : ContractSecur /// protected abstract SecurityType SecurityType { get; } + /// + /// Gets the greeks of the given contract + /// + protected abstract Greeks GetGreeks(TData contract); + + /// + /// Gets the implied volatility of the given contract + /// + protected abstract decimal GetImpliedVolatility(TData contract); + + /// + /// Gets the open interest of the given contract + /// + protected abstract decimal GetOpenInterest(TData contract); + /// /// The underlying price data /// @@ -237,7 +253,7 @@ public TUniverse Strikes(int minStrike, int maxStrike) Data = Data .Where(data => { - var price = data.ID.StrikePrice; + var price = data.Symbol.ID.StrikePrice; return price >= minPrice && price <= maxPrice; } ).ToList(); @@ -493,9 +509,9 @@ public TUniverse ProtectiveCollar(int minDaysTillExpiry = 30, decimal callStrike var filtered = CallPutSpread(minDaysTillExpiry, callStrikeFromAtm, putStrikeFromAtm); - var call = filtered.SingleOrDefault(x => x.ID.OptionRight == OptionRight.Call); - var put = filtered.SingleOrDefault(x => x.ID.OptionRight == OptionRight.Put); - if (call == null || put == null || call.ID.StrikePrice <= put.ID.StrikePrice) + var call = filtered.SingleOrDefault(x => x.Symbol.ID.OptionRight == OptionRight.Call); + var put = filtered.SingleOrDefault(x => x.Symbol.ID.OptionRight == OptionRight.Put); + if (call == null || put == null || call.Symbol.ID.StrikePrice <= put.Symbol.ID.StrikePrice) { return Empty(); } @@ -604,8 +620,8 @@ private TUniverse Butterfly(OptionRight right, int minDaysTillExpiry, decimal st // Select the contracts var filtered = Contracts(data => data.Where(x => - x.ID.Date == contracts[0].ID.Date && x.ID.OptionRight == right && - (x.ID.StrikePrice == atmStrike || x.ID.StrikePrice == lowerStrike || x.ID.StrikePrice == upperStrike))); + x.Symbol.ID.Date == contracts[0].ID.Date && x.Symbol.ID.OptionRight == right && + (x.Symbol.ID.StrikePrice == atmStrike || x.Symbol.ID.StrikePrice == lowerStrike || x.Symbol.ID.StrikePrice == upperStrike))); if (filtered.Count() != 3) { return Empty(); @@ -652,10 +668,10 @@ public TUniverse IronButterfly(int minDaysTillExpiry = 30, decimal strikeSpread } var filtered = Contracts(data => data.Where(x => - x.ID.Date == contracts[0].ID.Date && ( - x.ID.StrikePrice == atmStrike || - (x.ID.OptionRight == OptionRight.Call && x.ID.StrikePrice == otmCallStrike) || - (x.ID.OptionRight == OptionRight.Put && x.ID.StrikePrice == otmPutStrike) + x.Symbol.ID.Date == contracts[0].ID.Date && ( + x.Symbol.ID.StrikePrice == atmStrike || + (x.Symbol.ID.OptionRight == OptionRight.Call && x.Symbol.ID.StrikePrice == otmCallStrike) || + (x.Symbol.ID.OptionRight == OptionRight.Put && x.Symbol.ID.StrikePrice == otmPutStrike) ))); if (filtered.Count() != 4) { @@ -714,11 +730,11 @@ public TUniverse IronCondor(int minDaysTillExpiry = 30, decimal nearStrikeSpread // Select the contracts var filtered = Contracts(data => data.Where(x => - x.ID.Date == contracts[0].ID.Date && ( - (x.ID.OptionRight == OptionRight.Call && x.ID.StrikePrice == nearCallStrike) || - (x.ID.OptionRight == OptionRight.Put && x.ID.StrikePrice == nearPutStrike) || - (x.ID.OptionRight == OptionRight.Call && x.ID.StrikePrice == farCallStrike) || - (x.ID.OptionRight == OptionRight.Put && x.ID.StrikePrice == farPutStrike) + x.Symbol.ID.Date == contracts[0].ID.Date && ( + (x.Symbol.ID.OptionRight == OptionRight.Call && x.Symbol.ID.StrikePrice == nearCallStrike) || + (x.Symbol.ID.OptionRight == OptionRight.Put && x.Symbol.ID.StrikePrice == nearPutStrike) || + (x.Symbol.ID.OptionRight == OptionRight.Call && x.Symbol.ID.StrikePrice == farCallStrike) || + (x.Symbol.ID.OptionRight == OptionRight.Put && x.Symbol.ID.StrikePrice == farPutStrike) ))); if (filtered.Count() != 4) { @@ -760,8 +776,8 @@ public TUniverse BoxSpread(int minDaysTillExpiry = 30, decimal strikeSpread = 5) // Select the contracts var filtered = Contracts(data => data.Where(x => - (x.ID.StrikePrice == higherStrike || x.ID.StrikePrice == lowerStrike) && - x.ID.Date == contracts[0].ID.Date)); + (x.Symbol.ID.StrikePrice == higherStrike || x.Symbol.ID.StrikePrice == lowerStrike) && + x.Symbol.ID.Date == contracts[0].ID.Date)); if (filtered.Count() != 4) { return Empty(); @@ -816,7 +832,7 @@ public TUniverse JellyRoll(decimal strikeFromAtm = 0, int minNearDaysTillExpiry } var farExpiry = farExpiryContract.ID.Date; - var filtered = Contracts(data => data.Where(x => x.ID.StrikePrice == strike && (x.ID.Date == nearExpiry || x.ID.Date == farExpiry))); + var filtered = Contracts(data => data.Where(x => x.Symbol.ID.StrikePrice == strike && (x.Symbol.ID.Date == nearExpiry || x.Symbol.ID.Date == farExpiry))); if (filtered.Count() != 4) { return Empty(); @@ -861,7 +877,7 @@ public TUniverse PutLadder(int minDaysTillExpiry, decimal higherStrikeFromAtm, d public TUniverse Delta(decimal min, decimal max) { ValidateSecurityTypeForSupportedFilters(nameof(Delta)); - return Contracts(data => data.Where(contractData => contractData.Greeks.Delta >= min && contractData.Greeks.Delta <= max)); + return InRange(contract => GetGreeks(contract).Delta, min, max); } /// @@ -885,7 +901,7 @@ public TUniverse D(decimal min, decimal max) public TUniverse Gamma(decimal min, decimal max) { ValidateSecurityTypeForSupportedFilters(nameof(Gamma)); - return Contracts(data => data.Where(contractData => contractData.Greeks.Gamma >= min && contractData.Greeks.Gamma <= max)); + return InRange(contract => GetGreeks(contract).Gamma, min, max); } /// @@ -909,7 +925,7 @@ public TUniverse G(decimal min, decimal max) public TUniverse Theta(decimal min, decimal max) { ValidateSecurityTypeForSupportedFilters(nameof(Theta)); - return Contracts(data => data.Where(contractData => contractData.Greeks.Theta >= min && contractData.Greeks.Theta <= max)); + return InRange(contract => GetGreeks(contract).Theta, min, max); } /// @@ -933,7 +949,7 @@ public TUniverse T(decimal min, decimal max) public TUniverse Vega(decimal min, decimal max) { ValidateSecurityTypeForSupportedFilters(nameof(Vega)); - return Contracts(data => data.Where(contractData => contractData.Greeks.Vega >= min && contractData.Greeks.Vega <= max)); + return InRange(contract => GetGreeks(contract).Vega, min, max); } /// @@ -957,7 +973,7 @@ public TUniverse V(decimal min, decimal max) public TUniverse Rho(decimal min, decimal max) { ValidateSecurityTypeForSupportedFilters(nameof(Rho)); - return Contracts(data => data.Where(contractData => contractData.Greeks.Rho >= min && contractData.Greeks.Rho <= max)); + return InRange(contract => GetGreeks(contract).Rho, min, max); } /// @@ -981,7 +997,7 @@ public TUniverse R(decimal min, decimal max) public TUniverse ImpliedVolatility(decimal min, decimal max) { ValidateSecurityTypeForSupportedFilters(nameof(ImpliedVolatility)); - return Contracts(data => data.Where(contractData => contractData.ImpliedVolatility >= min && contractData.ImpliedVolatility <= max)); + return InRange(GetImpliedVolatility, min, max); } /// @@ -1005,7 +1021,7 @@ public TUniverse IV(decimal min, decimal max) public TUniverse OpenInterest(long min, long max) { ValidateSecurityTypeForSupportedFilters(nameof(OpenInterest)); - return Contracts(data => data.Where(contractData => contractData.OpenInterest >= min && contractData.OpenInterest <= max)); + return InRange(GetOpenInterest, min, max); } /// @@ -1071,6 +1087,18 @@ private IEnumerable GetContractsForExpiry(IEnumerable symbols, i ?.OrderBy(x => x.ID) ?? Enumerable.Empty(); } + /// + /// Selects the contracts whose value, given by the selector, is within the given range. The selector runs once per contract + /// + private TUniverse InRange(Func selector, decimal min, decimal max) + { + return Contracts(data => data.Where(contract => + { + var value = selector(contract); + return value >= min && value <= max; + })); + } + /// /// Helper method that will select no contract /// @@ -1158,6 +1186,21 @@ protected override OptionUniverse CreateDataInstance(Symbol symbol) }; } + /// + /// Gets the greeks of the given contract + /// + protected override Greeks GetGreeks(OptionUniverse contract) => contract.Greeks; + + /// + /// Gets the implied volatility of the given contract + /// + protected override decimal GetImpliedVolatility(OptionUniverse contract) => contract.ImpliedVolatility; + + /// + /// Gets the open interest of the given contract + /// + protected override decimal GetOpenInterest(OptionUniverse contract) => contract.OpenInterest; + /// /// Implicitly convert the universe to a list of symbols /// From 7394ca5ea605148a9ac470ed598a7c6e2a77a888 Mon Sep 17 00:00:00 2001 From: Jhonathan Abreu Date: Tue, 8 Sep 2026 18:54:09 -0400 Subject: [PATCH 13/17] Keep the chain exchange time internal and take it from the data The slice factory stamps the chain with its data's end time, which is already in the exchange time zone, instead of converting the UTC frontier. --- Common/Data/Market/BaseChain.cs | 9 ++++----- Engine/DataFeeds/TimeSliceFactory.cs | 15 +++++++-------- Tests/Engine/DataFeeds/TimeSliceTests.cs | 4 ++-- 3 files changed, 13 insertions(+), 15 deletions(-) diff --git a/Common/Data/Market/BaseChain.cs b/Common/Data/Market/BaseChain.cs index 24c6d3b332f9..c199926efce1 100644 --- a/Common/Data/Market/BaseChain.cs +++ b/Common/Data/Market/BaseChain.cs @@ -61,14 +61,13 @@ public BaseData Underlying } /// - /// Gets the chain time in the exchange time zone, the reference date for the contract filters. - /// Defaults to , which the engine stamps in the algorithm time zone + /// The chain time in the exchange time zone, the reference date for the contract filters. Slice chains carry + /// their data's end time, chains built from universe data default to /// - [PandasIgnore] - public DateTime ExchangeTime + internal DateTime ExchangeTime { get => _exchangeTime ?? Time; - internal set => _exchangeTime = value; + set => _exchangeTime = value; } /// diff --git a/Engine/DataFeeds/TimeSliceFactory.cs b/Engine/DataFeeds/TimeSliceFactory.cs index ca4768700b53..153db02c58ba 100644 --- a/Engine/DataFeeds/TimeSliceFactory.cs +++ b/Engine/DataFeeds/TimeSliceFactory.cs @@ -282,7 +282,7 @@ public TimeSlice Create(DateTime utcDateTime, { optionChains[baseData.Symbol] = (OptionChain)baseData; } - else if (!HandleOptionData(utcDateTime, algorithmTime, baseData, optionChains, packet.Security, packet.Configuration, sliceFuture, optionUnderlyingUpdates)) + else if (!HandleOptionData(algorithmTime, baseData, optionChains, packet.Security, sliceFuture, optionUnderlyingUpdates)) { continue; } @@ -304,7 +304,7 @@ public TimeSlice Create(DateTime utcDateTime, { futuresChains[baseData.Symbol] = (FuturesChain)baseData; } - else if (!HandleFuturesData(utcDateTime, algorithmTime, baseData, futuresChains, packet.Security, packet.Configuration)) + else if (!HandleFuturesData(algorithmTime, baseData, futuresChains, packet.Security, packet.Configuration)) { continue; } @@ -419,8 +419,7 @@ private void UpdateEmptyCollections(DateTime algorithmTime) #pragma warning restore 0618 } - private bool HandleOptionData(DateTime utcDateTime, DateTime algorithmTime, BaseData baseData, OptionChains optionChains, ISecurityPrice security, - SubscriptionDataConfig configuration, Lazy sliceFuture, IReadOnlyDictionary optionUnderlyingUpdates) + private bool HandleOptionData(DateTime algorithmTime, BaseData baseData, OptionChains optionChains, ISecurityPrice security, Lazy sliceFuture, IReadOnlyDictionary optionUnderlyingUpdates) { var symbol = baseData.Symbol; @@ -428,7 +427,8 @@ private bool HandleOptionData(DateTime utcDateTime, DateTime algorithmTime, Base var canonical = symbol.Canonical; if (!optionChains.TryGetValue(canonical, out chain)) { - chain = new OptionChain(canonical, algorithmTime) { ExchangeTime = utcDateTime.ConvertFromUtc(configuration.ExchangeTimeZone) }; + // the data is already in the exchange time zone, unlike the algorithm time the chain is stamped with + chain = new OptionChain(canonical, algorithmTime) { ExchangeTime = baseData.EndTime }; optionChains[canonical] = chain; } @@ -491,8 +491,7 @@ private bool HandleOptionData(DateTime utcDateTime, DateTime algorithmTime, Base } - private bool HandleFuturesData(DateTime utcDateTime, DateTime algorithmTime, BaseData baseData, FuturesChains futuresChains, ISecurityPrice security, - SubscriptionDataConfig configuration) + private bool HandleFuturesData(DateTime algorithmTime, BaseData baseData, FuturesChains futuresChains, ISecurityPrice security, SubscriptionDataConfig configuration) { var symbol = baseData.Symbol; @@ -506,7 +505,7 @@ private bool HandleFuturesData(DateTime utcDateTime, DateTime algorithmTime, Bas return false; } - chain = new FuturesChain(canonical, algorithmTime) { ExchangeTime = utcDateTime.ConvertFromUtc(configuration.ExchangeTimeZone) }; + chain = new FuturesChain(canonical, algorithmTime) { ExchangeTime = baseData.EndTime }; futuresChains[canonical] = chain; } diff --git a/Tests/Engine/DataFeeds/TimeSliceTests.cs b/Tests/Engine/DataFeeds/TimeSliceTests.cs index faadb7e30116..66ac87a81a82 100644 --- a/Tests/Engine/DataFeeds/TimeSliceTests.cs +++ b/Tests/Engine/DataFeeds/TimeSliceTests.cs @@ -185,9 +185,9 @@ public void ChainExchangeTimeIsInTheExchangeTimeZone(SecurityType securityType) var symbol = securityType == SecurityType.Option ? Symbols.SPY_C_192_Feb19_2016 : Symbols.Fut_SPY_Mar19_2016; var config = new SubscriptionDataConfig(typeof(TradeBar), symbol, Resolution.Minute, TimeZones.NewYork, TimeZones.NewYork, true, true, false); var security = GetSecurity(config); - // 15:00 in New York on 2016-02-18 is already 05:00 on 2016-02-19 in Tokyo + // 15:00 in New York on 2016-02-18 is already 05:00 on 2016-02-19 in Tokyo; the bars end at the slice time var utcTime = new DateTime(2016, 2, 18, 20, 0, 0); - var time = utcTime.ConvertFromUtc(TimeZones.NewYork); + var time = utcTime.ConvertFromUtc(TimeZones.NewYork).AddMinutes(-1); var packets = new List(); if (security is Option option) From 529daf50339aeffc11dc7114e5fbfba060c21088 Mon Sep 17 00:00:00 2001 From: Jhonathan Abreu Date: Wed, 9 Sep 2026 13:49:13 -0400 Subject: [PATCH 14/17] Apply option chain filters lazily on first read A chain built by a filter selects its contracts when first read, and a filter applied to an unread chain continues from the universe that selects the parent's contracts, so a chain of calls runs its steps only once the result is used. The strategy filters still validate their arguments at call time. The chain filter universe reads the contracts unsorted, since the sorted view of the dictionary costs a sort per rebuild the filters do not need. --- Common/Data/Market/BaseChain.cs | 65 +++++++---- Common/Data/Market/DataDictionary.cs | 5 + Common/Data/Market/OptionChain.Filters.cs | 67 ++++++++---- Common/Data/Market/OptionChain.cs | 10 +- .../Option/OptionChainFilterUniverse.cs | 48 +++++++-- Tests/Common/Data/Market/OptionChainTests.cs | 102 ++++++++++++++++++ 6 files changed, 244 insertions(+), 53 deletions(-) diff --git a/Common/Data/Market/BaseChain.cs b/Common/Data/Market/BaseChain.cs index c199926efce1..c6a431da1808 100644 --- a/Common/Data/Market/BaseChain.cs +++ b/Common/Data/Market/BaseChain.cs @@ -34,6 +34,8 @@ public class BaseChain : BaseData, IEnumerable { private Dictionary>> _auxiliaryData; private readonly Lazy _dataframe; + private TContractsCollection _contracts; + private Func> _pendingContracts; private readonly bool _flatten; private DateTime? _exchangeTime; @@ -102,9 +104,31 @@ public QuoteBars QuoteBars /// public TContractsCollection Contracts { - get; private set; + get + { + if (_pendingContracts != null) + { + // a chain built by a filter selects its contracts on first read + var pending = _pendingContracts; + _pendingContracts = null; + foreach (var contract in pending()) + { + _contracts[contract.Symbol] = contract; + } + } + return _contracts; + } + private set + { + _contracts = value; + } } + /// + /// Whether the contracts have been selected, false while a chain built by a filter has not been read + /// + protected internal bool IsMaterialized => _pendingContracts == null; + /// /// Gets the set of symbols that passed the /// @@ -175,9 +199,27 @@ protected BaseChain(Symbol canonicalOptionSymbol, DateTime time, MarketDataType } /// - /// Initializes a new instance of the class as a copy of the specified chain + /// Initializes a new instance of the class as a copy of the specified chain, + /// sharing its contracts /// protected BaseChain(BaseChain other) + : this(other, other.Contracts) + { + } + + /// + /// Initializes a new instance of the class as a copy of the specified chain + /// whose contracts are selected on first read. The underlying, ticks, trade bars, quote bars and auxiliary data are shared with the source chain + /// + /// The chain to copy + /// Selects the contracts to keep, called once when the chain is first read + protected BaseChain(BaseChain other, Func> contracts) + : this(other, new TContractsCollection { Time = other._contracts.Time }) + { + _pendingContracts = contracts; + } + + private BaseChain(BaseChain other, TContractsCollection contracts) : this(other.DataType, other._flatten) { Symbol = other.Symbol; @@ -188,28 +230,11 @@ protected BaseChain(BaseChain other) Ticks = other.Ticks; QuoteBars = other.QuoteBars; TradeBars = other.TradeBars; - Contracts = other.Contracts; + _contracts = contracts; FilteredContracts = other.FilteredContracts; _auxiliaryData = other._auxiliaryData; } - /// - /// Initializes a new instance of the class as a copy of the specified chain - /// containing only the given subset of its contracts. The underlying, ticks, trade bars, quote bars and auxiliary data are shared with the source chain - /// - /// The chain to copy - /// The contracts to keep - protected BaseChain(BaseChain other, IEnumerable contracts) - : this(other) - { - Contracts = new(); - Contracts.Time = other.Contracts.Time; - foreach (var contract in contracts) - { - Contracts[contract.Symbol] = contract; - } - } - /// /// Gets the auxiliary data with the specified type and symbol /// diff --git a/Common/Data/Market/DataDictionary.cs b/Common/Data/Market/DataDictionary.cs index 4e4e36b36811..c514d582b92d 100644 --- a/Common/Data/Market/DataDictionary.cs +++ b/Common/Data/Market/DataDictionary.cs @@ -146,6 +146,11 @@ public override ICollection Values } } + /// + /// The values in dictionary order, for readers that do not need them sorted by symbol + /// + internal ICollection UnsortedValues => Dictionary.Values; + /// /// Gets a collection containing the keys in the dictionary /// diff --git a/Common/Data/Market/OptionChain.Filters.cs b/Common/Data/Market/OptionChain.Filters.cs index 3fdaac86b4ac..8ab686a7f8ea 100644 --- a/Common/Data/Market/OptionChain.Filters.cs +++ b/Common/Data/Market/OptionChain.Filters.cs @@ -295,7 +295,12 @@ public OptionChain OI(long min, long max) /// A new chain with the filter applied public OptionChain Where(PyObject predicate) { - return new OptionChain(this, Contracts.Values.Where(predicate.SafeAs>())); + var keep = predicate.SafeAs>(); + return Filter(universe => + { + universe.Data = universe.Data.Where(keep).ToList(); + return universe; + }); } #endregion @@ -310,7 +315,7 @@ public OptionChain Where(PyObject predicate) /// A new chain with the selected contracts, empty if there is no match public OptionChain NakedCall(int minDaysTillExpiry = 30, decimal strikeFromAtm = 0) { - return Filter(universe => universe.NakedCall(minDaysTillExpiry, strikeFromAtm)); + return StrategyFilter(universe => universe.NakedCall(minDaysTillExpiry, strikeFromAtm)); } /// @@ -321,7 +326,7 @@ public OptionChain NakedCall(int minDaysTillExpiry = 30, decimal strikeFromAtm = /// A new chain with the selected contracts, empty if there is no match public OptionChain NakedPut(int minDaysTillExpiry = 30, decimal strikeFromAtm = 0) { - return Filter(universe => universe.NakedPut(minDaysTillExpiry, strikeFromAtm)); + return StrategyFilter(universe => universe.NakedPut(minDaysTillExpiry, strikeFromAtm)); } /// @@ -333,7 +338,7 @@ public OptionChain NakedPut(int minDaysTillExpiry = 30, decimal strikeFromAtm = /// A new chain with the selected contracts, empty if there is no match public OptionChain CallSpread(int minDaysTillExpiry = 30, decimal higherStrikeFromAtm = 5, decimal? lowerStrikeFromAtm = null) { - return Filter(universe => universe.CallSpread(minDaysTillExpiry, higherStrikeFromAtm, lowerStrikeFromAtm)); + return StrategyFilter(universe => universe.CallSpread(minDaysTillExpiry, higherStrikeFromAtm, lowerStrikeFromAtm)); } /// @@ -345,7 +350,7 @@ public OptionChain CallSpread(int minDaysTillExpiry = 30, decimal higherStrikeFr /// A new chain with the selected contracts, empty if there is no match public OptionChain PutSpread(int minDaysTillExpiry = 30, decimal higherStrikeFromAtm = 5, decimal? lowerStrikeFromAtm = null) { - return Filter(universe => universe.PutSpread(minDaysTillExpiry, higherStrikeFromAtm, lowerStrikeFromAtm)); + return StrategyFilter(universe => universe.PutSpread(minDaysTillExpiry, higherStrikeFromAtm, lowerStrikeFromAtm)); } /// @@ -357,7 +362,7 @@ public OptionChain PutSpread(int minDaysTillExpiry = 30, decimal higherStrikeFro /// A new chain with the selected contracts, empty if there is no match public OptionChain CallCalendarSpread(decimal strikeFromAtm = 0, int minNearDaysTillExpiry = 30, int minFarDaysTillExpiry = 60) { - return Filter(universe => universe.CallCalendarSpread(strikeFromAtm, minNearDaysTillExpiry, minFarDaysTillExpiry)); + return StrategyFilter(universe => universe.CallCalendarSpread(strikeFromAtm, minNearDaysTillExpiry, minFarDaysTillExpiry)); } /// @@ -369,7 +374,7 @@ public OptionChain CallCalendarSpread(decimal strikeFromAtm = 0, int minNearDays /// A new chain with the selected contracts, empty if there is no match public OptionChain PutCalendarSpread(decimal strikeFromAtm = 0, int minNearDaysTillExpiry = 30, int minFarDaysTillExpiry = 60) { - return Filter(universe => universe.PutCalendarSpread(strikeFromAtm, minNearDaysTillExpiry, minFarDaysTillExpiry)); + return StrategyFilter(universe => universe.PutCalendarSpread(strikeFromAtm, minNearDaysTillExpiry, minFarDaysTillExpiry)); } /// @@ -381,7 +386,7 @@ public OptionChain PutCalendarSpread(decimal strikeFromAtm = 0, int minNearDaysT /// A new chain with the selected contracts, empty if there is no match public OptionChain Strangle(int minDaysTillExpiry = 30, decimal callStrikeFromAtm = 5, decimal putStrikeFromAtm = -5) { - return Filter(universe => universe.Strangle(minDaysTillExpiry, callStrikeFromAtm, putStrikeFromAtm)); + return StrategyFilter(universe => universe.Strangle(minDaysTillExpiry, callStrikeFromAtm, putStrikeFromAtm)); } /// @@ -391,7 +396,7 @@ public OptionChain Strangle(int minDaysTillExpiry = 30, decimal callStrikeFromAt /// A new chain with the selected contracts, empty if there is no match public OptionChain Straddle(int minDaysTillExpiry = 30) { - return Filter(universe => universe.Straddle(minDaysTillExpiry)); + return StrategyFilter(universe => universe.Straddle(minDaysTillExpiry)); } /// @@ -403,7 +408,7 @@ public OptionChain Straddle(int minDaysTillExpiry = 30) /// A new chain with the selected contracts, empty if there is no match public OptionChain ProtectiveCollar(int minDaysTillExpiry = 30, decimal callStrikeFromAtm = 5, decimal putStrikeFromAtm = -5) { - return Filter(universe => universe.ProtectiveCollar(minDaysTillExpiry, callStrikeFromAtm, putStrikeFromAtm)); + return StrategyFilter(universe => universe.ProtectiveCollar(minDaysTillExpiry, callStrikeFromAtm, putStrikeFromAtm)); } /// @@ -414,7 +419,7 @@ public OptionChain ProtectiveCollar(int minDaysTillExpiry = 30, decimal callStri /// A new chain with the selected contracts, empty if there is no match public OptionChain Conversion(int minDaysTillExpiry = 30, decimal strikeFromAtm = 5) { - return Filter(universe => universe.Conversion(minDaysTillExpiry, strikeFromAtm)); + return StrategyFilter(universe => universe.Conversion(minDaysTillExpiry, strikeFromAtm)); } /// @@ -425,7 +430,7 @@ public OptionChain Conversion(int minDaysTillExpiry = 30, decimal strikeFromAtm /// A new chain with the selected contracts, empty if there is no match public OptionChain CallButterfly(int minDaysTillExpiry = 30, decimal strikeSpread = 5) { - return Filter(universe => universe.CallButterfly(minDaysTillExpiry, strikeSpread)); + return StrategyFilter(universe => universe.CallButterfly(minDaysTillExpiry, strikeSpread)); } /// @@ -436,7 +441,7 @@ public OptionChain CallButterfly(int minDaysTillExpiry = 30, decimal strikeSprea /// A new chain with the selected contracts, empty if there is no match public OptionChain PutButterfly(int minDaysTillExpiry = 30, decimal strikeSpread = 5) { - return Filter(universe => universe.PutButterfly(minDaysTillExpiry, strikeSpread)); + return StrategyFilter(universe => universe.PutButterfly(minDaysTillExpiry, strikeSpread)); } /// @@ -447,7 +452,7 @@ public OptionChain PutButterfly(int minDaysTillExpiry = 30, decimal strikeSpread /// A new chain with the selected contracts, empty if there is no match public OptionChain IronButterfly(int minDaysTillExpiry = 30, decimal strikeSpread = 5) { - return Filter(universe => universe.IronButterfly(minDaysTillExpiry, strikeSpread)); + return StrategyFilter(universe => universe.IronButterfly(minDaysTillExpiry, strikeSpread)); } /// @@ -459,7 +464,7 @@ public OptionChain IronButterfly(int minDaysTillExpiry = 30, decimal strikeSprea /// A new chain with the selected contracts, empty if there is no match public OptionChain IronCondor(int minDaysTillExpiry = 30, decimal nearStrikeSpread = 5, decimal farStrikeSpread = 10) { - return Filter(universe => universe.IronCondor(minDaysTillExpiry, nearStrikeSpread, farStrikeSpread)); + return StrategyFilter(universe => universe.IronCondor(minDaysTillExpiry, nearStrikeSpread, farStrikeSpread)); } /// @@ -470,7 +475,7 @@ public OptionChain IronCondor(int minDaysTillExpiry = 30, decimal nearStrikeSpre /// A new chain with the selected contracts, empty if there is no match public OptionChain BoxSpread(int minDaysTillExpiry = 30, decimal strikeSpread = 5) { - return Filter(universe => universe.BoxSpread(minDaysTillExpiry, strikeSpread)); + return StrategyFilter(universe => universe.BoxSpread(minDaysTillExpiry, strikeSpread)); } /// @@ -482,7 +487,7 @@ public OptionChain BoxSpread(int minDaysTillExpiry = 30, decimal strikeSpread = /// A new chain with the selected contracts, empty if there is no match public OptionChain JellyRoll(decimal strikeFromAtm = 0, int minNearDaysTillExpiry = 30, int minFarDaysTillExpiry = 60) { - return Filter(universe => universe.JellyRoll(strikeFromAtm, minNearDaysTillExpiry, minFarDaysTillExpiry)); + return StrategyFilter(universe => universe.JellyRoll(strikeFromAtm, minNearDaysTillExpiry, minFarDaysTillExpiry)); } /// @@ -495,7 +500,7 @@ public OptionChain JellyRoll(decimal strikeFromAtm = 0, int minNearDaysTillExpir /// A new chain with the selected contracts, empty if there is no match public OptionChain CallLadder(int minDaysTillExpiry, decimal higherStrikeFromAtm, decimal middleStrikeFromAtm, decimal lowerStrikeFromAtm) { - return Filter(universe => universe.CallLadder(minDaysTillExpiry, higherStrikeFromAtm, middleStrikeFromAtm, lowerStrikeFromAtm)); + return StrategyFilter(universe => universe.CallLadder(minDaysTillExpiry, higherStrikeFromAtm, middleStrikeFromAtm, lowerStrikeFromAtm)); } /// @@ -508,18 +513,34 @@ public OptionChain CallLadder(int minDaysTillExpiry, decimal higherStrikeFromAtm /// A new chain with the selected contracts, empty if there is no match public OptionChain PutLadder(int minDaysTillExpiry, decimal higherStrikeFromAtm, decimal middleStrikeFromAtm, decimal lowerStrikeFromAtm) { - return Filter(universe => universe.PutLadder(minDaysTillExpiry, higherStrikeFromAtm, middleStrikeFromAtm, lowerStrikeFromAtm)); + return StrategyFilter(universe => universe.PutLadder(minDaysTillExpiry, higherStrikeFromAtm, middleStrikeFromAtm, lowerStrikeFromAtm)); } /// - /// Applies the given universe filter to the contracts of this chain and returns the result as a new chain + /// Returns a new chain that applies the given universe filter to the contracts of this chain when first read /// /// The universe filter to apply private OptionChain Filter(Func filter) { - var universe = new OptionChainFilterUniverse(this); - // the type filters (standards/weeklys) are only applied on demand, like the universe selection does after the user filter - return new OptionChain(this, filter(universe).ApplyTypesFilter()); + var universe = new Lazy(() => + { + // an unread chain is filtered from the universe that selects its contracts, a read one from its current contracts + var source = IsMaterialized ? new OptionChainFilterUniverse(this) : new OptionChainFilterUniverse(_pendingUniverse.Value); + // the type filters (standards/weeklys) are only applied on demand, like the universe selection does after the user filter + return filter(source).ApplyTypesFilter(); + }, isThreadSafe: false); + return new OptionChain(this, universe); + } + + /// + /// Same as for the strategy filters, which validate their arguments: they are checked now, not on first read + /// + /// The universe strategy filter to apply + private OptionChain StrategyFilter(Func filter) + { + // the strategy filters check their arguments before reading any data, so an empty run throws the same exceptions + filter(new OptionChainFilterUniverse(Symbol, ExchangeTime)); + return Filter(filter); } #endregion diff --git a/Common/Data/Market/OptionChain.cs b/Common/Data/Market/OptionChain.cs index 20c1ec0a7126..844d86060cf1 100644 --- a/Common/Data/Market/OptionChain.cs +++ b/Common/Data/Market/OptionChain.cs @@ -29,6 +29,9 @@ namespace QuantConnect.Data.Market /// public partial class OptionChain : BaseChain, IOptionContractFilters { + // The filter universe selecting this chain's contracts, run on first read; null for chains not built by a filter + private readonly Lazy _pendingUniverse; + /// /// Initializes a new instance of the class /// @@ -76,11 +79,12 @@ private OptionChain(OptionChain other) /// /// Initializes a new instance of the class as a copy of the specified chain - /// containing only the given subset of its contracts + /// containing only the contracts the given filter universe selects, run on first read /// - private OptionChain(OptionChain other, IEnumerable contracts) - : base(other, contracts) + private OptionChain(OptionChain other, Lazy universe) + : base(other, () => universe.Value.Data) { + _pendingUniverse = universe; } /// diff --git a/Common/Securities/Option/OptionChainFilterUniverse.cs b/Common/Securities/Option/OptionChainFilterUniverse.cs index 37d68d575813..837646c83c79 100644 --- a/Common/Securities/Option/OptionChainFilterUniverse.cs +++ b/Common/Securities/Option/OptionChainFilterUniverse.cs @@ -28,6 +28,7 @@ namespace QuantConnect.Securities internal class OptionChainFilterUniverse : BaseOptionFilterUniverse { private readonly Symbol _symbol; + private readonly decimal _strikeMultiplier; private SecurityExchangeHours _exchangeHours; /// @@ -46,9 +47,42 @@ internal class OptionChainFilterUniverse : BaseOptionFilterUniverse /// The option chain to filter public OptionChainFilterUniverse(OptionChain chain) - : base(GetContracts(chain), GetUnderlying(chain), chain.ExchangeTime, GetStrikeMultiplier(chain)) + : this(GetContracts(chain), GetUnderlying(chain), chain.ExchangeTime, chain.Symbol) { - _symbol = chain.Symbol; + } + + /// + /// Initializes a new instance of the class over the contracts another instance selected, + /// reusing its underlying, time, strike multiplier and exchange hours + /// + /// The filter universe to continue from + public OptionChainFilterUniverse(OptionChainFilterUniverse other) + : this(other.Data, other.UnderlyingInternal, other.LocalTime, other._symbol, other._strikeMultiplier) + { + _exchangeHours = other._exchangeHours; + } + + /// + /// Initializes a new instance of the class without contracts + /// + /// The canonical option symbol + /// The current local time + public OptionChainFilterUniverse(Symbol symbol, DateTime localTime) + : this(new List(), null, localTime, symbol, 1) + { + } + + private OptionChainFilterUniverse(List contracts, BaseData underlying, DateTime localTime, Symbol symbol) + : this(contracts, underlying, localTime, symbol, GetStrikeMultiplier(contracts)) + { + } + + private OptionChainFilterUniverse(IReadOnlyList contracts, BaseData underlying, DateTime localTime, Symbol symbol, + decimal strikeMultiplier) + : base(contracts, underlying, localTime, strikeMultiplier) + { + _symbol = symbol; + _strikeMultiplier = strikeMultiplier; } /// @@ -74,10 +108,10 @@ protected override OptionContract CreateDataInstance(Symbol symbol) /// protected override decimal GetOpenInterest(OptionContract contract) => contract.OpenInterest; - private static IReadOnlyList GetContracts(OptionChain chain) + private static List GetContracts(OptionChain chain) { - // The dictionary caches its values as a list that is replaced, never mutated, so it is safe to share - return chain.Contracts.Values as IReadOnlyList ?? chain.Contracts.Values.ToList(); + // The sorted values view costs a sort per rebuild and the filters do not need the order + return new List(chain.Contracts.UnsortedValues); } private static BaseData GetUnderlying(OptionChain chain) @@ -87,9 +121,9 @@ private static BaseData GetUnderlying(OptionChain chain) return underlying != null && underlying.Price != 0 ? underlying : null; } - private static decimal GetStrikeMultiplier(OptionChain chain) + private static decimal GetStrikeMultiplier(List contracts) { - return chain.Contracts.Values.FirstOrDefault()?.SymbolProperties?.StrikeMultiplier ?? 1; + return contracts.Count > 0 ? contracts[0].SymbolProperties?.StrikeMultiplier ?? 1 : 1; } } } diff --git a/Tests/Common/Data/Market/OptionChainTests.cs b/Tests/Common/Data/Market/OptionChainTests.cs index f117c4dbd962..6ca215a3469c 100644 --- a/Tests/Common/Data/Market/OptionChainTests.cs +++ b/Tests/Common/Data/Market/OptionChainTests.cs @@ -145,6 +145,68 @@ public void ChainExposesEveryUniverseFilter() } } + [Test] + public void FiltersRunOnFirstRead() + { + var chain = CreateChain(); + var puts = chain.PutsOnly(); + var near = puts.Expiration(0, 30); + var far = puts.Expiration(31, 60); + Assert.IsTrue(chain.IsMaterialized); + Assert.IsFalse(puts.IsMaterialized); + Assert.IsFalse(near.IsMaterialized); + Assert.IsFalse(far.IsMaterialized); + + // reading a chain runs its filters, without reading the chains it was built from + var expectedNear = CreateUniverse().PutsOnly().Expiration(0, 30).Select(x => x.Symbol.Value).ToList(); + CollectionAssert.AreEquivalent(expectedNear, near.Select(x => x.Symbol.Value)); + Assert.IsTrue(near.IsMaterialized); + Assert.IsFalse(puts.IsMaterialized); + Assert.IsFalse(far.IsMaterialized); + + var expectedFar = CreateUniverse().PutsOnly().Expiration(31, 60).Select(x => x.Symbol.Value).ToList(); + CollectionAssert.AreEquivalent(expectedFar, far.Select(x => x.Symbol.Value)); + Assert.AreEqual(expectedNear.Count + expectedFar.Count, puts.Count); + Assert.IsTrue(puts.IsMaterialized); + } + + [Test] + public void UnreadChainIsReadThroughEveryMember() + { + var expected = CreateUniverse().CallsOnly().Select(x => x.Symbol).ToList(); + var chains = new[] { CreateChain(), CreateChain(), CreateChain(), CreateChain(), CreateChain() }; + + Assert.AreEqual(expected.Count, chains[0].CallsOnly().Count); + Assert.IsTrue(chains[1].CallsOnly().ContainsKey(expected[0])); + Assert.AreEqual(expected[0], chains[2].CallsOnly().Contracts[expected[0]].Symbol); + Assert.AreEqual(expected.Count, ((OptionChain)chains[3].CallsOnly().Clone()).Count); + using (Py.GIL()) + { + Assert.AreEqual(expected.Count, chains[4].CallsOnly().DataFrame.GetAttr("shape")[0].As()); + } + } + + [Test] + public void FiltersOnAFilteredChainSeeItsCurrentContracts() + { + var chain = CreateChain(); + var puts = chain.PutsOnly(); + var call = chain.CallsOnly().First(); + var put = puts.First(); + + // added, removed and replaced contracts are all picked up by the next filter + puts.Contracts[call.Symbol] = call; + Assert.AreEqual(1, puts.CallsOnly().Count); + Assert.AreEqual(puts.Count, puts.Expiration(0, 1000).Count); + + puts.Contracts.Remove(put.Symbol); + Assert.IsFalse(puts.PutsOnly().ContainsKey(put.Symbol)); + + var replacement = OptionContract.Create(_data.Single(x => x.Symbol == put.Symbol), _symbolProperties); + puts.Contracts[put.Symbol] = replacement; + Assert.AreSame(replacement, puts.PutsOnly().Single(x => x.Symbol == put.Symbol)); + } + [Test] public void FilteredChainSharesTheAuxiliaryData() { @@ -247,6 +309,9 @@ def filter_chain(chain): def where_chain(chain): return chain.where(lambda contract: contract.right == OptionRight.PUT and contract.strike > 100) + +def where_then_filter(chain): + return where_chain(chain).expiration(0, 30) "); using var pyChain = chain.ToPython(); @@ -255,6 +320,9 @@ def where_chain(chain): using var where = module.GetAttr("where_chain").Invoke(pyChain); CollectionAssert.AreEqual(expectedWhere, where.As().Select(x => x.Symbol).ToList()); + + using var whereThenFilter = module.GetAttr("where_then_filter").Invoke(pyChain); + CollectionAssert.AreEqual(expectedWhere.Where(x => x.ID.Date <= Date.AddDays(30)), whereThenFilter.As().Select(x => x.Symbol).ToList()); } } @@ -348,6 +416,40 @@ def naked_put(chain): } } + [Test, Explicit("Benchmark: reports the cost of chained filters on an index-sized chain")] + public void ChainedFiltersBenchmark() + { + var expiries = Enumerable.Range(1, 30).Select(i => Date.AddDays(7 * i)).ToArray(); + var strikes = Enumerable.Range(0, 150).Select(i => 60m + i).ToArray(); + var (data, _) = CreateUniverseData(Date, UnderlyingPrice, expiries, strikes); + var chain = new OptionChain(Canonical, Date, data, _symbolProperties); + const int iterations = 1000; + + void Report(string name, Func filter) + { + var result = filter(chain); + var best = double.MaxValue; + for (var round = 0; round < 3; round++) + { + GC.Collect(); + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + for (var i = 0; i < iterations; i++) + { + result = filter(chain); + _ = result.Count; + } + best = Math.Min(best, stopwatch.Elapsed.TotalMilliseconds / iterations); + } + TestContext.Progress.WriteLine($"{name}: {best:F3} ms per call, {result.Count} contracts"); + } + + TestContext.Progress.WriteLine($"{chain.Count} contracts"); + Report("PutsOnly", c => c.PutsOnly()); + Report("PutsOnly.Expiration", c => c.PutsOnly().Expiration(20, 40)); + Report("PutsOnly.Expiration.Strikes", c => c.PutsOnly().Expiration(20, 40).Strikes(-3, 0)); + Report("Expiration.Strikes.Delta.OI", c => c.Expiration(20, 60).Strikes(-10, 10).Delta(-0.6m, 0.6m).OpenInterest(0, 1000000)); + } + private class TestAuxData : BaseData { } From cad82a23c5ee182cf8e3e56928db5b5d84523a48 Mon Sep 17 00:00:00 2001 From: Jhonathan Abreu Date: Wed, 9 Sep 2026 14:07:13 -0400 Subject: [PATCH 15/17] Fix ambiguous Select calls in the lazy chain tests The universe's Select extension also applies to lambdas returning a string, so the expectations are built from a list. The far expiry window now covers the last expiry, which the previous binding hid. --- Tests/Common/Data/Market/OptionChainTests.cs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/Tests/Common/Data/Market/OptionChainTests.cs b/Tests/Common/Data/Market/OptionChainTests.cs index 6ca215a3469c..b286c3563e72 100644 --- a/Tests/Common/Data/Market/OptionChainTests.cs +++ b/Tests/Common/Data/Market/OptionChainTests.cs @@ -151,21 +151,22 @@ public void FiltersRunOnFirstRead() var chain = CreateChain(); var puts = chain.PutsOnly(); var near = puts.Expiration(0, 30); - var far = puts.Expiration(31, 60); + var far = puts.Expiration(31, 120); Assert.IsTrue(chain.IsMaterialized); Assert.IsFalse(puts.IsMaterialized); Assert.IsFalse(near.IsMaterialized); Assert.IsFalse(far.IsMaterialized); // reading a chain runs its filters, without reading the chains it was built from - var expectedNear = CreateUniverse().PutsOnly().Expiration(0, 30).Select(x => x.Symbol.Value).ToList(); + var expectedNear = CreateUniverse().PutsOnly().Expiration(0, 30).ToList().Select(x => x.Symbol.Value).ToList(); CollectionAssert.AreEquivalent(expectedNear, near.Select(x => x.Symbol.Value)); Assert.IsTrue(near.IsMaterialized); Assert.IsFalse(puts.IsMaterialized); Assert.IsFalse(far.IsMaterialized); - var expectedFar = CreateUniverse().PutsOnly().Expiration(31, 60).Select(x => x.Symbol.Value).ToList(); + var expectedFar = CreateUniverse().PutsOnly().Expiration(31, 120).ToList().Select(x => x.Symbol.Value).ToList(); CollectionAssert.AreEquivalent(expectedFar, far.Select(x => x.Symbol.Value)); + Assert.AreEqual(CreateUniverse().PutsOnly().Count(), puts.Count); Assert.AreEqual(expectedNear.Count + expectedFar.Count, puts.Count); Assert.IsTrue(puts.IsMaterialized); } @@ -173,7 +174,7 @@ public void FiltersRunOnFirstRead() [Test] public void UnreadChainIsReadThroughEveryMember() { - var expected = CreateUniverse().CallsOnly().Select(x => x.Symbol).ToList(); + var expected = CreateUniverse().CallsOnly().ToList().Select(x => x.Symbol).ToList(); var chains = new[] { CreateChain(), CreateChain(), CreateChain(), CreateChain(), CreateChain() }; Assert.AreEqual(expected.Count, chains[0].CallsOnly().Count); From c344fa8c317ad21a5006c0492cad8a9ce2e14e1e Mon Sep 17 00:00:00 2001 From: Jhonathan Abreu Date: Wed, 9 Sep 2026 16:18:40 -0400 Subject: [PATCH 16/17] Revert "Fix ambiguous Select calls in the lazy chain tests" This reverts commit cad82a23c5ee182cf8e3e56928db5b5d84523a48. --- Tests/Common/Data/Market/OptionChainTests.cs | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/Tests/Common/Data/Market/OptionChainTests.cs b/Tests/Common/Data/Market/OptionChainTests.cs index b286c3563e72..6ca215a3469c 100644 --- a/Tests/Common/Data/Market/OptionChainTests.cs +++ b/Tests/Common/Data/Market/OptionChainTests.cs @@ -151,22 +151,21 @@ public void FiltersRunOnFirstRead() var chain = CreateChain(); var puts = chain.PutsOnly(); var near = puts.Expiration(0, 30); - var far = puts.Expiration(31, 120); + var far = puts.Expiration(31, 60); Assert.IsTrue(chain.IsMaterialized); Assert.IsFalse(puts.IsMaterialized); Assert.IsFalse(near.IsMaterialized); Assert.IsFalse(far.IsMaterialized); // reading a chain runs its filters, without reading the chains it was built from - var expectedNear = CreateUniverse().PutsOnly().Expiration(0, 30).ToList().Select(x => x.Symbol.Value).ToList(); + var expectedNear = CreateUniverse().PutsOnly().Expiration(0, 30).Select(x => x.Symbol.Value).ToList(); CollectionAssert.AreEquivalent(expectedNear, near.Select(x => x.Symbol.Value)); Assert.IsTrue(near.IsMaterialized); Assert.IsFalse(puts.IsMaterialized); Assert.IsFalse(far.IsMaterialized); - var expectedFar = CreateUniverse().PutsOnly().Expiration(31, 120).ToList().Select(x => x.Symbol.Value).ToList(); + var expectedFar = CreateUniverse().PutsOnly().Expiration(31, 60).Select(x => x.Symbol.Value).ToList(); CollectionAssert.AreEquivalent(expectedFar, far.Select(x => x.Symbol.Value)); - Assert.AreEqual(CreateUniverse().PutsOnly().Count(), puts.Count); Assert.AreEqual(expectedNear.Count + expectedFar.Count, puts.Count); Assert.IsTrue(puts.IsMaterialized); } @@ -174,7 +173,7 @@ public void FiltersRunOnFirstRead() [Test] public void UnreadChainIsReadThroughEveryMember() { - var expected = CreateUniverse().CallsOnly().ToList().Select(x => x.Symbol).ToList(); + var expected = CreateUniverse().CallsOnly().Select(x => x.Symbol).ToList(); var chains = new[] { CreateChain(), CreateChain(), CreateChain(), CreateChain(), CreateChain() }; Assert.AreEqual(expected.Count, chains[0].CallsOnly().Count); From 6968b91fdd2f19aebede2bfa835eef12aedfde4c Mon Sep 17 00:00:00 2001 From: Jhonathan Abreu Date: Wed, 9 Sep 2026 16:18:40 -0400 Subject: [PATCH 17/17] Revert "Apply option chain filters lazily on first read" This reverts commit 529daf50339aeffc11dc7114e5fbfba060c21088. --- Common/Data/Market/BaseChain.cs | 65 ++++------- Common/Data/Market/DataDictionary.cs | 5 - Common/Data/Market/OptionChain.Filters.cs | 67 ++++-------- Common/Data/Market/OptionChain.cs | 10 +- .../Option/OptionChainFilterUniverse.cs | 48 ++------- Tests/Common/Data/Market/OptionChainTests.cs | 102 ------------------ 6 files changed, 53 insertions(+), 244 deletions(-) diff --git a/Common/Data/Market/BaseChain.cs b/Common/Data/Market/BaseChain.cs index c6a431da1808..c199926efce1 100644 --- a/Common/Data/Market/BaseChain.cs +++ b/Common/Data/Market/BaseChain.cs @@ -34,8 +34,6 @@ public class BaseChain : BaseData, IEnumerable { private Dictionary>> _auxiliaryData; private readonly Lazy _dataframe; - private TContractsCollection _contracts; - private Func> _pendingContracts; private readonly bool _flatten; private DateTime? _exchangeTime; @@ -104,31 +102,9 @@ public QuoteBars QuoteBars /// public TContractsCollection Contracts { - get - { - if (_pendingContracts != null) - { - // a chain built by a filter selects its contracts on first read - var pending = _pendingContracts; - _pendingContracts = null; - foreach (var contract in pending()) - { - _contracts[contract.Symbol] = contract; - } - } - return _contracts; - } - private set - { - _contracts = value; - } + get; private set; } - /// - /// Whether the contracts have been selected, false while a chain built by a filter has not been read - /// - protected internal bool IsMaterialized => _pendingContracts == null; - /// /// Gets the set of symbols that passed the /// @@ -198,28 +174,10 @@ protected BaseChain(Symbol canonicalOptionSymbol, DateTime time, MarketDataType Contracts.Time = time; } - /// - /// Initializes a new instance of the class as a copy of the specified chain, - /// sharing its contracts - /// - protected BaseChain(BaseChain other) - : this(other, other.Contracts) - { - } - /// /// Initializes a new instance of the class as a copy of the specified chain - /// whose contracts are selected on first read. The underlying, ticks, trade bars, quote bars and auxiliary data are shared with the source chain /// - /// The chain to copy - /// Selects the contracts to keep, called once when the chain is first read - protected BaseChain(BaseChain other, Func> contracts) - : this(other, new TContractsCollection { Time = other._contracts.Time }) - { - _pendingContracts = contracts; - } - - private BaseChain(BaseChain other, TContractsCollection contracts) + protected BaseChain(BaseChain other) : this(other.DataType, other._flatten) { Symbol = other.Symbol; @@ -230,11 +188,28 @@ private BaseChain(BaseChain other, TContractsCollection Ticks = other.Ticks; QuoteBars = other.QuoteBars; TradeBars = other.TradeBars; - _contracts = contracts; + Contracts = other.Contracts; FilteredContracts = other.FilteredContracts; _auxiliaryData = other._auxiliaryData; } + /// + /// Initializes a new instance of the class as a copy of the specified chain + /// containing only the given subset of its contracts. The underlying, ticks, trade bars, quote bars and auxiliary data are shared with the source chain + /// + /// The chain to copy + /// The contracts to keep + protected BaseChain(BaseChain other, IEnumerable contracts) + : this(other) + { + Contracts = new(); + Contracts.Time = other.Contracts.Time; + foreach (var contract in contracts) + { + Contracts[contract.Symbol] = contract; + } + } + /// /// Gets the auxiliary data with the specified type and symbol /// diff --git a/Common/Data/Market/DataDictionary.cs b/Common/Data/Market/DataDictionary.cs index c514d582b92d..4e4e36b36811 100644 --- a/Common/Data/Market/DataDictionary.cs +++ b/Common/Data/Market/DataDictionary.cs @@ -146,11 +146,6 @@ public override ICollection Values } } - /// - /// The values in dictionary order, for readers that do not need them sorted by symbol - /// - internal ICollection UnsortedValues => Dictionary.Values; - /// /// Gets a collection containing the keys in the dictionary /// diff --git a/Common/Data/Market/OptionChain.Filters.cs b/Common/Data/Market/OptionChain.Filters.cs index 8ab686a7f8ea..3fdaac86b4ac 100644 --- a/Common/Data/Market/OptionChain.Filters.cs +++ b/Common/Data/Market/OptionChain.Filters.cs @@ -295,12 +295,7 @@ public OptionChain OI(long min, long max) /// A new chain with the filter applied public OptionChain Where(PyObject predicate) { - var keep = predicate.SafeAs>(); - return Filter(universe => - { - universe.Data = universe.Data.Where(keep).ToList(); - return universe; - }); + return new OptionChain(this, Contracts.Values.Where(predicate.SafeAs>())); } #endregion @@ -315,7 +310,7 @@ public OptionChain Where(PyObject predicate) /// A new chain with the selected contracts, empty if there is no match public OptionChain NakedCall(int minDaysTillExpiry = 30, decimal strikeFromAtm = 0) { - return StrategyFilter(universe => universe.NakedCall(minDaysTillExpiry, strikeFromAtm)); + return Filter(universe => universe.NakedCall(minDaysTillExpiry, strikeFromAtm)); } /// @@ -326,7 +321,7 @@ public OptionChain NakedCall(int minDaysTillExpiry = 30, decimal strikeFromAtm = /// A new chain with the selected contracts, empty if there is no match public OptionChain NakedPut(int minDaysTillExpiry = 30, decimal strikeFromAtm = 0) { - return StrategyFilter(universe => universe.NakedPut(minDaysTillExpiry, strikeFromAtm)); + return Filter(universe => universe.NakedPut(minDaysTillExpiry, strikeFromAtm)); } /// @@ -338,7 +333,7 @@ public OptionChain NakedPut(int minDaysTillExpiry = 30, decimal strikeFromAtm = /// A new chain with the selected contracts, empty if there is no match public OptionChain CallSpread(int minDaysTillExpiry = 30, decimal higherStrikeFromAtm = 5, decimal? lowerStrikeFromAtm = null) { - return StrategyFilter(universe => universe.CallSpread(minDaysTillExpiry, higherStrikeFromAtm, lowerStrikeFromAtm)); + return Filter(universe => universe.CallSpread(minDaysTillExpiry, higherStrikeFromAtm, lowerStrikeFromAtm)); } /// @@ -350,7 +345,7 @@ public OptionChain CallSpread(int minDaysTillExpiry = 30, decimal higherStrikeFr /// A new chain with the selected contracts, empty if there is no match public OptionChain PutSpread(int minDaysTillExpiry = 30, decimal higherStrikeFromAtm = 5, decimal? lowerStrikeFromAtm = null) { - return StrategyFilter(universe => universe.PutSpread(minDaysTillExpiry, higherStrikeFromAtm, lowerStrikeFromAtm)); + return Filter(universe => universe.PutSpread(minDaysTillExpiry, higherStrikeFromAtm, lowerStrikeFromAtm)); } /// @@ -362,7 +357,7 @@ public OptionChain PutSpread(int minDaysTillExpiry = 30, decimal higherStrikeFro /// A new chain with the selected contracts, empty if there is no match public OptionChain CallCalendarSpread(decimal strikeFromAtm = 0, int minNearDaysTillExpiry = 30, int minFarDaysTillExpiry = 60) { - return StrategyFilter(universe => universe.CallCalendarSpread(strikeFromAtm, minNearDaysTillExpiry, minFarDaysTillExpiry)); + return Filter(universe => universe.CallCalendarSpread(strikeFromAtm, minNearDaysTillExpiry, minFarDaysTillExpiry)); } /// @@ -374,7 +369,7 @@ public OptionChain CallCalendarSpread(decimal strikeFromAtm = 0, int minNearDays /// A new chain with the selected contracts, empty if there is no match public OptionChain PutCalendarSpread(decimal strikeFromAtm = 0, int minNearDaysTillExpiry = 30, int minFarDaysTillExpiry = 60) { - return StrategyFilter(universe => universe.PutCalendarSpread(strikeFromAtm, minNearDaysTillExpiry, minFarDaysTillExpiry)); + return Filter(universe => universe.PutCalendarSpread(strikeFromAtm, minNearDaysTillExpiry, minFarDaysTillExpiry)); } /// @@ -386,7 +381,7 @@ public OptionChain PutCalendarSpread(decimal strikeFromAtm = 0, int minNearDaysT /// A new chain with the selected contracts, empty if there is no match public OptionChain Strangle(int minDaysTillExpiry = 30, decimal callStrikeFromAtm = 5, decimal putStrikeFromAtm = -5) { - return StrategyFilter(universe => universe.Strangle(minDaysTillExpiry, callStrikeFromAtm, putStrikeFromAtm)); + return Filter(universe => universe.Strangle(minDaysTillExpiry, callStrikeFromAtm, putStrikeFromAtm)); } /// @@ -396,7 +391,7 @@ public OptionChain Strangle(int minDaysTillExpiry = 30, decimal callStrikeFromAt /// A new chain with the selected contracts, empty if there is no match public OptionChain Straddle(int minDaysTillExpiry = 30) { - return StrategyFilter(universe => universe.Straddle(minDaysTillExpiry)); + return Filter(universe => universe.Straddle(minDaysTillExpiry)); } /// @@ -408,7 +403,7 @@ public OptionChain Straddle(int minDaysTillExpiry = 30) /// A new chain with the selected contracts, empty if there is no match public OptionChain ProtectiveCollar(int minDaysTillExpiry = 30, decimal callStrikeFromAtm = 5, decimal putStrikeFromAtm = -5) { - return StrategyFilter(universe => universe.ProtectiveCollar(minDaysTillExpiry, callStrikeFromAtm, putStrikeFromAtm)); + return Filter(universe => universe.ProtectiveCollar(minDaysTillExpiry, callStrikeFromAtm, putStrikeFromAtm)); } /// @@ -419,7 +414,7 @@ public OptionChain ProtectiveCollar(int minDaysTillExpiry = 30, decimal callStri /// A new chain with the selected contracts, empty if there is no match public OptionChain Conversion(int minDaysTillExpiry = 30, decimal strikeFromAtm = 5) { - return StrategyFilter(universe => universe.Conversion(minDaysTillExpiry, strikeFromAtm)); + return Filter(universe => universe.Conversion(minDaysTillExpiry, strikeFromAtm)); } /// @@ -430,7 +425,7 @@ public OptionChain Conversion(int minDaysTillExpiry = 30, decimal strikeFromAtm /// A new chain with the selected contracts, empty if there is no match public OptionChain CallButterfly(int minDaysTillExpiry = 30, decimal strikeSpread = 5) { - return StrategyFilter(universe => universe.CallButterfly(minDaysTillExpiry, strikeSpread)); + return Filter(universe => universe.CallButterfly(minDaysTillExpiry, strikeSpread)); } /// @@ -441,7 +436,7 @@ public OptionChain CallButterfly(int minDaysTillExpiry = 30, decimal strikeSprea /// A new chain with the selected contracts, empty if there is no match public OptionChain PutButterfly(int minDaysTillExpiry = 30, decimal strikeSpread = 5) { - return StrategyFilter(universe => universe.PutButterfly(minDaysTillExpiry, strikeSpread)); + return Filter(universe => universe.PutButterfly(minDaysTillExpiry, strikeSpread)); } /// @@ -452,7 +447,7 @@ public OptionChain PutButterfly(int minDaysTillExpiry = 30, decimal strikeSpread /// A new chain with the selected contracts, empty if there is no match public OptionChain IronButterfly(int minDaysTillExpiry = 30, decimal strikeSpread = 5) { - return StrategyFilter(universe => universe.IronButterfly(minDaysTillExpiry, strikeSpread)); + return Filter(universe => universe.IronButterfly(minDaysTillExpiry, strikeSpread)); } /// @@ -464,7 +459,7 @@ public OptionChain IronButterfly(int minDaysTillExpiry = 30, decimal strikeSprea /// A new chain with the selected contracts, empty if there is no match public OptionChain IronCondor(int minDaysTillExpiry = 30, decimal nearStrikeSpread = 5, decimal farStrikeSpread = 10) { - return StrategyFilter(universe => universe.IronCondor(minDaysTillExpiry, nearStrikeSpread, farStrikeSpread)); + return Filter(universe => universe.IronCondor(minDaysTillExpiry, nearStrikeSpread, farStrikeSpread)); } /// @@ -475,7 +470,7 @@ public OptionChain IronCondor(int minDaysTillExpiry = 30, decimal nearStrikeSpre /// A new chain with the selected contracts, empty if there is no match public OptionChain BoxSpread(int minDaysTillExpiry = 30, decimal strikeSpread = 5) { - return StrategyFilter(universe => universe.BoxSpread(minDaysTillExpiry, strikeSpread)); + return Filter(universe => universe.BoxSpread(minDaysTillExpiry, strikeSpread)); } /// @@ -487,7 +482,7 @@ public OptionChain BoxSpread(int minDaysTillExpiry = 30, decimal strikeSpread = /// A new chain with the selected contracts, empty if there is no match public OptionChain JellyRoll(decimal strikeFromAtm = 0, int minNearDaysTillExpiry = 30, int minFarDaysTillExpiry = 60) { - return StrategyFilter(universe => universe.JellyRoll(strikeFromAtm, minNearDaysTillExpiry, minFarDaysTillExpiry)); + return Filter(universe => universe.JellyRoll(strikeFromAtm, minNearDaysTillExpiry, minFarDaysTillExpiry)); } /// @@ -500,7 +495,7 @@ public OptionChain JellyRoll(decimal strikeFromAtm = 0, int minNearDaysTillExpir /// A new chain with the selected contracts, empty if there is no match public OptionChain CallLadder(int minDaysTillExpiry, decimal higherStrikeFromAtm, decimal middleStrikeFromAtm, decimal lowerStrikeFromAtm) { - return StrategyFilter(universe => universe.CallLadder(minDaysTillExpiry, higherStrikeFromAtm, middleStrikeFromAtm, lowerStrikeFromAtm)); + return Filter(universe => universe.CallLadder(minDaysTillExpiry, higherStrikeFromAtm, middleStrikeFromAtm, lowerStrikeFromAtm)); } /// @@ -513,34 +508,18 @@ public OptionChain CallLadder(int minDaysTillExpiry, decimal higherStrikeFromAtm /// A new chain with the selected contracts, empty if there is no match public OptionChain PutLadder(int minDaysTillExpiry, decimal higherStrikeFromAtm, decimal middleStrikeFromAtm, decimal lowerStrikeFromAtm) { - return StrategyFilter(universe => universe.PutLadder(minDaysTillExpiry, higherStrikeFromAtm, middleStrikeFromAtm, lowerStrikeFromAtm)); + return Filter(universe => universe.PutLadder(minDaysTillExpiry, higherStrikeFromAtm, middleStrikeFromAtm, lowerStrikeFromAtm)); } /// - /// Returns a new chain that applies the given universe filter to the contracts of this chain when first read + /// Applies the given universe filter to the contracts of this chain and returns the result as a new chain /// /// The universe filter to apply private OptionChain Filter(Func filter) { - var universe = new Lazy(() => - { - // an unread chain is filtered from the universe that selects its contracts, a read one from its current contracts - var source = IsMaterialized ? new OptionChainFilterUniverse(this) : new OptionChainFilterUniverse(_pendingUniverse.Value); - // the type filters (standards/weeklys) are only applied on demand, like the universe selection does after the user filter - return filter(source).ApplyTypesFilter(); - }, isThreadSafe: false); - return new OptionChain(this, universe); - } - - /// - /// Same as for the strategy filters, which validate their arguments: they are checked now, not on first read - /// - /// The universe strategy filter to apply - private OptionChain StrategyFilter(Func filter) - { - // the strategy filters check their arguments before reading any data, so an empty run throws the same exceptions - filter(new OptionChainFilterUniverse(Symbol, ExchangeTime)); - return Filter(filter); + var universe = new OptionChainFilterUniverse(this); + // the type filters (standards/weeklys) are only applied on demand, like the universe selection does after the user filter + return new OptionChain(this, filter(universe).ApplyTypesFilter()); } #endregion diff --git a/Common/Data/Market/OptionChain.cs b/Common/Data/Market/OptionChain.cs index 844d86060cf1..20c1ec0a7126 100644 --- a/Common/Data/Market/OptionChain.cs +++ b/Common/Data/Market/OptionChain.cs @@ -29,9 +29,6 @@ namespace QuantConnect.Data.Market /// public partial class OptionChain : BaseChain, IOptionContractFilters { - // The filter universe selecting this chain's contracts, run on first read; null for chains not built by a filter - private readonly Lazy _pendingUniverse; - /// /// Initializes a new instance of the class /// @@ -79,12 +76,11 @@ private OptionChain(OptionChain other) /// /// Initializes a new instance of the class as a copy of the specified chain - /// containing only the contracts the given filter universe selects, run on first read + /// containing only the given subset of its contracts /// - private OptionChain(OptionChain other, Lazy universe) - : base(other, () => universe.Value.Data) + private OptionChain(OptionChain other, IEnumerable contracts) + : base(other, contracts) { - _pendingUniverse = universe; } /// diff --git a/Common/Securities/Option/OptionChainFilterUniverse.cs b/Common/Securities/Option/OptionChainFilterUniverse.cs index 837646c83c79..37d68d575813 100644 --- a/Common/Securities/Option/OptionChainFilterUniverse.cs +++ b/Common/Securities/Option/OptionChainFilterUniverse.cs @@ -28,7 +28,6 @@ namespace QuantConnect.Securities internal class OptionChainFilterUniverse : BaseOptionFilterUniverse { private readonly Symbol _symbol; - private readonly decimal _strikeMultiplier; private SecurityExchangeHours _exchangeHours; /// @@ -47,42 +46,9 @@ internal class OptionChainFilterUniverse : BaseOptionFilterUniverse /// The option chain to filter public OptionChainFilterUniverse(OptionChain chain) - : this(GetContracts(chain), GetUnderlying(chain), chain.ExchangeTime, chain.Symbol) + : base(GetContracts(chain), GetUnderlying(chain), chain.ExchangeTime, GetStrikeMultiplier(chain)) { - } - - /// - /// Initializes a new instance of the class over the contracts another instance selected, - /// reusing its underlying, time, strike multiplier and exchange hours - /// - /// The filter universe to continue from - public OptionChainFilterUniverse(OptionChainFilterUniverse other) - : this(other.Data, other.UnderlyingInternal, other.LocalTime, other._symbol, other._strikeMultiplier) - { - _exchangeHours = other._exchangeHours; - } - - /// - /// Initializes a new instance of the class without contracts - /// - /// The canonical option symbol - /// The current local time - public OptionChainFilterUniverse(Symbol symbol, DateTime localTime) - : this(new List(), null, localTime, symbol, 1) - { - } - - private OptionChainFilterUniverse(List contracts, BaseData underlying, DateTime localTime, Symbol symbol) - : this(contracts, underlying, localTime, symbol, GetStrikeMultiplier(contracts)) - { - } - - private OptionChainFilterUniverse(IReadOnlyList contracts, BaseData underlying, DateTime localTime, Symbol symbol, - decimal strikeMultiplier) - : base(contracts, underlying, localTime, strikeMultiplier) - { - _symbol = symbol; - _strikeMultiplier = strikeMultiplier; + _symbol = chain.Symbol; } /// @@ -108,10 +74,10 @@ protected override OptionContract CreateDataInstance(Symbol symbol) /// protected override decimal GetOpenInterest(OptionContract contract) => contract.OpenInterest; - private static List GetContracts(OptionChain chain) + private static IReadOnlyList GetContracts(OptionChain chain) { - // The sorted values view costs a sort per rebuild and the filters do not need the order - return new List(chain.Contracts.UnsortedValues); + // The dictionary caches its values as a list that is replaced, never mutated, so it is safe to share + return chain.Contracts.Values as IReadOnlyList ?? chain.Contracts.Values.ToList(); } private static BaseData GetUnderlying(OptionChain chain) @@ -121,9 +87,9 @@ private static BaseData GetUnderlying(OptionChain chain) return underlying != null && underlying.Price != 0 ? underlying : null; } - private static decimal GetStrikeMultiplier(List contracts) + private static decimal GetStrikeMultiplier(OptionChain chain) { - return contracts.Count > 0 ? contracts[0].SymbolProperties?.StrikeMultiplier ?? 1 : 1; + return chain.Contracts.Values.FirstOrDefault()?.SymbolProperties?.StrikeMultiplier ?? 1; } } } diff --git a/Tests/Common/Data/Market/OptionChainTests.cs b/Tests/Common/Data/Market/OptionChainTests.cs index 6ca215a3469c..f117c4dbd962 100644 --- a/Tests/Common/Data/Market/OptionChainTests.cs +++ b/Tests/Common/Data/Market/OptionChainTests.cs @@ -145,68 +145,6 @@ public void ChainExposesEveryUniverseFilter() } } - [Test] - public void FiltersRunOnFirstRead() - { - var chain = CreateChain(); - var puts = chain.PutsOnly(); - var near = puts.Expiration(0, 30); - var far = puts.Expiration(31, 60); - Assert.IsTrue(chain.IsMaterialized); - Assert.IsFalse(puts.IsMaterialized); - Assert.IsFalse(near.IsMaterialized); - Assert.IsFalse(far.IsMaterialized); - - // reading a chain runs its filters, without reading the chains it was built from - var expectedNear = CreateUniverse().PutsOnly().Expiration(0, 30).Select(x => x.Symbol.Value).ToList(); - CollectionAssert.AreEquivalent(expectedNear, near.Select(x => x.Symbol.Value)); - Assert.IsTrue(near.IsMaterialized); - Assert.IsFalse(puts.IsMaterialized); - Assert.IsFalse(far.IsMaterialized); - - var expectedFar = CreateUniverse().PutsOnly().Expiration(31, 60).Select(x => x.Symbol.Value).ToList(); - CollectionAssert.AreEquivalent(expectedFar, far.Select(x => x.Symbol.Value)); - Assert.AreEqual(expectedNear.Count + expectedFar.Count, puts.Count); - Assert.IsTrue(puts.IsMaterialized); - } - - [Test] - public void UnreadChainIsReadThroughEveryMember() - { - var expected = CreateUniverse().CallsOnly().Select(x => x.Symbol).ToList(); - var chains = new[] { CreateChain(), CreateChain(), CreateChain(), CreateChain(), CreateChain() }; - - Assert.AreEqual(expected.Count, chains[0].CallsOnly().Count); - Assert.IsTrue(chains[1].CallsOnly().ContainsKey(expected[0])); - Assert.AreEqual(expected[0], chains[2].CallsOnly().Contracts[expected[0]].Symbol); - Assert.AreEqual(expected.Count, ((OptionChain)chains[3].CallsOnly().Clone()).Count); - using (Py.GIL()) - { - Assert.AreEqual(expected.Count, chains[4].CallsOnly().DataFrame.GetAttr("shape")[0].As()); - } - } - - [Test] - public void FiltersOnAFilteredChainSeeItsCurrentContracts() - { - var chain = CreateChain(); - var puts = chain.PutsOnly(); - var call = chain.CallsOnly().First(); - var put = puts.First(); - - // added, removed and replaced contracts are all picked up by the next filter - puts.Contracts[call.Symbol] = call; - Assert.AreEqual(1, puts.CallsOnly().Count); - Assert.AreEqual(puts.Count, puts.Expiration(0, 1000).Count); - - puts.Contracts.Remove(put.Symbol); - Assert.IsFalse(puts.PutsOnly().ContainsKey(put.Symbol)); - - var replacement = OptionContract.Create(_data.Single(x => x.Symbol == put.Symbol), _symbolProperties); - puts.Contracts[put.Symbol] = replacement; - Assert.AreSame(replacement, puts.PutsOnly().Single(x => x.Symbol == put.Symbol)); - } - [Test] public void FilteredChainSharesTheAuxiliaryData() { @@ -309,9 +247,6 @@ def filter_chain(chain): def where_chain(chain): return chain.where(lambda contract: contract.right == OptionRight.PUT and contract.strike > 100) - -def where_then_filter(chain): - return where_chain(chain).expiration(0, 30) "); using var pyChain = chain.ToPython(); @@ -320,9 +255,6 @@ def where_then_filter(chain): using var where = module.GetAttr("where_chain").Invoke(pyChain); CollectionAssert.AreEqual(expectedWhere, where.As().Select(x => x.Symbol).ToList()); - - using var whereThenFilter = module.GetAttr("where_then_filter").Invoke(pyChain); - CollectionAssert.AreEqual(expectedWhere.Where(x => x.ID.Date <= Date.AddDays(30)), whereThenFilter.As().Select(x => x.Symbol).ToList()); } } @@ -416,40 +348,6 @@ def naked_put(chain): } } - [Test, Explicit("Benchmark: reports the cost of chained filters on an index-sized chain")] - public void ChainedFiltersBenchmark() - { - var expiries = Enumerable.Range(1, 30).Select(i => Date.AddDays(7 * i)).ToArray(); - var strikes = Enumerable.Range(0, 150).Select(i => 60m + i).ToArray(); - var (data, _) = CreateUniverseData(Date, UnderlyingPrice, expiries, strikes); - var chain = new OptionChain(Canonical, Date, data, _symbolProperties); - const int iterations = 1000; - - void Report(string name, Func filter) - { - var result = filter(chain); - var best = double.MaxValue; - for (var round = 0; round < 3; round++) - { - GC.Collect(); - var stopwatch = System.Diagnostics.Stopwatch.StartNew(); - for (var i = 0; i < iterations; i++) - { - result = filter(chain); - _ = result.Count; - } - best = Math.Min(best, stopwatch.Elapsed.TotalMilliseconds / iterations); - } - TestContext.Progress.WriteLine($"{name}: {best:F3} ms per call, {result.Count} contracts"); - } - - TestContext.Progress.WriteLine($"{chain.Count} contracts"); - Report("PutsOnly", c => c.PutsOnly()); - Report("PutsOnly.Expiration", c => c.PutsOnly().Expiration(20, 40)); - Report("PutsOnly.Expiration.Strikes", c => c.PutsOnly().Expiration(20, 40).Strikes(-3, 0)); - Report("Expiration.Strikes.Delta.OI", c => c.Expiration(20, 60).Strikes(-10, 10).Delta(-0.6m, 0.6m).OpenInterest(0, 1000000)); - } - private class TestAuxData : BaseData { }