From f805bc386efb38fdf19a2e884488bac0199d008f Mon Sep 17 00:00:00 2001 From: Jhonathan Abreu Date: Fri, 4 Sep 2026 16:44:28 -0400 Subject: [PATCH 01/14] Add option chain selection helpers Single contract pickers and views on OptionChain, null-safe instead of raising: - select() and its synonym pick(): best match by right, target/min/max days to expiration and one of moneyness, strike_from_atm or target_delta - closest_expiry(), at(expiry), at_the_money(), calls, puts, expiries and strike_prices (StrikeList with closest_to, first_above, first_below) - days_to_expiry on contracts, counted to the last trading date for options Expirations and days to expiration follow the last trading date, so Saturday expiring equity options before February 2015 match their Friday. --- ...hainSelectionHelpersRegressionAlgorithm.cs | 225 ++++++++ ...hainSelectionHelpersRegressionAlgorithm.py | 109 ++++ Common/Data/Market/BaseContract.cs | 6 + Common/Data/Market/OptionChain.Selection.cs | 236 +++++++++ Common/Data/Market/OptionContract.cs | 9 + Common/Data/Market/StrikeList.cs | 92 ++++ .../Option/OptionChainFilterUniverse.cs | 16 + .../Data/Market/OptionChainSelectionTests.cs | 489 ++++++++++++++++++ 8 files changed, 1182 insertions(+) create mode 100644 Algorithm.CSharp/OptionChainSelectionHelpersRegressionAlgorithm.cs create mode 100644 Algorithm.Python/OptionChainSelectionHelpersRegressionAlgorithm.py create mode 100644 Common/Data/Market/OptionChain.Selection.cs create mode 100644 Common/Data/Market/StrikeList.cs create mode 100644 Tests/Common/Data/Market/OptionChainSelectionTests.cs diff --git a/Algorithm.CSharp/OptionChainSelectionHelpersRegressionAlgorithm.cs b/Algorithm.CSharp/OptionChainSelectionHelpersRegressionAlgorithm.cs new file mode 100644 index 000000000000..62936079cdd2 --- /dev/null +++ b/Algorithm.CSharp/OptionChainSelectionHelpersRegressionAlgorithm.cs @@ -0,0 +1,225 @@ +/* + * 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 its synonym ), + /// , , + /// , and + /// , which replace the usual hand-rolled 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 strike criteria 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.54, closest expiry to 10 days out is 2015-12-31 (7 days), ATM strike is 747.50 + if (contract.Expiry != new DateTime(2015, 12, 31) || contract.Strike != 747.5m || contract.DaysToExpiry != 7) + { + throw new RegressionTestException($"Unexpected contract selected: {contract.Symbol.Value}, {contract.DaysToExpiry} days to expiry"); + } + + // Pick is a synonym of Select + if (!contract.Symbol.Equals(chain.Pick(right: OptionRight.Call, targetDte: 10).Symbol)) + { + throw new RegressionTestException("Pick() and Select() must select the same contract"); + } + + // 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}"); + } + // The sorted expiries start at the chain date, contracts expiring today are still in the chain + if (chain.Expiries[0] != Time.Date || chain.Expiries.Last() != chain.Expiries.Max()) + { + throw new RegressionTestException($"Unexpected expiries: {string.Join(", ", chain.Expiries)}"); + } + + // Single-expiry view: composes with Calls/Puts, StrikePrices, AtTheMoney and the universe filters + 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 || atExpiry.Calls.Count != atExpiry.CallsOnly().Count) + { + throw new RegressionTestException("At().Calls/.Puts should not be empty and agree with CallsOnly()"); + } + 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}"); + } + + // Strike prices helpers: strictly above/below and closest to the underlying price + var strikes = atExpiry.StrikePrices; + if (strikes.ClosestTo(spot) != 747.5m || strikes.FirstAbove(spot) != 750m || strikes.FirstBelow(spot) != 747.5m) + { + throw new RegressionTestException( + $"StrikePrices helpers mismatch: {strikes.ClosestTo(spot)}/{strikes.FirstAbove(spot)}/{strikes.FirstBelow(spot)}"); + } + + // Strike distance in price units, like the universe strategy filters: 5 above the spot is the 752.50 call + var otmCall = chain.Select(right: OptionRight.Call, targetDte: 7, strikeFromAtm: 5); + if (otmCall == null || otmCall.Strike != 752.5m) + { + throw new RegressionTestException($"Select(strikeFromAtm) expected the 752.50 call but got {otmCall?.Symbol.Value}"); + } + + // 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..213eb51044b7 --- /dev/null +++ b/Algorithm.Python/OptionChainSelectionHelpersRegressionAlgorithm.py @@ -0,0 +1,109 @@ +# 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() (and its synonym pick()), +### closest_expiry(), at(), at_the_money(), strike_prices and expiries, 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 strike criteria 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.54, closest expiry to 10 days out is 2015-12-31 (7 days), ATM strike is 747.50 + if contract.expiry != datetime(2015, 12, 31) or contract.strike != 747.5 or contract.days_to_expiry != 7: + raise AssertionError(f"Unexpected contract selected: {contract.symbol.value}, {contract.days_to_expiry} days to expiry") + + # pick() is a synonym of select() + if contract.symbol != chain.pick(right=OptionRight.CALL, target_dte=10).symbol: + raise AssertionError("pick() and select() must select the same contract") + + # 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}") + # The sorted expiries start at the chain date, contracts expiring today are still in the chain + if chain.expiries[0] != self.time or chain.expiries[-1] != max(chain.expiries): + raise AssertionError(f"Unexpected expiries: {chain.expiries}") + + # Single-expiry view: composes with calls/puts, strike_prices, at_the_money and the universe filters + 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 or len(at_expiry.calls) != at_expiry.calls_only().count: + raise AssertionError("at().calls/.puts should not be empty and agree with calls_only()") + 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}") + + # Strike prices helpers: strictly above/below and closest to the underlying price + strikes = at_expiry.strike_prices + if strikes.closest_to(spot) != 747.5 or strikes.first_above(spot) != 750 or strikes.first_below(spot) != 747.5: + raise AssertionError( + f"strike_prices helpers mismatch: {strikes.closest_to(spot)}/{strikes.first_above(spot)}/{strikes.first_below(spot)}") + + # Strike distance in price units, like the universe strategy filters: 5 above the spot is the 752.50 call + otm_call = chain.select(right=OptionRight.CALL, target_dte=7, strike_from_atm=5) + if otm_call is None or otm_call.strike != 752.5: + raise AssertionError(f"select(strike_from_atm) expected the 752.50 call but got {otm_call}") + + # 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}") + + # 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/BaseContract.cs b/Common/Data/Market/BaseContract.cs index 19110435d8f0..7706f0c746c6 100644 --- a/Common/Data/Market/BaseContract.cs +++ b/Common/Data/Market/BaseContract.cs @@ -48,6 +48,12 @@ public Symbol Symbol /// public DateTime Expiry => Symbol.ID.Date; + /// + /// Gets the number of calendar days until the contract stops trading, counted from the contract's current time + /// + [PandasIgnore] + public virtual int DaysToExpiry => (Expiry.Date - Time.Date).Days; + /// /// Gets the local date time this contract's data was last updated /// diff --git a/Common/Data/Market/OptionChain.Selection.cs b/Common/Data/Market/OptionChain.Selection.cs new file mode 100644 index 000000000000..8f930b392a39 --- /dev/null +++ b/Common/Data/Market/OptionChain.Selection.cs @@ -0,0 +1,236 @@ +/* + * 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.Python; +using QuantConnect.Securities; + +namespace QuantConnect.Data.Market +{ + /// + /// The option chain selection helpers: views of the contracts and single contract pickers, + /// null-safe (None in Python) instead of raising when nothing matches + /// + public partial class OptionChain + { + /// + /// 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, with helpers to find the strike + /// closest to, right above or right below a price. See + /// + [PandasIgnore] + public StrikeList StrikePrices => new(Contracts.Values.Select(contract => contract.Strike)); + + /// + /// Gets the distinct expiration dates in the chain, sorted in ascending order + /// + [PandasIgnore] + public List Expiries => Contracts.Values.Select(contract => contract.Expiry).Distinct().OrderBy(expiry => expiry).ToList(); + + #region Selection helpers + + /// + /// Selects the single contract that best matches the given criteria, e.g. + /// chain.select(right=OptionRight.PUT, target_dte=30, moneyness=-0.15) or chain.select(OptionRight.CALL, 45, target_delta=0.3). + /// Null-safe: returns null (None in Python) instead of throwing when nothing matches. + /// Unlike the universe strategy filters, e.g. , which take a minimum + /// days to expiration and pick the first expiration at or after it, this takes a target and picks the expiration closest to it + /// + /// If set, only contracts of this right are considered + /// If set, only the expiration closest to this many days from the chain date is considered. See + /// If set, expirations closer than this many days are excluded + /// If set, expirations further than this many days are excluded + /// Signed distance of the strike 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. + /// Mutually exclusive with and + /// Signed distance of the strike from the underlying price, in price units, like the universe strategy filters take. + /// Mutually exclusive with and + /// 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 are ignored. + /// Mutually exclusive with and + /// The best matching contract, or null if none matches. Without strike criteria the at-the-money contract is returned + public OptionContract Select(OptionRight? right = null, int? targetDte = null, int? minDte = null, int? maxDte = null, + decimal? moneyness = null, decimal? strikeFromAtm = null, decimal? targetDelta = null) + { + var strikeCriteria = (moneyness.HasValue ? 1 : 0) + (strikeFromAtm.HasValue ? 1 : 0) + (targetDelta.HasValue ? 1 : 0); + if (strikeCriteria > 1) + { + throw new ArgumentException("OptionChain.Select(): moneyness, strikeFromAtm and targetDelta are mutually exclusive, please set only one of them."); + } + + var universe = new OptionChainFilterUniverse(this); + IEnumerable candidates = Contracts.Values; + if (right.HasValue) + { + candidates = candidates.Where(contract => contract.Right == right.Value).ToList(); + } + + if (targetDte.HasValue || minDte.HasValue || maxDte.HasValue) + { + var expiry = GetClosestExpiry(universe, 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 report a zero delta: they are excluded so a chain without greeks returns null + return candidates + .Where(contract => contract.Greeks.Delta != 0) + .OrderBy(contract => Math.Abs(Math.Abs(contract.Greeks.Delta) - target)) + .ThenBy(contract => contract.Expiry) + .ThenBy(contract => contract.Strike) + .ThenBy(contract => contract.Right) + .FirstOrDefault(); + } + + var underlyingPrice = universe.Underlying?.Price; + if (!underlyingPrice.HasValue) + { + return null; + } + var targetStrike = strikeFromAtm.HasValue + ? underlyingPrice.Value + strikeFromAtm.Value + : underlyingPrice.Value * (1 + (moneyness ?? 0)); + return GetClosestByStrike(candidates, targetStrike); + } + + /// + /// Selects the single contract that best matches the given criteria. Synonym of + /// + /// If set, only contracts of this right are considered + /// If set, only the expiration closest to this many days from the chain date is considered + /// If set, expirations closer than this many days are excluded + /// If set, expirations further than this many days are excluded + /// Signed distance of the strike from the underlying price as a fraction of it + /// Signed distance of the strike from the underlying price, in price units + /// If set, the contract whose absolute delta is closest to the absolute value of this target is selected + /// The best matching contract, or null if none matches + public OptionContract Pick(OptionRight? right = null, int? targetDte = null, int? minDte = null, int? maxDte = null, + decimal? moneyness = null, decimal? strikeFromAtm = null, decimal? targetDelta = null) + { + return Select(right, targetDte, minDte, maxDte, moneyness, strikeFromAtm, targetDelta); + } + + /// + /// Gets the expiration date in the chain closest to the target number of days from the chain date. + /// Days to expiration are counted to the contract's last trading date, so Saturday expirations of equity options + /// before February 2015 count on the preceding Friday. + /// 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, i.e. 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 + public DateTime? ClosestExpiry(int? targetDte = null, int? minDte = null, int? maxDte = null) + { + return GetClosestExpiry(new OptionChainFilterUniverse(this), Contracts.Values, targetDte, minDte, maxDte); + } + + /// + /// Gets a new chain containing only the contracts with the given expiration date, e.g. chain.at(expiry).puts. + /// Matching is done on the last trading date, so a chain of Saturday expiring contracts (equity options before February 2015) + /// is also matched by the preceding Friday + /// + /// 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 universe = new OptionChainFilterUniverse(this); + var expiryDate = universe.ToLastTradingDate(expiry); + return Filter(u => + { + u.Data = u.Data.Where(contract => universe.ToLastTradingDate(contract.Expiry) == expiryDate).ToList(); + return u; + }); + } + + /// + /// Gets the contract whose strike is closest to the current underlying price, of the given right if any. + /// When two strikes are equidistant the lower one is returned, and among equal strikes the nearest expiration. + /// Null-safe: returns null (None in Python) when the chain has no matching contracts or the underlying price is unavailable + /// + /// If set, only contracts of this right are considered + /// The at-the-money contract, or null if there is none + public OptionContract AtTheMoney(OptionRight? right = null) + { + return Select(right); + } + + private List GetContracts(OptionRight right) + { + return Contracts.Values + .Where(contract => contract.Right == right) + .OrderBy(contract => contract.Expiry) + .ThenBy(contract => contract.Strike) + .ToList(); + } + + private static DateTime? GetClosestExpiry(OptionChainFilterUniverse universe, IEnumerable contracts, + int? targetDte, int? minDte, int? maxDte) + { + var target = targetDte ?? minDte ?? 0; + DateTime? result = null; + var resultDistance = int.MaxValue; + foreach (var contract in contracts.DistinctBy(contract => contract.Expiry)) + { + var dte = universe.GetDaysToExpiry(contract); + // 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 && contract.Expiry < result.Value)) + { + result = contract.Expiry; + resultDistance = distance; + } + } + return result; + } + + private static OptionContract GetClosestByStrike(IEnumerable contracts, decimal targetStrike) + { + // Scaled strikes are in underlying price units, see SymbolProperties.StrikeMultiplier + return contracts + .OrderBy(contract => Math.Abs(contract.ScaledStrike - targetStrike)) + .ThenBy(contract => contract.Strike) + .ThenBy(contract => contract.Expiry) + .ThenBy(contract => contract.Right) + .FirstOrDefault(); + } + + #endregion + } +} diff --git a/Common/Data/Market/OptionContract.cs b/Common/Data/Market/OptionContract.cs index 4006c4c573aa..00c7da8b023e 100644 --- a/Common/Data/Market/OptionContract.cs +++ b/Common/Data/Market/OptionContract.cs @@ -15,6 +15,7 @@ using QuantConnect.Data.UniverseSelection; using QuantConnect.Interfaces; +using QuantConnect.Python; using QuantConnect.Securities; using QuantConnect.Securities.Option; using System; @@ -28,6 +29,7 @@ public class OptionContract : BaseContract { private IOptionData _optionData = OptionPriceModelResultData.Null; private readonly SymbolProperties _symbolProperties; + private DateTime? _lastTradingDate; /// /// Gets the strike price @@ -104,6 +106,13 @@ public class OptionContract : BaseContract /// public decimal UnderlyingLastPrice => _optionData.UnderlyingLastPrice; + /// + /// Gets the number of calendar days until the contract stops trading, counted from the contract's current time. + /// Expirations on a non trading day, like the Saturday expirations of equity options before February 2015, count on the previous trading day + /// + [PandasIgnore] + public override int DaysToExpiry => ((_lastTradingDate ??= OptionSymbol.GetLastDayOfTrading(Symbol)) - Time.Date).Days; + /// /// The option symbol properties /// diff --git a/Common/Data/Market/StrikeList.cs b/Common/Data/Market/StrikeList.cs new file mode 100644 index 000000000000..7fb54e1e2dd7 --- /dev/null +++ b/Common/Data/Market/StrikeList.cs @@ -0,0 +1,92 @@ +/* + * 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 (None in Python) instead of throwing when no strike matches + /// + 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/Common/Securities/Option/OptionChainFilterUniverse.cs b/Common/Securities/Option/OptionChainFilterUniverse.cs index 37d68d575813..6821ed971398 100644 --- a/Common/Securities/Option/OptionChainFilterUniverse.cs +++ b/Common/Securities/Option/OptionChainFilterUniverse.cs @@ -51,6 +51,22 @@ public OptionChainFilterUniverse(OptionChain chain) _symbol = chain.Symbol; } + /// + /// Gets the number of days until the given contract stops trading, counted from the chain date + /// + internal int GetDaysToExpiry(OptionContract contract) + { + return (GetLastTradingDate(contract.ID.Date) - AdjustExpirationReferenceDate(LocalTime.Date)).Days; + } + + /// + /// Gets the last trading date for the given expiration date + /// + internal DateTime ToLastTradingDate(DateTime expiry) + { + return GetLastTradingDate(expiry); + } + /// /// Not supported: the chain filters only ever select contracts that are already in the chain /// diff --git a/Tests/Common/Data/Market/OptionChainSelectionTests.cs b/Tests/Common/Data/Market/OptionChainSelectionTests.cs new file mode 100644 index 000000000000..b8611a54efd1 --- /dev/null +++ b/Tests/Common/Data/Market/OptionChainSelectionTests.cs @@ -0,0 +1,489 @@ +/* + * 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.Linq; +using NUnit.Framework; +using Python.Runtime; +using QuantConnect.Data.Market; +using QuantConnect.Data.UniverseSelection; +using QuantConnect.Securities; + +namespace QuantConnect.Tests.Common.Data.Market +{ + [TestFixture] + public class OptionChainSelectionTests + { + // Chain date: Thursday. Available expiries below are +1, +8, +15 and +36 days out, none on a holiday + private static readonly DateTime ChainTime = new(2016, 2, 25, 10, 0, 0); + private static readonly DateTime Expiry1 = new(2016, 2, 26); + private static readonly DateTime Expiry2 = new(2016, 3, 4); + private static readonly DateTime Expiry3 = new(2016, 3, 11); + private static readonly DateTime Expiry4 = new(2016, 4, 1); + + 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 rows = contracts.Select(x => ( + Symbol.CreateOption(Symbols.SPY, QuantConnect.Market.USA, OptionStyle.American, x.right, x.strike, x.expiry), + 100m, 0.5m, new Greeks(x.delta, 0.01m, 0.02m, -0.03m * 365m, 0.04m, 0))); + // Like the algorithm does, the chain is built from the previous day's universe data, whose end time is the chain date + var (universeContracts, _) = OptionChainTests.CreateUniverseData(canonical, chainTime.Date.AddDays(-1), underlyingPrice, rows); + + return new OptionChain(canonical, chainTime, universeContracts, SymbolProperties.GetDefault(Currencies.USD)); + } + + 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 StrikePricesAreDistinctAndSorted() + { + var chain = CreateDefaultChain(); + CollectionAssert.AreEqual(new[] { 85m, 90m, 95m, 100m, 105m, 110m }, chain.StrikePrices); + } + + [Test] + public void ExpiriesAreDistinctAndSorted() + { + var chain = CreateDefaultChain(); + CollectionAssert.AreEqual(new[] { Expiry1, Expiry2, Expiry3, Expiry4 }, chain.Expiries); + Assert.IsEmpty(CreateEmptyChain().Expiries); + } + + [TestCase(97, 95)] + // Equidistant from 95 and 100: the lower strike wins + [TestCase(97.5, 95)] + [TestCase(120, 110)] + public void StrikePricesClosestTo(double price, double expected) + { + var chain = CreateDefaultChain(); + Assert.AreEqual((decimal)expected, chain.StrikePrices.ClosestTo((decimal)price)); + } + + [Test] + public void StrikePricesFirstAboveAndBelowAreStrict() + { + var chain = CreateDefaultChain(); + var strikes = chain.StrikePrices; + + 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 StrikePricesHelpersAreNullSafeOnEmptyChain() + { + var strikes = CreateEmptyChain().StrikePrices; + Assert.IsEmpty(strikes); + Assert.IsNull(strikes.ClosestTo(100m)); + Assert.IsNull(strikes.FirstAbove(100m)); + Assert.IsNull(strikes.FirstBelow(100m)); + } + + [TestCase(0, null, null, "20160226")] + [TestCase(10, null, null, "20160304")] + [TestCase(12, null, null, "20160311")] + [TestCase(100, null, null, "20160401")] + // min/max window excludes the otherwise closest expiry + [TestCase(0, 5, null, "20160304")] + [TestCase(100, null, 20, "20160311")] + [TestCase(10, 12, 20, "20160311")] + // no target: defaults to the nearest expiry within the window + [TestCase(null, null, null, "20160226")] + [TestCase(null, 10, null, "20160311")] + public void ClosestExpirySelectsBestMatch(int? targetDte, int? minDte, int? maxDte, string expected) + { + var chain = CreateDefaultChain(); + var expectedExpiry = DateTime.ParseExact(expected, "yyyyMMdd", CultureInfo.InvariantCulture); + Assert.AreEqual(expectedExpiry, chain.ClosestExpiry(targetDte, minDte, maxDte)); + } + + [Test] + public void ClosestExpiryPrefersEarlierExpiryOnTies() + { + // Friday +1 and Wednesday +6 are equidistant from a target of 3.5, use +1 and +5 with target 3 + var chain = CreateChain(new[] + { + (ChainTime.Date.AddDays(1), 100m, OptionRight.Call, 0.5m), + (ChainTime.Date.AddDays(5), 100m, OptionRight.Call, 0.5m) + }); + Assert.AreEqual(ChainTime.Date.AddDays(1), chain.ClosestExpiry(targetDte: 3)); + } + + [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); + Assert.AreEqual(3, filtered.CallsOnly().Count); + CollectionAssert.AreEqual(new[] { 90m, 100m, 110m }, filtered.StrikePrices); + 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() + { + // Equity options before February 2015 have Saturday expiration dates: asking for the + // last trading date (Friday) must still match the chain + 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)); + Assert.IsTrue(chain.All(x => x.DaysToExpiry == 4)); + } + + [Test] + public void DaysToExpiryCountsCalendarDaysToTheLastTradingDate() + { + var chain = CreateDefaultChain(); + CollectionAssert.AreEquivalent(new[] { 1, 8, 15, 36 }, chain.Select(x => x.DaysToExpiry).Distinct()); + Assert.AreEqual(1, chain.At(Expiry1).First().DaysToExpiry); + Assert.AreEqual(36, chain.At(Expiry4).First().DaysToExpiry); + } + + [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 AtTheMoneyWithoutRightPrefersTheNearestExpiryThenCalls() + { + var chain = CreateDefaultChain(); + var contract = chain.AtTheMoney(); + + Assert.AreEqual(100m, contract.Strike); + Assert.AreEqual(Expiry1, contract.Expiry); + 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 AtTheMoneyUsesTheContractsUnderlyingPrice() + { + // Chains built from universe data carry the underlying price on each contract + var chain = CreateChain(new[] { (Expiry1, 100m, OptionRight.Call, 0.5m) }, underlyingPrice: 100.5m); + + Assert.AreEqual(100.5m, chain.Underlying.Price); + Assert.AreEqual(100m, chain.AtTheMoney(OptionRight.Call).Strike); + } + + [Test] + public void SelectReplacesTheSortedComprehensionCeremony() + { + var chain = CreateDefaultChain(); + + // The 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); + } + + [Test] + public void PickIsASynonymOfSelect() + { + var chain = CreateDefaultChain(); + + Assert.AreEqual(chain.Select(right: OptionRight.Put, targetDte: 8).Symbol, chain.Pick(right: OptionRight.Put, targetDte: 8).Symbol); + Assert.AreEqual(chain.Select(right: OptionRight.Call, targetDelta: 0.2m).Symbol, chain.Pick(right: OptionRight.Call, targetDelta: 0.2m).Symbol); + Assert.IsNull(chain.Pick(minDte: 40, maxDte: 60)); + } + + [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(-10, 90)] + [TestCase(0, 100)] + [TestCase(8, 110)] + [TestCase(-4, 100)] + public void SelectByStrikeFromAtm(double strikeFromAtm, double expectedStrike) + { + var chain = CreateDefaultChain(); + var contract = chain.Select(right: OptionRight.Put, targetDte: 8, strikeFromAtm: (decimal)strikeFromAtm); + + 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(); + + // An explicit window never selects a nearer expiry than requested, even if the chain carries it + 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 SelectThrowsWhenMoreThanOneStrikeCriteriaIsSet() + { + var chain = CreateDefaultChain(); + Assert.Throws(() => chain.Select(moneyness: -0.15m, targetDelta: 0.3m)); + Assert.Throws(() => chain.Select(moneyness: -0.15m, strikeFromAtm: -5m)); + Assert.Throws(() => chain.Select(strikeFromAtm: -5m, targetDelta: 0.3m)); + } + + [Test] + public void SelectionHelpersAreAvailableFromPython() + { + var chain = CreateDefaultChain(); + var expected = chain.Select(right: OptionRight.Put, targetDte: 8, moneyness: -0.1m); + + using (Py.GIL()) + { + using var module = PyModule.FromString(nameof(OptionChainSelectionTests), @" +from AlgorithmImports import * + +def select(chain): + return chain.select(right=OptionRight.PUT, target_dte=8, moneyness=-0.1) + +def pick(chain): + return chain.pick(OptionRight.PUT, 8, strike_from_atm=-10) + +def helpers(chain): + at_expiry = chain.at(chain.closest_expiry(target_dte=8)) + return (at_expiry.strike_prices.first_above(100), at_expiry.expiries[0], len(at_expiry.puts), + at_expiry.at_the_money(OptionRight.CALL).days_to_expiry, chain.select(min_dte=40) is None) +"); + using var pyChain = chain.ToPython(); + + using var selected = module.GetAttr("select").Invoke(pyChain); + Assert.AreEqual(expected.Symbol, selected.As().Symbol); + + using var picked = module.GetAttr("pick").Invoke(pyChain); + Assert.AreEqual(expected.Symbol, picked.As().Symbol); + + using var helpers = module.GetAttr("helpers").Invoke(pyChain); + Assert.AreEqual(110m, helpers[0].As()); + Assert.AreEqual(Expiry2, helpers[1].As()); + Assert.AreEqual(3, helpers[2].As()); + Assert.AreEqual(8, helpers[3].As()); + Assert.IsTrue(helpers[4].As()); + } + } + } +} From a97693b693fc6e04bc9dc7b918644a20b565104c Mon Sep 17 00:00:00 2001 From: Jhonathan Abreu Date: Fri, 4 Sep 2026 17:25:01 -0400 Subject: [PATCH 02/14] Cache the option chain views and refresh DataDictionary caches on indexer set Calls, Puts, StrikePrices and Expiries are computed once per contract count and returned as read-only views, since slice chains are filled in as data arrives. DataDictionary's indexer setter now clears its cached keys and values like Add() does, otherwise Values kept returning the list from before the set. --- Common/Data/Market/DataDictionary.cs | 2 +- Common/Data/Market/OptionChain.Selection.cs | 35 +++++++++++-- .../Common/Data/Market/DataDictionaryTests.cs | 49 +++++++++++++++++++ .../Data/Market/OptionChainSelectionTests.cs | 26 ++++++++++ 4 files changed, 107 insertions(+), 5 deletions(-) create mode 100644 Tests/Common/Data/Market/DataDictionaryTests.cs diff --git a/Common/Data/Market/DataDictionary.cs b/Common/Data/Market/DataDictionary.cs index 4e4e36b36811..8103ab39f4c8 100644 --- a/Common/Data/Market/DataDictionary.cs +++ b/Common/Data/Market/DataDictionary.cs @@ -85,7 +85,7 @@ public override T this[Symbol symbol] } set { - _items = null; + ClearCache(); base[symbol] = value; } } diff --git a/Common/Data/Market/OptionChain.Selection.cs b/Common/Data/Market/OptionChain.Selection.cs index 8f930b392a39..6882ad8a93e5 100644 --- a/Common/Data/Market/OptionChain.Selection.cs +++ b/Common/Data/Market/OptionChain.Selection.cs @@ -27,30 +27,38 @@ namespace QuantConnect.Data.Market /// public partial class OptionChain { + // Cached views, valid for the contract count they were built at + private int _viewsContractsCount = -1; + private IReadOnlyList _calls; + private IReadOnlyList _puts; + private StrikeList _strikePrices; + private IReadOnlyList _expiries; + /// /// Gets all call contracts in the chain, sorted by expiration and strike /// [PandasIgnore] - public List Calls => GetContracts(OptionRight.Call); + public IReadOnlyList Calls => GetView(ref _calls, () => GetContracts(OptionRight.Call)); /// /// Gets all put contracts in the chain, sorted by expiration and strike /// [PandasIgnore] - public List Puts => GetContracts(OptionRight.Put); + public IReadOnlyList Puts => GetView(ref _puts, () => GetContracts(OptionRight.Put)); /// /// Gets the distinct strike prices in the chain, sorted in ascending order, with helpers to find the strike /// closest to, right above or right below a price. See /// [PandasIgnore] - public StrikeList StrikePrices => new(Contracts.Values.Select(contract => contract.Strike)); + public StrikeList StrikePrices => GetView(ref _strikePrices, () => new StrikeList(Contracts.Values.Select(contract => contract.Strike))); /// /// Gets the distinct expiration dates in the chain, sorted in ascending order /// [PandasIgnore] - public List Expiries => Contracts.Values.Select(contract => contract.Expiry).Distinct().OrderBy(expiry => expiry).ToList(); + public IReadOnlyList Expiries => GetView(ref _expiries, + () => Contracts.Values.Select(contract => contract.Expiry).Distinct().OrderBy(expiry => expiry).ToList()); #region Selection helpers @@ -187,6 +195,25 @@ public OptionContract AtTheMoney(OptionRight? right = null) return Select(right); } + /// + /// Gets a cached view of the contracts, recomputed when contracts have been added to the chain since it was built + /// + private T GetView(ref T view, Func compute) + where T : class + { + // Slice chains are filled in as data arrives, so a new contract count invalidates every view. + // Contracts are only ever added, never replaced, and the views derive from the contract symbols + if (_viewsContractsCount != Contracts.Count) + { + _calls = null; + _puts = null; + _strikePrices = null; + _expiries = null; + _viewsContractsCount = Contracts.Count; + } + return view ??= compute(); + } + private List GetContracts(OptionRight right) { return Contracts.Values diff --git a/Tests/Common/Data/Market/DataDictionaryTests.cs b/Tests/Common/Data/Market/DataDictionaryTests.cs new file mode 100644 index 000000000000..f47466f55135 --- /dev/null +++ b/Tests/Common/Data/Market/DataDictionaryTests.cs @@ -0,0 +1,49 @@ +/* + * 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 NUnit.Framework; +using QuantConnect.Data.Market; + +namespace QuantConnect.Tests.Common.Data.Market +{ + [TestFixture] + public class DataDictionaryTests + { + [Test] + public void IndexerSetterRefreshesTheCachedKeysAndValues() + { + var dictionary = new TradeBars(new DateTime(2016, 2, 26)); + dictionary.Add(Symbols.SPY, new TradeBar { Symbol = Symbols.SPY, Close = 1 }); + + // read every cached view, then add through the indexer like the option chains do + Assert.AreEqual(1, dictionary.Keys.Count); + Assert.AreEqual(1, dictionary.Values.Count); + Assert.AreEqual(1, dictionary.Count()); + + dictionary[Symbols.AAPL] = new TradeBar { Symbol = Symbols.AAPL, Close = 2 }; + + CollectionAssert.AreEquivalent(new[] { Symbols.SPY, Symbols.AAPL }, dictionary.Keys); + CollectionAssert.AreEquivalent(new[] { 1m, 2m }, dictionary.Values.Select(x => x.Close)); + Assert.AreEqual(2, dictionary.Count()); + + // replacing an entry refreshes the values too + dictionary[Symbols.AAPL] = new TradeBar { Symbol = Symbols.AAPL, Close = 3 }; + + CollectionAssert.AreEquivalent(new[] { 1m, 3m }, dictionary.Values.Select(x => x.Close)); + } + } +} diff --git a/Tests/Common/Data/Market/OptionChainSelectionTests.cs b/Tests/Common/Data/Market/OptionChainSelectionTests.cs index b8611a54efd1..ff9ca692cd4c 100644 --- a/Tests/Common/Data/Market/OptionChainSelectionTests.cs +++ b/Tests/Common/Data/Market/OptionChainSelectionTests.cs @@ -99,6 +99,32 @@ public void CallsAndPutsAreFilteredAndSorted() puts.Select(x => x.Symbol)); } + [Test] + public void ViewsAreCachedUntilContractsAreAdded() + { + var chain = CreateDefaultChain(); + var calls = chain.Calls; + var puts = chain.Puts; + var strikes = chain.StrikePrices; + var expiries = chain.Expiries; + + Assert.AreSame(calls, chain.Calls); + Assert.AreSame(puts, chain.Puts); + Assert.AreSame(strikes, chain.StrikePrices); + Assert.AreSame(expiries, chain.Expiries); + + // Slice chains get their contracts one by one as data arrives: the views follow + var added = CreateChain(new[] { (new DateTime(2016, 5, 20), 120m, OptionRight.Call, 0.05m) }).Single(); + chain.Contracts[added.Symbol] = added; + + Assert.AreNotSame(calls, chain.Calls); + Assert.AreEqual(calls.Count + 1, chain.Calls.Count); + Assert.AreSame(added, chain.Calls.Last()); + Assert.AreEqual(puts.Count, chain.Puts.Count); + Assert.AreEqual(120m, chain.StrikePrices.Last()); + Assert.AreEqual(new DateTime(2016, 5, 20), chain.Expiries.Last()); + } + [Test] public void StrikePricesAreDistinctAndSorted() { From 1de7d0d63013172ad83ae4c926c8521791c4a56e Mon Sep 17 00:00:00 2001 From: Jhonathan Abreu Date: Fri, 4 Sep 2026 17:46:01 -0400 Subject: [PATCH 03/14] Select option chain contracts by StrikeTarget StrikeTarget carries the one strike criterion of OptionChain.Select and Pick, at the money, moneyness, distance from ATM or delta, so the criteria can no longer conflict and the selection math is testable on its own. Shorter doc comments on the selection helpers, StrikeList and days to expiry. --- ...hainSelectionHelpersRegressionAlgorithm.cs | 9 +- ...hainSelectionHelpersRegressionAlgorithm.py | 8 +- Common/Data/Market/BaseContract.cs | 2 +- Common/Data/Market/OptionChain.Selection.cs | 132 +++++------------ Common/Data/Market/OptionContract.cs | 4 +- Common/Data/Market/StrikeList.cs | 21 ++- Common/Data/Market/StrikeTarget.cs | 133 ++++++++++++++++++ .../Data/Market/OptionChainSelectionTests.cs | 44 +++--- 8 files changed, 221 insertions(+), 132 deletions(-) create mode 100644 Common/Data/Market/StrikeTarget.cs diff --git a/Algorithm.CSharp/OptionChainSelectionHelpersRegressionAlgorithm.cs b/Algorithm.CSharp/OptionChainSelectionHelpersRegressionAlgorithm.cs index 62936079cdd2..dc795acb2579 100644 --- a/Algorithm.CSharp/OptionChainSelectionHelpersRegressionAlgorithm.cs +++ b/Algorithm.CSharp/OptionChainSelectionHelpersRegressionAlgorithm.cs @@ -18,6 +18,7 @@ using System.Collections.Generic; using System.Linq; using QuantConnect.Data; +using QuantConnect.Data.Market; using QuantConnect.Interfaces; namespace QuantConnect.Algorithm.CSharp @@ -113,21 +114,21 @@ public override void Initialize() } // Strike distance in price units, like the universe strategy filters: 5 above the spot is the 752.50 call - var otmCall = chain.Select(right: OptionRight.Call, targetDte: 7, strikeFromAtm: 5); + var otmCall = chain.Select(right: OptionRight.Call, targetDte: 7, strike: StrikeTarget.FromAtm(5)); if (otmCall == null || otmCall.Strike != 752.5m) { - throw new RegressionTestException($"Select(strikeFromAtm) expected the 752.50 call but got {otmCall?.Symbol.Value}"); + throw new RegressionTestException($"Select(StrikeTarget.FromAtm) expected the 752.50 call but got {otmCall?.Symbol.Value}"); } // 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 deltaPut = chain.Select(right: OptionRight.Put, targetDte: 7, strike: StrikeTarget.Delta(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}"); + throw new RegressionTestException($"Select(StrikeTarget.Delta) mismatch: {deltaPut?.Symbol.Value} != {ceremonyDeltaPut.Symbol.Value}"); } // The helpers are null-safe: no match returns null instead of throwing like min()/First() would diff --git a/Algorithm.Python/OptionChainSelectionHelpersRegressionAlgorithm.py b/Algorithm.Python/OptionChainSelectionHelpersRegressionAlgorithm.py index 213eb51044b7..5b927dba494b 100644 --- a/Algorithm.Python/OptionChainSelectionHelpersRegressionAlgorithm.py +++ b/Algorithm.Python/OptionChainSelectionHelpersRegressionAlgorithm.py @@ -75,17 +75,17 @@ def initialize(self): f"strike_prices helpers mismatch: {strikes.closest_to(spot)}/{strikes.first_above(spot)}/{strikes.first_below(spot)}") # Strike distance in price units, like the universe strategy filters: 5 above the spot is the 752.50 call - otm_call = chain.select(right=OptionRight.CALL, target_dte=7, strike_from_atm=5) + otm_call = chain.select(right=OptionRight.CALL, target_dte=7, strike=StrikeTarget.from_atm(5)) if otm_call is None or otm_call.strike != 752.5: - raise AssertionError(f"select(strike_from_atm) expected the 752.50 call but got {otm_call}") + raise AssertionError(f"select(StrikeTarget.from_atm) expected the 752.50 call but got {otm_call}") # 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) + delta_put = chain.select(right=OptionRight.PUT, target_dte=7, strike=StrikeTarget.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}") + raise AssertionError(f"select(StrikeTarget.delta) mismatch: {delta_put} != {ceremony_delta_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 diff --git a/Common/Data/Market/BaseContract.cs b/Common/Data/Market/BaseContract.cs index 7706f0c746c6..0d9210d2bb61 100644 --- a/Common/Data/Market/BaseContract.cs +++ b/Common/Data/Market/BaseContract.cs @@ -49,7 +49,7 @@ public Symbol Symbol public DateTime Expiry => Symbol.ID.Date; /// - /// Gets the number of calendar days until the contract stops trading, counted from the contract's current time + /// Calendar days from this contract's time until it stops trading /// [PandasIgnore] public virtual int DaysToExpiry => (Expiry.Date - Time.Date).Days; diff --git a/Common/Data/Market/OptionChain.Selection.cs b/Common/Data/Market/OptionChain.Selection.cs index 6882ad8a93e5..78bc582a681c 100644 --- a/Common/Data/Market/OptionChain.Selection.cs +++ b/Common/Data/Market/OptionChain.Selection.cs @@ -35,26 +35,25 @@ public partial class OptionChain private IReadOnlyList _expiries; /// - /// Gets all call contracts in the chain, sorted by expiration and strike + /// The call contracts, sorted by expiration then strike /// [PandasIgnore] public IReadOnlyList Calls => GetView(ref _calls, () => GetContracts(OptionRight.Call)); /// - /// Gets all put contracts in the chain, sorted by expiration and strike + /// The put contracts, sorted by expiration then strike /// [PandasIgnore] public IReadOnlyList Puts => GetView(ref _puts, () => GetContracts(OptionRight.Put)); /// - /// Gets the distinct strike prices in the chain, sorted in ascending order, with helpers to find the strike - /// closest to, right above or right below a price. See + /// The distinct strikes, ascending, with helpers to find the closest, next above or next below a price /// [PandasIgnore] public StrikeList StrikePrices => GetView(ref _strikePrices, () => new StrikeList(Contracts.Values.Select(contract => contract.Strike))); /// - /// Gets the distinct expiration dates in the chain, sorted in ascending order + /// The distinct expiration dates, ascending /// [PandasIgnore] public IReadOnlyList Expiries => GetView(ref _expiries, @@ -63,34 +62,20 @@ public partial class OptionChain #region Selection helpers /// - /// Selects the single contract that best matches the given criteria, e.g. - /// chain.select(right=OptionRight.PUT, target_dte=30, moneyness=-0.15) or chain.select(OptionRight.CALL, 45, target_delta=0.3). - /// Null-safe: returns null (None in Python) instead of throwing when nothing matches. - /// Unlike the universe strategy filters, e.g. , which take a minimum - /// days to expiration and pick the first expiration at or after it, this takes a target and picks the expiration closest to it + /// Selects the single contract closest to the criteria, e.g. + /// chain.select(OptionRight.PUT, target_dte=30, strike=StrikeTarget.moneyness(-0.15)). + /// Returns null (None in Python) when nothing matches. Unlike the universe strategy filters, + /// which take a minimum days to expiration, this takes a target and picks the closest expiration /// - /// If set, only contracts of this right are considered - /// If set, only the expiration closest to this many days from the chain date is considered. See - /// If set, expirations closer than this many days are excluded - /// If set, expirations further than this many days are excluded - /// Signed distance of the strike 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. - /// Mutually exclusive with and - /// Signed distance of the strike from the underlying price, in price units, like the universe strategy filters take. - /// Mutually exclusive with and - /// 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 are ignored. - /// Mutually exclusive with and - /// The best matching contract, or null if none matches. Without strike criteria the at-the-money contract is returned + /// Only consider contracts of this right, any right when null + /// Only consider the expiration closest to this many days out, see + /// Exclude expirations closer than this many days + /// Exclude expirations further than this many days + /// The strike criterion, at the money when null. See + /// The best matching contract, or null public OptionContract Select(OptionRight? right = null, int? targetDte = null, int? minDte = null, int? maxDte = null, - decimal? moneyness = null, decimal? strikeFromAtm = null, decimal? targetDelta = null) + StrikeTarget strike = null) { - var strikeCriteria = (moneyness.HasValue ? 1 : 0) + (strikeFromAtm.HasValue ? 1 : 0) + (targetDelta.HasValue ? 1 : 0); - if (strikeCriteria > 1) - { - throw new ArgumentException("OptionChain.Select(): moneyness, strikeFromAtm and targetDelta are mutually exclusive, please set only one of them."); - } - var universe = new OptionChainFilterUniverse(this); IEnumerable candidates = Contracts.Values; if (right.HasValue) @@ -108,70 +93,43 @@ public OptionContract Select(OptionRight? right = null, int? targetDte = null, i candidates = candidates.Where(contract => contract.Expiry == expiry.Value).ToList(); } - if (targetDelta.HasValue) - { - var target = Math.Abs(targetDelta.Value); - // Contracts without greeks report a zero delta: they are excluded so a chain without greeks returns null - return candidates - .Where(contract => contract.Greeks.Delta != 0) - .OrderBy(contract => Math.Abs(Math.Abs(contract.Greeks.Delta) - target)) - .ThenBy(contract => contract.Expiry) - .ThenBy(contract => contract.Strike) - .ThenBy(contract => contract.Right) - .FirstOrDefault(); - } - - var underlyingPrice = universe.Underlying?.Price; - if (!underlyingPrice.HasValue) - { - return null; - } - var targetStrike = strikeFromAtm.HasValue - ? underlyingPrice.Value + strikeFromAtm.Value - : underlyingPrice.Value * (1 + (moneyness ?? 0)); - return GetClosestByStrike(candidates, targetStrike); + return (strike ?? StrikeTarget.AtTheMoney).Select(candidates, universe.Underlying?.Price); } /// - /// Selects the single contract that best matches the given criteria. Synonym of + /// Synonym of /// - /// If set, only contracts of this right are considered - /// If set, only the expiration closest to this many days from the chain date is considered - /// If set, expirations closer than this many days are excluded - /// If set, expirations further than this many days are excluded - /// Signed distance of the strike from the underlying price as a fraction of it - /// Signed distance of the strike from the underlying price, in price units - /// If set, the contract whose absolute delta is closest to the absolute value of this target is selected - /// The best matching contract, or null if none matches + /// Only consider contracts of this right, any right when null + /// Only consider the expiration closest to this many days out + /// Exclude expirations closer than this many days + /// Exclude expirations further than this many days + /// The strike criterion, at the money when null + /// The best matching contract, or null public OptionContract Pick(OptionRight? right = null, int? targetDte = null, int? minDte = null, int? maxDte = null, - decimal? moneyness = null, decimal? strikeFromAtm = null, decimal? targetDelta = null) + StrikeTarget strike = null) { - return Select(right, targetDte, minDte, maxDte, moneyness, strikeFromAtm, targetDelta); + return Select(right, targetDte, minDte, maxDte, strike); } /// - /// Gets the expiration date in the chain closest to the target number of days from the chain date. - /// Days to expiration are counted to the contract's last trading date, so Saturday expirations of equity options - /// before February 2015 count on the preceding Friday. - /// Null-safe: returns null (None in Python) when the chain is empty or no expiration falls within the requested window + /// Gets the expiration closest to the target days out. Days are counted to the last trading date, + /// so Saturday expirations count on their Friday. Returns null (None in Python) when none falls in the window /// - /// The target days to expiration. When two expirations are equidistant the earlier one is returned. - /// Defaults to minDte if set, else 0, i.e. 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 + /// The target days to expiration, ties go to the earlier expiration. Defaults to minDte, else 0 + /// Exclude expirations closer than this many days + /// Exclude expirations further than this many days + /// The expiration date as stored in the contracts, or null public DateTime? ClosestExpiry(int? targetDte = null, int? minDte = null, int? maxDte = null) { return GetClosestExpiry(new OptionChainFilterUniverse(this), Contracts.Values, targetDte, minDte, maxDte); } /// - /// Gets a new chain containing only the contracts with the given expiration date, e.g. chain.at(expiry).puts. - /// Matching is done on the last trading date, so a chain of Saturday expiring contracts (equity options before February 2015) - /// is also matched by the preceding Friday + /// Gets a new chain with the contracts of the given expiration, matched on the last trading date, + /// so Saturday expirations are also matched by their Friday. Time of day is ignored /// - /// The expiration date, time of day is ignored - /// A new chain with only the matching contracts, empty if none matches + /// The expiration date + /// A new chain, empty when nothing matches public OptionChain At(DateTime expiry) { var universe = new OptionChainFilterUniverse(this); @@ -184,12 +142,11 @@ public OptionChain At(DateTime expiry) } /// - /// Gets the contract whose strike is closest to the current underlying price, of the given right if any. - /// When two strikes are equidistant the lower one is returned, and among equal strikes the nearest expiration. - /// Null-safe: returns null (None in Python) when the chain has no matching contracts or the underlying price is unavailable + /// Gets the contract with the strike closest to the underlying price. Ties go to the lower strike, + /// then the nearest expiration. Returns null (None in Python) when there is none /// - /// If set, only contracts of this right are considered - /// The at-the-money contract, or null if there is none + /// Only consider contracts of this right, any right when null + /// The at-the-money contract, or null public OptionContract AtTheMoney(OptionRight? right = null) { return Select(right); @@ -247,17 +204,6 @@ private List GetContracts(OptionRight right) return result; } - private static OptionContract GetClosestByStrike(IEnumerable contracts, decimal targetStrike) - { - // Scaled strikes are in underlying price units, see SymbolProperties.StrikeMultiplier - return contracts - .OrderBy(contract => Math.Abs(contract.ScaledStrike - targetStrike)) - .ThenBy(contract => contract.Strike) - .ThenBy(contract => contract.Expiry) - .ThenBy(contract => contract.Right) - .FirstOrDefault(); - } - #endregion } } diff --git a/Common/Data/Market/OptionContract.cs b/Common/Data/Market/OptionContract.cs index 00c7da8b023e..3d5875932826 100644 --- a/Common/Data/Market/OptionContract.cs +++ b/Common/Data/Market/OptionContract.cs @@ -107,8 +107,8 @@ public class OptionContract : BaseContract public decimal UnderlyingLastPrice => _optionData.UnderlyingLastPrice; /// - /// Gets the number of calendar days until the contract stops trading, counted from the contract's current time. - /// Expirations on a non trading day, like the Saturday expirations of equity options before February 2015, count on the previous trading day + /// Calendar days from this contract's time until its last trading date, the previous trading day + /// for expirations on a Saturday or holiday /// [PandasIgnore] public override int DaysToExpiry => ((_lastTradingDate ??= OptionSymbol.GetLastDayOfTrading(Symbol)) - Time.Date).Days; diff --git a/Common/Data/Market/StrikeList.cs b/Common/Data/Market/StrikeList.cs index 7fb54e1e2dd7..9fb6c73cf7a1 100644 --- a/Common/Data/Market/StrikeList.cs +++ b/Common/Data/Market/StrikeList.cs @@ -20,27 +20,24 @@ 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 (None in Python) instead of throwing when no strike matches + /// The distinct strikes of a chain, ascending, with helpers that return null (None in Python) when no strike matches /// public class StrikeList : List { /// - /// Initializes a new instance of the class with the distinct - /// values of the given strikes, sorted in ascending order + /// Creates the list from the given strikes, in any order, duplicates allowed /// - /// The strike prices, in any order, duplicates allowed + /// The strike prices 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 strike closest to the price, the lower one on ties /// /// The reference price, e.g. the underlying price - /// The closest strike, or null if there are no strikes + /// The closest strike, or null when the list is empty public decimal? ClosestTo(decimal price) { decimal? closest = null; @@ -56,10 +53,10 @@ public StrikeList(IEnumerable strikes) } /// - /// Gets the lowest strike strictly greater than the given price + /// The lowest strike above the price /// /// The reference price, e.g. the underlying price - /// The first strike above the price, or null if there is none + /// The first strike above the price, or null when there is none public decimal? FirstAbove(decimal price) { foreach (var strike in this) @@ -73,10 +70,10 @@ public StrikeList(IEnumerable strikes) } /// - /// Gets the highest strike strictly less than the given price + /// The highest strike below the price /// /// The reference price, e.g. the underlying price - /// The first strike below the price, or null if there is none + /// The first strike below the price, or null when there is none public decimal? FirstBelow(decimal price) { for (var i = Count - 1; i >= 0; i--) diff --git a/Common/Data/Market/StrikeTarget.cs b/Common/Data/Market/StrikeTarget.cs new file mode 100644 index 000000000000..b5eca7d8c69f --- /dev/null +++ b/Common/Data/Market/StrikeTarget.cs @@ -0,0 +1,133 @@ +/* + * 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 strike criterion uses to pick a contract: the strike closest to the underlying price + /// (), to a fraction of it (), to a distance from it in price units + /// () or the contract whose delta is closest to a target (). + /// Only one criterion can be expressed, so there is nothing to validate at the call site + /// + public class StrikeTarget + { + private enum Criterion + { + Moneyness, + FromAtm, + Delta + } + + private readonly Criterion _criterion; + private readonly decimal _value; + + /// + /// The strike closest to the underlying price + /// + public static StrikeTarget AtTheMoney { get; } = new(Criterion.Moneyness, 0); + + private StrikeTarget(Criterion criterion, decimal value) + { + _criterion = criterion; + _value = value; + } + + /// + /// The strike closest to the underlying price times one plus the given fraction, 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 + /// + /// The signed distance from the underlying price as a fraction of it + public static StrikeTarget Moneyness(decimal moneyness) + { + return new StrikeTarget(Criterion.Moneyness, moneyness); + } + + /// + /// The strike closest to the underlying price plus the given distance in price units, + /// like the universe strategy filters take, e.g. -5 targets the strike closest to 5 below the underlying price + /// + /// The signed distance from the underlying price + public static StrikeTarget FromAtm(decimal strikeFromAtm) + { + return new StrikeTarget(Criterion.FromAtm, strikeFromAtm); + } + + /// + /// The contract whose absolute delta is closest to the absolute value of the given target, + /// so a 30 delta put can be requested as either 0.3 or -0.3. Contracts without greeks are ignored + /// + /// The target delta + public static StrikeTarget Delta(decimal targetDelta) + { + return new StrikeTarget(Criterion.Delta, Math.Abs(targetDelta)); + } + + /// + /// Selects the contract that best matches this target + /// + /// The candidate contracts + /// The underlying price, null when unknown + /// The best matching contract, or null when there is none or the target needs an unknown underlying price + internal OptionContract Select(IEnumerable contracts, decimal? underlyingPrice) + { + if (_criterion == Criterion.Delta) + { + // Contracts without greeks report a zero delta: they are excluded so a chain without greeks returns null + return contracts + .Where(contract => contract.Greeks.Delta != 0) + .OrderBy(contract => Math.Abs(Math.Abs(contract.Greeks.Delta) - _value)) + .ThenBy(contract => contract.Expiry) + .ThenBy(contract => contract.Strike) + .ThenBy(contract => contract.Right) + .FirstOrDefault(); + } + + if (!underlyingPrice.HasValue) + { + return null; + } + + var targetStrike = _criterion == Criterion.FromAtm + ? underlyingPrice.Value + _value + : underlyingPrice.Value * (1 + _value); + // Scaled strikes are in underlying price units, see SymbolProperties.StrikeMultiplier. + // Ties go to the lower strike, then the nearest expiration, then calls + return contracts + .OrderBy(contract => Math.Abs(contract.ScaledStrike - targetStrike)) + .ThenBy(contract => contract.Strike) + .ThenBy(contract => contract.Expiry) + .ThenBy(contract => contract.Right) + .FirstOrDefault(); + } + + /// + /// Returns a string that represents the target + /// + public override string ToString() + { + return _criterion switch + { + Criterion.Delta => $"Delta({_value})", + Criterion.FromAtm => $"FromAtm({_value})", + _ => _value == 0 ? "AtTheMoney" : $"Moneyness({_value})" + }; + } + } +} diff --git a/Tests/Common/Data/Market/OptionChainSelectionTests.cs b/Tests/Common/Data/Market/OptionChainSelectionTests.cs index ff9ca692cd4c..3ee0cae3d63a 100644 --- a/Tests/Common/Data/Market/OptionChainSelectionTests.cs +++ b/Tests/Common/Data/Market/OptionChainSelectionTests.cs @@ -346,7 +346,7 @@ public void PickIsASynonymOfSelect() var chain = CreateDefaultChain(); Assert.AreEqual(chain.Select(right: OptionRight.Put, targetDte: 8).Symbol, chain.Pick(right: OptionRight.Put, targetDte: 8).Symbol); - Assert.AreEqual(chain.Select(right: OptionRight.Call, targetDelta: 0.2m).Symbol, chain.Pick(right: OptionRight.Call, targetDelta: 0.2m).Symbol); + Assert.AreEqual(chain.Select(right: OptionRight.Call, strike: StrikeTarget.Delta(0.2m)).Symbol, chain.Pick(right: OptionRight.Call, strike: StrikeTarget.Delta(0.2m)).Symbol); Assert.IsNull(chain.Pick(minDte: 40, maxDte: 60)); } @@ -356,7 +356,7 @@ public void PickIsASynonymOfSelect() public void SelectByMoneyness(double moneyness, double expectedStrike) { var chain = CreateDefaultChain(); - var contract = chain.Select(right: OptionRight.Put, targetDte: 8, moneyness: (decimal)moneyness); + var contract = chain.Select(right: OptionRight.Put, targetDte: 8, strike: StrikeTarget.Moneyness((decimal)moneyness)); Assert.IsNotNull(contract); Assert.AreEqual(OptionRight.Put, contract.Right); @@ -371,7 +371,7 @@ public void SelectByMoneyness(double moneyness, double expectedStrike) public void SelectByStrikeFromAtm(double strikeFromAtm, double expectedStrike) { var chain = CreateDefaultChain(); - var contract = chain.Select(right: OptionRight.Put, targetDte: 8, strikeFromAtm: (decimal)strikeFromAtm); + var contract = chain.Select(right: OptionRight.Put, targetDte: 8, strike: StrikeTarget.FromAtm((decimal)strikeFromAtm)); Assert.IsNotNull(contract); Assert.AreEqual(OptionRight.Put, contract.Right); @@ -386,7 +386,7 @@ 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); + var contract = chain.Select(right: OptionRight.Put, targetDte: 8, strike: StrikeTarget.Delta((decimal)targetDelta)); Assert.IsNotNull(contract); Assert.AreEqual(OptionRight.Put, contract.Right); @@ -404,7 +404,7 @@ public void SelectByDeltaIgnoresContractsWithoutGreeks() (Expiry1, 100m, OptionRight.Call, 0.5m) }); - var contract = chain.Select(right: OptionRight.Call, targetDelta: 0.05m); + var contract = chain.Select(right: OptionRight.Call, strike: StrikeTarget.Delta(0.05m)); Assert.AreEqual(100m, contract.Strike); // A chain without any greeks data returns null instead of an arbitrary contract @@ -413,7 +413,7 @@ public void SelectByDeltaIgnoresContractsWithoutGreeks() (Expiry1, 95m, OptionRight.Call, 0m), (Expiry1, 100m, OptionRight.Call, 0m) }); - Assert.IsNull(noGreeks.Select(right: OptionRight.Call, targetDelta: 0.05m)); + Assert.IsNull(noGreeks.Select(right: OptionRight.Call, strike: StrikeTarget.Delta(0.05m))); } [Test] @@ -458,26 +458,38 @@ public void SelectWithoutCriteriaReturnsAtTheMoney() [Test] public void SelectIsNullSafe() { - Assert.IsNull(CreateEmptyChain().Select(right: OptionRight.Put, targetDte: 30, moneyness: -0.15m)); + Assert.IsNull(CreateEmptyChain().Select(right: OptionRight.Put, targetDte: 30, strike: StrikeTarget.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)); + Assert.IsNull(noUnderlying.Select(right: OptionRight.Call, strike: StrikeTarget.Moneyness(-0.15m))); } [Test] - public void SelectThrowsWhenMoreThanOneStrikeCriteriaIsSet() + public void StrikeTargetsSelectFromAnyContractList() { - var chain = CreateDefaultChain(); - Assert.Throws(() => chain.Select(moneyness: -0.15m, targetDelta: 0.3m)); - Assert.Throws(() => chain.Select(moneyness: -0.15m, strikeFromAtm: -5m)); - Assert.Throws(() => chain.Select(strikeFromAtm: -5m, targetDelta: 0.3m)); + var contracts = CreateDefaultChain().At(Expiry2).ToList(); + + Assert.AreEqual(100m, StrikeTarget.AtTheMoney.Select(contracts, 101m).Strike); + Assert.AreEqual(110m, StrikeTarget.Moneyness(0.08m).Select(contracts, 100m).Strike); + Assert.AreEqual(90m, StrikeTarget.FromAtm(-8m).Select(contracts, 100m).Strike); + // delta needs no underlying price and is sign insensitive + Assert.AreEqual(90m, StrikeTarget.Delta(-0.1m).Select(contracts, null).Strike); + Assert.AreEqual(OptionRight.Put, StrikeTarget.Delta(-0.1m).Select(contracts, null).Right); + // strike based targets need the underlying price + Assert.IsNull(StrikeTarget.Moneyness(0.08m).Select(contracts, null)); + Assert.IsNull(StrikeTarget.AtTheMoney.Select(Enumerable.Empty(), 100m)); + + Assert.AreEqual("AtTheMoney", StrikeTarget.AtTheMoney.ToString()); + Assert.AreEqual("Moneyness(-0.15)", StrikeTarget.Moneyness(-0.15m).ToString()); + Assert.AreEqual("FromAtm(-5)", StrikeTarget.FromAtm(-5m).ToString()); + Assert.AreEqual("Delta(0.3)", StrikeTarget.Delta(-0.3m).ToString()); } [Test] public void SelectionHelpersAreAvailableFromPython() { var chain = CreateDefaultChain(); - var expected = chain.Select(right: OptionRight.Put, targetDte: 8, moneyness: -0.1m); + var expected = chain.Select(right: OptionRight.Put, targetDte: 8, strike: StrikeTarget.Moneyness(-0.1m)); using (Py.GIL()) { @@ -485,10 +497,10 @@ public void SelectionHelpersAreAvailableFromPython() from AlgorithmImports import * def select(chain): - return chain.select(right=OptionRight.PUT, target_dte=8, moneyness=-0.1) + return chain.select(right=OptionRight.PUT, target_dte=8, strike=StrikeTarget.moneyness(-0.1)) def pick(chain): - return chain.pick(OptionRight.PUT, 8, strike_from_atm=-10) + return chain.pick(OptionRight.PUT, 8, strike=StrikeTarget.from_atm(-10)) def helpers(chain): at_expiry = chain.at(chain.closest_expiry(target_dte=8)) From 6eabdc235102eb2e3596319701ef976c85619d3c Mon Sep 17 00:00:00 2001 From: Jhonathan Abreu Date: Fri, 4 Sep 2026 18:07:07 -0400 Subject: [PATCH 04/14] Replace StrikeTarget with SelectByStrikeDistance and SelectByDelta One method per strike criterion instead of a target type: Select and Pick take the moneyness, SelectByStrikeDistance the distance from the underlying price and SelectByDelta the target delta, all sharing the right and expiration narrowing. --- ...hainSelectionHelpersRegressionAlgorithm.cs | 9 +- ...hainSelectionHelpersRegressionAlgorithm.py | 8 +- Common/Data/Market/OptionChain.Selection.cs | 120 ++++++++++++---- Common/Data/Market/StrikeTarget.cs | 133 ------------------ .../Data/Market/OptionChainSelectionTests.cs | 50 +++---- 5 files changed, 121 insertions(+), 199 deletions(-) delete mode 100644 Common/Data/Market/StrikeTarget.cs diff --git a/Algorithm.CSharp/OptionChainSelectionHelpersRegressionAlgorithm.cs b/Algorithm.CSharp/OptionChainSelectionHelpersRegressionAlgorithm.cs index dc795acb2579..dc2f5cdcd223 100644 --- a/Algorithm.CSharp/OptionChainSelectionHelpersRegressionAlgorithm.cs +++ b/Algorithm.CSharp/OptionChainSelectionHelpersRegressionAlgorithm.cs @@ -18,7 +18,6 @@ using System.Collections.Generic; using System.Linq; using QuantConnect.Data; -using QuantConnect.Data.Market; using QuantConnect.Interfaces; namespace QuantConnect.Algorithm.CSharp @@ -114,21 +113,21 @@ public override void Initialize() } // Strike distance in price units, like the universe strategy filters: 5 above the spot is the 752.50 call - var otmCall = chain.Select(right: OptionRight.Call, targetDte: 7, strike: StrikeTarget.FromAtm(5)); + var otmCall = chain.SelectByStrikeDistance(5, OptionRight.Call, targetDte: 7); if (otmCall == null || otmCall.Strike != 752.5m) { - throw new RegressionTestException($"Select(StrikeTarget.FromAtm) expected the 752.50 call but got {otmCall?.Symbol.Value}"); + throw new RegressionTestException($"SelectByStrikeDistance() expected the 752.50 call but got {otmCall?.Symbol.Value}"); } // 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, strike: StrikeTarget.Delta(0.35m)); + var deltaPut = chain.SelectByDelta(0.35m, OptionRight.Put, targetDte: 7); 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(StrikeTarget.Delta) mismatch: {deltaPut?.Symbol.Value} != {ceremonyDeltaPut.Symbol.Value}"); + throw new RegressionTestException($"SelectByDelta() mismatch: {deltaPut?.Symbol.Value} != {ceremonyDeltaPut.Symbol.Value}"); } // The helpers are null-safe: no match returns null instead of throwing like min()/First() would diff --git a/Algorithm.Python/OptionChainSelectionHelpersRegressionAlgorithm.py b/Algorithm.Python/OptionChainSelectionHelpersRegressionAlgorithm.py index 5b927dba494b..8b817e0d0aa1 100644 --- a/Algorithm.Python/OptionChainSelectionHelpersRegressionAlgorithm.py +++ b/Algorithm.Python/OptionChainSelectionHelpersRegressionAlgorithm.py @@ -75,17 +75,17 @@ def initialize(self): f"strike_prices helpers mismatch: {strikes.closest_to(spot)}/{strikes.first_above(spot)}/{strikes.first_below(spot)}") # Strike distance in price units, like the universe strategy filters: 5 above the spot is the 752.50 call - otm_call = chain.select(right=OptionRight.CALL, target_dte=7, strike=StrikeTarget.from_atm(5)) + otm_call = chain.select_by_strike_distance(5, OptionRight.CALL, target_dte=7) if otm_call is None or otm_call.strike != 752.5: - raise AssertionError(f"select(StrikeTarget.from_atm) expected the 752.50 call but got {otm_call}") + raise AssertionError(f"select_by_strike_distance() expected the 752.50 call but got {otm_call}") # 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, strike=StrikeTarget.delta(0.35)) + delta_put = chain.select_by_delta(0.35, OptionRight.PUT, target_dte=7) 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(StrikeTarget.delta) mismatch: {delta_put} != {ceremony_delta_put.symbol.value}") + raise AssertionError(f"select_by_delta() mismatch: {delta_put} != {ceremony_delta_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 diff --git a/Common/Data/Market/OptionChain.Selection.cs b/Common/Data/Market/OptionChain.Selection.cs index 78bc582a681c..16f92a7dbc4f 100644 --- a/Common/Data/Market/OptionChain.Selection.cs +++ b/Common/Data/Market/OptionChain.Selection.cs @@ -62,53 +62,76 @@ public partial class OptionChain #region Selection helpers /// - /// Selects the single contract closest to the criteria, e.g. - /// chain.select(OptionRight.PUT, target_dte=30, strike=StrikeTarget.moneyness(-0.15)). + /// Selects the single contract closest to the criteria, e.g. chain.select(OptionRight.PUT, target_dte=30, moneyness=-0.15). /// Returns null (None in Python) when nothing matches. Unlike the universe strategy filters, - /// which take a minimum days to expiration, this takes a target and picks the closest expiration + /// which take a minimum days to expiration, this takes a target and picks the closest expiration. + /// See also and /// /// Only consider contracts of this right, any right when null /// Only consider the expiration closest to this many days out, see /// Exclude expirations closer than this many days /// Exclude expirations further than this many days - /// The strike criterion, at the money when null. See - /// The best matching contract, or null - public OptionContract Select(OptionRight? right = null, int? targetDte = null, int? minDte = null, int? maxDte = null, - StrikeTarget strike = null) + /// Target strike as a signed fraction of the underlying price, e.g. -0.15 targets 85% of it. 0 is at the money + /// The contract with the strike closest to the target, or null + public OptionContract Select(OptionRight? right = null, int? targetDte = null, int? minDte = null, int? maxDte = null, decimal moneyness = 0) { - var universe = new OptionChainFilterUniverse(this); - IEnumerable candidates = Contracts.Values; - if (right.HasValue) - { - candidates = candidates.Where(contract => contract.Right == right.Value).ToList(); - } + var candidates = GetCandidates(right, targetDte, minDte, maxDte, out var underlyingPrice); + return underlyingPrice.HasValue ? GetClosestByStrike(candidates, underlyingPrice.Value * (1 + moneyness)) : null; + } - if (targetDte.HasValue || minDte.HasValue || maxDte.HasValue) - { - var expiry = GetClosestExpiry(universe, candidates, targetDte, minDte, maxDte); - if (!expiry.HasValue) - { - return null; - } - candidates = candidates.Where(contract => contract.Expiry == expiry.Value).ToList(); - } + /// + /// Synonym of + /// + /// Only consider contracts of this right, any right when null + /// Only consider the expiration closest to this many days out + /// Exclude expirations closer than this many days + /// Exclude expirations further than this many days + /// Target strike as a signed fraction of the underlying price, 0 is at the money + /// The contract with the strike closest to the target, or null + public OptionContract Pick(OptionRight? right = null, int? targetDte = null, int? minDte = null, int? maxDte = null, decimal moneyness = 0) + { + return Select(right, targetDte, minDte, maxDte, moneyness); + } - return (strike ?? StrikeTarget.AtTheMoney).Select(candidates, universe.Underlying?.Price); + /// + /// Like , with the target strike given as a distance from the underlying price in price units, + /// as the universe strategy filters take it, e.g. chain.select_by_strike_distance(-5, OptionRight.PUT, target_dte=30) + /// + /// Signed distance of the target strike from the underlying price + /// Only consider contracts of this right, any right when null + /// Only consider the expiration closest to this many days out + /// Exclude expirations closer than this many days + /// Exclude expirations further than this many days + /// The contract with the strike closest to the target, or null + public OptionContract SelectByStrikeDistance(decimal strikeFromAtm, OptionRight? right = null, int? targetDte = null, int? minDte = null, + int? maxDte = null) + { + var candidates = GetCandidates(right, targetDte, minDte, maxDte, out var underlyingPrice); + return underlyingPrice.HasValue ? GetClosestByStrike(candidates, underlyingPrice.Value + strikeFromAtm) : null; } /// - /// Synonym of + /// Like , targeting a delta instead of a strike: the contract whose absolute delta is closest to the + /// absolute target, so a 30 delta put is 0.3 or -0.3, e.g. chain.select_by_delta(0.3, OptionRight.PUT, target_dte=30). + /// Contracts without greeks are ignored /// + /// The target delta /// Only consider contracts of this right, any right when null /// Only consider the expiration closest to this many days out /// Exclude expirations closer than this many days /// Exclude expirations further than this many days - /// The strike criterion, at the money when null - /// The best matching contract, or null - public OptionContract Pick(OptionRight? right = null, int? targetDte = null, int? minDte = null, int? maxDte = null, - StrikeTarget strike = null) + /// The contract with the delta closest to the target, or null + public OptionContract SelectByDelta(decimal targetDelta, OptionRight? right = null, int? targetDte = null, int? minDte = null, int? maxDte = null) { - return Select(right, targetDte, minDte, maxDte, strike); + var target = Math.Abs(targetDelta); + // Contracts without greeks report a zero delta: they are excluded so a chain without greeks returns null + return GetCandidates(right, targetDte, minDte, maxDte, out _) + .Where(contract => contract.Greeks.Delta != 0) + .OrderBy(contract => Math.Abs(Math.Abs(contract.Greeks.Delta) - target)) + .ThenBy(contract => contract.Expiry) + .ThenBy(contract => contract.Strike) + .ThenBy(contract => contract.Right) + .FirstOrDefault(); } /// @@ -171,6 +194,45 @@ private T GetView(ref T view, Func compute) return view ??= compute(); } + /// + /// Gets the contracts of the given right and of the expiration closest to the target, none when no expiration is in the window + /// + private IEnumerable GetCandidates(OptionRight? right, int? targetDte, int? minDte, int? maxDte, out decimal? underlyingPrice) + { + var universe = new OptionChainFilterUniverse(this); + underlyingPrice = universe.Underlying?.Price; + + IEnumerable candidates = Contracts.Values; + if (right.HasValue) + { + candidates = candidates.Where(contract => contract.Right == right.Value).ToList(); + } + + if (targetDte.HasValue || minDte.HasValue || maxDte.HasValue) + { + var expiry = GetClosestExpiry(universe, candidates, targetDte, minDte, maxDte); + if (!expiry.HasValue) + { + return Enumerable.Empty(); + } + candidates = candidates.Where(contract => contract.Expiry == expiry.Value).ToList(); + } + + return candidates; + } + + private static OptionContract GetClosestByStrike(IEnumerable contracts, decimal targetStrike) + { + // Scaled strikes are in underlying price units, see SymbolProperties.StrikeMultiplier. + // Ties go to the lower strike, then the nearest expiration, then calls + return contracts + .OrderBy(contract => Math.Abs(contract.ScaledStrike - targetStrike)) + .ThenBy(contract => contract.Strike) + .ThenBy(contract => contract.Expiry) + .ThenBy(contract => contract.Right) + .FirstOrDefault(); + } + private List GetContracts(OptionRight right) { return Contracts.Values diff --git a/Common/Data/Market/StrikeTarget.cs b/Common/Data/Market/StrikeTarget.cs deleted file mode 100644 index b5eca7d8c69f..000000000000 --- a/Common/Data/Market/StrikeTarget.cs +++ /dev/null @@ -1,133 +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 System; -using System.Collections.Generic; -using System.Linq; - -namespace QuantConnect.Data.Market -{ - /// - /// The strike criterion uses to pick a contract: the strike closest to the underlying price - /// (), to a fraction of it (), to a distance from it in price units - /// () or the contract whose delta is closest to a target (). - /// Only one criterion can be expressed, so there is nothing to validate at the call site - /// - public class StrikeTarget - { - private enum Criterion - { - Moneyness, - FromAtm, - Delta - } - - private readonly Criterion _criterion; - private readonly decimal _value; - - /// - /// The strike closest to the underlying price - /// - public static StrikeTarget AtTheMoney { get; } = new(Criterion.Moneyness, 0); - - private StrikeTarget(Criterion criterion, decimal value) - { - _criterion = criterion; - _value = value; - } - - /// - /// The strike closest to the underlying price times one plus the given fraction, 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 - /// - /// The signed distance from the underlying price as a fraction of it - public static StrikeTarget Moneyness(decimal moneyness) - { - return new StrikeTarget(Criterion.Moneyness, moneyness); - } - - /// - /// The strike closest to the underlying price plus the given distance in price units, - /// like the universe strategy filters take, e.g. -5 targets the strike closest to 5 below the underlying price - /// - /// The signed distance from the underlying price - public static StrikeTarget FromAtm(decimal strikeFromAtm) - { - return new StrikeTarget(Criterion.FromAtm, strikeFromAtm); - } - - /// - /// The contract whose absolute delta is closest to the absolute value of the given target, - /// so a 30 delta put can be requested as either 0.3 or -0.3. Contracts without greeks are ignored - /// - /// The target delta - public static StrikeTarget Delta(decimal targetDelta) - { - return new StrikeTarget(Criterion.Delta, Math.Abs(targetDelta)); - } - - /// - /// Selects the contract that best matches this target - /// - /// The candidate contracts - /// The underlying price, null when unknown - /// The best matching contract, or null when there is none or the target needs an unknown underlying price - internal OptionContract Select(IEnumerable contracts, decimal? underlyingPrice) - { - if (_criterion == Criterion.Delta) - { - // Contracts without greeks report a zero delta: they are excluded so a chain without greeks returns null - return contracts - .Where(contract => contract.Greeks.Delta != 0) - .OrderBy(contract => Math.Abs(Math.Abs(contract.Greeks.Delta) - _value)) - .ThenBy(contract => contract.Expiry) - .ThenBy(contract => contract.Strike) - .ThenBy(contract => contract.Right) - .FirstOrDefault(); - } - - if (!underlyingPrice.HasValue) - { - return null; - } - - var targetStrike = _criterion == Criterion.FromAtm - ? underlyingPrice.Value + _value - : underlyingPrice.Value * (1 + _value); - // Scaled strikes are in underlying price units, see SymbolProperties.StrikeMultiplier. - // Ties go to the lower strike, then the nearest expiration, then calls - return contracts - .OrderBy(contract => Math.Abs(contract.ScaledStrike - targetStrike)) - .ThenBy(contract => contract.Strike) - .ThenBy(contract => contract.Expiry) - .ThenBy(contract => contract.Right) - .FirstOrDefault(); - } - - /// - /// Returns a string that represents the target - /// - public override string ToString() - { - return _criterion switch - { - Criterion.Delta => $"Delta({_value})", - Criterion.FromAtm => $"FromAtm({_value})", - _ => _value == 0 ? "AtTheMoney" : $"Moneyness({_value})" - }; - } - } -} diff --git a/Tests/Common/Data/Market/OptionChainSelectionTests.cs b/Tests/Common/Data/Market/OptionChainSelectionTests.cs index 3ee0cae3d63a..b90bbdbd43fe 100644 --- a/Tests/Common/Data/Market/OptionChainSelectionTests.cs +++ b/Tests/Common/Data/Market/OptionChainSelectionTests.cs @@ -346,7 +346,7 @@ public void PickIsASynonymOfSelect() var chain = CreateDefaultChain(); Assert.AreEqual(chain.Select(right: OptionRight.Put, targetDte: 8).Symbol, chain.Pick(right: OptionRight.Put, targetDte: 8).Symbol); - Assert.AreEqual(chain.Select(right: OptionRight.Call, strike: StrikeTarget.Delta(0.2m)).Symbol, chain.Pick(right: OptionRight.Call, strike: StrikeTarget.Delta(0.2m)).Symbol); + Assert.AreEqual(chain.Select(right: OptionRight.Call, moneyness: 0.05m).Symbol, chain.Pick(right: OptionRight.Call, moneyness: 0.05m).Symbol); Assert.IsNull(chain.Pick(minDte: 40, maxDte: 60)); } @@ -356,7 +356,7 @@ public void PickIsASynonymOfSelect() public void SelectByMoneyness(double moneyness, double expectedStrike) { var chain = CreateDefaultChain(); - var contract = chain.Select(right: OptionRight.Put, targetDte: 8, strike: StrikeTarget.Moneyness((decimal)moneyness)); + var contract = chain.Select(right: OptionRight.Put, targetDte: 8, moneyness: (decimal)moneyness); Assert.IsNotNull(contract); Assert.AreEqual(OptionRight.Put, contract.Right); @@ -368,10 +368,10 @@ public void SelectByMoneyness(double moneyness, double expectedStrike) [TestCase(0, 100)] [TestCase(8, 110)] [TestCase(-4, 100)] - public void SelectByStrikeFromAtm(double strikeFromAtm, double expectedStrike) + public void SelectByStrikeDistance(double strikeFromAtm, double expectedStrike) { var chain = CreateDefaultChain(); - var contract = chain.Select(right: OptionRight.Put, targetDte: 8, strike: StrikeTarget.FromAtm((decimal)strikeFromAtm)); + var contract = chain.SelectByStrikeDistance((decimal)strikeFromAtm, OptionRight.Put, targetDte: 8); Assert.IsNotNull(contract); Assert.AreEqual(OptionRight.Put, contract.Right); @@ -386,7 +386,7 @@ 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, strike: StrikeTarget.Delta((decimal)targetDelta)); + var contract = chain.SelectByDelta((decimal)targetDelta, OptionRight.Put, targetDte: 8); Assert.IsNotNull(contract); Assert.AreEqual(OptionRight.Put, contract.Right); @@ -404,7 +404,7 @@ public void SelectByDeltaIgnoresContractsWithoutGreeks() (Expiry1, 100m, OptionRight.Call, 0.5m) }); - var contract = chain.Select(right: OptionRight.Call, strike: StrikeTarget.Delta(0.05m)); + var contract = chain.SelectByDelta(0.05m, OptionRight.Call); Assert.AreEqual(100m, contract.Strike); // A chain without any greeks data returns null instead of an arbitrary contract @@ -413,7 +413,7 @@ public void SelectByDeltaIgnoresContractsWithoutGreeks() (Expiry1, 95m, OptionRight.Call, 0m), (Expiry1, 100m, OptionRight.Call, 0m) }); - Assert.IsNull(noGreeks.Select(right: OptionRight.Call, strike: StrikeTarget.Delta(0.05m))); + Assert.IsNull(noGreeks.SelectByDelta(0.05m, OptionRight.Call)); } [Test] @@ -458,38 +458,32 @@ public void SelectWithoutCriteriaReturnsAtTheMoney() [Test] public void SelectIsNullSafe() { - Assert.IsNull(CreateEmptyChain().Select(right: OptionRight.Put, targetDte: 30, strike: StrikeTarget.Moneyness(-0.15m))); + 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, strike: StrikeTarget.Moneyness(-0.15m))); + Assert.IsNull(noUnderlying.Select(right: OptionRight.Call, moneyness: -0.15m)); } [Test] - public void StrikeTargetsSelectFromAnyContractList() + public void SelectByStrikeDistanceAndDeltaAreNullSafe() { - var contracts = CreateDefaultChain().At(Expiry2).ToList(); - - Assert.AreEqual(100m, StrikeTarget.AtTheMoney.Select(contracts, 101m).Strike); - Assert.AreEqual(110m, StrikeTarget.Moneyness(0.08m).Select(contracts, 100m).Strike); - Assert.AreEqual(90m, StrikeTarget.FromAtm(-8m).Select(contracts, 100m).Strike); - // delta needs no underlying price and is sign insensitive - Assert.AreEqual(90m, StrikeTarget.Delta(-0.1m).Select(contracts, null).Strike); - Assert.AreEqual(OptionRight.Put, StrikeTarget.Delta(-0.1m).Select(contracts, null).Right); - // strike based targets need the underlying price - Assert.IsNull(StrikeTarget.Moneyness(0.08m).Select(contracts, null)); - Assert.IsNull(StrikeTarget.AtTheMoney.Select(Enumerable.Empty(), 100m)); + var chain = CreateDefaultChain(); + Assert.IsNull(chain.SelectByStrikeDistance(-5m, minDte: 40, maxDte: 60)); + Assert.IsNull(chain.SelectByDelta(0.3m, minDte: 40, maxDte: 60)); + Assert.IsNull(CreateEmptyChain().SelectByStrikeDistance(-5m)); + Assert.IsNull(CreateEmptyChain().SelectByDelta(0.3m)); - Assert.AreEqual("AtTheMoney", StrikeTarget.AtTheMoney.ToString()); - Assert.AreEqual("Moneyness(-0.15)", StrikeTarget.Moneyness(-0.15m).ToString()); - Assert.AreEqual("FromAtm(-5)", StrikeTarget.FromAtm(-5m).ToString()); - Assert.AreEqual("Delta(0.3)", StrikeTarget.Delta(-0.3m).ToString()); + // delta needs no underlying price, a strike distance does + var noUnderlying = CreateChain(new[] { (Expiry1, 100m, OptionRight.Call, 0.5m) }, underlyingPrice: null); + Assert.IsNull(noUnderlying.SelectByStrikeDistance(-5m, OptionRight.Call)); + Assert.AreEqual(100m, noUnderlying.SelectByDelta(0.5m, OptionRight.Call).Strike); } [Test] public void SelectionHelpersAreAvailableFromPython() { var chain = CreateDefaultChain(); - var expected = chain.Select(right: OptionRight.Put, targetDte: 8, strike: StrikeTarget.Moneyness(-0.1m)); + var expected = chain.Select(right: OptionRight.Put, targetDte: 8, moneyness: -0.1m); using (Py.GIL()) { @@ -497,10 +491,10 @@ public void SelectionHelpersAreAvailableFromPython() from AlgorithmImports import * def select(chain): - return chain.select(right=OptionRight.PUT, target_dte=8, strike=StrikeTarget.moneyness(-0.1)) + return chain.select(right=OptionRight.PUT, target_dte=8, moneyness=-0.1) def pick(chain): - return chain.pick(OptionRight.PUT, 8, strike=StrikeTarget.from_atm(-10)) + return chain.select_by_strike_distance(-10, OptionRight.PUT, target_dte=8) def helpers(chain): at_expiry = chain.at(chain.closest_expiry(target_dte=8)) From 8c46303a920d0bdf0d9ec52005855dcc1667c9b2 Mon Sep 17 00:00:00 2001 From: Jhonathan Abreu Date: Tue, 8 Sep 2026 14:21:54 -0400 Subject: [PATCH 05/14] Search the strike and expiry lists instead of scanning them StrikeList is a read only collection whose closest, first above and first below lookups binary search the sorted strikes. ClosestExpiry reads the cached expiry view and Select the distinct expiries of its candidates, both searched by days to expiration for the window bounds and the target. --- Common/Data/Market/OptionChain.Selection.cs | 59 ++++++++++++---- Common/Data/Market/StrikeList.cs | 69 +++++++++++-------- .../Option/OptionChainFilterUniverse.cs | 4 +- .../Data/Market/OptionChainSelectionTests.cs | 16 +++++ 4 files changed, 103 insertions(+), 45 deletions(-) diff --git a/Common/Data/Market/OptionChain.Selection.cs b/Common/Data/Market/OptionChain.Selection.cs index 16f92a7dbc4f..7970f22675af 100644 --- a/Common/Data/Market/OptionChain.Selection.cs +++ b/Common/Data/Market/OptionChain.Selection.cs @@ -144,7 +144,7 @@ public OptionContract SelectByDelta(decimal targetDelta, OptionRight? right = nu /// The expiration date as stored in the contracts, or null public DateTime? ClosestExpiry(int? targetDte = null, int? minDte = null, int? maxDte = null) { - return GetClosestExpiry(new OptionChainFilterUniverse(this), Contracts.Values, targetDte, minDte, maxDte); + return GetClosestExpiry(new OptionChainFilterUniverse(this), Expiries, targetDte, minDte, maxDte); } /// @@ -210,7 +210,8 @@ private IEnumerable GetCandidates(OptionRight? right, int? targe if (targetDte.HasValue || minDte.HasValue || maxDte.HasValue) { - var expiry = GetClosestExpiry(universe, candidates, targetDte, minDte, maxDte); + var expiries = candidates.Select(contract => contract.Expiry).Distinct().OrderBy(expiry => expiry).ToList(); + var expiry = GetClosestExpiry(universe, expiries, targetDte, minDte, maxDte); if (!expiry.HasValue) { return Enumerable.Empty(); @@ -242,28 +243,56 @@ private List GetContracts(OptionRight right) .ToList(); } - private static DateTime? GetClosestExpiry(OptionChainFilterUniverse universe, IEnumerable contracts, + /// + /// Gets the expiration closest to the target days out among sorted distinct expirations, null when none is in the window + /// + private static DateTime? GetClosestExpiry(OptionChainFilterUniverse universe, IReadOnlyList expiries, int? targetDte, int? minDte, int? maxDte) { + // Days to expiration grow with the expiration date, so the window and the target can be searched instead of scanned + var low = minDte.HasValue ? FirstIndex(expiries, universe, 0, expiries.Count, dte => dte >= minDte.Value) : 0; + var high = maxDte.HasValue ? FirstIndex(expiries, universe, low, expiries.Count, dte => dte > maxDte.Value) : expiries.Count; + if (low >= high) + { + return null; + } + var target = targetDte ?? minDte ?? 0; - DateTime? result = null; - var resultDistance = int.MaxValue; - foreach (var contract in contracts.DistinctBy(contract => contract.Expiry)) + // the first expiration at or beyond the target and the one before it are the only candidates, ties go to the earlier one + var index = FirstIndex(expiries, universe, low, high, dte => dte >= target); + if (index == high) + { + return expiries[high - 1]; + } + if (index == low) + { + return expiries[low]; + } + + var before = expiries[index - 1]; + var after = expiries[index]; + return universe.GetDaysToExpiry(after) - target < target - universe.GetDaysToExpiry(before) ? after : before; + } + + /// + /// Gets the first index in [low, high) whose days to expiration satisfy the predicate, high when none does. + /// The predicate must be false then true along the sorted expirations + /// + private static int FirstIndex(IReadOnlyList expiries, OptionChainFilterUniverse universe, int low, int high, Func predicate) + { + while (low < high) { - var dte = universe.GetDaysToExpiry(contract); - // Lifted comparisons are false when the bound is null, i.e. unset bounds don't exclude anything - if (dte < minDte || dte > maxDte) + var middle = low + (high - low) / 2; + if (predicate(universe.GetDaysToExpiry(expiries[middle]))) { - continue; + high = middle; } - var distance = Math.Abs(dte - target); - if (distance < resultDistance || (distance == resultDistance && contract.Expiry < result.Value)) + else { - result = contract.Expiry; - resultDistance = distance; + low = middle + 1; } } - return result; + return low; } #endregion diff --git a/Common/Data/Market/StrikeList.cs b/Common/Data/Market/StrikeList.cs index 9fb6c73cf7a1..4626198554ee 100644 --- a/Common/Data/Market/StrikeList.cs +++ b/Common/Data/Market/StrikeList.cs @@ -13,26 +13,34 @@ * limitations under the License. */ -using System; using System.Collections.Generic; +using System.Collections.ObjectModel; using System.Linq; namespace QuantConnect.Data.Market { /// - /// The distinct strikes of a chain, ascending, with helpers that return null (None in Python) when no strike matches + /// The distinct strikes of a chain, ascending and read only, with helpers that return null (None in Python) when no strike matches /// - public class StrikeList : List + public class StrikeList : ReadOnlyCollection { + private readonly List _strikes; + /// /// Creates the list from the given strikes, in any order, duplicates allowed /// /// The strike prices public StrikeList(IEnumerable strikes) - : base(strikes.Distinct().OrderBy(strike => strike)) + : this(strikes.Distinct().OrderBy(strike => strike).ToList()) { } + private StrikeList(List strikes) + : base(strikes) + { + _strikes = strikes; + } + /// /// The strike closest to the price, the lower one on ties /// @@ -40,16 +48,31 @@ public StrikeList(IEnumerable strikes) /// The closest strike, or null when the list is empty public decimal? ClosestTo(decimal price) { - decimal? closest = null; - foreach (var strike in this) + if (_strikes.Count == 0) { - // 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 null; } - return closest; + + var index = _strikes.BinarySearch(price); + if (index >= 0) + { + return _strikes[index]; + } + + // the complement is the first strike above the price, so the candidates are it and the one before + index = ~index; + if (index == 0) + { + return _strikes[0]; + } + if (index == _strikes.Count) + { + return _strikes[index - 1]; + } + + var below = _strikes[index - 1]; + var above = _strikes[index]; + return above - price < price - below ? above : below; } /// @@ -59,14 +82,9 @@ public StrikeList(IEnumerable strikes) /// The first strike above the price, or null when there is none public decimal? FirstAbove(decimal price) { - foreach (var strike in this) - { - if (strike > price) - { - return strike; - } - } - return null; + var index = _strikes.BinarySearch(price); + index = index >= 0 ? index + 1 : ~index; + return index < _strikes.Count ? _strikes[index] : null; } /// @@ -76,14 +94,9 @@ public StrikeList(IEnumerable strikes) /// The first strike below the price, or null when 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; + var index = _strikes.BinarySearch(price); + index = (index >= 0 ? index : ~index) - 1; + return index >= 0 ? _strikes[index] : null; } } } diff --git a/Common/Securities/Option/OptionChainFilterUniverse.cs b/Common/Securities/Option/OptionChainFilterUniverse.cs index 6821ed971398..f1c5473579b5 100644 --- a/Common/Securities/Option/OptionChainFilterUniverse.cs +++ b/Common/Securities/Option/OptionChainFilterUniverse.cs @@ -54,9 +54,9 @@ public OptionChainFilterUniverse(OptionChain chain) /// /// Gets the number of days until the given contract stops trading, counted from the chain date /// - internal int GetDaysToExpiry(OptionContract contract) + internal int GetDaysToExpiry(DateTime expiry) { - return (GetLastTradingDate(contract.ID.Date) - AdjustExpirationReferenceDate(LocalTime.Date)).Days; + return (GetLastTradingDate(expiry) - AdjustExpirationReferenceDate(LocalTime.Date)).Days; } /// diff --git a/Tests/Common/Data/Market/OptionChainSelectionTests.cs b/Tests/Common/Data/Market/OptionChainSelectionTests.cs index b90bbdbd43fe..20ac2d13be6d 100644 --- a/Tests/Common/Data/Market/OptionChainSelectionTests.cs +++ b/Tests/Common/Data/Market/OptionChainSelectionTests.cs @@ -143,6 +143,9 @@ public void ExpiriesAreDistinctAndSorted() [TestCase(97, 95)] // Equidistant from 95 and 100: the lower strike wins [TestCase(97.5, 95)] + [TestCase(99, 100)] + [TestCase(100, 100)] + [TestCase(0, 85)] [TestCase(120, 110)] public void StrikePricesClosestTo(double price, double expected) { @@ -165,6 +168,16 @@ public void StrikePricesFirstAboveAndBelowAreStrict() Assert.IsNull(strikes.FirstBelow(85m)); } + [Test] + public void StrikePricesAreReadOnly() + { + var strikes = (IList)CreateDefaultChain().StrikePrices; + + Assert.IsTrue(strikes.IsReadOnly); + Assert.Throws(() => strikes.Add(1m)); + Assert.Throws(() => strikes.Clear()); + } + [Test] public void StrikePricesHelpersAreNullSafeOnEmptyChain() { @@ -178,6 +191,9 @@ public void StrikePricesHelpersAreNullSafeOnEmptyChain() [TestCase(0, null, null, "20160226")] [TestCase(10, null, null, "20160304")] [TestCase(12, null, null, "20160311")] + [TestCase(14, null, null, "20160311")] + [TestCase(5, 8, 15, "20160304")] + [TestCase(40, 8, 15, "20160311")] [TestCase(100, null, null, "20160401")] // min/max window excludes the otherwise closest expiry [TestCase(0, 5, null, "20160304")] From 3007d540e20c7794149105a0c1590949b352416f Mon Sep 17 00:00:00 2001 From: Jhonathan Abreu Date: Tue, 8 Sep 2026 16:45:18 -0400 Subject: [PATCH 06/14] Count days to the listed expiration date The chain pickers, at() and days_to_expiry use the contract's listed date, matching the shared filters. Counting Saturday and holiday expiries on their last trading day moves to its own change. --- Common/Data/Market/OptionChain.Selection.cs | 11 ++++----- Common/Data/Market/OptionContract.cs | 9 ------- .../Option/OptionChainFilterUniverse.cs | 12 ++-------- .../Data/Market/OptionChainSelectionTests.cs | 24 +------------------ 4 files changed, 7 insertions(+), 49 deletions(-) diff --git a/Common/Data/Market/OptionChain.Selection.cs b/Common/Data/Market/OptionChain.Selection.cs index 7970f22675af..a99de7e0b33c 100644 --- a/Common/Data/Market/OptionChain.Selection.cs +++ b/Common/Data/Market/OptionChain.Selection.cs @@ -135,8 +135,7 @@ public OptionContract SelectByDelta(decimal targetDelta, OptionRight? right = nu } /// - /// Gets the expiration closest to the target days out. Days are counted to the last trading date, - /// so Saturday expirations count on their Friday. Returns null (None in Python) when none falls in the window + /// Gets the expiration closest to the target days out. Returns null (None in Python) when none falls in the window /// /// The target days to expiration, ties go to the earlier expiration. Defaults to minDte, else 0 /// Exclude expirations closer than this many days @@ -148,18 +147,16 @@ public OptionContract SelectByDelta(decimal targetDelta, OptionRight? right = nu } /// - /// Gets a new chain with the contracts of the given expiration, matched on the last trading date, - /// so Saturday expirations are also matched by their Friday. Time of day is ignored + /// Gets a new chain with the contracts of the given expiration. Time of day is ignored /// /// The expiration date /// A new chain, empty when nothing matches public OptionChain At(DateTime expiry) { - var universe = new OptionChainFilterUniverse(this); - var expiryDate = universe.ToLastTradingDate(expiry); + var expiryDate = expiry.Date; return Filter(u => { - u.Data = u.Data.Where(contract => universe.ToLastTradingDate(contract.Expiry) == expiryDate).ToList(); + u.Data = u.Data.Where(contract => contract.Expiry.Date == expiryDate).ToList(); return u; }); } diff --git a/Common/Data/Market/OptionContract.cs b/Common/Data/Market/OptionContract.cs index 3d5875932826..4006c4c573aa 100644 --- a/Common/Data/Market/OptionContract.cs +++ b/Common/Data/Market/OptionContract.cs @@ -15,7 +15,6 @@ using QuantConnect.Data.UniverseSelection; using QuantConnect.Interfaces; -using QuantConnect.Python; using QuantConnect.Securities; using QuantConnect.Securities.Option; using System; @@ -29,7 +28,6 @@ public class OptionContract : BaseContract { private IOptionData _optionData = OptionPriceModelResultData.Null; private readonly SymbolProperties _symbolProperties; - private DateTime? _lastTradingDate; /// /// Gets the strike price @@ -106,13 +104,6 @@ public class OptionContract : BaseContract /// public decimal UnderlyingLastPrice => _optionData.UnderlyingLastPrice; - /// - /// Calendar days from this contract's time until its last trading date, the previous trading day - /// for expirations on a Saturday or holiday - /// - [PandasIgnore] - public override int DaysToExpiry => ((_lastTradingDate ??= OptionSymbol.GetLastDayOfTrading(Symbol)) - Time.Date).Days; - /// /// The option symbol properties /// diff --git a/Common/Securities/Option/OptionChainFilterUniverse.cs b/Common/Securities/Option/OptionChainFilterUniverse.cs index f1c5473579b5..38df4886ec0e 100644 --- a/Common/Securities/Option/OptionChainFilterUniverse.cs +++ b/Common/Securities/Option/OptionChainFilterUniverse.cs @@ -52,19 +52,11 @@ public OptionChainFilterUniverse(OptionChain chain) } /// - /// Gets the number of days until the given contract stops trading, counted from the chain date + /// Gets the number of days until the given expiration, counted from the chain date /// internal int GetDaysToExpiry(DateTime expiry) { - return (GetLastTradingDate(expiry) - AdjustExpirationReferenceDate(LocalTime.Date)).Days; - } - - /// - /// Gets the last trading date for the given expiration date - /// - internal DateTime ToLastTradingDate(DateTime expiry) - { - return GetLastTradingDate(expiry); + return (expiry.Date - AdjustExpirationReferenceDate(LocalTime.Date)).Days; } /// diff --git a/Tests/Common/Data/Market/OptionChainSelectionTests.cs b/Tests/Common/Data/Market/OptionChainSelectionTests.cs index 20ac2d13be6d..7246242a0d77 100644 --- a/Tests/Common/Data/Market/OptionChainSelectionTests.cs +++ b/Tests/Common/Data/Market/OptionChainSelectionTests.cs @@ -256,29 +256,7 @@ public void AtIgnoresTimeOfDayAndIsNullSafe() } [Test] - public void AtMatchesSaturdayExpiryByLastTradingDate() - { - // Equity options before February 2015 have Saturday expiration dates: asking for the - // last trading date (Friday) must still match the chain - 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)); - Assert.IsTrue(chain.All(x => x.DaysToExpiry == 4)); - } - - [Test] - public void DaysToExpiryCountsCalendarDaysToTheLastTradingDate() + public void DaysToExpiryCountsCalendarDays() { var chain = CreateDefaultChain(); CollectionAssert.AreEquivalent(new[] { 1, 8, 15, 36 }, chain.Select(x => x.DaysToExpiry).Distinct()); From e861b8a49b6ad690f8763b3404f2468f33f02175 Mon Sep 17 00:00:00 2001 From: Jhonathan Abreu Date: Thu, 10 Sep 2026 12:44:17 -0400 Subject: [PATCH 07/14] Add moneyness, exact strike and expiration filters, drop the Calls and Puts views New shared filters on the option universe, the IOptionContractFilters interface and the chain: Strikes(strike) and Expiration(date) select exact values, ZeroDte the contracts expiring today, OutOfTheMoney/OTM, InTheMoney/ITM and AtTheMoney/ATM split the contracts around the underlying price, ATM being the closest strike with the lower one on ties. OptionPayoff gains IsInTheMoney, IsAtTheMoney and IsOutOfTheMoney, and the strategy filters' closest strike helper is shared with an explicit tie-break. The Calls and Puts views and the AtTheMoney(right) picker are removed: CallsOnly(), PutsOnly() and Select(right) already cover them. At(expiry) delegates to Expiration(expiry). --- .../OptionChainFiltersRegressionAlgorithm.cs | 23 ++++ ...hainSelectionHelpersRegressionAlgorithm.cs | 12 +- .../OptionChainFiltersRegressionAlgorithm.py | 17 +++ ...hainSelectionHelpersRegressionAlgorithm.py | 12 +- Common/Data/Market/OptionChain.Filters.cs | 87 ++++++++++++++ Common/Data/Market/OptionChain.Selection.cs | 48 +------- .../ContractSecurityFilterUniverse.cs | 12 ++ .../Option/IOptionContractFilters.cs | 45 +++++++ .../Securities/Option/OptionFilterUniverse.cs | 113 +++++++++++++++++- Common/Util/OptionPayoff.cs | 78 ++++++++++++ .../Data/Market/OptionChainSelectionTests.cs | 64 +++------- Tests/Common/Data/Market/OptionChainTests.cs | 55 +++++++++ Tests/Common/Util/OptionPayoffTests.cs | 45 +++++++ 13 files changed, 507 insertions(+), 104 deletions(-) create mode 100644 Tests/Common/Util/OptionPayoffTests.cs diff --git a/Algorithm.CSharp/OptionChainFiltersRegressionAlgorithm.cs b/Algorithm.CSharp/OptionChainFiltersRegressionAlgorithm.cs index d73971b1fa41..ec2120d5e9e4 100644 --- a/Algorithm.CSharp/OptionChainFiltersRegressionAlgorithm.cs +++ b/Algorithm.CSharp/OptionChainFiltersRegressionAlgorithm.cs @@ -88,6 +88,29 @@ public override void Initialize() { throw new RegressionTestException("Delta filter mismatch"); } + + // Moneyness filters split the strikes around the underlying price, ATM is the closest strike + var price = chain.Underlying.Price; + var otm = chain.OutOfTheMoney(); + var itm = chain.InTheMoney(); + if (otm.Count == 0 || itm.Count == 0 || otm.Count + itm.Count + chain.Strikes(price).Count != chain.Count + || otm.Any(x => x.Right == OptionRight.Call ? x.Strike <= price : x.Strike >= price) + || itm.Any(x => x.Right == OptionRight.Call ? x.Strike >= price : x.Strike <= price)) + { + throw new RegressionTestException("Out/in the money filters mismatch"); + } + var atm = chain.AtTheMoney(); + if (atm.Count == 0 || atm.Any(x => x.Strike != 747.5m) || atm.Count != chain.Strikes(747.5m).Count) + { + throw new RegressionTestException("Expected AtTheMoney() to select every contract at the 747.50 strike"); + } + + // Exact expiration and strike, and today's expiration + if (chain.Expiration(new DateTime(2015, 12, 24)).Count != chain.FrontMonth().Count + || chain.ZeroDte().Count != chain.Expiration(0, 0).Count) + { + throw new RegressionTestException("Expiration(date) and ZeroDte() should match the front month"); + } } public override void OnData(Slice slice) diff --git a/Algorithm.CSharp/OptionChainSelectionHelpersRegressionAlgorithm.cs b/Algorithm.CSharp/OptionChainSelectionHelpersRegressionAlgorithm.cs index dc2f5cdcd223..a1178b95a185 100644 --- a/Algorithm.CSharp/OptionChainSelectionHelpersRegressionAlgorithm.cs +++ b/Algorithm.CSharp/OptionChainSelectionHelpersRegressionAlgorithm.cs @@ -26,7 +26,7 @@ namespace QuantConnect.Algorithm.CSharp /// Regression algorithm demonstrating the option chain selection helpers: /// (and its synonym ), /// , , - /// , and + /// , and /// , which replace the usual hand-rolled contract selection with a single call /// public class OptionChainSelectionHelpersRegressionAlgorithm : QCAlgorithm, IRegressionAlgorithmDefinition @@ -88,20 +88,20 @@ public override void Initialize() throw new RegressionTestException($"Unexpected expiries: {string.Join(", ", chain.Expiries)}"); } - // Single-expiry view: composes with Calls/Puts, StrikePrices, AtTheMoney and the universe filters + // Single-expiry view: composes with StrikePrices, Select and the universe filters 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 || atExpiry.Calls.Count != atExpiry.CallsOnly().Count) + if (atExpiry.CallsOnly().Count == 0 || atExpiry.PutsOnly().Count == 0) { - throw new RegressionTestException("At().Calls/.Puts should not be empty and agree with CallsOnly()"); + throw new RegressionTestException("At().CallsOnly()/.PutsOnly() should not be empty"); } - var atmPut = atExpiry.AtTheMoney(OptionRight.Put); + var atmPut = atExpiry.Select(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}"); + throw new RegressionTestException($"Select(Put) expected the 747.50 put but got {atmPut?.Symbol.Value}"); } // Strike prices helpers: strictly above/below and closest to the underlying price diff --git a/Algorithm.Python/OptionChainFiltersRegressionAlgorithm.py b/Algorithm.Python/OptionChainFiltersRegressionAlgorithm.py index 3452d3235d80..0ca7312fe742 100644 --- a/Algorithm.Python/OptionChainFiltersRegressionAlgorithm.py +++ b/Algorithm.Python/OptionChainFiltersRegressionAlgorithm.py @@ -61,6 +61,23 @@ def initialize(self): 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") + # Moneyness filters split the strikes around the underlying price, ATM is the closest strike + price = chain.underlying.price + otm = chain.otm() + itm = chain.itm() + if (otm.count == 0 or itm.count == 0 or otm.count + itm.count + chain.strikes(price).count != chain.count + or any((x.strike <= price if x.right == OptionRight.CALL else x.strike >= price) for x in otm) + or any((x.strike >= price if x.right == OptionRight.CALL else x.strike <= price) for x in itm)): + raise AssertionError("Out/in the money filters mismatch") + atm = chain.atm() + if atm.count == 0 or any(x.strike != 747.5 for x in atm) or atm.count != chain.strikes(747.5).count: + raise AssertionError("Expected atm() to select every contract at the 747.50 strike") + + # Exact expiration and strike, and today's expiration + if (chain.expiration(datetime(2015, 12, 24)).count != chain.front_month().count + or chain.zero_dte().count != chain.expiration(0, 0).count): + raise AssertionError("expiration(date) and zero_dte() should match the front month") + # 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): diff --git a/Algorithm.Python/OptionChainSelectionHelpersRegressionAlgorithm.py b/Algorithm.Python/OptionChainSelectionHelpersRegressionAlgorithm.py index 8b817e0d0aa1..61a769e0bb9d 100644 --- a/Algorithm.Python/OptionChainSelectionHelpersRegressionAlgorithm.py +++ b/Algorithm.Python/OptionChainSelectionHelpersRegressionAlgorithm.py @@ -15,7 +15,7 @@ ### ### Regression algorithm demonstrating the option chain selection helpers: select() (and its synonym pick()), -### closest_expiry(), at(), at_the_money(), strike_prices and expiries, which replace the usual hand-rolled +### closest_expiry(), at(), strike_prices and expiries, which replace the usual hand-rolled ### sorted-comprehension contract selection with a single call. ### class OptionChainSelectionHelpersRegressionAlgorithm(QCAlgorithm): @@ -58,15 +58,15 @@ def initialize(self): if chain.expiries[0] != self.time or chain.expiries[-1] != max(chain.expiries): raise AssertionError(f"Unexpected expiries: {chain.expiries}") - # Single-expiry view: composes with calls/puts, strike_prices, at_the_money and the universe filters + # Single-expiry view: composes with strike_prices, select and the universe filters 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 or len(at_expiry.calls) != at_expiry.calls_only().count: - raise AssertionError("at().calls/.puts should not be empty and agree with calls_only()") - atm_put = at_expiry.at_the_money(OptionRight.PUT) + if at_expiry.calls_only().count == 0 or at_expiry.puts_only().count == 0: + raise AssertionError("at().calls_only()/.puts_only() should not be empty") + atm_put = at_expiry.select(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}") + raise AssertionError(f"select(PUT) expected the 747.50 put but got {atm_put}") # Strike prices helpers: strictly above/below and closest to the underlying price strikes = at_expiry.strike_prices diff --git a/Common/Data/Market/OptionChain.Filters.cs b/Common/Data/Market/OptionChain.Filters.cs index 3fdaac86b4ac..651c9816e3db 100644 --- a/Common/Data/Market/OptionChain.Filters.cs +++ b/Common/Data/Market/OptionChain.Filters.cs @@ -68,6 +68,36 @@ public OptionChain Expiration(int minExpiryDays, int maxExpiryDays) return Filter(universe => universe.Expiration(minExpiryDays, maxExpiryDays)); } + /// + /// Selects the contracts expiring on the given date. Time of day is ignored. + /// Same as + /// + /// The expiration date + /// A new chain with the filter applied + public OptionChain Expiration(DateTime expiry) + { + return Filter(universe => universe.Expiration(expiry)); + } + + /// + /// Selects the contracts with the given strike price. Same as + /// + /// The strike price + /// A new chain with the filter applied + public OptionChain Strikes(decimal strike) + { + return Filter(universe => universe.Strikes(strike)); + } + + /// + /// Selects the contracts expiring today. Same as + /// + /// A new chain with the filter applied + public OptionChain ZeroDte() + { + return Filter(universe => universe.ZeroDte()); + } + /// /// Selects the call contracts. Same as /// @@ -86,6 +116,63 @@ public OptionChain PutsOnly() return Filter(universe => universe.PutsOnly()); } + /// + /// Selects the out of the money contracts: calls with strikes above the underlying price and puts with strikes below it. + /// Same as + /// + /// A new chain with the filter applied, empty when the underlying price is unknown + public OptionChain OutOfTheMoney() + { + return Filter(universe => universe.OutOfTheMoney()); + } + + /// + /// Selects the out of the money contracts. Alias for + /// + /// A new chain with the filter applied + public OptionChain OTM() + { + return OutOfTheMoney(); + } + + /// + /// Selects the in the money contracts: calls with strikes below the underlying price and puts with strikes above it. + /// Same as + /// + /// A new chain with the filter applied, empty when the underlying price is unknown + public OptionChain InTheMoney() + { + return Filter(universe => universe.InTheMoney()); + } + + /// + /// Selects the in the money contracts. Alias for + /// + /// A new chain with the filter applied + public OptionChain ITM() + { + return InTheMoney(); + } + + /// + /// Selects the contracts at the strike closest to the underlying price, the lower strike on ties. + /// Same as + /// + /// A new chain with the filter applied, empty when the underlying price is unknown + public OptionChain AtTheMoney() + { + return Filter(universe => universe.AtTheMoney()); + } + + /// + /// Selects the contracts at the strike closest to the underlying price. Alias for + /// + /// A new chain with the filter applied + public OptionChain ATM() + { + return AtTheMoney(); + } + /// /// 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 diff --git a/Common/Data/Market/OptionChain.Selection.cs b/Common/Data/Market/OptionChain.Selection.cs index a99de7e0b33c..0be20ffed2e7 100644 --- a/Common/Data/Market/OptionChain.Selection.cs +++ b/Common/Data/Market/OptionChain.Selection.cs @@ -22,30 +22,16 @@ namespace QuantConnect.Data.Market { /// - /// The option chain selection helpers: views of the contracts and single contract pickers, + /// The option chain selection helpers: views of the strikes and expiries and single contract pickers, /// null-safe (None in Python) instead of raising when nothing matches /// public partial class OptionChain { // Cached views, valid for the contract count they were built at private int _viewsContractsCount = -1; - private IReadOnlyList _calls; - private IReadOnlyList _puts; private StrikeList _strikePrices; private IReadOnlyList _expiries; - /// - /// The call contracts, sorted by expiration then strike - /// - [PandasIgnore] - public IReadOnlyList Calls => GetView(ref _calls, () => GetContracts(OptionRight.Call)); - - /// - /// The put contracts, sorted by expiration then strike - /// - [PandasIgnore] - public IReadOnlyList Puts => GetView(ref _puts, () => GetContracts(OptionRight.Put)); - /// /// The distinct strikes, ascending, with helpers to find the closest, next above or next below a price /// @@ -147,29 +133,14 @@ public OptionContract SelectByDelta(decimal targetDelta, OptionRight? right = nu } /// - /// Gets a new chain with the contracts of the given expiration. Time of day is ignored + /// Gets a new chain with the contracts of the given expiration. Time of day is ignored. + /// Same as /// /// The expiration date /// A new chain, empty when nothing matches public OptionChain At(DateTime expiry) { - var expiryDate = expiry.Date; - return Filter(u => - { - u.Data = u.Data.Where(contract => contract.Expiry.Date == expiryDate).ToList(); - return u; - }); - } - - /// - /// Gets the contract with the strike closest to the underlying price. Ties go to the lower strike, - /// then the nearest expiration. Returns null (None in Python) when there is none - /// - /// Only consider contracts of this right, any right when null - /// The at-the-money contract, or null - public OptionContract AtTheMoney(OptionRight? right = null) - { - return Select(right); + return Expiration(expiry); } /// @@ -182,8 +153,6 @@ private T GetView(ref T view, Func compute) // Contracts are only ever added, never replaced, and the views derive from the contract symbols if (_viewsContractsCount != Contracts.Count) { - _calls = null; - _puts = null; _strikePrices = null; _expiries = null; _viewsContractsCount = Contracts.Count; @@ -231,15 +200,6 @@ private static OptionContract GetClosestByStrike(IEnumerable con .FirstOrDefault(); } - private List GetContracts(OptionRight right) - { - return Contracts.Values - .Where(contract => contract.Right == right) - .OrderBy(contract => contract.Expiry) - .ThenBy(contract => contract.Strike) - .ToList(); - } - /// /// Gets the expiration closest to the target days out among sorted distinct expirations, null when none is in the window /// diff --git a/Common/Securities/ContractSecurityFilterUniverse.cs b/Common/Securities/ContractSecurityFilterUniverse.cs index ec1be57ef2f4..eff672ad523d 100644 --- a/Common/Securities/ContractSecurityFilterUniverse.cs +++ b/Common/Securities/ContractSecurityFilterUniverse.cs @@ -344,6 +344,18 @@ public T Expiration(int minExpiryDays, int maxExpiryDays) return Expiration(TimeSpan.FromDays(minExpiryDays), TimeSpan.FromDays(maxExpiryDays)); } + /// + /// Applies filter selecting the contracts expiring on the given date. Time of day is ignored + /// + /// The expiration date + /// Universe with filter applied + public T Expiration(DateTime expiry) + { + var expiryDate = expiry.Date; + Data = Data.Where(data => data.Symbol.ID.Date.Date == expiryDate).ToList(); + return (T)this; + } + /// /// Explicitly sets the selected contract symbols for this universe. /// This overrides and and all other methods of selecting symbols assuming it is called last. diff --git a/Common/Securities/Option/IOptionContractFilters.cs b/Common/Securities/Option/IOptionContractFilters.cs index 095601441bc1..210a20840fe6 100644 --- a/Common/Securities/Option/IOptionContractFilters.cs +++ b/Common/Securities/Option/IOptionContractFilters.cs @@ -40,6 +40,51 @@ public interface IOptionContractFilters /// TSelf Expiration(int minExpiryDays, int maxExpiryDays); + /// + /// Selects the contracts expiring on the given date, ignoring the time of day + /// + TSelf Expiration(DateTime expiry); + + /// + /// Selects the contracts with the given strike price + /// + TSelf Strikes(decimal strike); + + /// + /// Selects the contracts expiring today + /// + TSelf ZeroDte(); + + /// + /// Selects the out of the money contracts: calls above and puts below the underlying price + /// + TSelf OutOfTheMoney(); + + /// + /// Selects the out of the money contracts. Alias for + /// + TSelf OTM(); + + /// + /// Selects the in the money contracts: calls below and puts above the underlying price + /// + TSelf InTheMoney(); + + /// + /// Selects the in the money contracts. Alias for + /// + TSelf ITM(); + + /// + /// Selects the contracts at the strike closest to the underlying price, the lower strike on ties + /// + TSelf AtTheMoney(); + + /// + /// Selects the contracts at the strike closest to the underlying price. Alias for + /// + TSelf ATM(); + /// /// Selects the call contracts /// diff --git a/Common/Securities/Option/OptionFilterUniverse.cs b/Common/Securities/Option/OptionFilterUniverse.cs index f7050a692203..0eb7baa42093 100644 --- a/Common/Securities/Option/OptionFilterUniverse.cs +++ b/Common/Securities/Option/OptionFilterUniverse.cs @@ -25,6 +25,7 @@ using QuantConnect.Securities.FutureOption; using QuantConnect.Securities.IndexOption; using QuantConnect.Securities.Option; +using QuantConnect.Util; namespace QuantConnect.Securities { @@ -279,6 +280,95 @@ public TUniverse PutsOnly() return Contracts(contracts => contracts.Where(x => x.Symbol.ID.OptionRight == OptionRight.Put)); } + /// + /// Applies filter selecting the contracts with the given strike price + /// + /// The strike price + /// Universe with filter applied + public TUniverse Strikes(decimal strike) + { + return Contracts(contracts => contracts.Where(x => x.Symbol.ID.StrikePrice == strike)); + } + + /// + /// Applies filter selecting the contracts expiring today + /// + /// Universe with filter applied + public TUniverse ZeroDte() + { + return Expiration(0, 0); + } + + /// + /// Applies filter selecting the out of the money contracts: calls with strikes above the underlying price + /// and puts with strikes below it. Selects nothing when the underlying price is unknown + /// + /// Universe with filter applied + public TUniverse OutOfTheMoney() + { + if (!TryGetUnderlyingPrice(out var price)) + { + return Empty(); + } + return Contracts(contracts => contracts.Where(x => OptionPayoff.IsOutOfTheMoney(price, x.Symbol.ID.StrikePrice, x.Symbol.ID.OptionRight))); + } + + /// + /// Applies filter selecting the out of the money contracts. Alias for + /// + /// Universe with filter applied + public TUniverse OTM() + { + return OutOfTheMoney(); + } + + /// + /// Applies filter selecting the in the money contracts: calls with strikes below the underlying price + /// and puts with strikes above it. Selects nothing when the underlying price is unknown + /// + /// Universe with filter applied + public TUniverse InTheMoney() + { + if (!TryGetUnderlyingPrice(out var price)) + { + return Empty(); + } + return Contracts(contracts => contracts.Where(x => OptionPayoff.IsInTheMoney(price, x.Symbol.ID.StrikePrice, x.Symbol.ID.OptionRight))); + } + + /// + /// Applies filter selecting the in the money contracts. Alias for + /// + /// Universe with filter applied + public TUniverse ITM() + { + return InTheMoney(); + } + + /// + /// Applies filter selecting the contracts at the strike closest to the underlying price, the lower strike on ties. + /// Selects nothing when the underlying price is unknown. Unlike with (0, 0), + /// which selects the first strike at or above the price + /// + /// Universe with filter applied + public TUniverse AtTheMoney() + { + if (!TryGetUnderlyingPrice(out var price)) + { + return Empty(); + } + return Strikes(GetClosestStrike(AllSymbols, price)); + } + + /// + /// Applies filter selecting the contracts at the strike closest to the underlying price. Alias for + /// + /// Universe with filter applied + public TUniverse ATM() + { + return AtTheMoney(); + } + /// /// Sets universe of a single call contract with the closest match to criteria given /// @@ -1099,6 +1189,16 @@ private TUniverse InRange(Func selector, decimal min, decimal ma })); } + /// + /// Gets the underlying price in strike units, false when the underlying is unknown + /// + private bool TryGetUnderlyingPrice(out decimal price) + { + // some option strikes are a fraction of the underlying, see SymbolProperties.StrikeMultiplier + price = UnderlyingInternal == null ? 0 : UnderlyingInternal.Price / _underlyingScaleFactor; + return UnderlyingInternal != null; + } + /// /// Helper method that will select no contract /// @@ -1119,8 +1219,17 @@ private TUniverse SymbolList(List contracts) private decimal GetStrike(IEnumerable symbols, decimal strikeFromAtm) { - return symbols.OrderBy(x => Math.Abs(Underlying.Price + strikeFromAtm - x.ID.StrikePrice)) - .Select(x => x.ID.StrikePrice) + return GetClosestStrike(symbols, Underlying.Price + strikeFromAtm); + } + + /// + /// Gets the strike closest to the target price, the lower one on ties, or decimal.MaxValue when there are no symbols + /// + private static decimal GetClosestStrike(IEnumerable symbols, decimal targetPrice) + { + return symbols.Select(x => x.ID.StrikePrice) + .OrderBy(strike => Math.Abs(targetPrice - strike)) + .ThenBy(strike => strike) .DefaultIfEmpty(decimal.MaxValue) .First(); } diff --git a/Common/Util/OptionPayoff.cs b/Common/Util/OptionPayoff.cs index bd4545fc2f9f..06e670ab88c6 100644 --- a/Common/Util/OptionPayoff.cs +++ b/Common/Util/OptionPayoff.cs @@ -74,5 +74,83 @@ public static double GetPayOff(double underlyingPrice, double strike, OptionRigh { return right == OptionRight.Call ? underlyingPrice - strike : strike - underlyingPrice; } + + /// + /// Whether the option is in the money: a call with the strike below the underlying price, a put with the strike above it + /// + /// The price of the underlying + /// The strike price of the option + /// The option right of the option, call or put + /// True if the option has intrinsic value + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool IsInTheMoney(decimal underlyingPrice, decimal strike, OptionRight right) + { + return GetPayOff(underlyingPrice, strike, right) > 0; + } + + /// + /// Whether the option is in the money: a call with the strike below the underlying price, a put with the strike above it + /// + /// The price of the underlying + /// The strike price of the option + /// The option right of the option, call or put + /// True if the option has intrinsic value + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool IsInTheMoney(double underlyingPrice, double strike, OptionRight right) + { + return GetPayOff(underlyingPrice, strike, right) > 0; + } + + /// + /// Whether the option is at the money: the strike equals the underlying price + /// + /// The price of the underlying + /// The strike price of the option + /// The option right of the option, call or put + /// True if the strike equals the underlying price + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool IsAtTheMoney(decimal underlyingPrice, decimal strike, OptionRight right) + { + return GetPayOff(underlyingPrice, strike, right) == 0; + } + + /// + /// Whether the option is at the money: the strike equals the underlying price + /// + /// The price of the underlying + /// The strike price of the option + /// The option right of the option, call or put + /// True if the strike equals the underlying price + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool IsAtTheMoney(double underlyingPrice, double strike, OptionRight right) + { + return GetPayOff(underlyingPrice, strike, right) == 0; + } + + /// + /// Whether the option is out of the money: a call with the strike above the underlying price, a put with the strike below it + /// + /// The price of the underlying + /// The strike price of the option + /// The option right of the option, call or put + /// True if the option has no intrinsic value and is not at the money + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool IsOutOfTheMoney(decimal underlyingPrice, decimal strike, OptionRight right) + { + return GetPayOff(underlyingPrice, strike, right) < 0; + } + + /// + /// Whether the option is out of the money: a call with the strike above the underlying price, a put with the strike below it + /// + /// The price of the underlying + /// The strike price of the option + /// The option right of the option, call or put + /// True if the option has no intrinsic value and is not at the money + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool IsOutOfTheMoney(double underlyingPrice, double strike, OptionRight right) + { + return GetPayOff(underlyingPrice, strike, right) < 0; + } } } diff --git a/Tests/Common/Data/Market/OptionChainSelectionTests.cs b/Tests/Common/Data/Market/OptionChainSelectionTests.cs index 7246242a0d77..0f1b51ce3e79 100644 --- a/Tests/Common/Data/Market/OptionChainSelectionTests.cs +++ b/Tests/Common/Data/Market/OptionChainSelectionTests.cs @@ -79,37 +79,13 @@ 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 ViewsAreCachedUntilContractsAreAdded() { var chain = CreateDefaultChain(); - var calls = chain.Calls; - var puts = chain.Puts; var strikes = chain.StrikePrices; var expiries = chain.Expiries; - Assert.AreSame(calls, chain.Calls); - Assert.AreSame(puts, chain.Puts); Assert.AreSame(strikes, chain.StrikePrices); Assert.AreSame(expiries, chain.Expiries); @@ -117,10 +93,8 @@ public void ViewsAreCachedUntilContractsAreAdded() var added = CreateChain(new[] { (new DateTime(2016, 5, 20), 120m, OptionRight.Call, 0.05m) }).Single(); chain.Contracts[added.Symbol] = added; - Assert.AreNotSame(calls, chain.Calls); - Assert.AreEqual(calls.Count + 1, chain.Calls.Count); - Assert.AreSame(added, chain.Calls.Last()); - Assert.AreEqual(puts.Count, chain.Puts.Count); + Assert.AreNotSame(strikes, chain.StrikePrices); + Assert.AreEqual(strikes.Count + 1, chain.StrikePrices.Count); Assert.AreEqual(120m, chain.StrikePrices.Last()); Assert.AreEqual(new DateTime(2016, 5, 20), chain.Expiries.Last()); } @@ -239,11 +213,11 @@ public void AtFiltersContractsByExpiry() 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); Assert.AreEqual(3, filtered.CallsOnly().Count); + Assert.AreEqual(3, filtered.PutsOnly().Count); CollectionAssert.AreEqual(new[] { 90m, 100m, 110m }, filtered.StrikePrices); - Assert.AreEqual(100m, filtered.AtTheMoney(OptionRight.Call).Strike); + Assert.AreEqual(100m, filtered.Select(OptionRight.Call).Strike); + Assert.AreEqual(2, filtered.AtTheMoney().Count); } [Test] @@ -268,7 +242,7 @@ public void DaysToExpiryCountsCalendarDays() [TestCase(103, 105)] // Equidistant between 95 and 100: lower strike wins [TestCase(97.5, 95)] - public void AtTheMoneySelectsClosestStrike(double underlyingPrice, double expectedStrike) + public void SelectAndAtTheMoneyPickTheClosestStrike(double underlyingPrice, double expectedStrike) { var chain = CreateChain(new[] { @@ -277,17 +251,19 @@ public void AtTheMoneySelectsClosestStrike(double underlyingPrice, double expect (Expiry1, 105m, OptionRight.Call, 0.2m) }, (decimal)underlyingPrice); - var contract = chain.AtTheMoney(OptionRight.Call); + var contract = chain.Select(OptionRight.Call); Assert.IsNotNull(contract); Assert.AreEqual((decimal)expectedStrike, contract.Strike); Assert.AreEqual(OptionRight.Call, contract.Right); + // the filter keeps every contract at that strike + Assert.AreSame(contract, chain.AtTheMoney().Single()); } [Test] - public void AtTheMoneyWithoutRightPrefersTheNearestExpiryThenCalls() + public void SelectWithoutRightPrefersTheNearestExpiryThenCalls() { var chain = CreateDefaultChain(); - var contract = chain.AtTheMoney(); + var contract = chain.Select(); Assert.AreEqual(100m, contract.Strike); Assert.AreEqual(Expiry1, contract.Expiry); @@ -295,25 +271,21 @@ public void AtTheMoneyWithoutRightPrefersTheNearestExpiryThenCalls() } [Test] - public void AtTheMoneyIsNullSafe() + public void AtTheMoneyIsEmptyWithoutContractsOrUnderlyingPrice() { - 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 + Assert.AreEqual(0, CreateEmptyChain().AtTheMoney().Count); var noUnderlying = CreateChain(new[] { (Expiry1, 100m, OptionRight.Call, 0.5m) }, underlyingPrice: null); - Assert.IsNull(noUnderlying.AtTheMoney(OptionRight.Call)); + Assert.AreEqual(0, noUnderlying.AtTheMoney().Count); } [Test] - public void AtTheMoneyUsesTheContractsUnderlyingPrice() + public void SelectUsesTheContractsUnderlyingPrice() { // Chains built from universe data carry the underlying price on each contract var chain = CreateChain(new[] { (Expiry1, 100m, OptionRight.Call, 0.5m) }, underlyingPrice: 100.5m); Assert.AreEqual(100.5m, chain.Underlying.Price); - Assert.AreEqual(100m, chain.AtTheMoney(OptionRight.Call).Strike); + Assert.AreEqual(100m, chain.Select(OptionRight.Call).Strike); } [Test] @@ -492,8 +464,8 @@ def pick(chain): def helpers(chain): at_expiry = chain.at(chain.closest_expiry(target_dte=8)) - return (at_expiry.strike_prices.first_above(100), at_expiry.expiries[0], len(at_expiry.puts), - at_expiry.at_the_money(OptionRight.CALL).days_to_expiry, chain.select(min_dte=40) is None) + return (at_expiry.strike_prices.first_above(100), at_expiry.expiries[0], at_expiry.puts_only().count, + at_expiry.at_the_money().select(OptionRight.CALL).days_to_expiry, chain.select(min_dte=40) is None) "); using var pyChain = chain.ToPython(); diff --git a/Tests/Common/Data/Market/OptionChainTests.cs b/Tests/Common/Data/Market/OptionChainTests.cs index f117c4dbd962..504fadc0db3e 100644 --- a/Tests/Common/Data/Market/OptionChainTests.cs +++ b/Tests/Common/Data/Market/OptionChainTests.cs @@ -62,8 +62,20 @@ private static IEnumerable FilterCases() 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("Expiration(date)", u => u.Expiration(Expiries[1]), c => c.Expiration(Expiries[1])); + yield return Case("Expiration(date, time of day)", u => u.Expiration(Expiries[1].AddHours(10)), c => c.Expiration(Expiries[1].AddHours(10))); + yield return Case("Expiration(unlisted date)", u => u.Expiration(Date), c => c.Expiration(Date), empty: true); + yield return Case("Strikes(100)", u => u.Strikes(100m), c => c.Strikes(100m)); + yield return Case("Strikes(101)", u => u.Strikes(101m), c => c.Strikes(101m), empty: true); + yield return Case("ZeroDte", u => u.ZeroDte(), c => c.ZeroDte(), 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("OutOfTheMoney", u => u.OutOfTheMoney(), c => c.OutOfTheMoney()); + yield return Case("OTM.CallsOnly", u => u.OTM().CallsOnly(), c => c.OTM().CallsOnly()); + yield return Case("InTheMoney", u => u.InTheMoney(), c => c.InTheMoney()); + yield return Case("ITM.PutsOnly.Expiration(0, 10)", u => u.ITM().PutsOnly().Expiration(0, 10), c => c.ITM().PutsOnly().Expiration(0, 10)); + yield return Case("AtTheMoney", u => u.AtTheMoney(), c => c.AtTheMoney()); + yield return Case("Expiration(0, 10).ATM", u => u.Expiration(0, 10).ATM(), c => c.Expiration(0, 10).ATM()); 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()); @@ -304,6 +316,49 @@ public void StrategyFiltersValidateArgumentsLikeTheUniverseFilters() } } + [TestCase(101, 100)] + [TestCase(100, 100)] + [TestCase(103.75, 102.5)] + // Equidistant between 100 and 102.5: the lower strike wins, unlike Strikes(0, 0) + [TestCase(101.25, 100)] + public void MoneynessFiltersSplitTheStrikesAroundTheUnderlyingPrice(double underlyingPrice, double atmStrike) + { + var price = (decimal)underlyingPrice; + var (data, _) = CreateUniverseData(Date, price, Expiries, Strikes); + var chain = new OptionChain(Canonical, Date, data, _symbolProperties); + Assert.AreEqual(price, chain.Underlying.Price); + + var otm = chain.OutOfTheMoney(); + var itm = chain.InTheMoney(); + var atm = chain.AtTheMoney(); + Assert.IsNotEmpty(otm); + Assert.IsNotEmpty(itm); + Assert.IsTrue(otm.All(x => x.Right == OptionRight.Call ? x.Strike > price : x.Strike < price)); + Assert.IsTrue(itm.All(x => x.Right == OptionRight.Call ? x.Strike < price : x.Strike > price)); + // a strike equal to the price is neither out nor in the money + Assert.AreEqual(chain.Count, otm.Count + itm.Count + chain.Strikes(price).Count); + Assert.AreEqual(2 * Expiries.Length, atm.Count); + Assert.IsTrue(atm.All(x => x.Strike == (decimal)atmStrike)); + } + + [Test] + public void MoneynessFiltersSelectNothingWithoutUnderlyingPrice() + { + 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.OutOfTheMoney().Count); + Assert.AreEqual(0, chain.InTheMoney().Count); + Assert.AreEqual(0, chain.AtTheMoney().Count); + foreach (var filter in new Func[] { u => u.OutOfTheMoney(), u => u.InTheMoney(), u => u.AtTheMoney() }) + { + var universe = new OptionFilterUniverse(_option); + universe.Refresh(contracts, null, Date); + Assert.AreEqual(0, filter(universe).Count); + } + } + [Test] public void TypeFiltersApplyToTheChainContractsInAnyOrder() { diff --git a/Tests/Common/Util/OptionPayoffTests.cs b/Tests/Common/Util/OptionPayoffTests.cs new file mode 100644 index 000000000000..dfe05d1245aa --- /dev/null +++ b/Tests/Common/Util/OptionPayoffTests.cs @@ -0,0 +1,45 @@ +/* + * QUANTCONNECT.COM - Democratizing Finance, Empowering Individuals. + * Lean Algorithmic Trading Engine v2.0. Copyright 2014 QuantConnect Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. +*/ + +using NUnit.Framework; +using QuantConnect.Util; + +namespace QuantConnect.Tests.Common.Util +{ + [TestFixture] + public class OptionPayoffTests + { + // A call is in the money below the underlying price, a put above it, both are at the money at the price + [TestCase(OptionRight.Call, 100, 90, true, false, false)] + [TestCase(OptionRight.Call, 100, 100, false, true, false)] + [TestCase(OptionRight.Call, 100, 110, false, false, true)] + [TestCase(OptionRight.Put, 100, 90, false, false, true)] + [TestCase(OptionRight.Put, 100, 100, false, true, false)] + [TestCase(OptionRight.Put, 100, 110, true, false, false)] + public void ClassifiesMoneyness(OptionRight right, double underlyingPrice, double strike, bool inTheMoney, bool atTheMoney, bool outOfTheMoney) + { + Assert.AreEqual(inTheMoney, OptionPayoff.IsInTheMoney((decimal)underlyingPrice, (decimal)strike, right)); + Assert.AreEqual(atTheMoney, OptionPayoff.IsAtTheMoney((decimal)underlyingPrice, (decimal)strike, right)); + Assert.AreEqual(outOfTheMoney, OptionPayoff.IsOutOfTheMoney((decimal)underlyingPrice, (decimal)strike, right)); + + Assert.AreEqual(inTheMoney, OptionPayoff.IsInTheMoney(underlyingPrice, strike, right)); + Assert.AreEqual(atTheMoney, OptionPayoff.IsAtTheMoney(underlyingPrice, strike, right)); + Assert.AreEqual(outOfTheMoney, OptionPayoff.IsOutOfTheMoney(underlyingPrice, strike, right)); + + // in the money contracts are the ones with intrinsic value + Assert.AreEqual(inTheMoney, OptionPayoff.GetIntrinsicValue((decimal)underlyingPrice, (decimal)strike, right) > 0); + } + } +} From 31b50bcb7bddc24b1ab0291bbe2de0aeebf610d1 Mon Sep 17 00:00:00 2001 From: Jhonathan Abreu Date: Thu, 10 Sep 2026 16:38:30 -0400 Subject: [PATCH 08/14] Replace the chain pickers with strike and expiration set, bound and farthest expiration filters The single contract pickers, the strike and expiry views and StrikeList are removed: the filters cover the same selections and return chains. Strikes and Expiration take sets of values, StrikesAbove, StrikesBelow, ExpiringAfter and ExpiringBefore select strict bounds, and FarthestExpiration is the opposite of ZeroDte, built like FrontMonth. All are shared by the option universe, the IOptionContractFilters interface and the chain. --- .../OptionChainFiltersRegressionAlgorithm.cs | 26 +- ...hainSelectionHelpersRegressionAlgorithm.cs | 225 -------- .../OptionChainFiltersRegressionAlgorithm.py | 25 +- ...hainSelectionHelpersRegressionAlgorithm.py | 109 ---- Common/Data/Market/OptionChain.Filters.cs | 73 ++- Common/Data/Market/OptionChain.Selection.cs | 257 --------- Common/Data/Market/StrikeList.cs | 102 ---- .../ContractSecurityFilterUniverse.cs | 49 +- .../Option/IOptionContractFilters.cs | 34 +- .../Option/OptionChainFilterUniverse.cs | 8 - .../Securities/Option/OptionFilterUniverse.cs | 31 +- .../Data/Market/OptionChainSelectionTests.cs | 487 ------------------ Tests/Common/Data/Market/OptionChainTests.cs | 58 ++- 13 files changed, 254 insertions(+), 1230 deletions(-) delete mode 100644 Algorithm.CSharp/OptionChainSelectionHelpersRegressionAlgorithm.cs delete mode 100644 Algorithm.Python/OptionChainSelectionHelpersRegressionAlgorithm.py delete mode 100644 Common/Data/Market/OptionChain.Selection.cs delete mode 100644 Common/Data/Market/StrikeList.cs delete mode 100644 Tests/Common/Data/Market/OptionChainSelectionTests.cs diff --git a/Algorithm.CSharp/OptionChainFiltersRegressionAlgorithm.cs b/Algorithm.CSharp/OptionChainFiltersRegressionAlgorithm.cs index ec2120d5e9e4..be7a8273ce8c 100644 --- a/Algorithm.CSharp/OptionChainFiltersRegressionAlgorithm.cs +++ b/Algorithm.CSharp/OptionChainFiltersRegressionAlgorithm.cs @@ -93,23 +93,37 @@ public override void Initialize() var price = chain.Underlying.Price; var otm = chain.OutOfTheMoney(); var itm = chain.InTheMoney(); - if (otm.Count == 0 || itm.Count == 0 || otm.Count + itm.Count + chain.Strikes(price).Count != chain.Count + if (otm.Count == 0 || itm.Count == 0 || otm.Count + itm.Count + chain.Strikes([price]).Count != chain.Count || otm.Any(x => x.Right == OptionRight.Call ? x.Strike <= price : x.Strike >= price) || itm.Any(x => x.Right == OptionRight.Call ? x.Strike >= price : x.Strike <= price)) { throw new RegressionTestException("Out/in the money filters mismatch"); } var atm = chain.AtTheMoney(); - if (atm.Count == 0 || atm.Any(x => x.Strike != 747.5m) || atm.Count != chain.Strikes(747.5m).Count) + if (atm.Count == 0 || atm.Any(x => x.Strike != 747.5m) || atm.Count != chain.Strikes([747.5m]).Count) { throw new RegressionTestException("Expected AtTheMoney() to select every contract at the 747.50 strike"); } - // Exact expiration and strike, and today's expiration - if (chain.Expiration(new DateTime(2015, 12, 24)).Count != chain.FrontMonth().Count - || chain.ZeroDte().Count != chain.Expiration(0, 0).Count) + // Strike sets and bounds are absolute, unlike the relative Strikes(min, max) + var strikes = chain.Strikes([745m, 750m]); + if (strikes.Count == 0 || strikes.Any(x => x.Strike != 745m && x.Strike != 750m) + || chain.StrikesAbove(750m).StrikesBelow(755m).Any(x => x.Strike != 752.5m) + || chain.StrikesAbove(price).Count + chain.StrikesBelow(price).Count + chain.Strikes([price]).Count != chain.Count) { - throw new RegressionTestException("Expiration(date) and ZeroDte() should match the front month"); + throw new RegressionTestException("Strike set or bound filters mismatch"); + } + + // Expiration sets and bounds, today's expiration and the farthest one + var frontMonth = new DateTime(2015, 12, 24); + var farthest = chain.FarthestExpiration(); + if (chain.Expiration([frontMonth]).Count != chain.FrontMonth().Count + || chain.ZeroDte().Count != chain.Expiration(0, 0).Count + || chain.ExpiringAfter(frontMonth).Count + chain.FrontMonth().Count != chain.Count + || chain.ExpiringBefore(frontMonth).Count != 0 + || farthest.Count == 0 || farthest.Any(x => x.Expiry != chain.Max(c => c.Expiry))) + { + throw new RegressionTestException("Expiration set, bound, ZeroDte() or FarthestExpiration() filters mismatch"); } } diff --git a/Algorithm.CSharp/OptionChainSelectionHelpersRegressionAlgorithm.cs b/Algorithm.CSharp/OptionChainSelectionHelpersRegressionAlgorithm.cs deleted file mode 100644 index a1178b95a185..000000000000 --- a/Algorithm.CSharp/OptionChainSelectionHelpersRegressionAlgorithm.cs +++ /dev/null @@ -1,225 +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 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 its synonym ), - /// , , - /// , and - /// , which replace the usual hand-rolled 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 strike criteria 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.54, closest expiry to 10 days out is 2015-12-31 (7 days), ATM strike is 747.50 - if (contract.Expiry != new DateTime(2015, 12, 31) || contract.Strike != 747.5m || contract.DaysToExpiry != 7) - { - throw new RegressionTestException($"Unexpected contract selected: {contract.Symbol.Value}, {contract.DaysToExpiry} days to expiry"); - } - - // Pick is a synonym of Select - if (!contract.Symbol.Equals(chain.Pick(right: OptionRight.Call, targetDte: 10).Symbol)) - { - throw new RegressionTestException("Pick() and Select() must select the same contract"); - } - - // 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}"); - } - // The sorted expiries start at the chain date, contracts expiring today are still in the chain - if (chain.Expiries[0] != Time.Date || chain.Expiries.Last() != chain.Expiries.Max()) - { - throw new RegressionTestException($"Unexpected expiries: {string.Join(", ", chain.Expiries)}"); - } - - // Single-expiry view: composes with StrikePrices, Select and the universe filters - 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.CallsOnly().Count == 0 || atExpiry.PutsOnly().Count == 0) - { - throw new RegressionTestException("At().CallsOnly()/.PutsOnly() should not be empty"); - } - var atmPut = atExpiry.Select(OptionRight.Put); - if (atmPut == null || atmPut.Strike != 747.5m || atmPut.Right != OptionRight.Put) - { - throw new RegressionTestException($"Select(Put) expected the 747.50 put but got {atmPut?.Symbol.Value}"); - } - - // Strike prices helpers: strictly above/below and closest to the underlying price - var strikes = atExpiry.StrikePrices; - if (strikes.ClosestTo(spot) != 747.5m || strikes.FirstAbove(spot) != 750m || strikes.FirstBelow(spot) != 747.5m) - { - throw new RegressionTestException( - $"StrikePrices helpers mismatch: {strikes.ClosestTo(spot)}/{strikes.FirstAbove(spot)}/{strikes.FirstBelow(spot)}"); - } - - // Strike distance in price units, like the universe strategy filters: 5 above the spot is the 752.50 call - var otmCall = chain.SelectByStrikeDistance(5, OptionRight.Call, targetDte: 7); - if (otmCall == null || otmCall.Strike != 752.5m) - { - throw new RegressionTestException($"SelectByStrikeDistance() expected the 752.50 call but got {otmCall?.Symbol.Value}"); - } - - // Delta targeting: the put with |delta| closest to 0.35, using the universe pre-calculated greeks - var deltaPut = chain.SelectByDelta(0.35m, OptionRight.Put, targetDte: 7); - 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($"SelectByDelta() 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/OptionChainFiltersRegressionAlgorithm.py b/Algorithm.Python/OptionChainFiltersRegressionAlgorithm.py index 0ca7312fe742..5a4a356bb515 100644 --- a/Algorithm.Python/OptionChainFiltersRegressionAlgorithm.py +++ b/Algorithm.Python/OptionChainFiltersRegressionAlgorithm.py @@ -65,18 +65,31 @@ def initialize(self): price = chain.underlying.price otm = chain.otm() itm = chain.itm() - if (otm.count == 0 or itm.count == 0 or otm.count + itm.count + chain.strikes(price).count != chain.count + if (otm.count == 0 or itm.count == 0 or otm.count + itm.count + chain.strikes([price]).count != chain.count or any((x.strike <= price if x.right == OptionRight.CALL else x.strike >= price) for x in otm) or any((x.strike >= price if x.right == OptionRight.CALL else x.strike <= price) for x in itm)): raise AssertionError("Out/in the money filters mismatch") atm = chain.atm() - if atm.count == 0 or any(x.strike != 747.5 for x in atm) or atm.count != chain.strikes(747.5).count: + if atm.count == 0 or any(x.strike != 747.5 for x in atm) or atm.count != chain.strikes([747.5]).count: raise AssertionError("Expected atm() to select every contract at the 747.50 strike") - # Exact expiration and strike, and today's expiration - if (chain.expiration(datetime(2015, 12, 24)).count != chain.front_month().count - or chain.zero_dte().count != chain.expiration(0, 0).count): - raise AssertionError("expiration(date) and zero_dte() should match the front month") + # Strike sets and bounds are absolute, unlike the relative strikes(min, max) + strikes = chain.strikes([745, 750]) + if (strikes.count == 0 or any(x.strike != 745 and x.strike != 750 for x in strikes) + or any(x.strike != 752.5 for x in chain.strikes_above(750).strikes_below(755)) + or chain.strikes_above(price).count + chain.strikes_below(price).count + chain.strikes([price]).count != chain.count): + raise AssertionError("Strike set or bound filters mismatch") + + # Expiration sets and bounds, today's expiration and the farthest one + front_month = datetime(2015, 12, 24) + farthest = chain.farthest_expiration() + max_expiry = max(x.expiry for x in chain) + if (chain.expiration([front_month]).count != chain.front_month().count + or chain.zero_dte().count != chain.expiration(0, 0).count + or chain.expiring_after(front_month).count + chain.front_month().count != chain.count + or chain.expiring_before(front_month).count != 0 + or farthest.count == 0 or any(x.expiry != max_expiry for x in farthest)): + raise AssertionError("Expiration set, bound, zero_dte() or farthest_expiration() filters mismatch") # where() takes a predicate, like the universe filter does high_open_interest = chain.where(lambda x: x.open_interest > 1000) diff --git a/Algorithm.Python/OptionChainSelectionHelpersRegressionAlgorithm.py b/Algorithm.Python/OptionChainSelectionHelpersRegressionAlgorithm.py deleted file mode 100644 index 61a769e0bb9d..000000000000 --- a/Algorithm.Python/OptionChainSelectionHelpersRegressionAlgorithm.py +++ /dev/null @@ -1,109 +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. - -from AlgorithmImports import * - -### -### Regression algorithm demonstrating the option chain selection helpers: select() (and its synonym pick()), -### closest_expiry(), at(), strike_prices and expiries, 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 strike criteria 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.54, closest expiry to 10 days out is 2015-12-31 (7 days), ATM strike is 747.50 - if contract.expiry != datetime(2015, 12, 31) or contract.strike != 747.5 or contract.days_to_expiry != 7: - raise AssertionError(f"Unexpected contract selected: {contract.symbol.value}, {contract.days_to_expiry} days to expiry") - - # pick() is a synonym of select() - if contract.symbol != chain.pick(right=OptionRight.CALL, target_dte=10).symbol: - raise AssertionError("pick() and select() must select the same contract") - - # 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}") - # The sorted expiries start at the chain date, contracts expiring today are still in the chain - if chain.expiries[0] != self.time or chain.expiries[-1] != max(chain.expiries): - raise AssertionError(f"Unexpected expiries: {chain.expiries}") - - # Single-expiry view: composes with strike_prices, select and the universe filters - 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 at_expiry.calls_only().count == 0 or at_expiry.puts_only().count == 0: - raise AssertionError("at().calls_only()/.puts_only() should not be empty") - atm_put = at_expiry.select(OptionRight.PUT) - if atm_put is None or atm_put.strike != 747.5 or atm_put.right != OptionRight.PUT: - raise AssertionError(f"select(PUT) expected the 747.50 put but got {atm_put}") - - # Strike prices helpers: strictly above/below and closest to the underlying price - strikes = at_expiry.strike_prices - if strikes.closest_to(spot) != 747.5 or strikes.first_above(spot) != 750 or strikes.first_below(spot) != 747.5: - raise AssertionError( - f"strike_prices helpers mismatch: {strikes.closest_to(spot)}/{strikes.first_above(spot)}/{strikes.first_below(spot)}") - - # Strike distance in price units, like the universe strategy filters: 5 above the spot is the 752.50 call - otm_call = chain.select_by_strike_distance(5, OptionRight.CALL, target_dte=7) - if otm_call is None or otm_call.strike != 752.5: - raise AssertionError(f"select_by_strike_distance() expected the 752.50 call but got {otm_call}") - - # Delta targeting: the put with |delta| closest to 0.35, using the universe pre-calculated greeks - delta_put = chain.select_by_delta(0.35, OptionRight.PUT, target_dte=7) - 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_by_delta() mismatch: {delta_put} != {ceremony_delta_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/OptionChain.Filters.cs b/Common/Data/Market/OptionChain.Filters.cs index 651c9816e3db..52cf40fc92b6 100644 --- a/Common/Data/Market/OptionChain.Filters.cs +++ b/Common/Data/Market/OptionChain.Filters.cs @@ -14,6 +14,7 @@ */ using System; +using System.Collections.Generic; using System.Linq; using Python.Runtime; using QuantConnect.Securities; @@ -69,24 +70,69 @@ public OptionChain Expiration(int minExpiryDays, int maxExpiryDays) } /// - /// Selects the contracts expiring on the given date. Time of day is ignored. - /// Same as + /// Selects the contracts expiring on any of the given dates. Time of day is ignored. + /// Same as /// - /// The expiration date + /// The expiration dates /// A new chain with the filter applied - public OptionChain Expiration(DateTime expiry) + public OptionChain Expiration(IEnumerable expiries) { - return Filter(universe => universe.Expiration(expiry)); + return Filter(universe => universe.Expiration(expiries)); } /// - /// Selects the contracts with the given strike price. Same as + /// Selects the contracts expiring after the given date, excluding it. Time of day is ignored. + /// Same as /// - /// The strike price + /// The date the expirations must be after /// A new chain with the filter applied - public OptionChain Strikes(decimal strike) + public OptionChain ExpiringAfter(DateTime date) { - return Filter(universe => universe.Strikes(strike)); + return Filter(universe => universe.ExpiringAfter(date)); + } + + /// + /// Selects the contracts expiring before the given date, excluding it. Time of day is ignored. + /// Same as + /// + /// The date the expirations must be before + /// A new chain with the filter applied + public OptionChain ExpiringBefore(DateTime date) + { + return Filter(universe => universe.ExpiringBefore(date)); + } + + /// + /// Selects the contracts with any of the given strike prices. + /// Same as + /// + /// The strike prices + /// A new chain with the filter applied + public OptionChain Strikes(IEnumerable strikes) + { + return Filter(universe => universe.Strikes(strikes)); + } + + /// + /// Selects the contracts with strikes above the given price, excluding it. + /// Same as + /// + /// The price the strikes must be above + /// A new chain with the filter applied + public OptionChain StrikesAbove(decimal price) + { + return Filter(universe => universe.StrikesAbove(price)); + } + + /// + /// Selects the contracts with strikes below the given price, excluding it. + /// Same as + /// + /// The price the strikes must be below + /// A new chain with the filter applied + public OptionChain StrikesBelow(decimal price) + { + return Filter(universe => universe.StrikesBelow(price)); } /// @@ -202,6 +248,15 @@ public OptionChain FrontMonth() return Filter(universe => universe.FrontMonth()); } + /// + /// Selects the contracts of the farthest expiration. Same as + /// + /// A new chain with the filter applied + public OptionChain FarthestExpiration() + { + return Filter(universe => universe.FarthestExpiration()); + } + /// /// Selects the contracts of all expirations but the nearest one. Same as /// diff --git a/Common/Data/Market/OptionChain.Selection.cs b/Common/Data/Market/OptionChain.Selection.cs deleted file mode 100644 index 0be20ffed2e7..000000000000 --- a/Common/Data/Market/OptionChain.Selection.cs +++ /dev/null @@ -1,257 +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 System; -using System.Collections.Generic; -using System.Linq; -using QuantConnect.Python; -using QuantConnect.Securities; - -namespace QuantConnect.Data.Market -{ - /// - /// The option chain selection helpers: views of the strikes and expiries and single contract pickers, - /// null-safe (None in Python) instead of raising when nothing matches - /// - public partial class OptionChain - { - // Cached views, valid for the contract count they were built at - private int _viewsContractsCount = -1; - private StrikeList _strikePrices; - private IReadOnlyList _expiries; - - /// - /// The distinct strikes, ascending, with helpers to find the closest, next above or next below a price - /// - [PandasIgnore] - public StrikeList StrikePrices => GetView(ref _strikePrices, () => new StrikeList(Contracts.Values.Select(contract => contract.Strike))); - - /// - /// The distinct expiration dates, ascending - /// - [PandasIgnore] - public IReadOnlyList Expiries => GetView(ref _expiries, - () => Contracts.Values.Select(contract => contract.Expiry).Distinct().OrderBy(expiry => expiry).ToList()); - - #region Selection helpers - - /// - /// Selects the single contract closest to the criteria, e.g. chain.select(OptionRight.PUT, target_dte=30, moneyness=-0.15). - /// Returns null (None in Python) when nothing matches. Unlike the universe strategy filters, - /// which take a minimum days to expiration, this takes a target and picks the closest expiration. - /// See also and - /// - /// Only consider contracts of this right, any right when null - /// Only consider the expiration closest to this many days out, see - /// Exclude expirations closer than this many days - /// Exclude expirations further than this many days - /// Target strike as a signed fraction of the underlying price, e.g. -0.15 targets 85% of it. 0 is at the money - /// The contract with the strike closest to the target, or null - public OptionContract Select(OptionRight? right = null, int? targetDte = null, int? minDte = null, int? maxDte = null, decimal moneyness = 0) - { - var candidates = GetCandidates(right, targetDte, minDte, maxDte, out var underlyingPrice); - return underlyingPrice.HasValue ? GetClosestByStrike(candidates, underlyingPrice.Value * (1 + moneyness)) : null; - } - - /// - /// Synonym of - /// - /// Only consider contracts of this right, any right when null - /// Only consider the expiration closest to this many days out - /// Exclude expirations closer than this many days - /// Exclude expirations further than this many days - /// Target strike as a signed fraction of the underlying price, 0 is at the money - /// The contract with the strike closest to the target, or null - public OptionContract Pick(OptionRight? right = null, int? targetDte = null, int? minDte = null, int? maxDte = null, decimal moneyness = 0) - { - return Select(right, targetDte, minDte, maxDte, moneyness); - } - - /// - /// Like , with the target strike given as a distance from the underlying price in price units, - /// as the universe strategy filters take it, e.g. chain.select_by_strike_distance(-5, OptionRight.PUT, target_dte=30) - /// - /// Signed distance of the target strike from the underlying price - /// Only consider contracts of this right, any right when null - /// Only consider the expiration closest to this many days out - /// Exclude expirations closer than this many days - /// Exclude expirations further than this many days - /// The contract with the strike closest to the target, or null - public OptionContract SelectByStrikeDistance(decimal strikeFromAtm, OptionRight? right = null, int? targetDte = null, int? minDte = null, - int? maxDte = null) - { - var candidates = GetCandidates(right, targetDte, minDte, maxDte, out var underlyingPrice); - return underlyingPrice.HasValue ? GetClosestByStrike(candidates, underlyingPrice.Value + strikeFromAtm) : null; - } - - /// - /// Like , targeting a delta instead of a strike: the contract whose absolute delta is closest to the - /// absolute target, so a 30 delta put is 0.3 or -0.3, e.g. chain.select_by_delta(0.3, OptionRight.PUT, target_dte=30). - /// Contracts without greeks are ignored - /// - /// The target delta - /// Only consider contracts of this right, any right when null - /// Only consider the expiration closest to this many days out - /// Exclude expirations closer than this many days - /// Exclude expirations further than this many days - /// The contract with the delta closest to the target, or null - public OptionContract SelectByDelta(decimal targetDelta, OptionRight? right = null, int? targetDte = null, int? minDte = null, int? maxDte = null) - { - var target = Math.Abs(targetDelta); - // Contracts without greeks report a zero delta: they are excluded so a chain without greeks returns null - return GetCandidates(right, targetDte, minDte, maxDte, out _) - .Where(contract => contract.Greeks.Delta != 0) - .OrderBy(contract => Math.Abs(Math.Abs(contract.Greeks.Delta) - target)) - .ThenBy(contract => contract.Expiry) - .ThenBy(contract => contract.Strike) - .ThenBy(contract => contract.Right) - .FirstOrDefault(); - } - - /// - /// Gets the expiration closest to the target days out. Returns null (None in Python) when none falls in the window - /// - /// The target days to expiration, ties go to the earlier expiration. Defaults to minDte, else 0 - /// Exclude expirations closer than this many days - /// Exclude expirations further than this many days - /// The expiration date as stored in the contracts, or null - public DateTime? ClosestExpiry(int? targetDte = null, int? minDte = null, int? maxDte = null) - { - return GetClosestExpiry(new OptionChainFilterUniverse(this), Expiries, targetDte, minDte, maxDte); - } - - /// - /// Gets a new chain with the contracts of the given expiration. Time of day is ignored. - /// Same as - /// - /// The expiration date - /// A new chain, empty when nothing matches - public OptionChain At(DateTime expiry) - { - return Expiration(expiry); - } - - /// - /// Gets a cached view of the contracts, recomputed when contracts have been added to the chain since it was built - /// - private T GetView(ref T view, Func compute) - where T : class - { - // Slice chains are filled in as data arrives, so a new contract count invalidates every view. - // Contracts are only ever added, never replaced, and the views derive from the contract symbols - if (_viewsContractsCount != Contracts.Count) - { - _strikePrices = null; - _expiries = null; - _viewsContractsCount = Contracts.Count; - } - return view ??= compute(); - } - - /// - /// Gets the contracts of the given right and of the expiration closest to the target, none when no expiration is in the window - /// - private IEnumerable GetCandidates(OptionRight? right, int? targetDte, int? minDte, int? maxDte, out decimal? underlyingPrice) - { - var universe = new OptionChainFilterUniverse(this); - underlyingPrice = universe.Underlying?.Price; - - IEnumerable candidates = Contracts.Values; - if (right.HasValue) - { - candidates = candidates.Where(contract => contract.Right == right.Value).ToList(); - } - - if (targetDte.HasValue || minDte.HasValue || maxDte.HasValue) - { - var expiries = candidates.Select(contract => contract.Expiry).Distinct().OrderBy(expiry => expiry).ToList(); - var expiry = GetClosestExpiry(universe, expiries, targetDte, minDte, maxDte); - if (!expiry.HasValue) - { - return Enumerable.Empty(); - } - candidates = candidates.Where(contract => contract.Expiry == expiry.Value).ToList(); - } - - return candidates; - } - - private static OptionContract GetClosestByStrike(IEnumerable contracts, decimal targetStrike) - { - // Scaled strikes are in underlying price units, see SymbolProperties.StrikeMultiplier. - // Ties go to the lower strike, then the nearest expiration, then calls - return contracts - .OrderBy(contract => Math.Abs(contract.ScaledStrike - targetStrike)) - .ThenBy(contract => contract.Strike) - .ThenBy(contract => contract.Expiry) - .ThenBy(contract => contract.Right) - .FirstOrDefault(); - } - - /// - /// Gets the expiration closest to the target days out among sorted distinct expirations, null when none is in the window - /// - private static DateTime? GetClosestExpiry(OptionChainFilterUniverse universe, IReadOnlyList expiries, - int? targetDte, int? minDte, int? maxDte) - { - // Days to expiration grow with the expiration date, so the window and the target can be searched instead of scanned - var low = minDte.HasValue ? FirstIndex(expiries, universe, 0, expiries.Count, dte => dte >= minDte.Value) : 0; - var high = maxDte.HasValue ? FirstIndex(expiries, universe, low, expiries.Count, dte => dte > maxDte.Value) : expiries.Count; - if (low >= high) - { - return null; - } - - var target = targetDte ?? minDte ?? 0; - // the first expiration at or beyond the target and the one before it are the only candidates, ties go to the earlier one - var index = FirstIndex(expiries, universe, low, high, dte => dte >= target); - if (index == high) - { - return expiries[high - 1]; - } - if (index == low) - { - return expiries[low]; - } - - var before = expiries[index - 1]; - var after = expiries[index]; - return universe.GetDaysToExpiry(after) - target < target - universe.GetDaysToExpiry(before) ? after : before; - } - - /// - /// Gets the first index in [low, high) whose days to expiration satisfy the predicate, high when none does. - /// The predicate must be false then true along the sorted expirations - /// - private static int FirstIndex(IReadOnlyList expiries, OptionChainFilterUniverse universe, int low, int high, Func predicate) - { - while (low < high) - { - var middle = low + (high - low) / 2; - if (predicate(universe.GetDaysToExpiry(expiries[middle]))) - { - high = middle; - } - else - { - low = middle + 1; - } - } - return low; - } - - #endregion - } -} diff --git a/Common/Data/Market/StrikeList.cs b/Common/Data/Market/StrikeList.cs deleted file mode 100644 index 4626198554ee..000000000000 --- a/Common/Data/Market/StrikeList.cs +++ /dev/null @@ -1,102 +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 System.Collections.Generic; -using System.Collections.ObjectModel; -using System.Linq; - -namespace QuantConnect.Data.Market -{ - /// - /// The distinct strikes of a chain, ascending and read only, with helpers that return null (None in Python) when no strike matches - /// - public class StrikeList : ReadOnlyCollection - { - private readonly List _strikes; - - /// - /// Creates the list from the given strikes, in any order, duplicates allowed - /// - /// The strike prices - public StrikeList(IEnumerable strikes) - : this(strikes.Distinct().OrderBy(strike => strike).ToList()) - { - } - - private StrikeList(List strikes) - : base(strikes) - { - _strikes = strikes; - } - - /// - /// The strike closest to the price, the lower one on ties - /// - /// The reference price, e.g. the underlying price - /// The closest strike, or null when the list is empty - public decimal? ClosestTo(decimal price) - { - if (_strikes.Count == 0) - { - return null; - } - - var index = _strikes.BinarySearch(price); - if (index >= 0) - { - return _strikes[index]; - } - - // the complement is the first strike above the price, so the candidates are it and the one before - index = ~index; - if (index == 0) - { - return _strikes[0]; - } - if (index == _strikes.Count) - { - return _strikes[index - 1]; - } - - var below = _strikes[index - 1]; - var above = _strikes[index]; - return above - price < price - below ? above : below; - } - - /// - /// The lowest strike above the price - /// - /// The reference price, e.g. the underlying price - /// The first strike above the price, or null when there is none - public decimal? FirstAbove(decimal price) - { - var index = _strikes.BinarySearch(price); - index = index >= 0 ? index + 1 : ~index; - return index < _strikes.Count ? _strikes[index] : null; - } - - /// - /// The highest strike below the price - /// - /// The reference price, e.g. the underlying price - /// The first strike below the price, or null when there is none - public decimal? FirstBelow(decimal price) - { - var index = _strikes.BinarySearch(price); - index = (index >= 0 ? index : ~index) - 1; - return index >= 0 ? _strikes[index] : null; - } - } -} diff --git a/Common/Securities/ContractSecurityFilterUniverse.cs b/Common/Securities/ContractSecurityFilterUniverse.cs index eff672ad523d..f12247633b22 100644 --- a/Common/Securities/ContractSecurityFilterUniverse.cs +++ b/Common/Securities/ContractSecurityFilterUniverse.cs @@ -268,6 +268,21 @@ public virtual T FrontMonth() return (T)this; } + /// + /// Returns the contracts of the farthest expiration + /// + /// Universe with filter applied + public virtual T FarthestExpiration() + { + ApplyTypesFilter(); + var ordered = Data.OrderByDescending(x => x.Symbol.ID.Date).ToList(); + if (ordered.Count == 0) return (T)this; + var farthest = ordered.TakeWhile(x => ordered[0].Symbol.ID.Date == x.Symbol.ID.Date); + + Data = farthest.ToList(); + return (T)this; + } + /// /// Returns a list of back month contracts /// @@ -345,14 +360,38 @@ public T Expiration(int minExpiryDays, int maxExpiryDays) } /// - /// Applies filter selecting the contracts expiring on the given date. Time of day is ignored + /// Applies filter selecting the contracts expiring on any of the given dates. Time of day is ignored + /// + /// The expiration dates + /// Universe with filter applied + public T Expiration(IEnumerable expiries) + { + var expiryDates = expiries.Select(expiry => expiry.Date).ToHashSet(); + Data = Data.Where(data => expiryDates.Contains(data.Symbol.ID.Date.Date)).ToList(); + return (T)this; + } + + /// + /// Applies filter selecting the contracts expiring after the given date, excluding it. Time of day is ignored + /// + /// The date the expirations must be after + /// Universe with filter applied + public T ExpiringAfter(DateTime date) + { + var expiryDate = date.Date; + Data = Data.Where(data => data.Symbol.ID.Date.Date > expiryDate).ToList(); + return (T)this; + } + + /// + /// Applies filter selecting the contracts expiring before the given date, excluding it. Time of day is ignored /// - /// The expiration date + /// The date the expirations must be before /// Universe with filter applied - public T Expiration(DateTime expiry) + public T ExpiringBefore(DateTime date) { - var expiryDate = expiry.Date; - Data = Data.Where(data => data.Symbol.ID.Date.Date == expiryDate).ToList(); + var expiryDate = date.Date; + Data = Data.Where(data => data.Symbol.ID.Date.Date < expiryDate).ToList(); return (T)this; } diff --git a/Common/Securities/Option/IOptionContractFilters.cs b/Common/Securities/Option/IOptionContractFilters.cs index 210a20840fe6..47762f647b46 100644 --- a/Common/Securities/Option/IOptionContractFilters.cs +++ b/Common/Securities/Option/IOptionContractFilters.cs @@ -14,6 +14,7 @@ */ using System; +using System.Collections.Generic; namespace QuantConnect.Securities { @@ -41,14 +42,34 @@ public interface IOptionContractFilters TSelf Expiration(int minExpiryDays, int maxExpiryDays); /// - /// Selects the contracts expiring on the given date, ignoring the time of day + /// Selects the contracts expiring on any of the given dates, ignoring the time of day /// - TSelf Expiration(DateTime expiry); + TSelf Expiration(IEnumerable expiries); /// - /// Selects the contracts with the given strike price + /// Selects the contracts expiring after the given date, excluding it /// - TSelf Strikes(decimal strike); + TSelf ExpiringAfter(DateTime date); + + /// + /// Selects the contracts expiring before the given date, excluding it + /// + TSelf ExpiringBefore(DateTime date); + + /// + /// Selects the contracts with any of the given strike prices + /// + TSelf Strikes(IEnumerable strikes); + + /// + /// Selects the contracts with strikes above the given price, excluding it + /// + TSelf StrikesAbove(decimal price); + + /// + /// Selects the contracts with strikes below the given price, excluding it + /// + TSelf StrikesBelow(decimal price); /// /// Selects the contracts expiring today @@ -110,6 +131,11 @@ public interface IOptionContractFilters /// TSelf FrontMonth(); + /// + /// Selects the contracts of the farthest expiration + /// + TSelf FarthestExpiration(); + /// /// Selects the contracts of all expirations but the nearest one /// diff --git a/Common/Securities/Option/OptionChainFilterUniverse.cs b/Common/Securities/Option/OptionChainFilterUniverse.cs index 38df4886ec0e..37d68d575813 100644 --- a/Common/Securities/Option/OptionChainFilterUniverse.cs +++ b/Common/Securities/Option/OptionChainFilterUniverse.cs @@ -51,14 +51,6 @@ public OptionChainFilterUniverse(OptionChain chain) _symbol = chain.Symbol; } - /// - /// Gets the number of days until the given expiration, counted from the chain date - /// - internal int GetDaysToExpiry(DateTime expiry) - { - return (expiry.Date - AdjustExpirationReferenceDate(LocalTime.Date)).Days; - } - /// /// Not supported: the chain filters only ever select contracts that are already in the chain /// diff --git a/Common/Securities/Option/OptionFilterUniverse.cs b/Common/Securities/Option/OptionFilterUniverse.cs index 0eb7baa42093..24eb64c3944a 100644 --- a/Common/Securities/Option/OptionFilterUniverse.cs +++ b/Common/Securities/Option/OptionFilterUniverse.cs @@ -281,13 +281,34 @@ public TUniverse PutsOnly() } /// - /// Applies filter selecting the contracts with the given strike price + /// Applies filter selecting the contracts with any of the given strike prices /// - /// The strike price + /// The strike prices /// Universe with filter applied - public TUniverse Strikes(decimal strike) + public TUniverse Strikes(IEnumerable strikes) { - return Contracts(contracts => contracts.Where(x => x.Symbol.ID.StrikePrice == strike)); + var strikeSet = strikes.ToHashSet(); + return Contracts(contracts => contracts.Where(x => strikeSet.Contains(x.Symbol.ID.StrikePrice))); + } + + /// + /// Applies filter selecting the contracts with strikes above the given price, excluding it + /// + /// The price the strikes must be above + /// Universe with filter applied + public TUniverse StrikesAbove(decimal price) + { + return Contracts(contracts => contracts.Where(x => x.Symbol.ID.StrikePrice > price)); + } + + /// + /// Applies filter selecting the contracts with strikes below the given price, excluding it + /// + /// The price the strikes must be below + /// Universe with filter applied + public TUniverse StrikesBelow(decimal price) + { + return Contracts(contracts => contracts.Where(x => x.Symbol.ID.StrikePrice < price)); } /// @@ -357,7 +378,7 @@ public TUniverse AtTheMoney() { return Empty(); } - return Strikes(GetClosestStrike(AllSymbols, price)); + return Strikes([GetClosestStrike(AllSymbols, price)]); } /// diff --git a/Tests/Common/Data/Market/OptionChainSelectionTests.cs b/Tests/Common/Data/Market/OptionChainSelectionTests.cs deleted file mode 100644 index 0f1b51ce3e79..000000000000 --- a/Tests/Common/Data/Market/OptionChainSelectionTests.cs +++ /dev/null @@ -1,487 +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 System; -using System.Collections.Generic; -using System.Globalization; -using System.Linq; -using NUnit.Framework; -using Python.Runtime; -using QuantConnect.Data.Market; -using QuantConnect.Data.UniverseSelection; -using QuantConnect.Securities; - -namespace QuantConnect.Tests.Common.Data.Market -{ - [TestFixture] - public class OptionChainSelectionTests - { - // Chain date: Thursday. Available expiries below are +1, +8, +15 and +36 days out, none on a holiday - private static readonly DateTime ChainTime = new(2016, 2, 25, 10, 0, 0); - private static readonly DateTime Expiry1 = new(2016, 2, 26); - private static readonly DateTime Expiry2 = new(2016, 3, 4); - private static readonly DateTime Expiry3 = new(2016, 3, 11); - private static readonly DateTime Expiry4 = new(2016, 4, 1); - - 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 rows = contracts.Select(x => ( - Symbol.CreateOption(Symbols.SPY, QuantConnect.Market.USA, OptionStyle.American, x.right, x.strike, x.expiry), - 100m, 0.5m, new Greeks(x.delta, 0.01m, 0.02m, -0.03m * 365m, 0.04m, 0))); - // Like the algorithm does, the chain is built from the previous day's universe data, whose end time is the chain date - var (universeContracts, _) = OptionChainTests.CreateUniverseData(canonical, chainTime.Date.AddDays(-1), underlyingPrice, rows); - - return new OptionChain(canonical, chainTime, universeContracts, SymbolProperties.GetDefault(Currencies.USD)); - } - - 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 ViewsAreCachedUntilContractsAreAdded() - { - var chain = CreateDefaultChain(); - var strikes = chain.StrikePrices; - var expiries = chain.Expiries; - - Assert.AreSame(strikes, chain.StrikePrices); - Assert.AreSame(expiries, chain.Expiries); - - // Slice chains get their contracts one by one as data arrives: the views follow - var added = CreateChain(new[] { (new DateTime(2016, 5, 20), 120m, OptionRight.Call, 0.05m) }).Single(); - chain.Contracts[added.Symbol] = added; - - Assert.AreNotSame(strikes, chain.StrikePrices); - Assert.AreEqual(strikes.Count + 1, chain.StrikePrices.Count); - Assert.AreEqual(120m, chain.StrikePrices.Last()); - Assert.AreEqual(new DateTime(2016, 5, 20), chain.Expiries.Last()); - } - - [Test] - public void StrikePricesAreDistinctAndSorted() - { - var chain = CreateDefaultChain(); - CollectionAssert.AreEqual(new[] { 85m, 90m, 95m, 100m, 105m, 110m }, chain.StrikePrices); - } - - [Test] - public void ExpiriesAreDistinctAndSorted() - { - var chain = CreateDefaultChain(); - CollectionAssert.AreEqual(new[] { Expiry1, Expiry2, Expiry3, Expiry4 }, chain.Expiries); - Assert.IsEmpty(CreateEmptyChain().Expiries); - } - - [TestCase(97, 95)] - // Equidistant from 95 and 100: the lower strike wins - [TestCase(97.5, 95)] - [TestCase(99, 100)] - [TestCase(100, 100)] - [TestCase(0, 85)] - [TestCase(120, 110)] - public void StrikePricesClosestTo(double price, double expected) - { - var chain = CreateDefaultChain(); - Assert.AreEqual((decimal)expected, chain.StrikePrices.ClosestTo((decimal)price)); - } - - [Test] - public void StrikePricesFirstAboveAndBelowAreStrict() - { - var chain = CreateDefaultChain(); - var strikes = chain.StrikePrices; - - 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 StrikePricesAreReadOnly() - { - var strikes = (IList)CreateDefaultChain().StrikePrices; - - Assert.IsTrue(strikes.IsReadOnly); - Assert.Throws(() => strikes.Add(1m)); - Assert.Throws(() => strikes.Clear()); - } - - [Test] - public void StrikePricesHelpersAreNullSafeOnEmptyChain() - { - var strikes = CreateEmptyChain().StrikePrices; - Assert.IsEmpty(strikes); - Assert.IsNull(strikes.ClosestTo(100m)); - Assert.IsNull(strikes.FirstAbove(100m)); - Assert.IsNull(strikes.FirstBelow(100m)); - } - - [TestCase(0, null, null, "20160226")] - [TestCase(10, null, null, "20160304")] - [TestCase(12, null, null, "20160311")] - [TestCase(14, null, null, "20160311")] - [TestCase(5, 8, 15, "20160304")] - [TestCase(40, 8, 15, "20160311")] - [TestCase(100, null, null, "20160401")] - // min/max window excludes the otherwise closest expiry - [TestCase(0, 5, null, "20160304")] - [TestCase(100, null, 20, "20160311")] - [TestCase(10, 12, 20, "20160311")] - // no target: defaults to the nearest expiry within the window - [TestCase(null, null, null, "20160226")] - [TestCase(null, 10, null, "20160311")] - public void ClosestExpirySelectsBestMatch(int? targetDte, int? minDte, int? maxDte, string expected) - { - var chain = CreateDefaultChain(); - var expectedExpiry = DateTime.ParseExact(expected, "yyyyMMdd", CultureInfo.InvariantCulture); - Assert.AreEqual(expectedExpiry, chain.ClosestExpiry(targetDte, minDte, maxDte)); - } - - [Test] - public void ClosestExpiryPrefersEarlierExpiryOnTies() - { - // Friday +1 and Wednesday +6 are equidistant from a target of 3.5, use +1 and +5 with target 3 - var chain = CreateChain(new[] - { - (ChainTime.Date.AddDays(1), 100m, OptionRight.Call, 0.5m), - (ChainTime.Date.AddDays(5), 100m, OptionRight.Call, 0.5m) - }); - Assert.AreEqual(ChainTime.Date.AddDays(1), chain.ClosestExpiry(targetDte: 3)); - } - - [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.CallsOnly().Count); - Assert.AreEqual(3, filtered.PutsOnly().Count); - CollectionAssert.AreEqual(new[] { 90m, 100m, 110m }, filtered.StrikePrices); - Assert.AreEqual(100m, filtered.Select(OptionRight.Call).Strike); - Assert.AreEqual(2, filtered.AtTheMoney().Count); - } - - [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 DaysToExpiryCountsCalendarDays() - { - var chain = CreateDefaultChain(); - CollectionAssert.AreEquivalent(new[] { 1, 8, 15, 36 }, chain.Select(x => x.DaysToExpiry).Distinct()); - Assert.AreEqual(1, chain.At(Expiry1).First().DaysToExpiry); - Assert.AreEqual(36, chain.At(Expiry4).First().DaysToExpiry); - } - - [TestCase(99, 100)] - [TestCase(103, 105)] - // Equidistant between 95 and 100: lower strike wins - [TestCase(97.5, 95)] - public void SelectAndAtTheMoneyPickTheClosestStrike(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.Select(OptionRight.Call); - Assert.IsNotNull(contract); - Assert.AreEqual((decimal)expectedStrike, contract.Strike); - Assert.AreEqual(OptionRight.Call, contract.Right); - // the filter keeps every contract at that strike - Assert.AreSame(contract, chain.AtTheMoney().Single()); - } - - [Test] - public void SelectWithoutRightPrefersTheNearestExpiryThenCalls() - { - var chain = CreateDefaultChain(); - var contract = chain.Select(); - - Assert.AreEqual(100m, contract.Strike); - Assert.AreEqual(Expiry1, contract.Expiry); - Assert.AreEqual(OptionRight.Call, contract.Right); - } - - [Test] - public void AtTheMoneyIsEmptyWithoutContractsOrUnderlyingPrice() - { - Assert.AreEqual(0, CreateEmptyChain().AtTheMoney().Count); - var noUnderlying = CreateChain(new[] { (Expiry1, 100m, OptionRight.Call, 0.5m) }, underlyingPrice: null); - Assert.AreEqual(0, noUnderlying.AtTheMoney().Count); - } - - [Test] - public void SelectUsesTheContractsUnderlyingPrice() - { - // Chains built from universe data carry the underlying price on each contract - var chain = CreateChain(new[] { (Expiry1, 100m, OptionRight.Call, 0.5m) }, underlyingPrice: 100.5m); - - Assert.AreEqual(100.5m, chain.Underlying.Price); - Assert.AreEqual(100m, chain.Select(OptionRight.Call).Strike); - } - - [Test] - public void SelectReplacesTheSortedComprehensionCeremony() - { - var chain = CreateDefaultChain(); - - // The 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); - } - - [Test] - public void PickIsASynonymOfSelect() - { - var chain = CreateDefaultChain(); - - Assert.AreEqual(chain.Select(right: OptionRight.Put, targetDte: 8).Symbol, chain.Pick(right: OptionRight.Put, targetDte: 8).Symbol); - Assert.AreEqual(chain.Select(right: OptionRight.Call, moneyness: 0.05m).Symbol, chain.Pick(right: OptionRight.Call, moneyness: 0.05m).Symbol); - Assert.IsNull(chain.Pick(minDte: 40, maxDte: 60)); - } - - [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(-10, 90)] - [TestCase(0, 100)] - [TestCase(8, 110)] - [TestCase(-4, 100)] - public void SelectByStrikeDistance(double strikeFromAtm, double expectedStrike) - { - var chain = CreateDefaultChain(); - var contract = chain.SelectByStrikeDistance((decimal)strikeFromAtm, OptionRight.Put, targetDte: 8); - - 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.SelectByDelta((decimal)targetDelta, OptionRight.Put, targetDte: 8); - - 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.SelectByDelta(0.05m, OptionRight.Call); - 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.SelectByDelta(0.05m, OptionRight.Call)); - } - - [Test] - public void SelectRespectsDteWindow() - { - var chain = CreateDefaultChain(); - - // An explicit window never selects a nearer expiry than requested, even if the chain carries it - 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 SelectByStrikeDistanceAndDeltaAreNullSafe() - { - var chain = CreateDefaultChain(); - Assert.IsNull(chain.SelectByStrikeDistance(-5m, minDte: 40, maxDte: 60)); - Assert.IsNull(chain.SelectByDelta(0.3m, minDte: 40, maxDte: 60)); - Assert.IsNull(CreateEmptyChain().SelectByStrikeDistance(-5m)); - Assert.IsNull(CreateEmptyChain().SelectByDelta(0.3m)); - - // delta needs no underlying price, a strike distance does - var noUnderlying = CreateChain(new[] { (Expiry1, 100m, OptionRight.Call, 0.5m) }, underlyingPrice: null); - Assert.IsNull(noUnderlying.SelectByStrikeDistance(-5m, OptionRight.Call)); - Assert.AreEqual(100m, noUnderlying.SelectByDelta(0.5m, OptionRight.Call).Strike); - } - - [Test] - public void SelectionHelpersAreAvailableFromPython() - { - var chain = CreateDefaultChain(); - var expected = chain.Select(right: OptionRight.Put, targetDte: 8, moneyness: -0.1m); - - using (Py.GIL()) - { - using var module = PyModule.FromString(nameof(OptionChainSelectionTests), @" -from AlgorithmImports import * - -def select(chain): - return chain.select(right=OptionRight.PUT, target_dte=8, moneyness=-0.1) - -def pick(chain): - return chain.select_by_strike_distance(-10, OptionRight.PUT, target_dte=8) - -def helpers(chain): - at_expiry = chain.at(chain.closest_expiry(target_dte=8)) - return (at_expiry.strike_prices.first_above(100), at_expiry.expiries[0], at_expiry.puts_only().count, - at_expiry.at_the_money().select(OptionRight.CALL).days_to_expiry, chain.select(min_dte=40) is None) -"); - using var pyChain = chain.ToPython(); - - using var selected = module.GetAttr("select").Invoke(pyChain); - Assert.AreEqual(expected.Symbol, selected.As().Symbol); - - using var picked = module.GetAttr("pick").Invoke(pyChain); - Assert.AreEqual(expected.Symbol, picked.As().Symbol); - - using var helpers = module.GetAttr("helpers").Invoke(pyChain); - Assert.AreEqual(110m, helpers[0].As()); - Assert.AreEqual(Expiry2, helpers[1].As()); - Assert.AreEqual(3, helpers[2].As()); - Assert.AreEqual(8, helpers[3].As()); - Assert.IsTrue(helpers[4].As()); - } - } - } -} diff --git a/Tests/Common/Data/Market/OptionChainTests.cs b/Tests/Common/Data/Market/OptionChainTests.cs index 504fadc0db3e..176bb7f8d422 100644 --- a/Tests/Common/Data/Market/OptionChainTests.cs +++ b/Tests/Common/Data/Market/OptionChainTests.cs @@ -62,11 +62,22 @@ private static IEnumerable FilterCases() 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("Expiration(date)", u => u.Expiration(Expiries[1]), c => c.Expiration(Expiries[1])); - yield return Case("Expiration(date, time of day)", u => u.Expiration(Expiries[1].AddHours(10)), c => c.Expiration(Expiries[1].AddHours(10))); - yield return Case("Expiration(unlisted date)", u => u.Expiration(Date), c => c.Expiration(Date), empty: true); - yield return Case("Strikes(100)", u => u.Strikes(100m), c => c.Strikes(100m)); - yield return Case("Strikes(101)", u => u.Strikes(101m), c => c.Strikes(101m), empty: true); + yield return Case("Expiration(dates)", u => u.Expiration([Expiries[1], Expiries[3]]), c => c.Expiration([Expiries[1], Expiries[3]])); + yield return Case("Expiration(date, time of day)", u => u.Expiration([Expiries[1].AddHours(10)]), c => c.Expiration([Expiries[1].AddHours(10)])); + yield return Case("Expiration(unlisted dates)", u => u.Expiration([Date, Date.AddDays(1)]), c => c.Expiration([Date, Date.AddDays(1)]), empty: true); + yield return Case("Expiration(no dates)", u => u.Expiration([]), c => c.Expiration([]), empty: true); + yield return Case("ExpiringAfter", u => u.ExpiringAfter(Expiries[1]), c => c.ExpiringAfter(Expiries[1])); + yield return Case("ExpiringBefore", u => u.ExpiringBefore(Expiries[1].AddHours(10)), c => c.ExpiringBefore(Expiries[1].AddHours(10))); + yield return Case("ExpiringAfter.ExpiringBefore", u => u.ExpiringAfter(Expiries[0]).ExpiringBefore(Expiries[3]), c => c.ExpiringAfter(Expiries[0]).ExpiringBefore(Expiries[3])); + yield return Case("ExpiringAfter(last)", u => u.ExpiringAfter(Expiries[3]), c => c.ExpiringAfter(Expiries[3]), empty: true); + yield return Case("FarthestExpiration", u => u.FarthestExpiration(), c => c.FarthestExpiration()); + yield return Case("StandardsOnly.FarthestExpiration", u => u.StandardsOnly().FarthestExpiration(), c => c.StandardsOnly().FarthestExpiration()); + yield return Case("Strikes(100, 105)", u => u.Strikes([100m, 105m]), c => c.Strikes([100m, 105m])); + yield return Case("Strikes(101)", u => u.Strikes([101m]), c => c.Strikes([101m]), empty: true); + yield return Case("StrikesAbove", u => u.StrikesAbove(100m), c => c.StrikesAbove(100m)); + yield return Case("StrikesBelow", u => u.StrikesBelow(100m), c => c.StrikesBelow(100m)); + yield return Case("StrikesAbove.StrikesBelow", u => u.StrikesAbove(95m).StrikesBelow(105m), c => c.StrikesAbove(95m).StrikesBelow(105m)); + yield return Case("StrikesAbove(max)", u => u.StrikesAbove(110m), c => c.StrikesAbove(110m), empty: true); yield return Case("ZeroDte", u => u.ZeroDte(), c => c.ZeroDte(), empty: true); yield return Case("CallsOnly", u => u.CallsOnly(), c => c.CallsOnly()); yield return Case("PutsOnly", u => u.PutsOnly(), c => c.PutsOnly()); @@ -259,6 +270,12 @@ def filter_chain(chain): def where_chain(chain): return chain.where(lambda contract: contract.right == OptionRight.PUT and contract.strike > 100) + +def sets(chain): + return chain.strikes([100, 105]).expiration([datetime(2016, 3, 18), datetime(2016, 6, 17)]) + +def bounds(chain): + return chain.strikes_above(95).strikes_below(105).expiring_after(datetime(2016, 3, 4)).expiring_before(datetime(2016, 6, 17)).farthest_expiration() "); using var pyChain = chain.ToPython(); @@ -267,6 +284,19 @@ def where_chain(chain): using var where = module.GetAttr("where_chain").Invoke(pyChain); CollectionAssert.AreEqual(expectedWhere, where.As().Select(x => x.Symbol).ToList()); + + // strike and date lists convert to the C# collections + var expectedSets = chain.Strikes([100m, 105m]).Expiration([Expiries[1], Expiries[3]]).Select(x => x.Symbol).ToList(); + Assert.AreEqual(8, expectedSets.Count); + using var sets = module.GetAttr("sets").Invoke(pyChain); + CollectionAssert.AreEqual(expectedSets, sets.As().Select(x => x.Symbol).ToList()); + + var expectedBounds = chain.StrikesAbove(95m).StrikesBelow(105m).ExpiringAfter(Expiries[0]).ExpiringBefore(Expiries[3]).FarthestExpiration() + .Select(x => x.Symbol).ToList(); + Assert.AreEqual(6, expectedBounds.Count); + Assert.IsTrue(expectedBounds.All(x => x.ID.Date == Expiries[2])); + using var bounds = module.GetAttr("bounds").Invoke(pyChain); + CollectionAssert.AreEqual(expectedBounds, bounds.As().Select(x => x.Symbol).ToList()); } } @@ -336,11 +366,24 @@ public void MoneynessFiltersSplitTheStrikesAroundTheUnderlyingPrice(double under Assert.IsTrue(otm.All(x => x.Right == OptionRight.Call ? x.Strike > price : x.Strike < price)); Assert.IsTrue(itm.All(x => x.Right == OptionRight.Call ? x.Strike < price : x.Strike > price)); // a strike equal to the price is neither out nor in the money - Assert.AreEqual(chain.Count, otm.Count + itm.Count + chain.Strikes(price).Count); + Assert.AreEqual(chain.Count, otm.Count + itm.Count + chain.Strikes([price]).Count); Assert.AreEqual(2 * Expiries.Length, atm.Count); Assert.IsTrue(atm.All(x => x.Strike == (decimal)atmStrike)); } + [Test] + public void ContractsCountTheDaysToTheirExpiration() + { + var chain = CreateChain(); + // universe rows are stamped at the end of their day, so the contracts count from the next date + var reference = chain.First().Time.Date; + Assert.AreEqual(Date.AddDays(1), reference); + var expected = Expiries.Select(expiry => (expiry - reference).Days).ToList(); + CollectionAssert.AreEquivalent(expected, chain.Select(x => x.DaysToExpiry).Distinct()); + Assert.AreEqual(expected[0], chain.FrontMonth().First().DaysToExpiry); + Assert.AreEqual(expected[3], chain.FarthestExpiration().First().DaysToExpiry); + } + [Test] public void MoneynessFiltersSelectNothingWithoutUnderlyingPrice() { @@ -351,7 +394,8 @@ public void MoneynessFiltersSelectNothingWithoutUnderlyingPrice() Assert.AreEqual(0, chain.OutOfTheMoney().Count); Assert.AreEqual(0, chain.InTheMoney().Count); Assert.AreEqual(0, chain.AtTheMoney().Count); - foreach (var filter in new Func[] { u => u.OutOfTheMoney(), u => u.InTheMoney(), u => u.AtTheMoney() }) + Func[] filters = [u => u.OutOfTheMoney(), u => u.InTheMoney(), u => u.AtTheMoney()]; + foreach (var filter in filters) { var universe = new OptionFilterUniverse(_option); universe.Refresh(contracts, null, Date); From 66f3bab78461d35433ccb8dee5094925e2b0d111 Mon Sep 17 00:00:00 2001 From: Jhonathan Abreu Date: Fri, 11 Sep 2026 10:33:13 -0400 Subject: [PATCH 09/14] Add index option examples of the chain filters and select the farthest expiration in one pass IndexOptionChainFiltersRegressionAlgorithm uses the strike, expiration and moneyness filters on SPX and SPXW contracts in the universe selection, on the slice chains and on OptionChain(). The GOOG universe filter keeps only the out of the money calls and its slice chain assertions cover the new filters. FarthestExpiration walks the contracts once instead of sorting them. --- ...exOptionChainFiltersRegressionAlgorithm.cs | 231 ++++++++++++++++++ .../OptionChainFiltersRegressionAlgorithm.cs | 23 +- ...exOptionChainFiltersRegressionAlgorithm.py | 118 +++++++++ .../OptionChainFiltersRegressionAlgorithm.py | 17 +- .../ContractSecurityFilterUniverse.cs | 21 +- 5 files changed, 399 insertions(+), 11 deletions(-) create mode 100644 Algorithm.CSharp/IndexOptionChainFiltersRegressionAlgorithm.cs create mode 100644 Algorithm.Python/IndexOptionChainFiltersRegressionAlgorithm.py diff --git a/Algorithm.CSharp/IndexOptionChainFiltersRegressionAlgorithm.cs b/Algorithm.CSharp/IndexOptionChainFiltersRegressionAlgorithm.cs new file mode 100644 index 000000000000..731f251ac4fe --- /dev/null +++ b/Algorithm.CSharp/IndexOptionChainFiltersRegressionAlgorithm.cs @@ -0,0 +1,231 @@ +/* + * 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; + +namespace QuantConnect.Algorithm.CSharp +{ + /// + /// Regression algorithm using the strike, expiration and moneyness filters on index options: in the universe selection + /// of standard and weekly contracts, on the chains of the and on + /// + public class IndexOptionChainFiltersRegressionAlgorithm : QCAlgorithm, IRegressionAlgorithmDefinition + { + private static readonly DateTime FirstDay = new(2021, 1, 4); + private static readonly DateTime StandardExpiry = new(2021, 1, 15); + + private Symbol _spx; + private Symbol _spxw; + private bool _spxChainSeen; + private bool _zeroDteSeen; + private bool _traded; + + public override void Initialize() + { + SetStartDate(2021, 1, 4); + SetEndDate(2021, 1, 8); + SetCash(1000000); + + // Standard SPX contracts: the out of the money ones with strikes below 4000 + var spx = AddIndexOption("SPX"); + spx.SetFilter(universe => universe.OutOfTheMoney().StrikesBelow(4000m)); + _spx = spx.Symbol; + + // Weekly SPXW contracts: the 3700 strike of the expirations after the first day + var spxw = AddIndexOption("SPX", "SPXW"); + spxw.SetFilter(universe => universe.Strikes([3700m]).ExpiringAfter(FirstDay)); + _spxw = spxw.Symbol; + + // The latest universe data, from 2020-12-31, lists the 3200, 3700, 3800 and 4250 calls and the 3200 and 4200 puts + // expiring on 2021-01-15, with the index at 3766.63: the same filters narrow the chain down + var chain = OptionChain(_spx); + if (chain.Count != 6 || chain.Underlying.Price != 3766.63m) + { + throw new RegressionTestException($"Expected the 6 SPX contracts at 3766.63 but got {chain.Count} at {chain.Underlying.Price}"); + } + AssertContracts(chain.OutOfTheMoney(), "OutOfTheMoney()", (3800m, OptionRight.Call), (4250m, OptionRight.Call), (3200m, OptionRight.Put)); + AssertContracts(chain.InTheMoney(), "InTheMoney()", (3200m, OptionRight.Call), (3700m, OptionRight.Call), (4200m, OptionRight.Put)); + AssertContracts(chain.AtTheMoney(), "AtTheMoney()", (3800m, OptionRight.Call)); + AssertContracts(chain.StrikesAbove(3700m).StrikesBelow(4250m), "StrikesAbove(3700).StrikesBelow(4250)", (3800m, OptionRight.Call), (4200m, OptionRight.Put)); + AssertContracts(chain.Strikes([3200m, 4250m]), "Strikes([3200, 4250])", (3200m, OptionRight.Call), (4250m, OptionRight.Call), (3200m, OptionRight.Put)); + AssertContracts(chain.OutOfTheMoney().StrikesBelow(4000m), "the SPX universe filter", (3800m, OptionRight.Call), (3200m, OptionRight.Put)); + if (chain.Expiration([StandardExpiry]).Count != chain.Count || chain.FarthestExpiration().Count != chain.Count + || chain.ExpiringAfter(StandardExpiry).Count != 0 || chain.ExpiringBefore(StandardExpiry).Count != 0 || chain.ZeroDte().Count != 0) + { + throw new RegressionTestException("Expected every SPX contract to expire on 2021-01-15"); + } + } + + public override void OnData(Slice slice) + { + if (slice.OptionChains.TryGetValue(_spx, out var spxChain)) + { + _spxChainSeen = true; + // The universe selected the out of the money contracts below 4000: the 3800 call and the 3200 put. + // The index stays between those strikes, so the chain filter agrees with the universe filter + AssertContracts(spxChain, "the SPX slice chain", (3800m, OptionRight.Call), (3200m, OptionRight.Put)); + if (spxChain.OutOfTheMoney().Count != spxChain.Count) + { + throw new RegressionTestException("Expected the SPX slice chain to be out of the money"); + } + AssertMoneyness(spxChain); + } + + if (!slice.OptionChains.TryGetValue(_spxw, out var chain)) + { + return; + } + + // The universe selected the 3700 strike of the expirations after the first day: 2021-01-06 and 2021-01-08 + if (chain.Count == 0 || chain.Strikes([3700m]).Count != chain.Count || chain.ExpiringAfter(FirstDay).Count != chain.Count + || chain.ExpiringBefore(new DateTime(2021, 1, 9)).Count != chain.Count) + { + throw new RegressionTestException("The SPXW slice chain disagrees with the universe filter"); + } + AssertMoneyness(chain); + + var zeroDte = chain.ZeroDte(); + if (zeroDte.Any(x => x.Expiry.Date != Time.Date)) + { + throw new RegressionTestException("ZeroDte() selected contracts not expiring today"); + } + _zeroDteSeen |= zeroDte.Count > 0; + + var farthest = chain.FarthestExpiration(); + var maxExpiry = chain.Max(x => x.Expiry); + if (farthest.Count == 0 || farthest.Any(x => x.Expiry != maxExpiry)) + { + throw new RegressionTestException("FarthestExpiration() mismatch"); + } + + // Buy the 3700 call of the nearest expiration after today + if (!_traded) + { + var contract = chain.CallsOnly().ExpiringAfter(Time).FrontMonth().FirstOrDefault(); + if (contract != null) + { + MarketOrder(contract.Symbol, 1); + _traded = true; + } + } + } + + public override void OnEndOfAlgorithm() + { + if (!_spxChainSeen || !_zeroDteSeen || !_traded) + { + throw new RegressionTestException($"Expected the SPX chain ({_spxChainSeen}), a 0DTE SPXW contract ({_zeroDteSeen}) and a trade ({_traded})"); + } + } + + /// + /// The moneyness filters partition the chain around the current index price, and match the strike bounds for a single right + /// + private static void AssertMoneyness(OptionChain chain) + { + var price = chain.Underlying.Price; + var otm = chain.OutOfTheMoney(); + var itm = chain.InTheMoney(); + if (otm.Count + itm.Count + chain.Strikes([price]).Count != chain.Count + || otm.Any(x => x.Right == OptionRight.Call ? x.Strike <= price : x.Strike >= price) + || itm.Any(x => x.Right == OptionRight.Call ? x.Strike >= price : x.Strike <= price) + || chain.CallsOnly().OutOfTheMoney().Count != chain.CallsOnly().StrikesAbove(price).Count + || chain.PutsOnly().OutOfTheMoney().Count != chain.PutsOnly().StrikesBelow(price).Count) + { + throw new RegressionTestException($"Moneyness filters mismatch at {price}"); + } + } + + private static void AssertContracts(OptionChain chain, string filter, params (decimal strike, OptionRight right)[] expected) + { + var actual = chain.Select(x => (x.Strike, x.Right)).OrderBy(x => x.Strike).ThenBy(x => x.Right).ToList(); + var expectedContracts = expected.OrderBy(x => x.strike).ThenBy(x => x.right).ToList(); + if (!actual.SequenceEqual(expectedContracts)) + { + throw new RegressionTestException($"{filter}: expected {Format(expectedContracts)} but got {Format(actual)}"); + } + } + + private static string Format(IEnumerable<(decimal strike, OptionRight right)> contracts) + { + return string.Join(", ", contracts.Select(x => $"{x.strike} {x.right}")); + } + + /// + /// 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 => 25607; + + /// + /// 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.75%"}, + {"Compounding Annual Return", "-42.123%"}, + {"Drawdown", "0.800%"}, + {"Expectancy", "-1"}, + {"Start Equity", "1000000"}, + {"End Equity", "992475"}, + {"Net Profit", "-0.752%"}, + {"Sharpe Ratio", "-3.457"}, + {"Sortino Ratio", "0"}, + {"Probabilistic Sharpe Ratio", "22.012%"}, + {"Loss Rate", "100%"}, + {"Win Rate", "0%"}, + {"Profit-Loss Ratio", "0"}, + {"Alpha", "1.468"}, + {"Beta", "-0.369"}, + {"Annual Standard Deviation", "0.04"}, + {"Annual Variance", "0.002"}, + {"Information Ratio", "-38.008"}, + {"Tracking Error", "0.118"}, + {"Treynor Ratio", "0.377"}, + {"Total Fees", "$0.00"}, + {"Estimated Strategy Capacity", "$940000.00"}, + {"Lowest Capacity Asset", "SPXW XKZ5O96SL626|SPX 31"}, + {"Portfolio Turnover", "0.13%"}, + {"Drawdown Recovery", "2"}, + {"OrderListHash", "8e3ebdde25785c0e5d3527d7260d2fdc"} + }; + } +} diff --git a/Algorithm.CSharp/OptionChainFiltersRegressionAlgorithm.cs b/Algorithm.CSharp/OptionChainFiltersRegressionAlgorithm.cs index be7a8273ce8c..a245e4ccc589 100644 --- a/Algorithm.CSharp/OptionChainFiltersRegressionAlgorithm.cs +++ b/Algorithm.CSharp/OptionChainFiltersRegressionAlgorithm.cs @@ -43,7 +43,7 @@ public override void Initialize() 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)); + option.SetFilter(universe => universe.CallsOnly().Expiration(1, 10).Strikes(-2, 2).OutOfTheMoney()); var chain = OptionChain(_option); if (chain.Count == 0) @@ -134,12 +134,27 @@ public override void OnData(Slice slice) 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) + // The universe only selected the out of the money calls expiring 1 to 10 days out, two strikes around the + // previous close: 750 and 752.5 on 2015-12-31. The chain filters agree with it + if (chain.CallsOnly().Expiration(1, 10).Count != chain.Count || chain.PutsOnly().Count != 0 + || chain.Strikes([750m, 752.5m]).Count != chain.Count || chain.Expiration([new DateTime(2015, 12, 31)]).Count != chain.Count) { throw new RegressionTestException("Slice chain filters disagree with the universe filter"); } + // On a calls only chain the moneyness filters are the strike bounds around the current price + var price = chain.Underlying.Price; + if (chain.OutOfTheMoney().Count != chain.StrikesAbove(price).Count || chain.InTheMoney().Count != chain.StrikesBelow(price).Count + || chain.OutOfTheMoney().Count + chain.InTheMoney().Count + chain.Strikes([price]).Count != chain.Count) + { + throw new RegressionTestException("Slice chain moneyness filters mismatch"); + } + if (chain.ExpiringAfter(Time).Count != chain.Count || chain.ExpiringBefore(Time).Count != 0 || chain.ZeroDte().Count != 0 + || chain.FarthestExpiration().Count != chain.Count) + { + throw new RegressionTestException("Slice chain expiration filters mismatch"); + } + // Buy the call at the first strike at or above the underlying price var contract = chain.Strikes(0, 0).FirstOrDefault(); if (contract != null) @@ -182,7 +197,7 @@ private static void AssertContracts(OptionChain chain, OptionRight right, DateTi /// /// Data Points count of all timeslices of algorithm /// - public long DataPoints => 7080; + public long DataPoints => 5861; /// /// Data Points count of the algorithm history diff --git a/Algorithm.Python/IndexOptionChainFiltersRegressionAlgorithm.py b/Algorithm.Python/IndexOptionChainFiltersRegressionAlgorithm.py new file mode 100644 index 000000000000..8cc7602da8f9 --- /dev/null +++ b/Algorithm.Python/IndexOptionChainFiltersRegressionAlgorithm.py @@ -0,0 +1,118 @@ +# 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 using the strike, expiration and moneyness filters on index options: in the universe selection +### of standard and weekly contracts, on the chains of the slice and on option_chain() +### +class IndexOptionChainFiltersRegressionAlgorithm(QCAlgorithm): + FIRST_DAY = datetime(2021, 1, 4) + STANDARD_EXPIRY = datetime(2021, 1, 15) + + def initialize(self): + self.set_start_date(2021, 1, 4) + self.set_end_date(2021, 1, 8) + self.set_cash(1000000) + + # Standard SPX contracts: the out of the money ones with strikes below 4000 + spx = self.add_index_option("SPX") + spx.set_filter(lambda universe: universe.out_of_the_money().strikes_below(4000)) + self._spx = spx.symbol + + # Weekly SPXW contracts: the 3700 strike of the expirations after the first day + spxw = self.add_index_option("SPX", "SPXW") + spxw.set_filter(lambda universe: universe.strikes([3700]).expiring_after(self.FIRST_DAY)) + self._spxw = spxw.symbol + + self._spx_chain_seen = False + self._zero_dte_seen = False + self._traded = False + + # The latest universe data, from 2020-12-31, lists the 3200, 3700, 3800 and 4250 calls and the 3200 and 4200 puts + # expiring on 2021-01-15, with the index at 3766.63: the same filters narrow the chain down + chain = self.option_chain(self._spx) + if chain.count != 6 or chain.underlying.price != 3766.63: + raise AssertionError(f"Expected the 6 SPX contracts at 3766.63 but got {chain.count} at {chain.underlying.price}") + self._assert_contracts(chain.out_of_the_money(), "out_of_the_money()", [(3800, OptionRight.CALL), (4250, OptionRight.CALL), (3200, OptionRight.PUT)]) + self._assert_contracts(chain.in_the_money(), "in_the_money()", [(3200, OptionRight.CALL), (3700, OptionRight.CALL), (4200, OptionRight.PUT)]) + self._assert_contracts(chain.at_the_money(), "at_the_money()", [(3800, OptionRight.CALL)]) + self._assert_contracts(chain.strikes_above(3700).strikes_below(4250), "strikes_above(3700).strikes_below(4250)", [(3800, OptionRight.CALL), (4200, OptionRight.PUT)]) + self._assert_contracts(chain.strikes([3200, 4250]), "strikes([3200, 4250])", [(3200, OptionRight.CALL), (4250, OptionRight.CALL), (3200, OptionRight.PUT)]) + self._assert_contracts(chain.out_of_the_money().strikes_below(4000), "the SPX universe filter", [(3800, OptionRight.CALL), (3200, OptionRight.PUT)]) + if (chain.expiration([self.STANDARD_EXPIRY]).count != chain.count or chain.farthest_expiration().count != chain.count + or chain.expiring_after(self.STANDARD_EXPIRY).count != 0 or chain.expiring_before(self.STANDARD_EXPIRY).count != 0 + or chain.zero_dte().count != 0): + raise AssertionError("Expected every SPX contract to expire on 2021-01-15") + + def on_data(self, slice): + spx_chain = slice.option_chains.get(self._spx) + if spx_chain: + self._spx_chain_seen = True + # The universe selected the out of the money contracts below 4000: the 3800 call and the 3200 put. + # The index stays between those strikes, so the chain filter agrees with the universe filter + self._assert_contracts(spx_chain, "the SPX slice chain", [(3800, OptionRight.CALL), (3200, OptionRight.PUT)]) + if spx_chain.out_of_the_money().count != spx_chain.count: + raise AssertionError("Expected the SPX slice chain to be out of the money") + self._assert_moneyness(spx_chain) + + chain = slice.option_chains.get(self._spxw) + if not chain: + return + + # The universe selected the 3700 strike of the expirations after the first day: 2021-01-06 and 2021-01-08 + if (chain.count == 0 or chain.strikes([3700]).count != chain.count or chain.expiring_after(self.FIRST_DAY).count != chain.count + or chain.expiring_before(datetime(2021, 1, 9)).count != chain.count): + raise AssertionError("The SPXW slice chain disagrees with the universe filter") + self._assert_moneyness(chain) + + zero_dte = chain.zero_dte() + if any(x.expiry.date() != self.time.date() for x in zero_dte): + raise AssertionError("zero_dte() selected contracts not expiring today") + self._zero_dte_seen |= zero_dte.count > 0 + + farthest = chain.farthest_expiration() + max_expiry = max(x.expiry for x in chain) + if farthest.count == 0 or any(x.expiry != max_expiry for x in farthest): + raise AssertionError("farthest_expiration() mismatch") + + # Buy the 3700 call of the nearest expiration after today + if not self._traded: + contract = next(iter(chain.calls_only().expiring_after(self.time).front_month()), None) + if contract is not None: + self.market_order(contract.symbol, 1) + self._traded = True + + def on_end_of_algorithm(self): + if not self._spx_chain_seen or not self._zero_dte_seen or not self._traded: + raise AssertionError(f"Expected the SPX chain ({self._spx_chain_seen}), a 0DTE SPXW contract ({self._zero_dte_seen}) and a trade ({self._traded})") + + def _assert_moneyness(self, chain): + '''The moneyness filters partition the chain around the current index price, and match the strike bounds for a single right''' + price = chain.underlying.price + otm = chain.out_of_the_money() + itm = chain.in_the_money() + if (otm.count + itm.count + chain.strikes([price]).count != chain.count + or any((x.strike <= price if x.right == OptionRight.CALL else x.strike >= price) for x in otm) + or any((x.strike >= price if x.right == OptionRight.CALL else x.strike <= price) for x in itm) + or chain.calls_only().out_of_the_money().count != chain.calls_only().strikes_above(price).count + or chain.puts_only().out_of_the_money().count != chain.puts_only().strikes_below(price).count): + raise AssertionError(f"Moneyness filters mismatch at {price}") + + def _assert_contracts(self, chain, filter_name, expected): + key = lambda contract: (float(contract[0]), contract[1] == OptionRight.PUT) + actual = sorted(((x.strike, x.right) for x in chain), key=key) + expected = sorted(expected, key=key) + if [key(x) for x in actual] != [key(x) for x in expected]: + raise AssertionError(f"{filter_name}: expected {expected} but got {actual}") diff --git a/Algorithm.Python/OptionChainFiltersRegressionAlgorithm.py b/Algorithm.Python/OptionChainFiltersRegressionAlgorithm.py index 5a4a356bb515..becf968560b7 100644 --- a/Algorithm.Python/OptionChainFiltersRegressionAlgorithm.py +++ b/Algorithm.Python/OptionChainFiltersRegressionAlgorithm.py @@ -27,7 +27,7 @@ def initialize(self): 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)) + option.set_filter(lambda universe: universe.calls_only().expiration(1, 10).strikes(-2, 2).out_of_the_money()) chain = self.option_chain(self._option) if chain.count == 0: @@ -105,10 +105,21 @@ def on_data(self, slice): 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: + # The universe only selected the out of the money calls expiring 1 to 10 days out, two strikes around the + # previous close: 750 and 752.5 on 2015-12-31. The chain filters agree with it + if (chain.calls_only().expiration(1, 10).count != chain.count or chain.puts_only().count != 0 + or chain.strikes([750, 752.5]).count != chain.count or chain.expiration([datetime(2015, 12, 31)]).count != chain.count): raise AssertionError("Slice chain filters disagree with the universe filter") + # On a calls only chain the moneyness filters are the strike bounds around the current price + price = chain.underlying.price + if (chain.out_of_the_money().count != chain.strikes_above(price).count or chain.in_the_money().count != chain.strikes_below(price).count + or chain.out_of_the_money().count + chain.in_the_money().count + chain.strikes([price]).count != chain.count): + raise AssertionError("Slice chain moneyness filters mismatch") + if (chain.expiring_after(self.time).count != chain.count or chain.expiring_before(self.time).count != 0 or chain.zero_dte().count != 0 + or chain.farthest_expiration().count != chain.count): + raise AssertionError("Slice chain expiration filters mismatch") + # 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: diff --git a/Common/Securities/ContractSecurityFilterUniverse.cs b/Common/Securities/ContractSecurityFilterUniverse.cs index f12247633b22..ba4b80ce5b1f 100644 --- a/Common/Securities/ContractSecurityFilterUniverse.cs +++ b/Common/Securities/ContractSecurityFilterUniverse.cs @@ -275,11 +275,24 @@ public virtual T FrontMonth() public virtual T FarthestExpiration() { ApplyTypesFilter(); - var ordered = Data.OrderByDescending(x => x.Symbol.ID.Date).ToList(); - if (ordered.Count == 0) return (T)this; - var farthest = ordered.TakeWhile(x => ordered[0].Symbol.ID.Date == x.Symbol.ID.Date); + // one pass: a later expiration restarts the selection, the same one extends it + var farthestDate = DateTime.MinValue; + var farthest = new List(); + foreach (var data in Data) + { + var date = data.Symbol.ID.Date; + if (date > farthestDate) + { + farthestDate = date; + farthest.Clear(); + } + if (date == farthestDate) + { + farthest.Add(data); + } + } - Data = farthest.ToList(); + Data = farthest; return (T)this; } From 8ff3714597430cc81849284fe5672d8d404c649e Mon Sep 17 00:00:00 2001 From: Jhonathan Abreu Date: Fri, 11 Sep 2026 10:45:38 -0400 Subject: [PATCH 10/14] Take a tolerance in AtTheMoney, exact strike by default The closest strike is at the money only when its distance to the underlying price is within the tolerance, in units of the underlying price. The default of zero requires a strike equal to the price, so a filtered chain whose nearest strike is far from the money no longer reports it as at the money. --- ...exOptionChainFiltersRegressionAlgorithm.cs | 5 ++- .../OptionChainFiltersRegressionAlgorithm.cs | 7 +++-- ...exOptionChainFiltersRegressionAlgorithm.py | 5 ++- .../OptionChainFiltersRegressionAlgorithm.py | 7 +++-- Common/Data/Market/OptionChain.Filters.cs | 18 ++++++----- .../Option/IOptionContractFilters.cs | 9 +++--- .../Securities/Option/OptionFilterUniverse.cs | 24 +++++++++----- Tests/Common/Data/Market/OptionChainTests.cs | 31 ++++++++++++------- 8 files changed, 68 insertions(+), 38 deletions(-) diff --git a/Algorithm.CSharp/IndexOptionChainFiltersRegressionAlgorithm.cs b/Algorithm.CSharp/IndexOptionChainFiltersRegressionAlgorithm.cs index 731f251ac4fe..677e865259c7 100644 --- a/Algorithm.CSharp/IndexOptionChainFiltersRegressionAlgorithm.cs +++ b/Algorithm.CSharp/IndexOptionChainFiltersRegressionAlgorithm.cs @@ -62,7 +62,10 @@ public override void Initialize() } AssertContracts(chain.OutOfTheMoney(), "OutOfTheMoney()", (3800m, OptionRight.Call), (4250m, OptionRight.Call), (3200m, OptionRight.Put)); AssertContracts(chain.InTheMoney(), "InTheMoney()", (3200m, OptionRight.Call), (3700m, OptionRight.Call), (4200m, OptionRight.Put)); - AssertContracts(chain.AtTheMoney(), "AtTheMoney()", (3800m, OptionRight.Call)); + // The closest strike, 3800, is 33.37 points away: at the money only within a tolerance that covers it + AssertContracts(chain.AtTheMoney(), "AtTheMoney()"); + AssertContracts(chain.AtTheMoney(25m), "AtTheMoney(25)"); + AssertContracts(chain.AtTheMoney(50m), "AtTheMoney(50)", (3800m, OptionRight.Call)); AssertContracts(chain.StrikesAbove(3700m).StrikesBelow(4250m), "StrikesAbove(3700).StrikesBelow(4250)", (3800m, OptionRight.Call), (4200m, OptionRight.Put)); AssertContracts(chain.Strikes([3200m, 4250m]), "Strikes([3200, 4250])", (3200m, OptionRight.Call), (4250m, OptionRight.Call), (3200m, OptionRight.Put)); AssertContracts(chain.OutOfTheMoney().StrikesBelow(4000m), "the SPX universe filter", (3800m, OptionRight.Call), (3200m, OptionRight.Put)); diff --git a/Algorithm.CSharp/OptionChainFiltersRegressionAlgorithm.cs b/Algorithm.CSharp/OptionChainFiltersRegressionAlgorithm.cs index a245e4ccc589..047f9c917b30 100644 --- a/Algorithm.CSharp/OptionChainFiltersRegressionAlgorithm.cs +++ b/Algorithm.CSharp/OptionChainFiltersRegressionAlgorithm.cs @@ -99,10 +99,11 @@ public override void Initialize() { throw new RegressionTestException("Out/in the money filters mismatch"); } - var atm = chain.AtTheMoney(); - if (atm.Count == 0 || atm.Any(x => x.Strike != 747.5m) || atm.Count != chain.Strikes([747.5m]).Count) + // No strike equals the 748.54 close, so the at the money contracts need a tolerance: one strike step reaches 747.5 + var atm = chain.AtTheMoney(2.5m); + if (chain.AtTheMoney().Count != 0 || atm.Count == 0 || atm.Any(x => x.Strike != 747.5m) || atm.Count != chain.Strikes([747.5m]).Count) { - throw new RegressionTestException("Expected AtTheMoney() to select every contract at the 747.50 strike"); + throw new RegressionTestException("Expected AtTheMoney(2.5) to select every contract at the 747.50 strike and AtTheMoney() none"); } // Strike sets and bounds are absolute, unlike the relative Strikes(min, max) diff --git a/Algorithm.Python/IndexOptionChainFiltersRegressionAlgorithm.py b/Algorithm.Python/IndexOptionChainFiltersRegressionAlgorithm.py index 8cc7602da8f9..b70935dda9f5 100644 --- a/Algorithm.Python/IndexOptionChainFiltersRegressionAlgorithm.py +++ b/Algorithm.Python/IndexOptionChainFiltersRegressionAlgorithm.py @@ -47,7 +47,10 @@ def initialize(self): raise AssertionError(f"Expected the 6 SPX contracts at 3766.63 but got {chain.count} at {chain.underlying.price}") self._assert_contracts(chain.out_of_the_money(), "out_of_the_money()", [(3800, OptionRight.CALL), (4250, OptionRight.CALL), (3200, OptionRight.PUT)]) self._assert_contracts(chain.in_the_money(), "in_the_money()", [(3200, OptionRight.CALL), (3700, OptionRight.CALL), (4200, OptionRight.PUT)]) - self._assert_contracts(chain.at_the_money(), "at_the_money()", [(3800, OptionRight.CALL)]) + # The closest strike, 3800, is 33.37 points away: at the money only within a tolerance that covers it + self._assert_contracts(chain.at_the_money(), "at_the_money()", []) + self._assert_contracts(chain.at_the_money(25), "at_the_money(25)", []) + self._assert_contracts(chain.at_the_money(50), "at_the_money(50)", [(3800, OptionRight.CALL)]) self._assert_contracts(chain.strikes_above(3700).strikes_below(4250), "strikes_above(3700).strikes_below(4250)", [(3800, OptionRight.CALL), (4200, OptionRight.PUT)]) self._assert_contracts(chain.strikes([3200, 4250]), "strikes([3200, 4250])", [(3200, OptionRight.CALL), (4250, OptionRight.CALL), (3200, OptionRight.PUT)]) self._assert_contracts(chain.out_of_the_money().strikes_below(4000), "the SPX universe filter", [(3800, OptionRight.CALL), (3200, OptionRight.PUT)]) diff --git a/Algorithm.Python/OptionChainFiltersRegressionAlgorithm.py b/Algorithm.Python/OptionChainFiltersRegressionAlgorithm.py index becf968560b7..31602d16cb47 100644 --- a/Algorithm.Python/OptionChainFiltersRegressionAlgorithm.py +++ b/Algorithm.Python/OptionChainFiltersRegressionAlgorithm.py @@ -69,9 +69,10 @@ def initialize(self): or any((x.strike <= price if x.right == OptionRight.CALL else x.strike >= price) for x in otm) or any((x.strike >= price if x.right == OptionRight.CALL else x.strike <= price) for x in itm)): raise AssertionError("Out/in the money filters mismatch") - atm = chain.atm() - if atm.count == 0 or any(x.strike != 747.5 for x in atm) or atm.count != chain.strikes([747.5]).count: - raise AssertionError("Expected atm() to select every contract at the 747.50 strike") + # No strike equals the 748.54 close, so the at the money contracts need a tolerance: one strike step reaches 747.5 + atm = chain.atm(2.5) + if chain.atm().count != 0 or atm.count == 0 or any(x.strike != 747.5 for x in atm) or atm.count != chain.strikes([747.5]).count: + raise AssertionError("Expected atm(2.5) to select every contract at the 747.50 strike and atm() none") # Strike sets and bounds are absolute, unlike the relative strikes(min, max) strikes = chain.strikes([745, 750]) diff --git a/Common/Data/Market/OptionChain.Filters.cs b/Common/Data/Market/OptionChain.Filters.cs index 52cf40fc92b6..9caa0d3e118b 100644 --- a/Common/Data/Market/OptionChain.Filters.cs +++ b/Common/Data/Market/OptionChain.Filters.cs @@ -201,22 +201,26 @@ public OptionChain ITM() } /// - /// Selects the contracts at the strike closest to the underlying price, the lower strike on ties. - /// Same as + /// Selects the contracts at the money: the ones at the strike closest to the underlying price, the lower strike on ties, + /// when that strike is within the tolerance. Same as /// + /// The largest distance between the closest strike and the underlying price for the strike to be + /// at the money, in units of the underlying price. Zero, the default, requires a strike equal to the underlying price /// A new chain with the filter applied, empty when the underlying price is unknown - public OptionChain AtTheMoney() + public OptionChain AtTheMoney(decimal tolerance = 0) { - return Filter(universe => universe.AtTheMoney()); + return Filter(universe => universe.AtTheMoney(tolerance)); } /// - /// Selects the contracts at the strike closest to the underlying price. Alias for + /// Selects the contracts at the money. Alias for /// + /// The largest distance between the closest strike and the underlying price for the strike to be + /// at the money, in units of the underlying price. Zero, the default, requires a strike equal to the underlying price /// A new chain with the filter applied - public OptionChain ATM() + public OptionChain ATM(decimal tolerance = 0) { - return AtTheMoney(); + return AtTheMoney(tolerance); } /// diff --git a/Common/Securities/Option/IOptionContractFilters.cs b/Common/Securities/Option/IOptionContractFilters.cs index 47762f647b46..7e9d3f71614a 100644 --- a/Common/Securities/Option/IOptionContractFilters.cs +++ b/Common/Securities/Option/IOptionContractFilters.cs @@ -97,14 +97,15 @@ public interface IOptionContractFilters TSelf ITM(); /// - /// Selects the contracts at the strike closest to the underlying price, the lower strike on ties + /// Selects the contracts at the strike closest to the underlying price, the lower strike on ties, when that strike + /// is within the tolerance, in units of the underlying price. Zero requires a strike equal to the price /// - TSelf AtTheMoney(); + TSelf AtTheMoney(decimal tolerance = 0); /// - /// Selects the contracts at the strike closest to the underlying price. Alias for + /// Selects the contracts at the money. Alias for /// - TSelf ATM(); + TSelf ATM(decimal tolerance = 0); /// /// Selects the call contracts diff --git a/Common/Securities/Option/OptionFilterUniverse.cs b/Common/Securities/Option/OptionFilterUniverse.cs index 24eb64c3944a..467ceaf51877 100644 --- a/Common/Securities/Option/OptionFilterUniverse.cs +++ b/Common/Securities/Option/OptionFilterUniverse.cs @@ -367,27 +367,35 @@ public TUniverse ITM() } /// - /// Applies filter selecting the contracts at the strike closest to the underlying price, the lower strike on ties. - /// Selects nothing when the underlying price is unknown. Unlike with (0, 0), - /// which selects the first strike at or above the price + /// Applies filter selecting the contracts at the money: the ones at the strike closest to the underlying price, the lower + /// strike on ties, when that strike is within the tolerance. Selects nothing when the underlying price is unknown /// + /// The largest distance between the closest strike and the underlying price for the strike to be + /// at the money, in units of the underlying price. Zero, the default, requires a strike equal to the underlying price /// Universe with filter applied - public TUniverse AtTheMoney() + public TUniverse AtTheMoney(decimal tolerance = 0) { + if (tolerance < 0) + { + throw new ArgumentException($"AtTheMoney(): {nameof(tolerance)} must not be negative"); + } if (!TryGetUnderlyingPrice(out var price)) { return Empty(); } - return Strikes([GetClosestStrike(AllSymbols, price)]); + var strike = GetClosestStrike(AllSymbols, price); + return Math.Abs(strike - price) <= tolerance ? Strikes([strike]) : Empty(); } /// - /// Applies filter selecting the contracts at the strike closest to the underlying price. Alias for + /// Applies filter selecting the contracts at the money. Alias for /// + /// The largest distance between the closest strike and the underlying price for the strike to be + /// at the money, in units of the underlying price. Zero, the default, requires a strike equal to the underlying price /// Universe with filter applied - public TUniverse ATM() + public TUniverse ATM(decimal tolerance = 0) { - return AtTheMoney(); + return AtTheMoney(tolerance); } /// diff --git a/Tests/Common/Data/Market/OptionChainTests.cs b/Tests/Common/Data/Market/OptionChainTests.cs index 176bb7f8d422..1e18c8efe8d7 100644 --- a/Tests/Common/Data/Market/OptionChainTests.cs +++ b/Tests/Common/Data/Market/OptionChainTests.cs @@ -85,8 +85,9 @@ private static IEnumerable FilterCases() yield return Case("OTM.CallsOnly", u => u.OTM().CallsOnly(), c => c.OTM().CallsOnly()); yield return Case("InTheMoney", u => u.InTheMoney(), c => c.InTheMoney()); yield return Case("ITM.PutsOnly.Expiration(0, 10)", u => u.ITM().PutsOnly().Expiration(0, 10), c => c.ITM().PutsOnly().Expiration(0, 10)); - yield return Case("AtTheMoney", u => u.AtTheMoney(), c => c.AtTheMoney()); - yield return Case("Expiration(0, 10).ATM", u => u.Expiration(0, 10).ATM(), c => c.Expiration(0, 10).ATM()); + yield return Case("AtTheMoney", u => u.AtTheMoney(), c => c.AtTheMoney(), empty: true); + yield return Case("AtTheMoney(1)", u => u.AtTheMoney(1m), c => c.AtTheMoney(1m)); + yield return Case("Expiration(0, 10).ATM(2.5)", u => u.Expiration(0, 10).ATM(2.5m), c => c.Expiration(0, 10).ATM(2.5m)); 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()); @@ -346,12 +347,17 @@ public void StrategyFiltersValidateArgumentsLikeTheUniverseFilters() } } - [TestCase(101, 100)] - [TestCase(100, 100)] - [TestCase(103.75, 102.5)] + // By default only a strike equal to the price is at the money + [TestCase(100, 0, 100)] + [TestCase(101, 0, null)] + // Within the tolerance the closest strike is + [TestCase(101, 1, 100)] + [TestCase(101, 0.5, null)] + [TestCase(103.75, 1.25, 102.5)] // Equidistant between 100 and 102.5: the lower strike wins, unlike Strikes(0, 0) - [TestCase(101.25, 100)] - public void MoneynessFiltersSplitTheStrikesAroundTheUnderlyingPrice(double underlyingPrice, double atmStrike) + [TestCase(101.25, 1.25, 100)] + [TestCase(101.25, 1, null)] + public void MoneynessFiltersSplitTheStrikesAroundTheUnderlyingPrice(double underlyingPrice, double tolerance, double? atmStrike) { var price = (decimal)underlyingPrice; var (data, _) = CreateUniverseData(Date, price, Expiries, Strikes); @@ -360,15 +366,18 @@ public void MoneynessFiltersSplitTheStrikesAroundTheUnderlyingPrice(double under var otm = chain.OutOfTheMoney(); var itm = chain.InTheMoney(); - var atm = chain.AtTheMoney(); Assert.IsNotEmpty(otm); Assert.IsNotEmpty(itm); Assert.IsTrue(otm.All(x => x.Right == OptionRight.Call ? x.Strike > price : x.Strike < price)); Assert.IsTrue(itm.All(x => x.Right == OptionRight.Call ? x.Strike < price : x.Strike > price)); // a strike equal to the price is neither out nor in the money Assert.AreEqual(chain.Count, otm.Count + itm.Count + chain.Strikes([price]).Count); - Assert.AreEqual(2 * Expiries.Length, atm.Count); + + var atm = chain.AtTheMoney((decimal)tolerance); + Assert.AreEqual(atmStrike.HasValue ? 2 * Expiries.Length : 0, atm.Count); Assert.IsTrue(atm.All(x => x.Strike == (decimal)atmStrike)); + Assert.Throws(() => chain.AtTheMoney(-1m)); + Assert.Throws(() => CreateUniverse().AtTheMoney(-1m)); } [Test] @@ -393,8 +402,8 @@ public void MoneynessFiltersSelectNothingWithoutUnderlyingPrice() Assert.AreEqual(0, chain.OutOfTheMoney().Count); Assert.AreEqual(0, chain.InTheMoney().Count); - Assert.AreEqual(0, chain.AtTheMoney().Count); - Func[] filters = [u => u.OutOfTheMoney(), u => u.InTheMoney(), u => u.AtTheMoney()]; + Assert.AreEqual(0, chain.AtTheMoney(100m).Count); + Func[] filters = [u => u.OutOfTheMoney(), u => u.InTheMoney(), u => u.AtTheMoney(100m)]; foreach (var filter in filters) { var universe = new OptionFilterUniverse(_option); From 131672c78804abb2d520f5a9c5da923ce794e6c9 Mon Sep 17 00:00:00 2001 From: Jhonathan Abreu Date: Fri, 11 Sep 2026 11:42:52 -0400 Subject: [PATCH 11/14] Cover the shared filters on futures universes and future option chains The expiration set and bound filters are tested on a futures universe and used in the futures universe selection of a regression algorithm. The option chain filters are tested on a future option chain built from universe rows, against the future option universe, and used in a regression algorithm on the universe selection of the future and its options, on the slice chains and on OptionChain(). --- ...reOptionChainFiltersRegressionAlgorithm.cs | 194 ++++++++++++++++++ ...utureUniverseFiltersRegressionAlgorithm.cs | 161 +++++++++++++++ ...reOptionChainFiltersRegressionAlgorithm.py | 92 +++++++++ ...utureUniverseFiltersRegressionAlgorithm.py | 69 +++++++ Tests/Common/Data/Market/OptionChainTests.cs | 63 +++++- Tests/Common/Securities/FutureFilterTests.cs | 24 +++ 6 files changed, 596 insertions(+), 7 deletions(-) create mode 100644 Algorithm.CSharp/FutureOptionChainFiltersRegressionAlgorithm.cs create mode 100644 Algorithm.CSharp/FutureUniverseFiltersRegressionAlgorithm.cs create mode 100644 Algorithm.Python/FutureOptionChainFiltersRegressionAlgorithm.py create mode 100644 Algorithm.Python/FutureUniverseFiltersRegressionAlgorithm.py diff --git a/Algorithm.CSharp/FutureOptionChainFiltersRegressionAlgorithm.cs b/Algorithm.CSharp/FutureOptionChainFiltersRegressionAlgorithm.cs new file mode 100644 index 000000000000..40fe190a3db9 --- /dev/null +++ b/Algorithm.CSharp/FutureOptionChainFiltersRegressionAlgorithm.cs @@ -0,0 +1,194 @@ +/* + * 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; + +namespace QuantConnect.Algorithm.CSharp +{ + /// + /// Regression algorithm using the strike, expiration and moneyness filters on future options: in the universe selection + /// of the future and of its options, on the chains of the and on + /// + public class FutureOptionChainFiltersRegressionAlgorithm : QCAlgorithm, IRegressionAlgorithmDefinition + { + private static readonly DateTime MarchExpiry = new(2020, 3, 20); + private static readonly decimal[] SelectedStrikes = [3200m, 3210m, 3220m, 3230m, 3240m, 3250m]; + + private Symbol _es; + private bool _chainSeen; + private bool _traded; + + public override void Initialize() + { + SetStartDate(2020, 1, 5); + SetEndDate(2020, 1, 6); + SetCash(1000000); + + // The March 2020 future, by its expiration date + var es = AddFuture(Futures.Indices.SP500EMini, Resolution.Minute, Market.CME); + es.SetFilter(universe => universe.Expiration([MarchExpiry])); + _es = es.Symbol; + + // Its options: the out of the money contracts within three strikes of the future price + AddFutureOption(_es, universe => universe.Strikes(-3, 3).OutOfTheMoney()); + + // The option chain of the March future from the universe data: one expiration, the future at 3223.75 + var chain = OptionChain(QuantConnect.Symbol.CreateFuture(Futures.Indices.SP500EMini, Market.CME, MarchExpiry)); + if (chain.Count == 0 || chain.Underlying.Price != 3223.75m || chain.Symbol.SecurityType != SecurityType.FutureOption) + { + throw new RegressionTestException($"Expected the March ES option chain at 3223.75 but got {chain.Count} contracts at {chain.Underlying.Price}"); + } + // Strikes are 10 points apart around the money: three each side of 3223.75 are 3200 to 3250 + AssertStrikes(chain.Strikes(-3, 3).OutOfTheMoney().CallsOnly(), "Strikes(-3, 3).OutOfTheMoney().CallsOnly()", 3230m, 3240m, 3250m); + AssertStrikes(chain.Strikes(-3, 3).OutOfTheMoney().PutsOnly(), "Strikes(-3, 3).OutOfTheMoney().PutsOnly()", 3200m, 3210m, 3220m); + // Only the put is listed at 3310 + AssertStrikes(chain.StrikesAbove(3300m).StrikesBelow(3320m), "StrikesAbove(3300).StrikesBelow(3320)", 3310m); + AssertStrikes(chain.StrikesAbove(3300m).StrikesBelow(3320m).CallsOnly(), "StrikesAbove(3300).StrikesBelow(3320).CallsOnly()"); + AssertStrikes(chain.AtTheMoney(5m), "AtTheMoney(5)", 3220m, 3220m); + if (chain.AtTheMoney().Count != 0 || chain.Expiration([MarchExpiry]).Count != chain.Count || chain.FarthestExpiration().Count != chain.Count + || chain.ExpiringAfter(MarchExpiry).Count != 0 || chain.ZeroDte().Count != 0 + || chain.StandardsOnly().Count != chain.Count || chain.WeeklysOnly().Count != 0) + { + throw new RegressionTestException("Expiration or contract type filters mismatch on the March ES option chain"); + } + } + + public override void OnData(Slice slice) + { + // One chain per future contract, keyed by its canonical option symbol + foreach (var chain in slice.OptionChains.Values) + { + if (chain.Symbol.Underlying.ID.Date != MarchExpiry) + { + throw new RegressionTestException($"Unexpected option chain for {chain.Symbol.Underlying}"); + } + _chainSeen = true; + + // The universe selected the out of the money contracts within three strikes of the previous close: 3200 to 3250 + if (chain.Count == 0 || chain.Strikes(SelectedStrikes).Count != chain.Count || chain.Expiration([MarchExpiry]).Count != chain.Count) + { + throw new RegressionTestException($"The option chain disagrees with the universe filter: {string.Join(", ", chain.Select(x => x.Symbol.Value))}"); + } + + // The moneyness filters partition the chain around the current future price, and match the strike bounds for a single right + var price = chain.Underlying.Price; + var otm = chain.OutOfTheMoney(); + var itm = chain.InTheMoney(); + if (otm.Count + itm.Count + chain.Strikes([price]).Count != chain.Count + || otm.Any(x => x.Right == OptionRight.Call ? x.Strike <= price : x.Strike >= price) + || itm.Any(x => x.Right == OptionRight.Call ? x.Strike >= price : x.Strike <= price) + || chain.CallsOnly().OutOfTheMoney().Count != chain.CallsOnly().StrikesAbove(price).Count + || chain.PutsOnly().OutOfTheMoney().Count != chain.PutsOnly().StrikesBelow(price).Count) + { + throw new RegressionTestException($"Moneyness filters mismatch at {price}"); + } + + // Buy the out of the money call closest to the future price + if (!_traded) + { + var contract = otm.CallsOnly().OrderBy(x => x.Strike).FirstOrDefault(); + if (contract != null) + { + MarketOrder(contract.Symbol, 1); + _traded = true; + } + } + } + } + + public override void OnEndOfAlgorithm() + { + if (!_chainSeen || !_traded) + { + throw new RegressionTestException($"Expected the March ES option chain ({_chainSeen}) and a trade ({_traded})"); + } + } + + private static void AssertStrikes(OptionChain chain, string filter, params decimal[] expected) + { + var actual = chain.Select(x => x.Strike).OrderBy(x => x).ToList(); + if (!actual.SequenceEqual(expected.OrderBy(x => x))) + { + throw new RegressionTestException($"{filter}: expected strikes {string.Join(", ", expected)} but got {string.Join(", ", actual)}"); + } + } + + /// + /// 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 => 7888; + + /// + /// 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", "1000000"}, + {"End Equity", "1000586.08"}, + {"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.42"}, + {"Estimated Strategy Capacity", "$6900000.00"}, + {"Lowest Capacity Asset", "ES XCZJLDR35F50|ES XCZJLC9NOB29"}, + {"Portfolio Turnover", "0.18%"}, + {"Drawdown Recovery", "0"}, + {"OrderListHash", "8786bed30a9a11b79580196098932f23"} + }; + } +} diff --git a/Algorithm.CSharp/FutureUniverseFiltersRegressionAlgorithm.cs b/Algorithm.CSharp/FutureUniverseFiltersRegressionAlgorithm.cs new file mode 100644 index 000000000000..575ace63d42a --- /dev/null +++ b/Algorithm.CSharp/FutureUniverseFiltersRegressionAlgorithm.cs @@ -0,0 +1,161 @@ +/* + * 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; +using QuantConnect.Securities; + +namespace QuantConnect.Algorithm.CSharp +{ + /// + /// Regression algorithm using the expiration set and bound filters in the futures universe selection, + /// the same ones the option universes and chains offer, and checking the selected chains in the + /// + public class FutureUniverseFiltersRegressionAlgorithm : QCAlgorithm, IRegressionAlgorithmDefinition + { + private static readonly DateTime EndOf2013 = new(2013, 12, 31); + private static readonly DateTime EndOfNovember2014 = new(2014, 11, 30); + + private Symbol _es; + private Symbol _gc; + private bool _esChainSeen; + private bool _gcChainSeen; + private bool _traded; + + public override void Initialize() + { + SetStartDate(2013, 10, 7); + SetEndDate(2013, 10, 9); + SetCash(1000000); + + // The 2014 contracts up to September + var es = AddFuture(Futures.Indices.SP500EMini, Resolution.Minute, Market.CME); + es.SetFilter(universe => universe.ExpiringAfter(EndOf2013).ExpiringBefore(EndOfNovember2014)); + _es = es.Symbol; + + // The contracts expiring this year + var gc = AddFuture(Futures.Metals.Gold, Resolution.Minute, Market.COMEX); + gc.SetFilter(universe => universe.ExpiringBefore(new DateTime(2014, 1, 1))); + _gc = gc.Symbol; + + // The full chain from the universe data lists the December 2013 contract and the March to December 2014 ones + var chain = FuturesChain(_es); + var expiries = chain.Select(x => x.Expiry).OrderBy(x => x).ToList(); + if (expiries.Count != 5 || expiries[0] > EndOf2013 || expiries.Skip(1).Any(x => x.Year != 2014)) + { + throw new RegressionTestException($"Unexpected ES chain expiries: {string.Join(", ", expiries)}"); + } + } + + public override void OnData(Slice slice) + { + if (slice.FuturesChains.TryGetValue(_es, out var esChain)) + { + _esChainSeen = true; + // March, June and September 2014 + if (esChain.Count == 0 || esChain.Count > 3 || esChain.Any(x => x.Expiry <= EndOf2013 || x.Expiry >= EndOfNovember2014)) + { + throw new RegressionTestException($"The ES chain disagrees with the universe filter: {string.Join(", ", esChain.Select(x => x.Expiry))}"); + } + if (!_traded) + { + MarketOrder(esChain.OrderBy(x => x.Expiry).First().Symbol, 1); + _traded = true; + } + } + + if (slice.FuturesChains.TryGetValue(_gc, out var gcChain)) + { + _gcChainSeen = true; + // October, November and December 2013 + if (gcChain.Count == 0 || gcChain.Count > 3 || gcChain.Any(x => x.Expiry.Year != 2013)) + { + throw new RegressionTestException($"The GC chain disagrees with the universe filter: {string.Join(", ", gcChain.Select(x => x.Expiry))}"); + } + } + } + + public override void OnEndOfAlgorithm() + { + if (!_esChainSeen || !_gcChainSeen || !_traded) + { + throw new RegressionTestException($"Expected the ES chain ({_esChainSeen}), the GC chain ({_gcChainSeen}) and a trade ({_traded})"); + } + } + + /// + /// 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 => 38894; + + /// + /// 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", "-11.911%"}, + {"Drawdown", "0.200%"}, + {"Expectancy", "0"}, + {"Start Equity", "1000000"}, + {"End Equity", "998958.2"}, + {"Net Profit", "-0.104%"}, + {"Sharpe Ratio", "-9.32"}, + {"Sortino Ratio", "0"}, + {"Probabilistic Sharpe Ratio", "0%"}, + {"Loss Rate", "0%"}, + {"Win Rate", "0%"}, + {"Profit-Loss Ratio", "0"}, + {"Alpha", "-0.048"}, + {"Beta", "0.095"}, + {"Annual Standard Deviation", "0.013"}, + {"Annual Variance", "0"}, + {"Information Ratio", "5.187"}, + {"Tracking Error", "0.123"}, + {"Treynor Ratio", "-1.269"}, + {"Total Fees", "$2.15"}, + {"Estimated Strategy Capacity", "$940000000.00"}, + {"Lowest Capacity Asset", "ES VP274HSU1AF5"}, + {"Portfolio Turnover", "2.77%"}, + {"Drawdown Recovery", "0"}, + {"OrderListHash", "3b6b723d50c0d435d763aa456af197a6"} + }; + } +} diff --git a/Algorithm.Python/FutureOptionChainFiltersRegressionAlgorithm.py b/Algorithm.Python/FutureOptionChainFiltersRegressionAlgorithm.py new file mode 100644 index 000000000000..97b220cc0f9e --- /dev/null +++ b/Algorithm.Python/FutureOptionChainFiltersRegressionAlgorithm.py @@ -0,0 +1,92 @@ +# 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 using the strike, expiration and moneyness filters on future options: in the universe selection +### of the future and of its options, on the chains of the slice and on option_chain() +### +class FutureOptionChainFiltersRegressionAlgorithm(QCAlgorithm): + MARCH_EXPIRY = datetime(2020, 3, 20) + SELECTED_STRIKES = [3200, 3210, 3220, 3230, 3240, 3250] + + def initialize(self): + self.set_start_date(2020, 1, 5) + self.set_end_date(2020, 1, 6) + self.set_cash(1000000) + + # The March 2020 future, by its expiration date + es = self.add_future(Futures.Indices.SP_500_E_MINI, Resolution.MINUTE, Market.CME) + es.set_filter(lambda universe: universe.expiration([self.MARCH_EXPIRY])) + self._es = es.symbol + + # Its options: the out of the money contracts within three strikes of the future price + self.add_future_option(self._es, lambda universe: universe.strikes(-3, 3).out_of_the_money()) + + self._chain_seen = False + self._traded = False + + # The option chain of the March future from the universe data: one expiration, the future at 3223.75 + chain = self.option_chain(Symbol.create_future(Futures.Indices.SP_500_E_MINI, Market.CME, self.MARCH_EXPIRY)) + if chain.count == 0 or chain.underlying.price != 3223.75 or chain.symbol.security_type != SecurityType.FUTURE_OPTION: + raise AssertionError(f"Expected the March ES option chain at 3223.75 but got {chain.count} contracts at {chain.underlying.price}") + # Strikes are 10 points apart around the money: three each side of 3223.75 are 3200 to 3250 + self._assert_strikes(chain.strikes(-3, 3).out_of_the_money().calls_only(), "strikes(-3, 3).out_of_the_money().calls_only()", [3230, 3240, 3250]) + self._assert_strikes(chain.strikes(-3, 3).out_of_the_money().puts_only(), "strikes(-3, 3).out_of_the_money().puts_only()", [3200, 3210, 3220]) + # Only the put is listed at 3310 + self._assert_strikes(chain.strikes_above(3300).strikes_below(3320), "strikes_above(3300).strikes_below(3320)", [3310]) + self._assert_strikes(chain.strikes_above(3300).strikes_below(3320).calls_only(), "strikes_above(3300).strikes_below(3320).calls_only()", []) + self._assert_strikes(chain.at_the_money(5), "at_the_money(5)", [3220, 3220]) + if (chain.at_the_money().count != 0 or chain.expiration([self.MARCH_EXPIRY]).count != chain.count or chain.farthest_expiration().count != chain.count + or chain.expiring_after(self.MARCH_EXPIRY).count != 0 or chain.zero_dte().count != 0 + or chain.standards_only().count != chain.count or chain.weeklys_only().count != 0): + raise AssertionError("Expiration or contract type filters mismatch on the March ES option chain") + + def on_data(self, slice): + # One chain per future contract, keyed by its canonical option symbol + for chain in slice.option_chains.values(): + if chain.symbol.underlying.id.date != self.MARCH_EXPIRY: + raise AssertionError(f"Unexpected option chain for {chain.symbol.underlying}") + self._chain_seen = True + + # The universe selected the out of the money contracts within three strikes of the previous close: 3200 to 3250 + if chain.count == 0 or chain.strikes(self.SELECTED_STRIKES).count != chain.count or chain.expiration([self.MARCH_EXPIRY]).count != chain.count: + raise AssertionError(f"The option chain disagrees with the universe filter: {[x.symbol.value for x in chain]}") + + # The moneyness filters partition the chain around the current future price, and match the strike bounds for a single right + price = chain.underlying.price + otm = chain.out_of_the_money() + itm = chain.in_the_money() + if (otm.count + itm.count + chain.strikes([price]).count != chain.count + or any((x.strike <= price if x.right == OptionRight.CALL else x.strike >= price) for x in otm) + or any((x.strike >= price if x.right == OptionRight.CALL else x.strike <= price) for x in itm) + or chain.calls_only().out_of_the_money().count != chain.calls_only().strikes_above(price).count + or chain.puts_only().out_of_the_money().count != chain.puts_only().strikes_below(price).count): + raise AssertionError(f"Moneyness filters mismatch at {price}") + + # Buy the out of the money call closest to the future price + if not self._traded: + calls = sorted(otm.calls_only(), key=lambda x: x.strike) + if calls: + self.market_order(calls[0].symbol, 1) + self._traded = True + + def on_end_of_algorithm(self): + if not self._chain_seen or not self._traded: + raise AssertionError(f"Expected the March ES option chain ({self._chain_seen}) and a trade ({self._traded})") + + def _assert_strikes(self, chain, filter_name, expected): + actual = sorted(float(x.strike) for x in chain) + if actual != sorted(float(x) for x in expected): + raise AssertionError(f"{filter_name}: expected strikes {expected} but got {actual}") diff --git a/Algorithm.Python/FutureUniverseFiltersRegressionAlgorithm.py b/Algorithm.Python/FutureUniverseFiltersRegressionAlgorithm.py new file mode 100644 index 000000000000..59b2f587af0a --- /dev/null +++ b/Algorithm.Python/FutureUniverseFiltersRegressionAlgorithm.py @@ -0,0 +1,69 @@ +# 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 using the expiration set and bound filters in the futures universe selection, +### the same ones the option universes and chains offer, and checking the selected chains in the slice +### +class FutureUniverseFiltersRegressionAlgorithm(QCAlgorithm): + END_OF_2013 = datetime(2013, 12, 31) + END_OF_NOVEMBER_2014 = datetime(2014, 11, 30) + + def initialize(self): + self.set_start_date(2013, 10, 7) + self.set_end_date(2013, 10, 9) + self.set_cash(1000000) + + # The 2014 contracts up to September + es = self.add_future(Futures.Indices.SP_500_E_MINI, Resolution.MINUTE, Market.CME) + es.set_filter(lambda universe: universe.expiring_after(self.END_OF_2013).expiring_before(self.END_OF_NOVEMBER_2014)) + self._es = es.symbol + + # The contracts expiring this year + gc = self.add_future(Futures.Metals.GOLD, Resolution.MINUTE, Market.COMEX) + gc.set_filter(lambda universe: universe.expiring_before(datetime(2014, 1, 1))) + self._gc = gc.symbol + + self._es_chain_seen = False + self._gc_chain_seen = False + self._traded = False + + # The full chain from the universe data lists the December 2013 contract and the March to December 2014 ones + chain = self.futures_chain(self._es) + expiries = sorted(x.expiry for x in chain) + if len(expiries) != 5 or expiries[0] > self.END_OF_2013 or any(x.year != 2014 for x in expiries[1:]): + raise AssertionError(f"Unexpected ES chain expiries: {expiries}") + + def on_data(self, slice): + es_chain = slice.futures_chains.get(self._es) + if es_chain: + self._es_chain_seen = True + # March, June and September 2014 + if es_chain.count == 0 or es_chain.count > 3 or any(x.expiry <= self.END_OF_2013 or x.expiry >= self.END_OF_NOVEMBER_2014 for x in es_chain): + raise AssertionError(f"The ES chain disagrees with the universe filter: {[x.expiry for x in es_chain]}") + if not self._traded: + self.market_order(min(es_chain, key=lambda x: x.expiry).symbol, 1) + self._traded = True + + gc_chain = slice.futures_chains.get(self._gc) + if gc_chain: + self._gc_chain_seen = True + # October, November and December 2013 + if gc_chain.count == 0 or gc_chain.count > 3 or any(x.expiry.year != 2013 for x in gc_chain): + raise AssertionError(f"The GC chain disagrees with the universe filter: {[x.expiry for x in gc_chain]}") + + def on_end_of_algorithm(self): + if not self._es_chain_seen or not self._gc_chain_seen or not self._traded: + raise AssertionError(f"Expected the ES chain ({self._es_chain_seen}), the GC chain ({self._gc_chain_seen}) and a trade ({self._traded})") diff --git a/Tests/Common/Data/Market/OptionChainTests.cs b/Tests/Common/Data/Market/OptionChainTests.cs index 1e18c8efe8d7..55ffb7079dfa 100644 --- a/Tests/Common/Data/Market/OptionChainTests.cs +++ b/Tests/Common/Data/Market/OptionChainTests.cs @@ -380,6 +380,50 @@ public void MoneynessFiltersSplitTheStrikesAroundTheUnderlyingPrice(double under Assert.Throws(() => CreateUniverse().AtTheMoney(-1m)); } + [Test] + public void FiltersWorkOnFutureOptionChains() + { + // March 2020 ES options on the March 2020 future, the universe rows carry the future price + var future = Symbol.CreateFuture("ES", QuantConnect.Market.CME, new DateTime(2020, 3, 20)); + var canonical = Symbol.CreateCanonicalOption(future); + var date = new DateTime(2020, 1, 3); + var contracts = new List<(Symbol, decimal, decimal, Greeks)>(); + foreach (var strike in new[] { 3200m, 3210m, 3220m, 3230m, 3240m }) + { + foreach (var right in new[] { OptionRight.Call, OptionRight.Put }) + { + var symbol = Symbol.CreateOption(future, QuantConnect.Market.CME, OptionStyle.American, right, strike, future.ID.Date); + contracts.Add((symbol, 100, 0.15m, new Greeks(0.5m, 0.01m, 5, -0.5m, 1, 0))); + } + } + var (data, underlying) = CreateUniverseData(canonical, date, 3223.75m, contracts); + var symbolProperties = SymbolPropertiesDatabase.FromDataFolder().GetSymbolProperties(QuantConnect.Market.CME, canonical, SecurityType.FutureOption, Currencies.USD); + var chain = new OptionChain(canonical, date, data, symbolProperties); + Assert.AreEqual(SecurityType.FutureOption, chain.Symbol.SecurityType); + Assert.AreEqual(10, chain.Count); + Assert.AreEqual(3223.75m, chain.Underlying.Price); + + // moneyness against the future price + CollectionAssert.AreEquivalent(new[] { 3230m, 3240m }, chain.OutOfTheMoney().CallsOnly().Select(x => x.Strike)); + CollectionAssert.AreEquivalent(new[] { 3200m, 3210m, 3220m }, chain.OutOfTheMoney().PutsOnly().Select(x => x.Strike)); + Assert.AreEqual(0, chain.AtTheMoney().Count); + CollectionAssert.AreEquivalent(new[] { 3220m, 3220m }, chain.AtTheMoney(5m).Select(x => x.Strike)); + + // the expiration filters count from the CME date, every ES option is a standard contract + Assert.AreEqual(10, chain.Expiration(70, 80).Count); + Assert.AreEqual(0, chain.ZeroDte().Count); + Assert.AreEqual(10, chain.StandardsOnly().FarthestExpiration().Count); + Assert.AreEqual(0, chain.WeeklysOnly().Count); + Assert.IsTrue(chain.All(x => x.DaysToExpiry == (future.ID.Date - x.Time.Date).Days)); + + // and match the universe filters of a future option over the same rows + var universe = new OptionFilterUniverse(CreateOption(canonical), data, underlying); + universe.Refresh(data, underlying, date); + var expected = universe.Strikes(-1, 1).OutOfTheMoney().ExpiringBefore(new DateTime(2020, 4, 1)).ToList().Select(x => x.Symbol.Value).ToList(); + Assert.IsNotEmpty(expected); + CollectionAssert.AreEquivalent(expected, chain.Strikes(-1, 1).OutOfTheMoney().ExpiringBefore(new DateTime(2020, 4, 1)).Select(x => x.Symbol.Value)); + } + [Test] public void ContractsCountTheDaysToTheirExpiration() { @@ -480,12 +524,13 @@ private OptionChain CreateChain() return new OptionChain(Canonical, Date, _data, _symbolProperties); } - private static Option CreateOption() + private static Option CreateOption(Symbol canonical = null) { - var exchangeHours = MarketHoursDatabase.FromDataFolder().GetExchangeHours(Canonical.ID.Market, Canonical, Canonical.SecurityType); + canonical ??= Canonical; + 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 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, @@ -576,10 +621,14 @@ internal static (List contracts, BaseData underlying) CreateUniv { 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); + // future option universe files carry no implied volatility or greeks + if (canonical.SecurityType != SecurityType.FutureOption) + { + 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); diff --git a/Tests/Common/Securities/FutureFilterTests.cs b/Tests/Common/Securities/FutureFilterTests.cs index 48a7f4c288fe..c99747347aef 100644 --- a/Tests/Common/Securities/FutureFilterTests.cs +++ b/Tests/Common/Securities/FutureFilterTests.cs @@ -15,6 +15,7 @@ */ using System; +using System.Collections.Generic; using System.Linq; using NUnit.Framework; using Python.Runtime; @@ -376,6 +377,29 @@ public void FiltersExpirationCycles() Assert.AreEqual(5, filtered.Count); } + [Test] + public void FiltersExpirationSetsBoundsAndFarthestExpiration() + { + var time = new DateTime(2013, 10, 7); + var expiries = new[] + { + new DateTime(2013, 12, 20), new DateTime(2014, 3, 21), new DateTime(2014, 6, 20), new DateTime(2014, 9, 19), new DateTime(2014, 12, 19) + }; + var data = expiries.Select(expiry => new FutureUniverse { Symbol = Symbol.CreateFuture("ES", Market.CME, expiry) }).ToList(); + FutureFilterUniverse Universe() => new(data, time); + static IEnumerable Expiries(FutureFilterUniverse universe) => universe.Select(x => x.Symbol.ID.Date); + + // sets ignore the time of day, bounds exclude the date itself + CollectionAssert.AreEqual(new[] { expiries[1], expiries[3] }, Expiries(Universe().Expiration([expiries[1], expiries[3].AddHours(10)]))); + Assert.AreEqual(0, Universe().Expiration([]).Count); + CollectionAssert.AreEqual(expiries.Skip(1), Expiries(Universe().ExpiringAfter(expiries[0]))); + CollectionAssert.AreEqual(expiries.Take(2), Expiries(Universe().ExpiringBefore(expiries[2]))); + CollectionAssert.AreEqual(new[] { expiries[2] }, Expiries(Universe().ExpiringAfter(expiries[1]).ExpiringBefore(expiries[3]))); + CollectionAssert.AreEqual(new[] { expiries[4] }, Expiries(Universe().FarthestExpiration())); + CollectionAssert.AreEqual(new[] { expiries[0] }, Expiries(Universe().FrontMonth())); + Assert.AreEqual(0, new FutureFilterUniverse(new List(), time).FarthestExpiration().Count); + } + [Test] public void FilterTypeDoesNotBreakOnMissingExpiryFunction() { From 71d15a0348c3a6c558ead80483f2ce69bae80c94 Mon Sep 17 00:00:00 2001 From: Jhonathan Abreu Date: Fri, 11 Sep 2026 12:02:08 -0400 Subject: [PATCH 12/14] Default the AtTheMoney tolerance to one percent of the underlying price A null tolerance, the default, accepts the closest strike within DefaultAtTheMoneyTolerance of the underlying price, so ordinary ladders report their nearest strike as at the money while a strike far from the money, as on a chain filtered away from it, is still rejected. An explicit tolerance stays an absolute distance in underlying price units, scaled by the strike multiplier like the price, and zero still requires an exact strike. --- ...reOptionChainFiltersRegressionAlgorithm.cs | 3 ++- ...exOptionChainFiltersRegressionAlgorithm.cs | 7 ++++--- .../OptionChainFiltersRegressionAlgorithm.cs | 9 +++++---- ...reOptionChainFiltersRegressionAlgorithm.py | 3 ++- ...exOptionChainFiltersRegressionAlgorithm.py | 7 ++++--- .../OptionChainFiltersRegressionAlgorithm.py | 9 +++++---- Common/Data/Market/OptionChain.Filters.cs | 10 ++++++---- .../Option/IOptionContractFilters.cs | 7 ++++--- .../Securities/Option/OptionFilterUniverse.cs | 19 ++++++++++++++----- Tests/Common/Data/Market/OptionChainTests.cs | 19 +++++++++++++------ 10 files changed, 59 insertions(+), 34 deletions(-) diff --git a/Algorithm.CSharp/FutureOptionChainFiltersRegressionAlgorithm.cs b/Algorithm.CSharp/FutureOptionChainFiltersRegressionAlgorithm.cs index 40fe190a3db9..e0981cbf58db 100644 --- a/Algorithm.CSharp/FutureOptionChainFiltersRegressionAlgorithm.cs +++ b/Algorithm.CSharp/FutureOptionChainFiltersRegressionAlgorithm.cs @@ -62,8 +62,9 @@ public override void Initialize() // Only the put is listed at 3310 AssertStrikes(chain.StrikesAbove(3300m).StrikesBelow(3320m), "StrikesAbove(3300).StrikesBelow(3320)", 3310m); AssertStrikes(chain.StrikesAbove(3300m).StrikesBelow(3320m).CallsOnly(), "StrikesAbove(3300).StrikesBelow(3320).CallsOnly()"); + AssertStrikes(chain.AtTheMoney(), "AtTheMoney()", 3220m, 3220m); AssertStrikes(chain.AtTheMoney(5m), "AtTheMoney(5)", 3220m, 3220m); - if (chain.AtTheMoney().Count != 0 || chain.Expiration([MarchExpiry]).Count != chain.Count || chain.FarthestExpiration().Count != chain.Count + if (chain.AtTheMoney(0).Count != 0 || chain.Expiration([MarchExpiry]).Count != chain.Count || chain.FarthestExpiration().Count != chain.Count || chain.ExpiringAfter(MarchExpiry).Count != 0 || chain.ZeroDte().Count != 0 || chain.StandardsOnly().Count != chain.Count || chain.WeeklysOnly().Count != 0) { diff --git a/Algorithm.CSharp/IndexOptionChainFiltersRegressionAlgorithm.cs b/Algorithm.CSharp/IndexOptionChainFiltersRegressionAlgorithm.cs index 677e865259c7..56c8ad76ce7b 100644 --- a/Algorithm.CSharp/IndexOptionChainFiltersRegressionAlgorithm.cs +++ b/Algorithm.CSharp/IndexOptionChainFiltersRegressionAlgorithm.cs @@ -62,10 +62,11 @@ public override void Initialize() } AssertContracts(chain.OutOfTheMoney(), "OutOfTheMoney()", (3800m, OptionRight.Call), (4250m, OptionRight.Call), (3200m, OptionRight.Put)); AssertContracts(chain.InTheMoney(), "InTheMoney()", (3200m, OptionRight.Call), (3700m, OptionRight.Call), (4200m, OptionRight.Put)); - // The closest strike, 3800, is 33.37 points away: at the money only within a tolerance that covers it - AssertContracts(chain.AtTheMoney(), "AtTheMoney()"); - AssertContracts(chain.AtTheMoney(25m), "AtTheMoney(25)"); + // The closest strike, 3800, is 33.37 points away: within the default 1% of the index, not within 25 points + AssertContracts(chain.AtTheMoney(), "AtTheMoney()", (3800m, OptionRight.Call)); AssertContracts(chain.AtTheMoney(50m), "AtTheMoney(50)", (3800m, OptionRight.Call)); + AssertContracts(chain.AtTheMoney(25m), "AtTheMoney(25)"); + AssertContracts(chain.AtTheMoney(0), "AtTheMoney(0)"); AssertContracts(chain.StrikesAbove(3700m).StrikesBelow(4250m), "StrikesAbove(3700).StrikesBelow(4250)", (3800m, OptionRight.Call), (4200m, OptionRight.Put)); AssertContracts(chain.Strikes([3200m, 4250m]), "Strikes([3200, 4250])", (3200m, OptionRight.Call), (4250m, OptionRight.Call), (3200m, OptionRight.Put)); AssertContracts(chain.OutOfTheMoney().StrikesBelow(4000m), "the SPX universe filter", (3800m, OptionRight.Call), (3200m, OptionRight.Put)); diff --git a/Algorithm.CSharp/OptionChainFiltersRegressionAlgorithm.cs b/Algorithm.CSharp/OptionChainFiltersRegressionAlgorithm.cs index 047f9c917b30..37b1d8f5a36c 100644 --- a/Algorithm.CSharp/OptionChainFiltersRegressionAlgorithm.cs +++ b/Algorithm.CSharp/OptionChainFiltersRegressionAlgorithm.cs @@ -99,11 +99,12 @@ public override void Initialize() { throw new RegressionTestException("Out/in the money filters mismatch"); } - // No strike equals the 748.54 close, so the at the money contracts need a tolerance: one strike step reaches 747.5 - var atm = chain.AtTheMoney(2.5m); - if (chain.AtTheMoney().Count != 0 || atm.Count == 0 || atm.Any(x => x.Strike != 747.5m) || atm.Count != chain.Strikes([747.5m]).Count) + // The closest strike to the 748.54 close is 747.5, within the default 1% tolerance but not within 1 point or exactly at the price + var atm = chain.AtTheMoney(); + if (atm.Count == 0 || atm.Any(x => x.Strike != 747.5m) || atm.Count != chain.Strikes([747.5m]).Count + || chain.AtTheMoney(2.5m).Count != atm.Count || chain.AtTheMoney(1m).Count != 0 || chain.AtTheMoney(0).Count != 0) { - throw new RegressionTestException("Expected AtTheMoney(2.5) to select every contract at the 747.50 strike and AtTheMoney() none"); + throw new RegressionTestException("Expected AtTheMoney() to select every contract at the 747.50 strike and AtTheMoney(1) none"); } // Strike sets and bounds are absolute, unlike the relative Strikes(min, max) diff --git a/Algorithm.Python/FutureOptionChainFiltersRegressionAlgorithm.py b/Algorithm.Python/FutureOptionChainFiltersRegressionAlgorithm.py index 97b220cc0f9e..bdae906cb8aa 100644 --- a/Algorithm.Python/FutureOptionChainFiltersRegressionAlgorithm.py +++ b/Algorithm.Python/FutureOptionChainFiltersRegressionAlgorithm.py @@ -47,8 +47,9 @@ def initialize(self): # Only the put is listed at 3310 self._assert_strikes(chain.strikes_above(3300).strikes_below(3320), "strikes_above(3300).strikes_below(3320)", [3310]) self._assert_strikes(chain.strikes_above(3300).strikes_below(3320).calls_only(), "strikes_above(3300).strikes_below(3320).calls_only()", []) + self._assert_strikes(chain.at_the_money(), "at_the_money()", [3220, 3220]) self._assert_strikes(chain.at_the_money(5), "at_the_money(5)", [3220, 3220]) - if (chain.at_the_money().count != 0 or chain.expiration([self.MARCH_EXPIRY]).count != chain.count or chain.farthest_expiration().count != chain.count + if (chain.at_the_money(0).count != 0 or chain.expiration([self.MARCH_EXPIRY]).count != chain.count or chain.farthest_expiration().count != chain.count or chain.expiring_after(self.MARCH_EXPIRY).count != 0 or chain.zero_dte().count != 0 or chain.standards_only().count != chain.count or chain.weeklys_only().count != 0): raise AssertionError("Expiration or contract type filters mismatch on the March ES option chain") diff --git a/Algorithm.Python/IndexOptionChainFiltersRegressionAlgorithm.py b/Algorithm.Python/IndexOptionChainFiltersRegressionAlgorithm.py index b70935dda9f5..10f6090a7c9a 100644 --- a/Algorithm.Python/IndexOptionChainFiltersRegressionAlgorithm.py +++ b/Algorithm.Python/IndexOptionChainFiltersRegressionAlgorithm.py @@ -47,10 +47,11 @@ def initialize(self): raise AssertionError(f"Expected the 6 SPX contracts at 3766.63 but got {chain.count} at {chain.underlying.price}") self._assert_contracts(chain.out_of_the_money(), "out_of_the_money()", [(3800, OptionRight.CALL), (4250, OptionRight.CALL), (3200, OptionRight.PUT)]) self._assert_contracts(chain.in_the_money(), "in_the_money()", [(3200, OptionRight.CALL), (3700, OptionRight.CALL), (4200, OptionRight.PUT)]) - # The closest strike, 3800, is 33.37 points away: at the money only within a tolerance that covers it - self._assert_contracts(chain.at_the_money(), "at_the_money()", []) - self._assert_contracts(chain.at_the_money(25), "at_the_money(25)", []) + # The closest strike, 3800, is 33.37 points away: within the default 1% of the index, not within 25 points + self._assert_contracts(chain.at_the_money(), "at_the_money()", [(3800, OptionRight.CALL)]) self._assert_contracts(chain.at_the_money(50), "at_the_money(50)", [(3800, OptionRight.CALL)]) + self._assert_contracts(chain.at_the_money(25), "at_the_money(25)", []) + self._assert_contracts(chain.at_the_money(0), "at_the_money(0)", []) self._assert_contracts(chain.strikes_above(3700).strikes_below(4250), "strikes_above(3700).strikes_below(4250)", [(3800, OptionRight.CALL), (4200, OptionRight.PUT)]) self._assert_contracts(chain.strikes([3200, 4250]), "strikes([3200, 4250])", [(3200, OptionRight.CALL), (4250, OptionRight.CALL), (3200, OptionRight.PUT)]) self._assert_contracts(chain.out_of_the_money().strikes_below(4000), "the SPX universe filter", [(3800, OptionRight.CALL), (3200, OptionRight.PUT)]) diff --git a/Algorithm.Python/OptionChainFiltersRegressionAlgorithm.py b/Algorithm.Python/OptionChainFiltersRegressionAlgorithm.py index 31602d16cb47..36627708dcda 100644 --- a/Algorithm.Python/OptionChainFiltersRegressionAlgorithm.py +++ b/Algorithm.Python/OptionChainFiltersRegressionAlgorithm.py @@ -69,10 +69,11 @@ def initialize(self): or any((x.strike <= price if x.right == OptionRight.CALL else x.strike >= price) for x in otm) or any((x.strike >= price if x.right == OptionRight.CALL else x.strike <= price) for x in itm)): raise AssertionError("Out/in the money filters mismatch") - # No strike equals the 748.54 close, so the at the money contracts need a tolerance: one strike step reaches 747.5 - atm = chain.atm(2.5) - if chain.atm().count != 0 or atm.count == 0 or any(x.strike != 747.5 for x in atm) or atm.count != chain.strikes([747.5]).count: - raise AssertionError("Expected atm(2.5) to select every contract at the 747.50 strike and atm() none") + # The closest strike to the 748.54 close is 747.5, within the default 1% tolerance but not within 1 point or exactly at the price + atm = chain.atm() + if (atm.count == 0 or any(x.strike != 747.5 for x in atm) or atm.count != chain.strikes([747.5]).count + or chain.atm(2.5).count != atm.count or chain.atm(1).count != 0 or chain.atm(0).count != 0): + raise AssertionError("Expected atm() to select every contract at the 747.50 strike and atm(1) none") # Strike sets and bounds are absolute, unlike the relative strikes(min, max) strikes = chain.strikes([745, 750]) diff --git a/Common/Data/Market/OptionChain.Filters.cs b/Common/Data/Market/OptionChain.Filters.cs index 9caa0d3e118b..b363d5fde37b 100644 --- a/Common/Data/Market/OptionChain.Filters.cs +++ b/Common/Data/Market/OptionChain.Filters.cs @@ -205,9 +205,10 @@ public OptionChain ITM() /// when that strike is within the tolerance. Same as /// /// The largest distance between the closest strike and the underlying price for the strike to be - /// at the money, in units of the underlying price. Zero, the default, requires a strike equal to the underlying price + /// at the money, in units of the underlying price. Zero requires a strike equal to the underlying price. Null, the default, + /// allows of the underlying price /// A new chain with the filter applied, empty when the underlying price is unknown - public OptionChain AtTheMoney(decimal tolerance = 0) + public OptionChain AtTheMoney(decimal? tolerance = null) { return Filter(universe => universe.AtTheMoney(tolerance)); } @@ -216,9 +217,10 @@ public OptionChain AtTheMoney(decimal tolerance = 0) /// Selects the contracts at the money. Alias for /// /// The largest distance between the closest strike and the underlying price for the strike to be - /// at the money, in units of the underlying price. Zero, the default, requires a strike equal to the underlying price + /// at the money, in units of the underlying price. Zero requires a strike equal to the underlying price. Null, the default, + /// allows of the underlying price /// A new chain with the filter applied - public OptionChain ATM(decimal tolerance = 0) + public OptionChain ATM(decimal? tolerance = null) { return AtTheMoney(tolerance); } diff --git a/Common/Securities/Option/IOptionContractFilters.cs b/Common/Securities/Option/IOptionContractFilters.cs index 7e9d3f71614a..0200d9bbf395 100644 --- a/Common/Securities/Option/IOptionContractFilters.cs +++ b/Common/Securities/Option/IOptionContractFilters.cs @@ -98,14 +98,15 @@ public interface IOptionContractFilters /// /// Selects the contracts at the strike closest to the underlying price, the lower strike on ties, when that strike - /// is within the tolerance, in units of the underlying price. Zero requires a strike equal to the price + /// is within the tolerance, in units of the underlying price. Zero requires a strike equal to the price, null allows + /// of it /// - TSelf AtTheMoney(decimal tolerance = 0); + TSelf AtTheMoney(decimal? tolerance = null); /// /// Selects the contracts at the money. Alias for /// - TSelf ATM(decimal tolerance = 0); + TSelf ATM(decimal? tolerance = null); /// /// Selects the call contracts diff --git a/Common/Securities/Option/OptionFilterUniverse.cs b/Common/Securities/Option/OptionFilterUniverse.cs index 467ceaf51877..88c6369590e1 100644 --- a/Common/Securities/Option/OptionFilterUniverse.cs +++ b/Common/Securities/Option/OptionFilterUniverse.cs @@ -39,6 +39,11 @@ public abstract class BaseOptionFilterUniverse : ContractSecur where TUniverse : BaseOptionFilterUniverse where TData : ISymbolProvider { + /// + /// The distance from the underlying price, as a fraction of it, within which the closest strike is at the money by default + /// + public const decimal DefaultAtTheMoneyTolerance = 0.01m; + // Fields used in relative strikes filter private List _uniqueStrikes; private bool _refreshUniqueStrikes; @@ -371,9 +376,10 @@ public TUniverse ITM() /// strike on ties, when that strike is within the tolerance. Selects nothing when the underlying price is unknown /// /// The largest distance between the closest strike and the underlying price for the strike to be - /// at the money, in units of the underlying price. Zero, the default, requires a strike equal to the underlying price + /// at the money, in units of the underlying price. Zero requires a strike equal to the underlying price. Null, the default, + /// allows of the underlying price /// Universe with filter applied - public TUniverse AtTheMoney(decimal tolerance = 0) + public TUniverse AtTheMoney(decimal? tolerance = null) { if (tolerance < 0) { @@ -383,17 +389,20 @@ public TUniverse AtTheMoney(decimal tolerance = 0) { return Empty(); } + // the price is in strike units, see SymbolProperties.StrikeMultiplier, so an explicit tolerance is scaled the same way + var maxDistance = tolerance.HasValue ? tolerance.Value / _underlyingScaleFactor : price * DefaultAtTheMoneyTolerance; var strike = GetClosestStrike(AllSymbols, price); - return Math.Abs(strike - price) <= tolerance ? Strikes([strike]) : Empty(); + return Math.Abs(strike - price) <= maxDistance ? Strikes([strike]) : Empty(); } /// /// Applies filter selecting the contracts at the money. Alias for /// /// The largest distance between the closest strike and the underlying price for the strike to be - /// at the money, in units of the underlying price. Zero, the default, requires a strike equal to the underlying price + /// at the money, in units of the underlying price. Zero requires a strike equal to the underlying price. Null, the default, + /// allows of the underlying price /// Universe with filter applied - public TUniverse ATM(decimal tolerance = 0) + public TUniverse ATM(decimal? tolerance = null) { return AtTheMoney(tolerance); } diff --git a/Tests/Common/Data/Market/OptionChainTests.cs b/Tests/Common/Data/Market/OptionChainTests.cs index 55ffb7079dfa..916756c97b99 100644 --- a/Tests/Common/Data/Market/OptionChainTests.cs +++ b/Tests/Common/Data/Market/OptionChainTests.cs @@ -85,7 +85,8 @@ private static IEnumerable FilterCases() yield return Case("OTM.CallsOnly", u => u.OTM().CallsOnly(), c => c.OTM().CallsOnly()); yield return Case("InTheMoney", u => u.InTheMoney(), c => c.InTheMoney()); yield return Case("ITM.PutsOnly.Expiration(0, 10)", u => u.ITM().PutsOnly().Expiration(0, 10), c => c.ITM().PutsOnly().Expiration(0, 10)); - yield return Case("AtTheMoney", u => u.AtTheMoney(), c => c.AtTheMoney(), empty: true); + yield return Case("AtTheMoney", u => u.AtTheMoney(), c => c.AtTheMoney()); + yield return Case("AtTheMoney(0)", u => u.AtTheMoney(0), c => c.AtTheMoney(0), empty: true); yield return Case("AtTheMoney(1)", u => u.AtTheMoney(1m), c => c.AtTheMoney(1m)); yield return Case("Expiration(0, 10).ATM(2.5)", u => u.Expiration(0, 10).ATM(2.5m), c => c.Expiration(0, 10).ATM(2.5m)); yield return Case("StandardsOnly", u => u.StandardsOnly(), c => c.StandardsOnly()); @@ -347,17 +348,22 @@ public void StrategyFiltersValidateArgumentsLikeTheUniverseFilters() } } - // By default only a strike equal to the price is at the money + // By default the closest strike is at the money within 1% of the price: 1 at 101, not 1.25 at 101.25 or 103.75 + [TestCase(100, null, 100)] + [TestCase(101, null, 100)] + [TestCase(101.25, null, null)] + [TestCase(103.75, null, null)] + // A zero tolerance requires a strike equal to the price [TestCase(100, 0, 100)] [TestCase(101, 0, null)] - // Within the tolerance the closest strike is + // Otherwise the closest strike within the tolerance [TestCase(101, 1, 100)] [TestCase(101, 0.5, null)] [TestCase(103.75, 1.25, 102.5)] // Equidistant between 100 and 102.5: the lower strike wins, unlike Strikes(0, 0) [TestCase(101.25, 1.25, 100)] [TestCase(101.25, 1, null)] - public void MoneynessFiltersSplitTheStrikesAroundTheUnderlyingPrice(double underlyingPrice, double tolerance, double? atmStrike) + public void MoneynessFiltersSplitTheStrikesAroundTheUnderlyingPrice(double underlyingPrice, double? tolerance, double? atmStrike) { var price = (decimal)underlyingPrice; var (data, _) = CreateUniverseData(Date, price, Expiries, Strikes); @@ -373,7 +379,7 @@ public void MoneynessFiltersSplitTheStrikesAroundTheUnderlyingPrice(double under // a strike equal to the price is neither out nor in the money Assert.AreEqual(chain.Count, otm.Count + itm.Count + chain.Strikes([price]).Count); - var atm = chain.AtTheMoney((decimal)tolerance); + var atm = chain.AtTheMoney((decimal?)tolerance); Assert.AreEqual(atmStrike.HasValue ? 2 * Expiries.Length : 0, atm.Count); Assert.IsTrue(atm.All(x => x.Strike == (decimal)atmStrike)); Assert.Throws(() => chain.AtTheMoney(-1m)); @@ -406,7 +412,8 @@ public void FiltersWorkOnFutureOptionChains() // moneyness against the future price CollectionAssert.AreEquivalent(new[] { 3230m, 3240m }, chain.OutOfTheMoney().CallsOnly().Select(x => x.Strike)); CollectionAssert.AreEquivalent(new[] { 3200m, 3210m, 3220m }, chain.OutOfTheMoney().PutsOnly().Select(x => x.Strike)); - Assert.AreEqual(0, chain.AtTheMoney().Count); + Assert.AreEqual(0, chain.AtTheMoney(0).Count); + CollectionAssert.AreEquivalent(new[] { 3220m, 3220m }, chain.AtTheMoney().Select(x => x.Strike)); CollectionAssert.AreEquivalent(new[] { 3220m, 3220m }, chain.AtTheMoney(5m).Select(x => x.Strike)); // the expiration filters count from the CME date, every ES option is a standard contract From 43b9e25e216fd2ae7ac7d365da174a024ad89753 Mon Sep 17 00:00:00 2001 From: Jhonathan Abreu Date: Fri, 11 Sep 2026 13:40:58 -0400 Subject: [PATCH 13/14] Select every strike within a distance of the underlying price in AtTheMoney The argument, renamed maxStrikeDistance, bounds how far a strike can be from the underlying price, in units of it, for its contracts to be at the money, and every strike within it is selected. Its default, DefaultAtTheMoneyStrikeDistance, is two percent of the price: the nearest strikes on the usual ladders, from one dollar steps on SPY to five on IBM or half a dollar on F. A zero distance is the exact strike, through Strikes. --- ...reOptionChainFiltersRegressionAlgorithm.cs | 7 +++- ...exOptionChainFiltersRegressionAlgorithm.cs | 4 +- .../OptionChainFiltersRegressionAlgorithm.cs | 10 +++-- ...reOptionChainFiltersRegressionAlgorithm.py | 5 ++- ...exOptionChainFiltersRegressionAlgorithm.py | 4 +- .../OptionChainFiltersRegressionAlgorithm.py | 9 ++-- Common/Data/Market/OptionChain.Filters.cs | 24 +++++------ .../Option/IOptionContractFilters.cs | 10 ++--- .../Securities/Option/OptionFilterUniverse.cs | 42 ++++++++++--------- Tests/Common/Data/Market/OptionChainTests.cs | 41 +++++++++--------- 10 files changed, 86 insertions(+), 70 deletions(-) diff --git a/Algorithm.CSharp/FutureOptionChainFiltersRegressionAlgorithm.cs b/Algorithm.CSharp/FutureOptionChainFiltersRegressionAlgorithm.cs index e0981cbf58db..7a1cc5d03374 100644 --- a/Algorithm.CSharp/FutureOptionChainFiltersRegressionAlgorithm.cs +++ b/Algorithm.CSharp/FutureOptionChainFiltersRegressionAlgorithm.cs @@ -62,7 +62,12 @@ public override void Initialize() // Only the put is listed at 3310 AssertStrikes(chain.StrikesAbove(3300m).StrikesBelow(3320m), "StrikesAbove(3300).StrikesBelow(3320)", 3310m); AssertStrikes(chain.StrikesAbove(3300m).StrikesBelow(3320m).CallsOnly(), "StrikesAbove(3300).StrikesBelow(3320).CallsOnly()"); - AssertStrikes(chain.AtTheMoney(), "AtTheMoney()", 3220m, 3220m); + // 2% of the future price, 64 points, reaches the strikes from 3160 to 3280; 5 points only 3220 + var atm = chain.AtTheMoney(); + if (atm.Count == 0 || atm.Count != chain.StrikesAbove(3159m).StrikesBelow(3289m).Count) + { + throw new RegressionTestException($"AtTheMoney(): expected the strikes within 2% of 3223.75 but got {string.Join(", ", atm.Select(x => x.Strike).Distinct())}"); + } AssertStrikes(chain.AtTheMoney(5m), "AtTheMoney(5)", 3220m, 3220m); if (chain.AtTheMoney(0).Count != 0 || chain.Expiration([MarchExpiry]).Count != chain.Count || chain.FarthestExpiration().Count != chain.Count || chain.ExpiringAfter(MarchExpiry).Count != 0 || chain.ZeroDte().Count != 0 diff --git a/Algorithm.CSharp/IndexOptionChainFiltersRegressionAlgorithm.cs b/Algorithm.CSharp/IndexOptionChainFiltersRegressionAlgorithm.cs index 56c8ad76ce7b..5544e2631ff8 100644 --- a/Algorithm.CSharp/IndexOptionChainFiltersRegressionAlgorithm.cs +++ b/Algorithm.CSharp/IndexOptionChainFiltersRegressionAlgorithm.cs @@ -62,8 +62,8 @@ public override void Initialize() } AssertContracts(chain.OutOfTheMoney(), "OutOfTheMoney()", (3800m, OptionRight.Call), (4250m, OptionRight.Call), (3200m, OptionRight.Put)); AssertContracts(chain.InTheMoney(), "InTheMoney()", (3200m, OptionRight.Call), (3700m, OptionRight.Call), (4200m, OptionRight.Put)); - // The closest strike, 3800, is 33.37 points away: within the default 1% of the index, not within 25 points - AssertContracts(chain.AtTheMoney(), "AtTheMoney()", (3800m, OptionRight.Call)); + // 2% of the index, 75 points, reaches the 3700 and 3800 strikes; 50 points only 3800, 25 points none + AssertContracts(chain.AtTheMoney(), "AtTheMoney()", (3700m, OptionRight.Call), (3800m, OptionRight.Call)); AssertContracts(chain.AtTheMoney(50m), "AtTheMoney(50)", (3800m, OptionRight.Call)); AssertContracts(chain.AtTheMoney(25m), "AtTheMoney(25)"); AssertContracts(chain.AtTheMoney(0), "AtTheMoney(0)"); diff --git a/Algorithm.CSharp/OptionChainFiltersRegressionAlgorithm.cs b/Algorithm.CSharp/OptionChainFiltersRegressionAlgorithm.cs index 37b1d8f5a36c..38ebc32f09ae 100644 --- a/Algorithm.CSharp/OptionChainFiltersRegressionAlgorithm.cs +++ b/Algorithm.CSharp/OptionChainFiltersRegressionAlgorithm.cs @@ -20,6 +20,7 @@ using QuantConnect.Data; using QuantConnect.Data.Market; using QuantConnect.Interfaces; +using QuantConnect.Securities; using QuantConnect.Securities.Option; namespace QuantConnect.Algorithm.CSharp @@ -99,12 +100,13 @@ public override void Initialize() { throw new RegressionTestException("Out/in the money filters mismatch"); } - // The closest strike to the 748.54 close is 747.5, within the default 1% tolerance but not within 1 point or exactly at the price + // By default the strikes within 2% of the 748.54 close; 2.5 points reach 747.5 and 750, 1 point reaches none var atm = chain.AtTheMoney(); - if (atm.Count == 0 || atm.Any(x => x.Strike != 747.5m) || atm.Count != chain.Strikes([747.5m]).Count - || chain.AtTheMoney(2.5m).Count != atm.Count || chain.AtTheMoney(1m).Count != 0 || chain.AtTheMoney(0).Count != 0) + var maxDistance = price * OptionFilterUniverse.DefaultAtTheMoneyStrikeDistance; + if (atm.Count == 0 || atm.Count != chain.Count(x => Math.Abs(x.Strike - price) <= maxDistance) || atm.Any(x => Math.Abs(x.Strike - price) > maxDistance) + || chain.AtTheMoney(2.5m).Count != chain.Strikes([747.5m, 750m]).Count || chain.AtTheMoney(1m).Count != 0 || chain.AtTheMoney(0).Count != 0) { - throw new RegressionTestException("Expected AtTheMoney() to select every contract at the 747.50 strike and AtTheMoney(1) none"); + throw new RegressionTestException("Expected AtTheMoney() to select the strikes within 2% of the close, AtTheMoney(2.5) 747.5 and 750, AtTheMoney(1) none"); } // Strike sets and bounds are absolute, unlike the relative Strikes(min, max) diff --git a/Algorithm.Python/FutureOptionChainFiltersRegressionAlgorithm.py b/Algorithm.Python/FutureOptionChainFiltersRegressionAlgorithm.py index bdae906cb8aa..1ec83aca9a82 100644 --- a/Algorithm.Python/FutureOptionChainFiltersRegressionAlgorithm.py +++ b/Algorithm.Python/FutureOptionChainFiltersRegressionAlgorithm.py @@ -47,7 +47,10 @@ def initialize(self): # Only the put is listed at 3310 self._assert_strikes(chain.strikes_above(3300).strikes_below(3320), "strikes_above(3300).strikes_below(3320)", [3310]) self._assert_strikes(chain.strikes_above(3300).strikes_below(3320).calls_only(), "strikes_above(3300).strikes_below(3320).calls_only()", []) - self._assert_strikes(chain.at_the_money(), "at_the_money()", [3220, 3220]) + # 2% of the future price, 64 points, reaches the strikes from 3160 to 3280; 5 points only 3220 + atm = chain.at_the_money() + if atm.count == 0 or atm.count != chain.strikes_above(3159).strikes_below(3289).count: + raise AssertionError(f"at_the_money(): expected the strikes within 2% of 3223.75 but got {sorted(set(x.strike for x in atm))}") self._assert_strikes(chain.at_the_money(5), "at_the_money(5)", [3220, 3220]) if (chain.at_the_money(0).count != 0 or chain.expiration([self.MARCH_EXPIRY]).count != chain.count or chain.farthest_expiration().count != chain.count or chain.expiring_after(self.MARCH_EXPIRY).count != 0 or chain.zero_dte().count != 0 diff --git a/Algorithm.Python/IndexOptionChainFiltersRegressionAlgorithm.py b/Algorithm.Python/IndexOptionChainFiltersRegressionAlgorithm.py index 10f6090a7c9a..ab263ea7b642 100644 --- a/Algorithm.Python/IndexOptionChainFiltersRegressionAlgorithm.py +++ b/Algorithm.Python/IndexOptionChainFiltersRegressionAlgorithm.py @@ -47,8 +47,8 @@ def initialize(self): raise AssertionError(f"Expected the 6 SPX contracts at 3766.63 but got {chain.count} at {chain.underlying.price}") self._assert_contracts(chain.out_of_the_money(), "out_of_the_money()", [(3800, OptionRight.CALL), (4250, OptionRight.CALL), (3200, OptionRight.PUT)]) self._assert_contracts(chain.in_the_money(), "in_the_money()", [(3200, OptionRight.CALL), (3700, OptionRight.CALL), (4200, OptionRight.PUT)]) - # The closest strike, 3800, is 33.37 points away: within the default 1% of the index, not within 25 points - self._assert_contracts(chain.at_the_money(), "at_the_money()", [(3800, OptionRight.CALL)]) + # 2% of the index, 75 points, reaches the 3700 and 3800 strikes; 50 points only 3800, 25 points none + self._assert_contracts(chain.at_the_money(), "at_the_money()", [(3700, OptionRight.CALL), (3800, OptionRight.CALL)]) self._assert_contracts(chain.at_the_money(50), "at_the_money(50)", [(3800, OptionRight.CALL)]) self._assert_contracts(chain.at_the_money(25), "at_the_money(25)", []) self._assert_contracts(chain.at_the_money(0), "at_the_money(0)", []) diff --git a/Algorithm.Python/OptionChainFiltersRegressionAlgorithm.py b/Algorithm.Python/OptionChainFiltersRegressionAlgorithm.py index 36627708dcda..80c8b9494df3 100644 --- a/Algorithm.Python/OptionChainFiltersRegressionAlgorithm.py +++ b/Algorithm.Python/OptionChainFiltersRegressionAlgorithm.py @@ -69,11 +69,12 @@ def initialize(self): or any((x.strike <= price if x.right == OptionRight.CALL else x.strike >= price) for x in otm) or any((x.strike >= price if x.right == OptionRight.CALL else x.strike <= price) for x in itm)): raise AssertionError("Out/in the money filters mismatch") - # The closest strike to the 748.54 close is 747.5, within the default 1% tolerance but not within 1 point or exactly at the price + # By default the strikes within 2% of the 748.54 close; 2.5 points reach 747.5 and 750, 1 point reaches none atm = chain.atm() - if (atm.count == 0 or any(x.strike != 747.5 for x in atm) or atm.count != chain.strikes([747.5]).count - or chain.atm(2.5).count != atm.count or chain.atm(1).count != 0 or chain.atm(0).count != 0): - raise AssertionError("Expected atm() to select every contract at the 747.50 strike and atm(1) none") + max_distance = price * 0.02 + if (atm.count == 0 or atm.count != sum(1 for x in chain if abs(x.strike - price) <= max_distance) or any(abs(x.strike - price) > max_distance for x in atm) + or chain.atm(2.5).count != chain.strikes([747.5, 750]).count or chain.atm(1).count != 0 or chain.atm(0).count != 0): + raise AssertionError("Expected atm() to select the strikes within 2% of the close, atm(2.5) 747.5 and 750, atm(1) none") # Strike sets and bounds are absolute, unlike the relative strikes(min, max) strikes = chain.strikes([745, 750]) diff --git a/Common/Data/Market/OptionChain.Filters.cs b/Common/Data/Market/OptionChain.Filters.cs index b363d5fde37b..8626df5d5b5e 100644 --- a/Common/Data/Market/OptionChain.Filters.cs +++ b/Common/Data/Market/OptionChain.Filters.cs @@ -201,28 +201,28 @@ public OptionChain ITM() } /// - /// Selects the contracts at the money: the ones at the strike closest to the underlying price, the lower strike on ties, - /// when that strike is within the tolerance. Same as + /// Selects the contracts at the money: the ones with strikes within the given distance of the underlying price. + /// Same as /// - /// The largest distance between the closest strike and the underlying price for the strike to be - /// at the money, in units of the underlying price. Zero requires a strike equal to the underlying price. Null, the default, - /// allows of the underlying price + /// The largest distance between a strike and the underlying price for its contracts to be at + /// the money, in units of the underlying price. Zero selects only a strike equal to the price. Null, the default, uses + /// of the underlying price /// A new chain with the filter applied, empty when the underlying price is unknown - public OptionChain AtTheMoney(decimal? tolerance = null) + public OptionChain AtTheMoney(decimal? maxStrikeDistance = null) { - return Filter(universe => universe.AtTheMoney(tolerance)); + return Filter(universe => universe.AtTheMoney(maxStrikeDistance)); } /// /// Selects the contracts at the money. Alias for /// - /// The largest distance between the closest strike and the underlying price for the strike to be - /// at the money, in units of the underlying price. Zero requires a strike equal to the underlying price. Null, the default, - /// allows of the underlying price + /// The largest distance between a strike and the underlying price for its contracts to be at + /// the money, in units of the underlying price. Zero selects only a strike equal to the price. Null, the default, uses + /// of the underlying price /// A new chain with the filter applied - public OptionChain ATM(decimal? tolerance = null) + public OptionChain ATM(decimal? maxStrikeDistance = null) { - return AtTheMoney(tolerance); + return AtTheMoney(maxStrikeDistance); } /// diff --git a/Common/Securities/Option/IOptionContractFilters.cs b/Common/Securities/Option/IOptionContractFilters.cs index 0200d9bbf395..a1617e21d5bf 100644 --- a/Common/Securities/Option/IOptionContractFilters.cs +++ b/Common/Securities/Option/IOptionContractFilters.cs @@ -97,16 +97,16 @@ public interface IOptionContractFilters TSelf ITM(); /// - /// Selects the contracts at the strike closest to the underlying price, the lower strike on ties, when that strike - /// is within the tolerance, in units of the underlying price. Zero requires a strike equal to the price, null allows - /// of it + /// Selects the contracts with strikes within the given distance of the underlying price, in units of it, zero only a strike + /// equal to the price; null, the default, uses + /// of the price /// - TSelf AtTheMoney(decimal? tolerance = null); + TSelf AtTheMoney(decimal? maxStrikeDistance = null); /// /// Selects the contracts at the money. Alias for /// - TSelf ATM(decimal? tolerance = null); + TSelf ATM(decimal? maxStrikeDistance = null); /// /// Selects the call contracts diff --git a/Common/Securities/Option/OptionFilterUniverse.cs b/Common/Securities/Option/OptionFilterUniverse.cs index 88c6369590e1..853b92a3ac8f 100644 --- a/Common/Securities/Option/OptionFilterUniverse.cs +++ b/Common/Securities/Option/OptionFilterUniverse.cs @@ -40,9 +40,10 @@ public abstract class BaseOptionFilterUniverse : ContractSecur where TData : ISymbolProvider { /// - /// The distance from the underlying price, as a fraction of it, within which the closest strike is at the money by default + /// The largest distance between a strike and the underlying price, as a fraction of the price, for its contracts to be at the + /// money by default: it reaches the strikes next to the price on the usual ladders, from $1 strikes on SPY to $5 strikes on IBM or $0.50 on F /// - public const decimal DefaultAtTheMoneyTolerance = 0.01m; + public const decimal DefaultAtTheMoneyStrikeDistance = 0.02m; // Fields used in relative strikes filter private List _uniqueStrikes; @@ -372,39 +373,42 @@ public TUniverse ITM() } /// - /// Applies filter selecting the contracts at the money: the ones at the strike closest to the underlying price, the lower - /// strike on ties, when that strike is within the tolerance. Selects nothing when the underlying price is unknown + /// Applies filter selecting the contracts at the money: the ones with strikes within the given distance of the underlying price. + /// Selects nothing when the underlying price is unknown /// - /// The largest distance between the closest strike and the underlying price for the strike to be - /// at the money, in units of the underlying price. Zero requires a strike equal to the underlying price. Null, the default, - /// allows of the underlying price + /// The largest distance between a strike and the underlying price for its contracts to be at + /// the money, in units of the underlying price. Zero selects only a strike equal to the price. Null, the default, uses + /// of the underlying price /// Universe with filter applied - public TUniverse AtTheMoney(decimal? tolerance = null) + public TUniverse AtTheMoney(decimal? maxStrikeDistance = null) { - if (tolerance < 0) + if (maxStrikeDistance < 0) { - throw new ArgumentException($"AtTheMoney(): {nameof(tolerance)} must not be negative"); + throw new ArgumentException($"AtTheMoney(): {nameof(maxStrikeDistance)} must not be negative"); } if (!TryGetUnderlyingPrice(out var price)) { return Empty(); } - // the price is in strike units, see SymbolProperties.StrikeMultiplier, so an explicit tolerance is scaled the same way - var maxDistance = tolerance.HasValue ? tolerance.Value / _underlyingScaleFactor : price * DefaultAtTheMoneyTolerance; - var strike = GetClosestStrike(AllSymbols, price); - return Math.Abs(strike - price) <= maxDistance ? Strikes([strike]) : Empty(); + // the price is in strike units, see SymbolProperties.StrikeMultiplier, so an explicit distance is scaled the same way + var maxDistance = maxStrikeDistance.HasValue ? maxStrikeDistance.Value / _underlyingScaleFactor : price * DefaultAtTheMoneyStrikeDistance; + if (maxDistance == 0) + { + return Strikes([price]); + } + return Contracts(contracts => contracts.Where(x => Math.Abs(x.Symbol.ID.StrikePrice - price) <= maxDistance)); } /// /// Applies filter selecting the contracts at the money. Alias for /// - /// The largest distance between the closest strike and the underlying price for the strike to be - /// at the money, in units of the underlying price. Zero requires a strike equal to the underlying price. Null, the default, - /// allows of the underlying price + /// The largest distance between a strike and the underlying price for its contracts to be at + /// the money, in units of the underlying price. Zero selects only a strike equal to the price. Null, the default, uses + /// of the underlying price /// Universe with filter applied - public TUniverse ATM(decimal? tolerance = null) + public TUniverse ATM(decimal? maxStrikeDistance = null) { - return AtTheMoney(tolerance); + return AtTheMoney(maxStrikeDistance); } /// diff --git a/Tests/Common/Data/Market/OptionChainTests.cs b/Tests/Common/Data/Market/OptionChainTests.cs index 916756c97b99..a862eb659fb1 100644 --- a/Tests/Common/Data/Market/OptionChainTests.cs +++ b/Tests/Common/Data/Market/OptionChainTests.cs @@ -348,22 +348,22 @@ public void StrategyFiltersValidateArgumentsLikeTheUniverseFilters() } } - // By default the closest strike is at the money within 1% of the price: 1 at 101, not 1.25 at 101.25 or 103.75 - [TestCase(100, null, 100)] - [TestCase(101, null, 100)] - [TestCase(101.25, null, null)] - [TestCase(103.75, null, null)] - // A zero tolerance requires a strike equal to the price - [TestCase(100, 0, 100)] - [TestCase(101, 0, null)] - // Otherwise the closest strike within the tolerance - [TestCase(101, 1, 100)] - [TestCase(101, 0.5, null)] - [TestCase(103.75, 1.25, 102.5)] - // Equidistant between 100 and 102.5: the lower strike wins, unlike Strikes(0, 0) - [TestCase(101.25, 1.25, 100)] - [TestCase(101.25, 1, null)] - public void MoneynessFiltersSplitTheStrikesAroundTheUnderlyingPrice(double underlyingPrice, double? tolerance, double? atmStrike) + // By default every strike within 2% of the price: 2 at 100 reaches only 100, 2.15 at 107.5 reaches neither 105 nor 110 + [TestCase(100, null, new[] { 100.0 })] + [TestCase(101, null, new[] { 100.0, 102.5 })] + [TestCase(103.75, null, new[] { 102.5, 105.0 })] + [TestCase(107.5, null, new double[0])] + // A zero distance requires a strike equal to the price + [TestCase(100, 0, new[] { 100.0 })] + [TestCase(101, 0, new double[0])] + // Otherwise every strike within the distance + [TestCase(101, 1, new[] { 100.0 })] + [TestCase(101, 0.5, new double[0])] + [TestCase(103.75, 1.25, new[] { 102.5, 105.0 })] + [TestCase(101.25, 1.25, new[] { 100.0, 102.5 })] + [TestCase(101.25, 1, new double[0])] + [TestCase(101, 5, new[] { 97.5, 100.0, 102.5, 105.0 })] + public void MoneynessFiltersSplitTheStrikesAroundTheUnderlyingPrice(double underlyingPrice, double? maxStrikeDistance, double[] atmStrikes) { var price = (decimal)underlyingPrice; var (data, _) = CreateUniverseData(Date, price, Expiries, Strikes); @@ -379,9 +379,9 @@ public void MoneynessFiltersSplitTheStrikesAroundTheUnderlyingPrice(double under // a strike equal to the price is neither out nor in the money Assert.AreEqual(chain.Count, otm.Count + itm.Count + chain.Strikes([price]).Count); - var atm = chain.AtTheMoney((decimal?)tolerance); - Assert.AreEqual(atmStrike.HasValue ? 2 * Expiries.Length : 0, atm.Count); - Assert.IsTrue(atm.All(x => x.Strike == (decimal)atmStrike)); + var atm = chain.AtTheMoney((decimal?)maxStrikeDistance); + Assert.AreEqual(atmStrikes.Length * 2 * Expiries.Length, atm.Count); + CollectionAssert.AreEquivalent(atmStrikes.Select(x => (decimal)x), atm.Select(x => x.Strike).Distinct()); Assert.Throws(() => chain.AtTheMoney(-1m)); Assert.Throws(() => CreateUniverse().AtTheMoney(-1m)); } @@ -413,7 +413,8 @@ public void FiltersWorkOnFutureOptionChains() CollectionAssert.AreEquivalent(new[] { 3230m, 3240m }, chain.OutOfTheMoney().CallsOnly().Select(x => x.Strike)); CollectionAssert.AreEquivalent(new[] { 3200m, 3210m, 3220m }, chain.OutOfTheMoney().PutsOnly().Select(x => x.Strike)); Assert.AreEqual(0, chain.AtTheMoney(0).Count); - CollectionAssert.AreEquivalent(new[] { 3220m, 3220m }, chain.AtTheMoney().Select(x => x.Strike)); + // 2% of 3223.75 reaches every strike listed, 5 points only 3220 + Assert.AreEqual(10, chain.AtTheMoney().Count); CollectionAssert.AreEquivalent(new[] { 3220m, 3220m }, chain.AtTheMoney(5m).Select(x => x.Strike)); // the expiration filters count from the CME date, every ES option is a standard contract From 467d5f4bed2d935b16832e0e62cf630cfb490580 Mon Sep 17 00:00:00 2001 From: Jhonathan Abreu Date: Fri, 11 Sep 2026 16:32:33 -0400 Subject: [PATCH 14/14] Select the strikes on either side of the underlying price in AtTheMoney by default Without a distance, AtTheMoney selects the highest strike at or below the underlying price and the lowest at or above it, each only when it is within OptionFilterUniverse.DefaultAtTheMoneyStrikeDistance of the price, a settable percentage that defaults to 2%. A percentage range was too wide on fine ladders like SPXW, where 2% spans dozens of strikes, while the bracketing strikes give the one or two contracts traders call at the money on every ladder. An explicit distance still selects every strike within it. --- ...reOptionChainFiltersRegressionAlgorithm.cs | 8 +- ...exOptionChainFiltersRegressionAlgorithm.cs | 2 +- .../OptionChainFiltersRegressionAlgorithm.cs | 11 +-- ...reOptionChainFiltersRegressionAlgorithm.py | 6 +- ...exOptionChainFiltersRegressionAlgorithm.py | 2 +- .../OptionChainFiltersRegressionAlgorithm.py | 11 +-- Common/Data/Market/OptionChain.Filters.cs | 14 ++-- .../Option/IOptionContractFilters.cs | 4 +- .../Securities/Option/OptionFilterUniverse.cs | 80 +++++++++++++++---- Tests/Common/Data/Market/OptionChainTests.cs | 34 +++++++- 10 files changed, 125 insertions(+), 47 deletions(-) diff --git a/Algorithm.CSharp/FutureOptionChainFiltersRegressionAlgorithm.cs b/Algorithm.CSharp/FutureOptionChainFiltersRegressionAlgorithm.cs index 7a1cc5d03374..fa197fdf129e 100644 --- a/Algorithm.CSharp/FutureOptionChainFiltersRegressionAlgorithm.cs +++ b/Algorithm.CSharp/FutureOptionChainFiltersRegressionAlgorithm.cs @@ -62,12 +62,8 @@ public override void Initialize() // Only the put is listed at 3310 AssertStrikes(chain.StrikesAbove(3300m).StrikesBelow(3320m), "StrikesAbove(3300).StrikesBelow(3320)", 3310m); AssertStrikes(chain.StrikesAbove(3300m).StrikesBelow(3320m).CallsOnly(), "StrikesAbove(3300).StrikesBelow(3320).CallsOnly()"); - // 2% of the future price, 64 points, reaches the strikes from 3160 to 3280; 5 points only 3220 - var atm = chain.AtTheMoney(); - if (atm.Count == 0 || atm.Count != chain.StrikesAbove(3159m).StrikesBelow(3289m).Count) - { - throw new RegressionTestException($"AtTheMoney(): expected the strikes within 2% of 3223.75 but got {string.Join(", ", atm.Select(x => x.Strike).Distinct())}"); - } + // The strikes on either side of 3223.75 are 3220 and 3230; within 5 points only 3220 + AssertStrikes(chain.AtTheMoney(), "AtTheMoney()", 3220m, 3220m, 3230m, 3230m); AssertStrikes(chain.AtTheMoney(5m), "AtTheMoney(5)", 3220m, 3220m); if (chain.AtTheMoney(0).Count != 0 || chain.Expiration([MarchExpiry]).Count != chain.Count || chain.FarthestExpiration().Count != chain.Count || chain.ExpiringAfter(MarchExpiry).Count != 0 || chain.ZeroDte().Count != 0 diff --git a/Algorithm.CSharp/IndexOptionChainFiltersRegressionAlgorithm.cs b/Algorithm.CSharp/IndexOptionChainFiltersRegressionAlgorithm.cs index 5544e2631ff8..cace1d17a085 100644 --- a/Algorithm.CSharp/IndexOptionChainFiltersRegressionAlgorithm.cs +++ b/Algorithm.CSharp/IndexOptionChainFiltersRegressionAlgorithm.cs @@ -62,7 +62,7 @@ public override void Initialize() } AssertContracts(chain.OutOfTheMoney(), "OutOfTheMoney()", (3800m, OptionRight.Call), (4250m, OptionRight.Call), (3200m, OptionRight.Put)); AssertContracts(chain.InTheMoney(), "InTheMoney()", (3200m, OptionRight.Call), (3700m, OptionRight.Call), (4200m, OptionRight.Put)); - // 2% of the index, 75 points, reaches the 3700 and 3800 strikes; 50 points only 3800, 25 points none + // The strikes on either side of 3766.63 are 3700 and 3800, both listed as calls only; 50 points reach 3800, 25 none AssertContracts(chain.AtTheMoney(), "AtTheMoney()", (3700m, OptionRight.Call), (3800m, OptionRight.Call)); AssertContracts(chain.AtTheMoney(50m), "AtTheMoney(50)", (3800m, OptionRight.Call)); AssertContracts(chain.AtTheMoney(25m), "AtTheMoney(25)"); diff --git a/Algorithm.CSharp/OptionChainFiltersRegressionAlgorithm.cs b/Algorithm.CSharp/OptionChainFiltersRegressionAlgorithm.cs index 38ebc32f09ae..6220b922dc27 100644 --- a/Algorithm.CSharp/OptionChainFiltersRegressionAlgorithm.cs +++ b/Algorithm.CSharp/OptionChainFiltersRegressionAlgorithm.cs @@ -100,13 +100,14 @@ public override void Initialize() { throw new RegressionTestException("Out/in the money filters mismatch"); } - // By default the strikes within 2% of the 748.54 close; 2.5 points reach 747.5 and 750, 1 point reaches none + // By default the strikes on either side of the 748.54 close, 747.5 and 750, also reached within 2.5 points but not within 1; + // a chain whose strikes start more than 2% above the close has no strike at the money var atm = chain.AtTheMoney(); - var maxDistance = price * OptionFilterUniverse.DefaultAtTheMoneyStrikeDistance; - if (atm.Count == 0 || atm.Count != chain.Count(x => Math.Abs(x.Strike - price) <= maxDistance) || atm.Any(x => Math.Abs(x.Strike - price) > maxDistance) - || chain.AtTheMoney(2.5m).Count != chain.Strikes([747.5m, 750m]).Count || chain.AtTheMoney(1m).Count != 0 || chain.AtTheMoney(0).Count != 0) + if (atm.Count == 0 || atm.Count != chain.Strikes([747.5m, 750m]).Count || atm.Any(x => x.Strike != 747.5m && x.Strike != 750m) + || chain.AtTheMoney(2.5m).Count != atm.Count || chain.AtTheMoney(1m).Count != 0 || chain.AtTheMoney(0).Count != 0 + || chain.StrikesAbove(price + 20).AtTheMoney().Count != 0) { - throw new RegressionTestException("Expected AtTheMoney() to select the strikes within 2% of the close, AtTheMoney(2.5) 747.5 and 750, AtTheMoney(1) none"); + throw new RegressionTestException("Expected AtTheMoney() to select the 747.5 and 750 strikes, AtTheMoney(1) none"); } // Strike sets and bounds are absolute, unlike the relative Strikes(min, max) diff --git a/Algorithm.Python/FutureOptionChainFiltersRegressionAlgorithm.py b/Algorithm.Python/FutureOptionChainFiltersRegressionAlgorithm.py index 1ec83aca9a82..fadf1e496084 100644 --- a/Algorithm.Python/FutureOptionChainFiltersRegressionAlgorithm.py +++ b/Algorithm.Python/FutureOptionChainFiltersRegressionAlgorithm.py @@ -47,10 +47,8 @@ def initialize(self): # Only the put is listed at 3310 self._assert_strikes(chain.strikes_above(3300).strikes_below(3320), "strikes_above(3300).strikes_below(3320)", [3310]) self._assert_strikes(chain.strikes_above(3300).strikes_below(3320).calls_only(), "strikes_above(3300).strikes_below(3320).calls_only()", []) - # 2% of the future price, 64 points, reaches the strikes from 3160 to 3280; 5 points only 3220 - atm = chain.at_the_money() - if atm.count == 0 or atm.count != chain.strikes_above(3159).strikes_below(3289).count: - raise AssertionError(f"at_the_money(): expected the strikes within 2% of 3223.75 but got {sorted(set(x.strike for x in atm))}") + # The strikes on either side of 3223.75 are 3220 and 3230; within 5 points only 3220 + self._assert_strikes(chain.at_the_money(), "at_the_money()", [3220, 3220, 3230, 3230]) self._assert_strikes(chain.at_the_money(5), "at_the_money(5)", [3220, 3220]) if (chain.at_the_money(0).count != 0 or chain.expiration([self.MARCH_EXPIRY]).count != chain.count or chain.farthest_expiration().count != chain.count or chain.expiring_after(self.MARCH_EXPIRY).count != 0 or chain.zero_dte().count != 0 diff --git a/Algorithm.Python/IndexOptionChainFiltersRegressionAlgorithm.py b/Algorithm.Python/IndexOptionChainFiltersRegressionAlgorithm.py index ab263ea7b642..ca67ee2b55cf 100644 --- a/Algorithm.Python/IndexOptionChainFiltersRegressionAlgorithm.py +++ b/Algorithm.Python/IndexOptionChainFiltersRegressionAlgorithm.py @@ -47,7 +47,7 @@ def initialize(self): raise AssertionError(f"Expected the 6 SPX contracts at 3766.63 but got {chain.count} at {chain.underlying.price}") self._assert_contracts(chain.out_of_the_money(), "out_of_the_money()", [(3800, OptionRight.CALL), (4250, OptionRight.CALL), (3200, OptionRight.PUT)]) self._assert_contracts(chain.in_the_money(), "in_the_money()", [(3200, OptionRight.CALL), (3700, OptionRight.CALL), (4200, OptionRight.PUT)]) - # 2% of the index, 75 points, reaches the 3700 and 3800 strikes; 50 points only 3800, 25 points none + # The strikes on either side of 3766.63 are 3700 and 3800, both listed as calls only; 50 points reach 3800, 25 none self._assert_contracts(chain.at_the_money(), "at_the_money()", [(3700, OptionRight.CALL), (3800, OptionRight.CALL)]) self._assert_contracts(chain.at_the_money(50), "at_the_money(50)", [(3800, OptionRight.CALL)]) self._assert_contracts(chain.at_the_money(25), "at_the_money(25)", []) diff --git a/Algorithm.Python/OptionChainFiltersRegressionAlgorithm.py b/Algorithm.Python/OptionChainFiltersRegressionAlgorithm.py index 80c8b9494df3..11ef3f0c45da 100644 --- a/Algorithm.Python/OptionChainFiltersRegressionAlgorithm.py +++ b/Algorithm.Python/OptionChainFiltersRegressionAlgorithm.py @@ -69,12 +69,13 @@ def initialize(self): or any((x.strike <= price if x.right == OptionRight.CALL else x.strike >= price) for x in otm) or any((x.strike >= price if x.right == OptionRight.CALL else x.strike <= price) for x in itm)): raise AssertionError("Out/in the money filters mismatch") - # By default the strikes within 2% of the 748.54 close; 2.5 points reach 747.5 and 750, 1 point reaches none + # By default the strikes on either side of the 748.54 close, 747.5 and 750, also reached within 2.5 points but not within 1; + # a chain whose strikes start more than 2% above the close has no strike at the money atm = chain.atm() - max_distance = price * 0.02 - if (atm.count == 0 or atm.count != sum(1 for x in chain if abs(x.strike - price) <= max_distance) or any(abs(x.strike - price) > max_distance for x in atm) - or chain.atm(2.5).count != chain.strikes([747.5, 750]).count or chain.atm(1).count != 0 or chain.atm(0).count != 0): - raise AssertionError("Expected atm() to select the strikes within 2% of the close, atm(2.5) 747.5 and 750, atm(1) none") + if (atm.count == 0 or atm.count != chain.strikes([747.5, 750]).count or any(x.strike != 747.5 and x.strike != 750 for x in atm) + or chain.atm(2.5).count != atm.count or chain.atm(1).count != 0 or chain.atm(0).count != 0 + or chain.strikes_above(price + 20).atm().count != 0): + raise AssertionError("Expected atm() to select the 747.5 and 750 strikes, atm(1) none") # Strike sets and bounds are absolute, unlike the relative strikes(min, max) strikes = chain.strikes([745, 750]) diff --git a/Common/Data/Market/OptionChain.Filters.cs b/Common/Data/Market/OptionChain.Filters.cs index 8626df5d5b5e..19b4c0179931 100644 --- a/Common/Data/Market/OptionChain.Filters.cs +++ b/Common/Data/Market/OptionChain.Filters.cs @@ -201,12 +201,13 @@ public OptionChain ITM() } /// - /// Selects the contracts at the money: the ones with strikes within the given distance of the underlying price. - /// Same as + /// Selects the contracts at the money: the ones with strikes within the given distance of the underlying price, or by default + /// the ones at the strikes on either side of it. Same as /// /// The largest distance between a strike and the underlying price for its contracts to be at - /// the money, in units of the underlying price. Zero selects only a strike equal to the price. Null, the default, uses - /// of the underlying price + /// the money, in units of the underlying price. Zero selects only a strike equal to the price. Null, the default, selects the + /// strikes on either side of the price, the highest at or below it and the lowest at or above it, each only when it is within + /// the percentage of the price given by /// A new chain with the filter applied, empty when the underlying price is unknown public OptionChain AtTheMoney(decimal? maxStrikeDistance = null) { @@ -217,8 +218,9 @@ public OptionChain AtTheMoney(decimal? maxStrikeDistance = null) /// Selects the contracts at the money. Alias for /// /// The largest distance between a strike and the underlying price for its contracts to be at - /// the money, in units of the underlying price. Zero selects only a strike equal to the price. Null, the default, uses - /// of the underlying price + /// the money, in units of the underlying price. Zero selects only a strike equal to the price. Null, the default, selects the + /// strikes on either side of the price, the highest at or below it and the lowest at or above it, each only when it is within + /// the percentage of the price given by /// A new chain with the filter applied public OptionChain ATM(decimal? maxStrikeDistance = null) { diff --git a/Common/Securities/Option/IOptionContractFilters.cs b/Common/Securities/Option/IOptionContractFilters.cs index a1617e21d5bf..ae24dc316f28 100644 --- a/Common/Securities/Option/IOptionContractFilters.cs +++ b/Common/Securities/Option/IOptionContractFilters.cs @@ -98,8 +98,8 @@ public interface IOptionContractFilters /// /// Selects the contracts with strikes within the given distance of the underlying price, in units of it, zero only a strike - /// equal to the price; null, the default, uses - /// of the price + /// equal to the price; null, the default, the strikes on either side of the price, each within the percentage of it + /// given by /// TSelf AtTheMoney(decimal? maxStrikeDistance = null); diff --git a/Common/Securities/Option/OptionFilterUniverse.cs b/Common/Securities/Option/OptionFilterUniverse.cs index 853b92a3ac8f..0d11f6b79a5a 100644 --- a/Common/Securities/Option/OptionFilterUniverse.cs +++ b/Common/Securities/Option/OptionFilterUniverse.cs @@ -39,12 +39,6 @@ public abstract class BaseOptionFilterUniverse : ContractSecur where TUniverse : BaseOptionFilterUniverse where TData : ISymbolProvider { - /// - /// The largest distance between a strike and the underlying price, as a fraction of the price, for its contracts to be at the - /// money by default: it reaches the strikes next to the price on the usual ladders, from $1 strikes on SPY to $5 strikes on IBM or $0.50 on F - /// - public const decimal DefaultAtTheMoneyStrikeDistance = 0.02m; - // Fields used in relative strikes filter private List _uniqueStrikes; private bool _refreshUniqueStrikes; @@ -373,12 +367,13 @@ public TUniverse ITM() } /// - /// Applies filter selecting the contracts at the money: the ones with strikes within the given distance of the underlying price. - /// Selects nothing when the underlying price is unknown + /// Applies filter selecting the contracts at the money: the ones with strikes within the given distance of the underlying price, + /// or by default the ones at the strikes on either side of it. Selects nothing when the underlying price is unknown /// /// The largest distance between a strike and the underlying price for its contracts to be at - /// the money, in units of the underlying price. Zero selects only a strike equal to the price. Null, the default, uses - /// of the underlying price + /// the money, in units of the underlying price. Zero selects only a strike equal to the price. Null, the default, selects the + /// strikes on either side of the price, the highest at or below it and the lowest at or above it, each only when it is within + /// the percentage of the price given by /// Universe with filter applied public TUniverse AtTheMoney(decimal? maxStrikeDistance = null) { @@ -390,8 +385,12 @@ public TUniverse AtTheMoney(decimal? maxStrikeDistance = null) { return Empty(); } - // the price is in strike units, see SymbolProperties.StrikeMultiplier, so an explicit distance is scaled the same way - var maxDistance = maxStrikeDistance.HasValue ? maxStrikeDistance.Value / _underlyingScaleFactor : price * DefaultAtTheMoneyStrikeDistance; + if (!maxStrikeDistance.HasValue) + { + return Strikes(GetBracketingStrikes(price)); + } + // the price is in strike units, see SymbolProperties.StrikeMultiplier, so the distance is scaled the same way + var maxDistance = maxStrikeDistance.Value / _underlyingScaleFactor; if (maxDistance == 0) { return Strikes([price]); @@ -403,8 +402,9 @@ public TUniverse AtTheMoney(decimal? maxStrikeDistance = null) /// Applies filter selecting the contracts at the money. Alias for /// /// The largest distance between a strike and the underlying price for its contracts to be at - /// the money, in units of the underlying price. Zero selects only a strike equal to the price. Null, the default, uses - /// of the underlying price + /// the money, in units of the underlying price. Zero selects only a strike equal to the price. Null, the default, selects the + /// strikes on either side of the price, the highest at or below it and the lowest at or above it, each only when it is within + /// the percentage of the price given by /// Universe with filter applied public TUniverse ATM(decimal? maxStrikeDistance = null) { @@ -1264,6 +1264,39 @@ private decimal GetStrike(IEnumerable symbols, decimal strikeFromAtm) return GetClosestStrike(symbols, Underlying.Price + strikeFromAtm); } + /// + /// Gets the highest strike at or below the price and the lowest at or above it, each only when it is within the percentage + /// of the price given by , one when they coincide + /// + private List GetBracketingStrikes(decimal price) + { + decimal? below = null; + decimal? above = null; + foreach (var strike in AllSymbols.Select(x => x.ID.StrikePrice)) + { + if (strike <= price && (below == null || strike > below)) + { + below = strike; + } + if (strike >= price && (above == null || strike < above)) + { + above = strike; + } + } + + var maxDistance = price * OptionFilterUniverse.DefaultAtTheMoneyStrikeDistance; + var strikes = new List(2); + if (below != null && price - below <= maxDistance) + { + strikes.Add(below.Value); + } + if (above != null && above != below && above - price <= maxDistance) + { + strikes.Add(above.Value); + } + return strikes; + } + /// /// Gets the strike closest to the target price, the lower one on ties, or decimal.MaxValue when there are no symbols /// @@ -1291,8 +1324,27 @@ private void ValidateSecurityTypeForSupportedFilters(string filterName) /// public class OptionFilterUniverse : BaseOptionFilterUniverse { + private static decimal _defaultAtTheMoneyStrikeDistance = 0.02m; + private readonly Option.Option _option; + /// + /// How far from the underlying price, as a percentage of it, a strike on either side can be and still count as at the money + /// by default in . 0.02, 2%, unless changed + /// + public static decimal DefaultAtTheMoneyStrikeDistance + { + get => _defaultAtTheMoneyStrikeDistance; + set + { + if (value < 0) + { + throw new ArgumentException($"{nameof(DefaultAtTheMoneyStrikeDistance)} must not be negative"); + } + _defaultAtTheMoneyStrikeDistance = value; + } + } + /// /// The option exchange hours /// diff --git a/Tests/Common/Data/Market/OptionChainTests.cs b/Tests/Common/Data/Market/OptionChainTests.cs index a862eb659fb1..a2456cc7bab2 100644 --- a/Tests/Common/Data/Market/OptionChainTests.cs +++ b/Tests/Common/Data/Market/OptionChainTests.cs @@ -348,11 +348,16 @@ public void StrategyFiltersValidateArgumentsLikeTheUniverseFilters() } } - // By default every strike within 2% of the price: 2 at 100 reaches only 100, 2.15 at 107.5 reaches neither 105 nor 110 + // By default the strikes on either side of the price that are within 2% of it: one when the price is a strike, one when the + // other side is too far (97.5 from 95.5), none when both are (105 and 110 from 107.5) or the price is outside the strikes [TestCase(100, null, new[] { 100.0 })] [TestCase(101, null, new[] { 100.0, 102.5 })] [TestCase(103.75, null, new[] { 102.5, 105.0 })] + [TestCase(95.5, null, new[] { 95.0 })] [TestCase(107.5, null, new double[0])] + [TestCase(110, null, new[] { 110.0 })] + [TestCase(120, null, new double[0])] + [TestCase(80, null, new double[0])] // A zero distance requires a strike equal to the price [TestCase(100, 0, new[] { 100.0 })] [TestCase(101, 0, new double[0])] @@ -413,8 +418,8 @@ public void FiltersWorkOnFutureOptionChains() CollectionAssert.AreEquivalent(new[] { 3230m, 3240m }, chain.OutOfTheMoney().CallsOnly().Select(x => x.Strike)); CollectionAssert.AreEquivalent(new[] { 3200m, 3210m, 3220m }, chain.OutOfTheMoney().PutsOnly().Select(x => x.Strike)); Assert.AreEqual(0, chain.AtTheMoney(0).Count); - // 2% of 3223.75 reaches every strike listed, 5 points only 3220 - Assert.AreEqual(10, chain.AtTheMoney().Count); + // the strikes on either side of 3223.75, and within 5 points only 3220 + CollectionAssert.AreEquivalent(new[] { 3220m, 3220m, 3230m, 3230m }, chain.AtTheMoney().Select(x => x.Strike)); CollectionAssert.AreEquivalent(new[] { 3220m, 3220m }, chain.AtTheMoney(5m).Select(x => x.Strike)); // the expiration filters count from the CME date, every ES option is a standard contract @@ -445,6 +450,29 @@ public void ContractsCountTheDaysToTheirExpiration() Assert.AreEqual(expected[3], chain.FarthestExpiration().First().DaysToExpiry); } + [Test] + public void DefaultAtTheMoneyStrikeDistanceIsConfigurable() + { + var chain = CreateChain(); + var original = OptionFilterUniverse.DefaultAtTheMoneyStrikeDistance; + try + { + // at 101 the strikes on either side are 1 and 1.5 away: within 2%, not within 0.5%, only the lower within 1.2% + CollectionAssert.AreEquivalent(new[] { 100m, 102.5m }, chain.AtTheMoney().Select(x => x.Strike).Distinct()); + OptionFilterUniverse.DefaultAtTheMoneyStrikeDistance = 0.005m; + Assert.AreEqual(0, chain.AtTheMoney().Count); + Assert.AreEqual(0, CreateUniverse().AtTheMoney().Count); + OptionFilterUniverse.DefaultAtTheMoneyStrikeDistance = 0.012m; + CollectionAssert.AreEquivalent(new[] { 100m }, chain.AtTheMoney().Select(x => x.Strike).Distinct()); + CollectionAssert.AreEquivalent(new[] { 100m }, CreateUniverse().AtTheMoney().Select(x => x.Symbol.ID.StrikePrice).Distinct()); + Assert.Throws(() => OptionFilterUniverse.DefaultAtTheMoneyStrikeDistance = -0.01m); + } + finally + { + OptionFilterUniverse.DefaultAtTheMoneyStrikeDistance = original; + } + } + [Test] public void MoneynessFiltersSelectNothingWithoutUnderlyingPrice() {