From a477012efbc50a70196e0976167f329805e4822d Mon Sep 17 00:00:00 2001 From: Jhonathan Abreu Date: Tue, 11 Aug 2026 13:05:11 -0400 Subject: [PATCH 1/2] Add option chain selection helpers Adds composable contract selection helpers to OptionChain so the usual hand-rolled nearest-expiry/nearest-strike/ATM scans become a single call: Select(), ClosestExpiry(), At(expiry), AtTheMoney(right), Calls/Puts and Strikes (with ClosestTo/FirstAbove/FirstBelow). All helpers are null-safe, count Saturday-convention expirations at their Friday last trading date, and support DTE windows to guard against selecting already-subscribed contracts outside the requested expiration range. Also fixes the OptionChain universe-contracts constructor never assigning the chain-level Underlying (the base constructor initializes it to an empty QuoteBar, so the null-coalescing assignment never fired). --- ...hainSelectionHelpersRegressionAlgorithm.cs | 207 +++++++++ ...hainSelectionHelpersRegressionAlgorithm.py | 105 +++++ Common/Data/Market/BaseChain.cs | 24 ++ Common/Data/Market/OptionChain.cs | 224 +++++++++- Common/Data/Market/StrikeList.cs | 93 ++++ Tests/Common/Data/Market/OptionChainTests.cs | 406 ++++++++++++++++++ 6 files changed, 1058 insertions(+), 1 deletion(-) create mode 100644 Algorithm.CSharp/OptionChainSelectionHelpersRegressionAlgorithm.cs create mode 100644 Algorithm.Python/OptionChainSelectionHelpersRegressionAlgorithm.py create mode 100644 Common/Data/Market/StrikeList.cs create mode 100644 Tests/Common/Data/Market/OptionChainTests.cs diff --git a/Algorithm.CSharp/OptionChainSelectionHelpersRegressionAlgorithm.cs b/Algorithm.CSharp/OptionChainSelectionHelpersRegressionAlgorithm.cs new file mode 100644 index 000000000000..01cb27358444 --- /dev/null +++ b/Algorithm.CSharp/OptionChainSelectionHelpersRegressionAlgorithm.cs @@ -0,0 +1,207 @@ +/* + * QUANTCONNECT.COM - Democratizing Finance, Empowering Individuals. + * Lean Algorithmic Trading Engine v2.0. Copyright 2014 QuantConnect Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * +*/ + +using System; +using System.Collections.Generic; +using System.Linq; +using QuantConnect.Data; +using QuantConnect.Interfaces; + +namespace QuantConnect.Algorithm.CSharp +{ + /// + /// Regression algorithm demonstrating the option chain selection helpers: + /// , , + /// , and + /// , which replace the usual hand-rolled + /// sorted-comprehension contract selection with a single call. + /// + public class OptionChainSelectionHelpersRegressionAlgorithm : QCAlgorithm, IRegressionAlgorithmDefinition + { + private Symbol _optionContract; + + public override void Initialize() + { + SetStartDate(2015, 12, 24); + SetEndDate(2015, 12, 24); + SetCash(100000); + + var goog = AddEquity("GOOG").Symbol; + var chain = OptionChain(goog); + + // One-line selection: the call at the expiry closest to 10 days out with the strike closest + // to the underlying price (at the money is the default when no moneyness/delta is given) + var contract = chain.Select(right: OptionRight.Call, targetDte: 10); + if (contract == null) + { + throw new RegressionTestException("Select(right, targetDte) returned no contract"); + } + + // The equivalent hand-rolled ceremony must select the very same contract + var spot = chain.Underlying.Price; + var calls = chain.Where(x => x.Right == OptionRight.Call).ToList(); + var ceremonyExpiry = calls.Select(x => x.Expiry).Distinct() + .OrderBy(expiry => Math.Abs((expiry.Date - Time.Date).Days - 10)) + .First(); + var ceremonyContract = calls.Where(x => x.Expiry == ceremonyExpiry) + .OrderBy(x => Math.Abs(x.Strike - spot)) + .First(); + if (!contract.Symbol.Equals(ceremonyContract.Symbol)) + { + throw new RegressionTestException($"Select() mismatch: {contract.Symbol.Value} != ceremony {ceremonyContract.Symbol.Value}"); + } + // 2015-12-24: GOOG at 748.40, closest expiry to 10 days out is 2015-12-31, ATM strike is 747.50 + if (contract.Expiry != new DateTime(2015, 12, 31) || contract.Strike != 747.5m) + { + throw new RegressionTestException($"Unexpected contract selected: {contract.Symbol.Value}"); + } + + // Expiry selection with a DTE window: 2015-12-31 (7 days out) is excluded by minDte, + // so the closest expiry to 10 days out is 2016-01-08 + var expiry = chain.ClosestExpiry(targetDte: 10, minDte: 8, maxDte: 40); + if (expiry != new DateTime(2016, 1, 8)) + { + throw new RegressionTestException($"ClosestExpiry() expected 2016-01-08 but got {expiry}"); + } + + // Single-expiry view: composes with Calls/Puts, Strikes and AtTheMoney + var atExpiry = chain.At(contract.Expiry); + if (atExpiry.Count == 0 || atExpiry.Any(x => x.Expiry != contract.Expiry)) + { + throw new RegressionTestException("At() returned contracts of other expiries"); + } + if (atExpiry.Calls.Count == 0 || atExpiry.Puts.Count == 0) + { + throw new RegressionTestException("At().Calls/.Puts should not be empty"); + } + var atmPut = atExpiry.AtTheMoney(OptionRight.Put); + if (atmPut == null || atmPut.Strike != 747.5m || atmPut.Right != OptionRight.Put) + { + throw new RegressionTestException($"AtTheMoney(Put) expected the 747.50 put but got {atmPut?.Symbol.Value}"); + } + + // Strikes helpers: strictly above/below and closest to the underlying price + var strikes = atExpiry.Strikes; + if (strikes.ClosestTo(spot) != 747.5m || strikes.FirstAbove(spot) != 750m || strikes.FirstBelow(spot) != 747.5m) + { + throw new RegressionTestException( + $"Strikes helpers mismatch: {strikes.ClosestTo(spot)}/{strikes.FirstAbove(spot)}/{strikes.FirstBelow(spot)}"); + } + + // Delta targeting: the put with |delta| closest to 0.35, using the universe pre-calculated greeks + var deltaPut = chain.Select(right: OptionRight.Put, targetDte: 7, targetDelta: 0.35m); + var ceremonyDeltaPut = chain + .Where(x => x.Right == OptionRight.Put && x.Expiry == contract.Expiry && x.Greeks.Delta != 0) + .OrderBy(x => Math.Abs(Math.Abs(x.Greeks.Delta) - 0.35m)) + .First(); + if (deltaPut == null || !deltaPut.Symbol.Equals(ceremonyDeltaPut.Symbol)) + { + throw new RegressionTestException($"Select(targetDelta) mismatch: {deltaPut?.Symbol.Value} != {ceremonyDeltaPut.Symbol.Value}"); + } + + // The helpers are null-safe: no match returns null instead of throwing like min()/First() would + if (chain.Select(right: OptionRight.Call, minDte: 2000) != null || + chain.ClosestExpiry(minDte: 2000) != null || + chain.At(new DateTime(2050, 1, 1)).Count != 0) + { + throw new RegressionTestException("Helpers should return null/empty when nothing matches"); + } + + _optionContract = AddOptionContract(contract.Symbol).Symbol; + } + + public override void OnData(Slice slice) + { + if (!Portfolio.Invested && slice.OptionChains.TryGetValue(_optionContract.Canonical, out var chain)) + { + // Same one-liner against the slice option chain + var contract = chain.Select(right: OptionRight.Call, targetDte: 7); + if (contract != null) + { + MarketOrder(contract.Symbol, 1); + } + } + } + + public override void OnEndOfAlgorithm() + { + if (!Portfolio.Invested) + { + throw new RegressionTestException("Expected to select and buy a contract from the slice option chain"); + } + } + + /// + /// 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 => 1051; + + /// + /// 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", "99769"}, + {"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", "$47000.00"}, + {"Lowest Capacity Asset", "GOOCV W6U7Q7WSA9ZA|GOOCV VP83T1ZUHROL"}, + {"Portfolio Turnover", "0.86%"}, + {"Drawdown Recovery", "0"}, + {"OrderListHash", "f57c16766cc7f8eb3d65d6c91457529e"} + }; + } +} diff --git a/Algorithm.Python/OptionChainSelectionHelpersRegressionAlgorithm.py b/Algorithm.Python/OptionChainSelectionHelpersRegressionAlgorithm.py new file mode 100644 index 000000000000..651647c37d16 --- /dev/null +++ b/Algorithm.Python/OptionChainSelectionHelpersRegressionAlgorithm.py @@ -0,0 +1,105 @@ +# 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 the option chain selection helpers: select(), closest_expiry(), +### at(), at_the_money() and strikes, which replace the usual hand-rolled sorted-comprehension +### contract selection with a single call. +### +class OptionChainSelectionHelpersRegressionAlgorithm(QCAlgorithm): + + def initialize(self): + self.set_start_date(2015, 12, 24) + self.set_end_date(2015, 12, 24) + self.set_cash(100000) + + goog = self.add_equity("GOOG").symbol + chain = self.option_chain(goog) + + # One-line selection: the call at the expiry closest to 10 days out with the strike closest + # to the underlying price (at the money is the default when no moneyness/delta is given) + contract = chain.select(right=OptionRight.CALL, target_dte=10) + if contract is None: + raise AssertionError("select(right, target_dte) returned no contract") + + # The equivalent hand-rolled ceremony must select the very same contract + spot = chain.underlying.price + calls = [x for x in chain if x.right == OptionRight.CALL] + ceremony_expiry = min({x.expiry for x in calls}, key=lambda expiry: abs((expiry - self.time).days - 10)) + ceremony_contract = min((x for x in calls if x.expiry == ceremony_expiry), key=lambda x: abs(x.strike - spot)) + if contract.symbol != ceremony_contract.symbol: + raise AssertionError(f"select() mismatch: {contract.symbol.value} != ceremony {ceremony_contract.symbol.value}") + # 2015-12-24: GOOG at 748.40, closest expiry to 10 days out is 2015-12-31, ATM strike is 747.50 + if contract.expiry != datetime(2015, 12, 31) or contract.strike != 747.5: + raise AssertionError(f"Unexpected contract selected: {contract.symbol.value}") + + # Expiry selection with a DTE window: 2015-12-31 (7 days out) is excluded by min_dte, + # so the closest expiry to 10 days out is 2016-01-08 + expiry = chain.closest_expiry(target_dte=10, min_dte=8, max_dte=40) + if expiry != datetime(2016, 1, 8): + raise AssertionError(f"closest_expiry() expected 2016-01-08 but got {expiry}") + + # Single-expiry view: composes with calls/puts, strikes and at_the_money + at_expiry = chain.at(contract.expiry) + if at_expiry.count == 0 or any(x.expiry != contract.expiry for x in at_expiry): + raise AssertionError("at() returned contracts of other expiries") + if len(at_expiry.calls) == 0 or len(at_expiry.puts) == 0: + raise AssertionError("at().calls/.puts should not be empty") + atm_put = at_expiry.at_the_money(OptionRight.PUT) + if atm_put is None or atm_put.strike != 747.5 or atm_put.right != OptionRight.PUT: + raise AssertionError(f"at_the_money(PUT) expected the 747.50 put but got {atm_put}") + + # Strikes helpers: strictly above/below and closest to the underlying price + strikes = at_expiry.strikes + if strikes.closest_to(spot) != 747.5 or strikes.first_above(spot) != 750 or strikes.first_below(spot) != 747.5: + raise AssertionError( + f"strikes helpers mismatch: {strikes.closest_to(spot)}/{strikes.first_above(spot)}/{strikes.first_below(spot)}") + + # Delta targeting: the put with |delta| closest to 0.35, using the universe pre-calculated greeks + delta_put = chain.select(right=OptionRight.PUT, target_dte=7, target_delta=0.35) + ceremony_delta_put = min( + (x for x in chain if x.right == OptionRight.PUT and x.expiry == contract.expiry and x.greeks.delta != 0), + key=lambda x: abs(abs(float(x.greeks.delta)) - 0.35)) + if delta_put is None or delta_put.symbol != ceremony_delta_put.symbol: + raise AssertionError(f"select(target_delta) mismatch: {delta_put} != {ceremony_delta_put.symbol.value}") + + # Moneyness targeting: the put with the strike closest to 5% below the underlying price + otm_put = chain.select(right=OptionRight.PUT, target_dte=7, moneyness=-0.05) + ceremony_otm_put = min( + (x for x in chain if x.right == OptionRight.PUT and x.expiry == contract.expiry), + key=lambda x: abs(float(x.strike) - float(spot) * 0.95)) + if otm_put is None or otm_put.symbol != ceremony_otm_put.symbol: + raise AssertionError(f"select(moneyness) mismatch: {otm_put} != {ceremony_otm_put.symbol.value}") + + # The helpers are None-safe: no match returns None instead of raising like min() would + if (chain.select(right=OptionRight.CALL, min_dte=2000) is not None + or chain.closest_expiry(min_dte=2000) is not None + or chain.at(datetime(2050, 1, 1)).count != 0): + raise AssertionError("Helpers should return None/empty when nothing matches") + + self._option_contract = self.add_option_contract(contract.symbol).symbol + + def on_data(self, slice): + if not self.portfolio.invested: + chain = slice.option_chains.get(self._option_contract.canonical) + if chain: + # Same one-liner against the slice option chain + contract = chain.select(right=OptionRight.CALL, target_dte=7) + if contract is not None: + self.market_order(contract.symbol, 1) + + def on_end_of_algorithm(self): + if not self.portfolio.invested: + raise AssertionError("Expected to select and buy a contract from the slice option chain") diff --git a/Common/Data/Market/BaseChain.cs b/Common/Data/Market/BaseChain.cs index 1341d16f51a8..e707f7c83860 100644 --- a/Common/Data/Market/BaseChain.cs +++ b/Common/Data/Market/BaseChain.cs @@ -180,6 +180,30 @@ protected BaseChain(BaseChain other) FilteredContracts = other.FilteredContracts; } + /// + /// Initializes a new instance of the class as a copy of the + /// specified chain, but containing only the given subset of its contracts. + /// The underlying data, ticks, trade bars and quote bars still reference the source chain's collections. + /// + 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(); + 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/OptionChain.cs b/Common/Data/Market/OptionChain.cs index 562cd18e0a0f..b10f6794b79a 100644 --- a/Common/Data/Market/OptionChain.cs +++ b/Common/Data/Market/OptionChain.cs @@ -15,7 +15,9 @@ using System; using System.Collections.Generic; +using System.Linq; using QuantConnect.Data.UniverseSelection; +using QuantConnect.Python; using QuantConnect.Securities; namespace QuantConnect.Data.Market @@ -26,6 +28,25 @@ namespace QuantConnect.Data.Market /// public class OptionChain : BaseChain { + /// + /// Gets all call contracts in the chain, sorted by expiration and strike + /// + [PandasIgnore] + public List Calls => GetContracts(OptionRight.Call); + + /// + /// Gets all put contracts in the chain, sorted by expiration and strike + /// + [PandasIgnore] + public List Puts => GetContracts(OptionRight.Put); + + /// + /// Gets the distinct strike prices in the chain, sorted in ascending order. + /// See , and + /// + [PandasIgnore] + public StrikeList Strikes => new(Contracts.Values.Select(contract => contract.Strike)); + /// /// Initializes a new instance of the class /// @@ -49,9 +70,16 @@ public OptionChain(Symbol canonicalOptionSymbol, DateTime time, IEnumerable class as a copy of the specified chain, + /// but 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 +110,190 @@ public override BaseData Clone() { return new OptionChain(this); } + + /// + /// Selects the single contract that best matches the given criteria, replacing the usual + /// sorted-comprehension ceremony with a single call, e.g. + /// chain.select(right=OptionRight.PUT, target_dte=30, moneyness=-0.15). + /// Null-safe: returns null (None in Python) instead of throwing when the chain is empty, + /// no expiration falls within the requested window or the underlying price is unavailable. + /// + /// If set, only contracts of this right are considered + /// If set, only contracts of the expiration closest to this many days from the + /// chain's current date are considered. See + /// If set, expirations closer than this many days are excluded + /// If set, expirations further than this many days are excluded + /// Signed distance from the underlying price as a fraction of it, regardless of right: + /// negative values target strikes below the underlying price, positive values above. + /// e.g. -0.15 targets the strike closest to 85% of the underlying price. + /// When neither moneyness nor targetDelta are set, the at-the-money contract (moneyness 0) is selected. + /// Mutually exclusive with + /// If set, the contract whose absolute delta is closest to the absolute value of this + /// target is selected, so a "30 delta put" can be requested as either 0.3 or -0.3. + /// Contracts without greeks data are ignored. Mutually exclusive with + /// The best matching contract, or null if no contract matches + public OptionContract Select(OptionRight? right = null, int? targetDte = null, int? minDte = null, int? maxDte = null, + decimal? moneyness = null, decimal? targetDelta = null) + { + if (moneyness.HasValue && targetDelta.HasValue) + { + throw new ArgumentException("OptionChain.Select(): moneyness and targetDelta are mutually exclusive, please set only one of them."); + } + + IEnumerable candidates = right.HasValue + ? Contracts.Values.Where(contract => contract.Right == right.Value).ToList() + : Contracts.Values; + + if (targetDte.HasValue || minDte.HasValue || maxDte.HasValue) + { + var expiry = GetClosestExpiry(candidates, targetDte, minDte, maxDte); + if (!expiry.HasValue) + { + return null; + } + candidates = candidates.Where(contract => contract.Expiry == expiry.Value).ToList(); + } + + if (targetDelta.HasValue) + { + var target = Math.Abs(targetDelta.Value); + // Contracts without greeks data report a flat zero delta: exclude them so a chain without + // greeks returns null instead of silently picking an arbitrary contract + return candidates + .Where(contract => contract.Greeks.Delta != 0) + .OrderBy(contract => Math.Abs(Math.Abs(contract.Greeks.Delta) - target)) + .ThenBy(contract => contract.Strike) + .ThenBy(contract => contract.Right) + .FirstOrDefault(); + } + + var underlyingPrice = GetUnderlyingPrice(); + if (!underlyingPrice.HasValue) + { + return null; + } + + var targetStrike = underlyingPrice.Value * (1 + (moneyness ?? 0)); + return GetClosestByStrike(candidates, targetStrike); + } + + /// + /// Gets the expiration date in the chain closest to the target number of days from the chain's current date. + /// Null-safe: returns null (None in Python) when the chain is empty or no expiration falls within the requested window. + /// + /// The target days to expiration. When two expirations are equidistant the earlier one is returned. + /// Defaults to minDte if set, else 0 (the nearest expiration) + /// If set, expirations closer than this many days are excluded + /// If set, expirations further than this many days are excluded + /// The best matching expiration date as stored in the chain's contracts, or null if none matches + /// Days to expiration are measured on the contract's last trading date: pre-2015 equity option metadata + /// uses the OCC Saturday expiration convention, which is counted as the preceding Friday + public DateTime? ClosestExpiry(int? targetDte = null, int? minDte = null, int? maxDte = null) + { + return GetClosestExpiry(Contracts.Values, targetDte, minDte, maxDte); + } + + /// + /// Gets a new chain containing only the contracts with the given expiration date, so contracts + /// for a single expiration can be selected with chain.at(expiry).calls or chain.at(expiry).puts. + /// Matching is date-tolerant: pre-2015 equity option metadata uses the OCC Saturday expiration convention, + /// so a chain whose contracts expire e.g. Saturday 2012-02-18 is also matched by the last trading + /// date, Friday 2012-02-17, which would otherwise silently match zero contracts. + /// + /// The expiration date, time of day is ignored + /// A new chain with only the matching contracts, empty if none matches + public OptionChain At(DateTime expiry) + { + var expiryDate = NormalizeExpiry(expiry); + return new OptionChain(this, Contracts.Values.Where(contract => NormalizeExpiry(contract.Expiry) == expiryDate)); + } + + /// + /// Gets the contract of the given right whose strike is closest to the current underlying price. + /// When two strikes are equidistant the lower one is returned. + /// Null-safe: returns null (None in Python) when the chain has no contracts of the given right + /// or the underlying price is unavailable. + /// + /// The contract right to search for + /// The at-the-money contract, or null if there is none + public OptionContract AtTheMoney(OptionRight right) + { + var underlyingPrice = GetUnderlyingPrice(); + if (!underlyingPrice.HasValue) + { + return null; + } + return GetClosestByStrike(Contracts.Values.Where(contract => contract.Right == right), underlyingPrice.Value); + } + + private List GetContracts(OptionRight right) + { + return Contracts.Values + .Where(contract => contract.Right == right) + .OrderBy(contract => contract.Expiry) + .ThenBy(contract => contract.Strike) + .ToList(); + } + + /// + /// Gets the underlying price for moneyness calculations. Chains built from universe data + /// might not have the chain-level underlying data populated, but their contracts carry it. + /// Returns null when unavailable so selection helpers can be null-safe instead of + /// silently treating the underlying price as zero. + /// + private decimal? GetUnderlyingPrice() + { + var price = Underlying?.Price ?? decimal.Zero; + if (price == decimal.Zero) + { + price = Contracts.Values.Select(contract => contract.UnderlyingLastPrice).FirstOrDefault(x => x != decimal.Zero); + } + return price == decimal.Zero ? null : price; + } + + /// + /// Normalizes an expiration date to the contract's last trading date for comparisons: + /// equity option metadata prior to February 2015 uses the OCC Saturday expiration convention, + /// while the contract actually stops trading the preceding Friday. + /// + private static DateTime NormalizeExpiry(DateTime expiry) + { + var date = expiry.Date; + return date.DayOfWeek == DayOfWeek.Saturday ? date.AddDays(-1) : date; + } + + private DateTime? GetClosestExpiry(IEnumerable contracts, int? targetDte, int? minDte, int? maxDte) + { + var target = targetDte ?? minDte ?? 0; + DateTime? result = null; + var resultDistance = int.MaxValue; + foreach (var expiry in contracts.Select(contract => contract.Expiry).Distinct()) + { + // Days to expiration measured against the chain's own date, so results are not + // affected by the time zone difference between the algorithm and the exchange + var dte = (NormalizeExpiry(expiry) - EndTime.Date).Days; + // Lifted comparisons are false when the bound is null, i.e. unset bounds don't exclude anything + if (dte < minDte || dte > maxDte) + { + continue; + } + var distance = Math.Abs(dte - target); + if (distance < resultDistance || (distance == resultDistance && expiry < result.Value)) + { + result = expiry; + resultDistance = distance; + } + } + return result; + } + + private static OptionContract GetClosestByStrike(IEnumerable contracts, decimal targetStrike) + { + return contracts + .OrderBy(contract => Math.Abs(contract.Strike - targetStrike)) + .ThenBy(contract => contract.Strike) + .ThenBy(contract => contract.Right) + .FirstOrDefault(); + } } } diff --git a/Common/Data/Market/StrikeList.cs b/Common/Data/Market/StrikeList.cs new file mode 100644 index 000000000000..d0362888c936 --- /dev/null +++ b/Common/Data/Market/StrikeList.cs @@ -0,0 +1,93 @@ +/* + * QUANTCONNECT.COM - Democratizing Finance, Empowering Individuals. + * Lean Algorithmic Trading Engine v2.0. Copyright 2014 QuantConnect Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. +*/ + +using System; +using System.Collections.Generic; +using System.Linq; + +namespace QuantConnect.Data.Market +{ + /// + /// The distinct strike prices of a chain of contracts, sorted in ascending order, + /// with helpers to find the strike closest to, immediately above or immediately below a given price. + /// All helpers are null-safe: they return null instead of throwing when no strike matches, + /// so callers can bail out with a simple null/None check. + /// + public class StrikeList : List + { + /// + /// Initializes a new instance of the class with the distinct + /// values of the given strikes, sorted in ascending order + /// + /// The strike prices, in any order, duplicates allowed + public StrikeList(IEnumerable strikes) + : base(strikes.Distinct().OrderBy(strike => strike)) + { + } + + /// + /// Gets the strike closest to the given price. When two strikes are equidistant, the lower one is returned. + /// + /// The reference price, e.g. the underlying price + /// The closest strike, or null if there are no strikes + public decimal? ClosestTo(decimal price) + { + decimal? closest = null; + foreach (var strike in this) + { + // ascending order plus strict comparison keeps the lower strike on ties + if (closest == null || Math.Abs(strike - price) < Math.Abs(closest.Value - price)) + { + closest = strike; + } + } + return closest; + } + + /// + /// Gets the lowest strike strictly greater than the given price + /// + /// The reference price, e.g. the underlying price + /// The first strike above the price, or null if there is none + public decimal? FirstAbove(decimal price) + { + foreach (var strike in this) + { + if (strike > price) + { + return strike; + } + } + return null; + } + + /// + /// Gets the highest strike strictly less than the given price + /// + /// The reference price, e.g. the underlying price + /// The first strike below the price, or null if there is none + public decimal? FirstBelow(decimal price) + { + for (var i = Count - 1; i >= 0; i--) + { + if (this[i] < price) + { + return this[i]; + } + } + return null; + } + } +} diff --git a/Tests/Common/Data/Market/OptionChainTests.cs b/Tests/Common/Data/Market/OptionChainTests.cs new file mode 100644 index 000000000000..549b912005c6 --- /dev/null +++ b/Tests/Common/Data/Market/OptionChainTests.cs @@ -0,0 +1,406 @@ +/* + * 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 NUnit.Framework; +using QuantConnect.Data.Market; +using QuantConnect.Data.UniverseSelection; +using QuantConnect.Securities; + +namespace QuantConnect.Tests.Common.Data.Market +{ + [TestFixture] + public class OptionChainTests + { + // Chain date: Thursday. Available expiries below are +1, +8, +15 and +29 days out + private static readonly DateTime ChainTime = new(2015, 12, 24, 10, 0, 0); + private static readonly DateTime Expiry1 = new(2015, 12, 25); + private static readonly DateTime Expiry2 = new(2016, 1, 1); + private static readonly DateTime Expiry3 = new(2016, 1, 8); + private static readonly DateTime Expiry4 = new(2016, 1, 22); + + private static OptionChain CreateChain( + IEnumerable<(DateTime expiry, decimal strike, OptionRight right, decimal delta)> contracts, + decimal? underlyingPrice = 100m, + DateTime? time = null) + { + var chainTime = time ?? ChainTime; + var canonical = Symbol.CreateCanonicalOption(Symbols.SPY); + var universeContracts = contracts.Select(x => + { + var symbol = Symbol.CreateOption(Symbols.SPY, QuantConnect.Market.USA, OptionStyle.American, x.right, x.strike, x.expiry); + // csv: open,high,low,close,volume,open_interest,implied_volatility,delta,gamma,vega,theta,rho + return new OptionUniverse(chainTime.Date, symbol, $"1,1,1,1,10,100,0.5,{x.delta},0.01,0.02,-0.03,0.04"); + }); + + var chain = new OptionChain(canonical, chainTime, universeContracts, SymbolProperties.GetDefault(Currencies.USD)); + if (underlyingPrice.HasValue) + { + chain.Underlying = new Tick { Symbol = Symbols.SPY, Value = underlyingPrice.Value, Time = chainTime }; + } + return chain; + } + + private static OptionChain CreateDefaultChain(decimal? underlyingPrice = 100m) + { + return CreateChain(new (DateTime, decimal, OptionRight, decimal)[] + { + (Expiry1, 95m, OptionRight.Call, 0.8m), + (Expiry1, 100m, OptionRight.Call, 0.5m), + (Expiry1, 105m, OptionRight.Call, 0.2m), + (Expiry1, 95m, OptionRight.Put, -0.2m), + (Expiry1, 100m, OptionRight.Put, -0.5m), + (Expiry1, 105m, OptionRight.Put, -0.8m), + (Expiry2, 90m, OptionRight.Call, 0.9m), + (Expiry2, 100m, OptionRight.Call, 0.5m), + (Expiry2, 110m, OptionRight.Call, 0.1m), + (Expiry2, 90m, OptionRight.Put, -0.1m), + (Expiry2, 100m, OptionRight.Put, -0.5m), + (Expiry2, 110m, OptionRight.Put, -0.9m), + (Expiry3, 85m, OptionRight.Put, -0.15m), + (Expiry3, 100m, OptionRight.Put, -0.5m), + (Expiry4, 85m, OptionRight.Put, -0.25m), + (Expiry4, 100m, OptionRight.Put, -0.55m) + }, underlyingPrice); + } + + private static OptionChain CreateEmptyChain() + { + return CreateChain(Enumerable.Empty<(DateTime, decimal, OptionRight, decimal)>(), underlyingPrice: null); + } + + [Test] + public void CallsAndPutsAreFilteredAndSorted() + { + var chain = CreateDefaultChain(); + + var calls = chain.Calls; + Assert.AreEqual(6, calls.Count); + Assert.IsTrue(calls.All(x => x.Right == OptionRight.Call)); + CollectionAssert.AreEqual( + calls.OrderBy(x => x.Expiry).ThenBy(x => x.Strike).Select(x => x.Symbol), + calls.Select(x => x.Symbol)); + + var puts = chain.Puts; + Assert.AreEqual(10, puts.Count); + Assert.IsTrue(puts.All(x => x.Right == OptionRight.Put)); + CollectionAssert.AreEqual( + puts.OrderBy(x => x.Expiry).ThenBy(x => x.Strike).Select(x => x.Symbol), + puts.Select(x => x.Symbol)); + } + + [Test] + public void StrikesAreDistinctAndSorted() + { + var chain = CreateDefaultChain(); + CollectionAssert.AreEqual(new[] { 85m, 90m, 95m, 100m, 105m, 110m }, chain.Strikes); + } + + [TestCase(97, 95)] + // Equidistant from 95 and 100: the lower strike wins + [TestCase(97.5, 95)] + [TestCase(120, 110)] + public void StrikesClosestTo(double price, double expected) + { + var chain = CreateDefaultChain(); + Assert.AreEqual((decimal)expected, chain.Strikes.ClosestTo((decimal)price)); + } + + [Test] + public void StrikesFirstAboveAndBelowAreStrict() + { + var chain = CreateDefaultChain(); + var strikes = chain.Strikes; + + Assert.AreEqual(105m, strikes.FirstAbove(100m)); + Assert.AreEqual(95m, strikes.FirstBelow(100m)); + Assert.AreEqual(85m, strikes.FirstAbove(0m)); + Assert.AreEqual(110m, strikes.FirstBelow(1000m)); + // No strike strictly above the highest / below the lowest + Assert.IsNull(strikes.FirstAbove(110m)); + Assert.IsNull(strikes.FirstBelow(85m)); + } + + [Test] + public void StrikesHelpersAreNullSafeOnEmptyChain() + { + var strikes = CreateEmptyChain().Strikes; + Assert.IsEmpty(strikes); + Assert.IsNull(strikes.ClosestTo(100m)); + Assert.IsNull(strikes.FirstAbove(100m)); + Assert.IsNull(strikes.FirstBelow(100m)); + } + + [TestCase(0, null, null, "20151225")] + [TestCase(10, null, null, "20160101")] + [TestCase(12, null, null, "20160108")] + [TestCase(100, null, null, "20160122")] + // min/max window excludes the otherwise closest expiry + [TestCase(0, 5, null, "20160101")] + [TestCase(100, null, 20, "20160108")] + [TestCase(10, 12, 20, "20160108")] + // no target: defaults to the nearest expiry within the window + [TestCase(null, null, null, "20151225")] + [TestCase(null, 10, null, "20160108")] + public void ClosestExpirySelectsBestMatch(int? targetDte, int? minDte, int? maxDte, string expected) + { + var chain = CreateDefaultChain(); + var expectedExpiry = DateTime.ParseExact(expected, "yyyyMMdd", null); + Assert.AreEqual(expectedExpiry, chain.ClosestExpiry(targetDte, minDte, maxDte)); + } + + [Test] + public void ClosestExpiryPrefersEarlierExpiryOnTies() + { + // +1 and +8 days, target 4.5 rounded is not possible: use +1 and +3 with target 2 + var chain = CreateChain(new[] + { + (ChainTime.Date.AddDays(1), 100m, OptionRight.Call, 0.5m), + (ChainTime.Date.AddDays(3), 100m, OptionRight.Call, 0.5m) + }); + Assert.AreEqual(ChainTime.Date.AddDays(1), chain.ClosestExpiry(targetDte: 2)); + } + + [Test] + public void ClosestExpiryIsNullSafe() + { + Assert.IsNull(CreateEmptyChain().ClosestExpiry(targetDte: 30)); + // Window excludes all expiries + Assert.IsNull(CreateDefaultChain().ClosestExpiry(targetDte: 50, minDte: 40, maxDte: 60)); + } + + [Test] + public void AtFiltersContractsByExpiry() + { + var chain = CreateDefaultChain(); + var filtered = chain.At(Expiry2); + + Assert.AreEqual(6, filtered.Count); + Assert.IsTrue(filtered.All(x => x.Expiry == Expiry2)); + // The filtered chain keeps the underlying data and composes with the other helpers + Assert.AreEqual(100m, filtered.Underlying.Price); + Assert.AreEqual(3, filtered.Calls.Count); + Assert.AreEqual(3, filtered.Puts.Count); + CollectionAssert.AreEqual(new[] { 90m, 100m, 110m }, filtered.Strikes); + Assert.AreEqual(100m, filtered.AtTheMoney(OptionRight.Call).Strike); + } + + [Test] + public void AtIgnoresTimeOfDayAndIsNullSafe() + { + var chain = CreateDefaultChain(); + Assert.AreEqual(6, chain.At(Expiry2.AddHours(15)).Count); + // Unknown expiry: empty chain rather than an exception + Assert.AreEqual(0, chain.At(new DateTime(2017, 1, 1)).Count); + } + + [Test] + public void AtMatchesSaturdayExpiryByLastTradingDate() + { + // Pre-2015 equity option metadata uses Saturday expiration dates: a user asking for the + // last trading date (Friday) must still match the chain (fleet evidence: strict + // date(2012, 2, 17) equality matched zero contracts because metadata says 2012-02-18) + var saturdayExpiry = new DateTime(2012, 2, 18); + var chainTime = new DateTime(2012, 2, 13, 10, 0, 0); + var chain = CreateChain(new[] + { + (saturdayExpiry, 95m, OptionRight.Call, 0.7m), + (saturdayExpiry, 100m, OptionRight.Call, 0.5m) + }, time: chainTime); + + Assert.AreEqual(2, chain.At(new DateTime(2012, 2, 17)).Count); + Assert.AreEqual(2, chain.At(saturdayExpiry).Count); + + // Days to expiration are counted to the Friday last trading date: Monday the 13th -> 4 days + Assert.AreEqual(saturdayExpiry, chain.ClosestExpiry(targetDte: 4, minDte: 4, maxDte: 4)); + Assert.IsNull(chain.ClosestExpiry(minDte: 5)); + } + + [TestCase(99, 100)] + [TestCase(103, 105)] + // Equidistant between 95 and 100: lower strike wins + [TestCase(97.5, 95)] + public void AtTheMoneySelectsClosestStrike(double underlyingPrice, double expectedStrike) + { + var chain = CreateChain(new[] + { + (Expiry1, 95m, OptionRight.Call, 0.8m), + (Expiry1, 100m, OptionRight.Call, 0.5m), + (Expiry1, 105m, OptionRight.Call, 0.2m) + }, (decimal)underlyingPrice); + + var contract = chain.AtTheMoney(OptionRight.Call); + Assert.IsNotNull(contract); + Assert.AreEqual((decimal)expectedStrike, contract.Strike); + Assert.AreEqual(OptionRight.Call, contract.Right); + } + + [Test] + public void AtTheMoneyIsNullSafe() + { + Assert.IsNull(CreateEmptyChain().AtTheMoney(OptionRight.Call)); + // No contracts of the requested right + var callsOnly = CreateChain(new[] { (Expiry1, 100m, OptionRight.Call, 0.5m) }); + Assert.IsNull(callsOnly.AtTheMoney(OptionRight.Put)); + // Unknown underlying price + var noUnderlying = CreateChain(new[] { (Expiry1, 100m, OptionRight.Call, 0.5m) }, underlyingPrice: null); + Assert.IsNull(noUnderlying.AtTheMoney(OptionRight.Call)); + } + + [Test] + public void AtTheMoneyFallsBackToContractUnderlyingPrice() + { + // Chains built from universe data carry the underlying price on each contract + var canonical = Symbol.CreateCanonicalOption(Symbols.SPY); + var symbol = Symbol.CreateOption(Symbols.SPY, QuantConnect.Market.USA, OptionStyle.American, OptionRight.Call, 100m, Expiry1); + var contractData = new OptionUniverse(ChainTime.Date, symbol, "1,1,1,1,10,100,0.5,0.5,0.01,0.02,-0.03,0.04"); + var underlyingData = new OptionUniverse(ChainTime.Date, Symbols.SPY, "99,101,98,100.5,1000,,,,,,,"); + contractData.Underlying = underlyingData; + + var chain = new OptionChain(canonical, ChainTime, new[] { contractData }, SymbolProperties.GetDefault(Currencies.USD)); + + // The chain-level underlying is populated from the contracts data + Assert.AreEqual(100.5m, chain.Underlying.Price); + Assert.AreEqual(100m, chain.AtTheMoney(OptionRight.Call).Strike); + } + + [Test] + public void SelectReplacesTheSortedComprehensionCeremony() + { + var chain = CreateDefaultChain(); + + // The ubiquitous hand-rolled idiom this replaces: + // expiry = min([c.expiry for c in chain], key=lambda e: abs((e - self.time).days - target_dte)) + // expiry_contracts = [c for c in chain if c.expiry == expiry and c.right == right] + // contract = min(expiry_contracts, key=lambda c: abs(c.strike - spot)) + var contract = chain.Select(right: OptionRight.Put, targetDte: 8); + + Assert.IsNotNull(contract); + Assert.AreEqual(OptionRight.Put, contract.Right); + Assert.AreEqual(Expiry2, contract.Expiry); + // Default target is the at-the-money strike + Assert.AreEqual(100m, contract.Strike); + } + + [TestCase(-0.1, 90)] + [TestCase(0.0, 100)] + [TestCase(0.08, 110)] + public void SelectByMoneyness(double moneyness, double expectedStrike) + { + var chain = CreateDefaultChain(); + var contract = chain.Select(right: OptionRight.Put, targetDte: 8, moneyness: (decimal)moneyness); + + Assert.IsNotNull(contract); + Assert.AreEqual(OptionRight.Put, contract.Right); + Assert.AreEqual(Expiry2, contract.Expiry); + Assert.AreEqual((decimal)expectedStrike, contract.Strike); + } + + [TestCase(0.15)] + [TestCase(-0.15)] + public void SelectByDeltaIsSignInsensitive(double targetDelta) + { + var chain = CreateDefaultChain(); + + // A "15 delta put" can be requested with either sign: put deltas are negative + var contract = chain.Select(right: OptionRight.Put, targetDte: 8, targetDelta: (decimal)targetDelta); + + Assert.IsNotNull(contract); + Assert.AreEqual(OptionRight.Put, contract.Right); + Assert.AreEqual(Expiry2, contract.Expiry); + Assert.AreEqual(90m, contract.Strike); + Assert.AreEqual(-0.1m, contract.Greeks.Delta); + } + + [Test] + public void SelectByDeltaIgnoresContractsWithoutGreeks() + { + var chain = CreateChain(new[] + { + (Expiry1, 95m, OptionRight.Call, 0m), + (Expiry1, 100m, OptionRight.Call, 0.5m) + }); + + var contract = chain.Select(right: OptionRight.Call, targetDelta: 0.05m); + Assert.AreEqual(100m, contract.Strike); + + // A chain without any greeks data returns null instead of an arbitrary contract + var noGreeks = CreateChain(new[] + { + (Expiry1, 95m, OptionRight.Call, 0m), + (Expiry1, 100m, OptionRight.Call, 0m) + }); + Assert.IsNull(noGreeks.Select(right: OptionRight.Call, targetDelta: 0.05m)); + } + + [Test] + public void SelectRespectsDteWindow() + { + var chain = CreateDefaultChain(); + + // Guards against the "already-subscribed contracts outside the filter window" trap: + // an explicit window never selects a nearer expiry than requested + var contract = chain.Select(right: OptionRight.Put, targetDte: 0, minDte: 25, maxDte: 60); + Assert.IsNotNull(contract); + Assert.AreEqual(Expiry4, contract.Expiry); + + Assert.IsNull(chain.Select(right: OptionRight.Put, minDte: 40, maxDte: 60)); + } + + [Test] + public void SelectConsidersOnlyTheRequestedRightForExpirySelection() + { + // Expiry3/Expiry4 have puts only: asking for a call must not land on a put-only expiry + var chain = CreateDefaultChain(); + var contract = chain.Select(right: OptionRight.Call, targetDte: 20); + + Assert.IsNotNull(contract); + Assert.AreEqual(OptionRight.Call, contract.Right); + Assert.AreEqual(Expiry2, contract.Expiry); + } + + [Test] + public void SelectWithoutCriteriaReturnsAtTheMoney() + { + var chain = CreateChain(new[] + { + (Expiry1, 95m, OptionRight.Call, 0.8m), + (Expiry1, 99m, OptionRight.Call, 0.5m), + (Expiry1, 105m, OptionRight.Call, 0.2m) + }); + + var contract = chain.Select(); + Assert.AreEqual(99m, contract.Strike); + } + + [Test] + public void SelectIsNullSafe() + { + Assert.IsNull(CreateEmptyChain().Select(right: OptionRight.Put, targetDte: 30, moneyness: -0.15m)); + // Underlying price unavailable: moneyness cannot be computed + var noUnderlying = CreateChain(new[] { (Expiry1, 100m, OptionRight.Call, 0.5m) }, underlyingPrice: null); + Assert.IsNull(noUnderlying.Select(right: OptionRight.Call, moneyness: -0.15m)); + } + + [Test] + public void SelectThrowsWhenMoneynessAndDeltaAreBothSet() + { + var chain = CreateDefaultChain(); + Assert.Throws(() => chain.Select(moneyness: -0.15m, targetDelta: 0.3m)); + } + } +} From 06da38e71c52e39af28c436446523085425e06bf Mon Sep 17 00:00:00 2001 From: Jhonathan Abreu Date: Wed, 12 Aug 2026 18:38:20 -0400 Subject: [PATCH 2/2] Generalize code comments --- Tests/Common/Data/Market/OptionChainTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/Common/Data/Market/OptionChainTests.cs b/Tests/Common/Data/Market/OptionChainTests.cs index 549b912005c6..96faa5d16d58 100644 --- a/Tests/Common/Data/Market/OptionChainTests.cs +++ b/Tests/Common/Data/Market/OptionChainTests.cs @@ -212,7 +212,7 @@ public void AtIgnoresTimeOfDayAndIsNullSafe() public void AtMatchesSaturdayExpiryByLastTradingDate() { // Pre-2015 equity option metadata uses Saturday expiration dates: a user asking for the - // last trading date (Friday) must still match the chain (fleet evidence: strict + // last trading date (Friday) must still match the chain (strict // date(2012, 2, 17) equality matched zero contracts because metadata says 2012-02-18) var saturdayExpiry = new DateTime(2012, 2, 18); var chainTime = new DateTime(2012, 2, 13, 10, 0, 0);