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