From 89a598af489ded8eb3a3f24966c729277680aa6e Mon Sep 17 00:00:00 2001 From: LouisSzeto Date: Sat, 6 Jan 2024 15:57:59 +0800 Subject: [PATCH] Add unit tests --- Tests/Indicators/ImpliedVolatilityTests.cs | 359 +++++++++++++++++++++ 1 file changed, 359 insertions(+) create mode 100644 Tests/Indicators/ImpliedVolatilityTests.cs diff --git a/Tests/Indicators/ImpliedVolatilityTests.cs b/Tests/Indicators/ImpliedVolatilityTests.cs new file mode 100644 index 000000000000..478b8c691056 --- /dev/null +++ b/Tests/Indicators/ImpliedVolatilityTests.cs @@ -0,0 +1,359 @@ +/* + * 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.Globalization; +using System.IO; +using System.Linq; +using NUnit.Framework; +using QuantConnect.Data.Consolidators; +using QuantConnect.Data.Market; +using QuantConnect.Indicators; + +namespace QuantConnect.Tests.Indicators +{ + [TestFixture] + public class ImpliedVolatilityTests : CommonIndicatorTests + { + protected override string TestColumnName => "ImpliedVolatility"; + + private DateTime _reference = new DateTime(2022, 9, 1, 10, 0, 0); + private Symbol _symbol; + private Symbol _underlying; + + protected override IndicatorBase CreateIndicator() + { + var indicator = new ImpliedVolatility("testImpliedVolatilityIndicator", _symbol, 0.04m); + return indicator; + } + + [SetUp] + public void SetUp() + { + _symbol = Symbol.CreateOption("SPY", Market.USA, OptionStyle.American, OptionRight.Call, 450m, new DateTime(2023, 9, 1)); + _underlying = _symbol.Underlying; + } + + [TestCase("SPX230811C04300000")] + [TestCase("SPX230811C04500000")] + [TestCase("SPX230811C04700000")] + [TestCase("SPX230811P04300000")] + [TestCase("SPX230811P04500000")] + [TestCase("SPX230811P04700000")] + [TestCase("SPX230901C04300000")] + [TestCase("SPX230901C04500000")] + [TestCase("SPX230901C04700000")] + [TestCase("SPX230901P04300000")] + [TestCase("SPX230901P04500000")] + [TestCase("SPX230901P04700000")] + [TestCase("SPY230811C00430000")] + [TestCase("SPY230811C00450000")] + [TestCase("SPY230811C00470000")] + [TestCase("SPY230811P00430000")] + [TestCase("SPY230811P00450000")] + [TestCase("SPY230811P00470000")] + [TestCase("SPY230901C00430000")] + [TestCase("SPY230901C00450000")] + [TestCase("SPY230901C00470000")] + [TestCase("SPY230901P00430000")] + [TestCase("SPY230901P00450000")] + [TestCase("SPY230901P00470000")] + public void ComparesAgainstExternalData(string fileName) + { + var path = Path.Combine("TestData", "greeks", $"{fileName}.csv"); + var symbol = ParseOptionSymbol(fileName); + var underlying = symbol.Underlying; + + var indicator = new ImpliedVolatility(symbol, 0.04m); + RunTestIndicator(path, indicator, symbol, underlying); + } + + [Test] + public override void ComparesAgainstExternalData() + { + // Not used + } + + [TestCase("SPX230811C04300000")] + [TestCase("SPX230811C04500000")] + [TestCase("SPX230811C04700000")] + [TestCase("SPX230811P04300000")] + [TestCase("SPX230811P04500000")] + [TestCase("SPX230811P04700000")] + [TestCase("SPX230901C04300000")] + [TestCase("SPX230901C04500000")] + [TestCase("SPX230901C04700000")] + [TestCase("SPX230901P04300000")] + [TestCase("SPX230901P04500000")] + [TestCase("SPX230901P04700000")] + [TestCase("SPY230811C00430000")] + [TestCase("SPY230811C00450000")] + [TestCase("SPY230811C00470000")] + [TestCase("SPY230811P00430000")] + [TestCase("SPY230811P00450000")] + [TestCase("SPY230811P00470000")] + [TestCase("SPY230901C00430000")] + [TestCase("SPY230901C00450000")] + [TestCase("SPY230901C00470000")] + [TestCase("SPY230901P00430000")] + [TestCase("SPY230901P00450000")] + [TestCase("SPY230901P00470000")] + public void ComparesAgainstExternalDataAfterReset(string fileName) + { + var path = Path.Combine("TestData", "greeks", $"{fileName}.csv"); + var symbol = ParseOptionSymbol(fileName); + var underlying = symbol.Underlying; + + var indicator = new ImpliedVolatility(symbol, 0.04m); + RunTestIndicator(path, indicator, symbol, underlying); + + indicator.Reset(); + RunTestIndicator(path, indicator, symbol, underlying); + } + + [Test] + public override void ComparesAgainstExternalDataAfterReset() + { + // Not used + } + + [TestCase(27.50, 450.0, OptionRight.Call, 60, 0.098)] + [TestCase(29.35, 450.0, OptionRight.Put, 60, 0.110)] + [TestCase(37.86, 470.0, OptionRight.Call, 60, 0.044)] + [TestCase(5.74, 470.0, OptionRight.Put, 60, 0.01)] // Volatility of deep OTM American put option will not converge in CRR model + [TestCase(3.44, 430.0, OptionRight.Call, 60, 0.027)] + [TestCase(40.13, 430.0, OptionRight.Put, 60, 0.241)] + [TestCase(17.74, 450.0, OptionRight.Call, 180, 0.022)] + [TestCase(19.72, 450.0, OptionRight.Put, 180, 0.046)] + [TestCase(38.45, 470.0, OptionRight.Call, 180, 0.057)] + [TestCase(0.43, 470.0, OptionRight.Put, 180, 0.01)] // Volatility of deep OTM American put option will not converge in CRR model + [TestCase(1.73, 430.0, OptionRight.Call, 180, 0.016)] + [TestCase(12.46, 430.0, OptionRight.Put, 180, 0.079)] + public void ComparesIVOnCRRModel(decimal price, decimal spotPrice, OptionRight right, int expiry, double refIV) + { + // Under CRR framework + var symbol = Symbol.CreateOption("SPY", Market.USA, OptionStyle.American, right, 450m, _reference.AddDays(expiry)); + var indicator = new ImpliedVolatility(_symbol, 0.04m, binomial: true); + + var optionTradeBar = new TradeBar(_reference, _symbol, price, price, price, price, 0m); + var spotTradeBar = new TradeBar(_reference, _underlying, spotPrice, spotPrice, spotPrice, spotPrice, 0m); + indicator.Update(optionTradeBar); + indicator.Update(spotTradeBar); + + Assert.AreEqual(refIV, (double)indicator.Current.Value, 0.03d); + } + + private Symbol ParseOptionSymbol(string fileName) + { + var ticker = fileName.Substring(0, 3); + var expiry = DateTime.ParseExact(fileName.Substring(3, 6), "yyMMdd", CultureInfo.InvariantCulture); + var right = fileName[9] == 'C' ? OptionRight.Call : OptionRight.Put; + var strike = Parse.Decimal(fileName.Substring(10, 8)) / 1000m; + var style = ticker == "SPY" ? OptionStyle.American : OptionStyle.European; + + return Symbol.CreateOption(ticker, Market.USA, style, right, strike, expiry); + } + + private void RunTestIndicator(string path, ImpliedVolatility indicator, Symbol symbol, Symbol underlying) + { + foreach (var line in File.ReadAllLines(path).Skip(1)) + { + var items = line.Split(','); + + var time = DateTime.ParseExact(items[0], "yyyyMMdd HH:mm:ss.ffffff", CultureInfo.InvariantCulture); + var price = Parse.Decimal(items[1]); + var spotPrice = Parse.Decimal(items[^1]); + var refIV = Parse.Double(items[2]); + + var optionTradeBar = new TradeBar(time.AddSeconds(-1), symbol, price, price, price, price, 0m, TimeSpan.FromSeconds(1)); + var spotTradeBar = new TradeBar(time.AddSeconds(-1), underlying, spotPrice, spotPrice, spotPrice, spotPrice, 0m, TimeSpan.FromSeconds(1)); + indicator.Update(optionTradeBar); + indicator.Update(spotTradeBar); + + // We're not sure IB's parameters and models, we'll accept a larger error from far OTM/ITM & close-to-expiry option + var acceptRange = Math.Max(0.03m, Math.Abs(symbol.ID.StrikePrice - spotPrice) / spotPrice * 30 / (decimal)(symbol.ID.Date - time).TotalDays); + Assert.AreEqual(refIV, (double)indicator.Current.Value, (double)acceptRange); + } + } + + [Test] + public override void ResetsProperly() + { + var indicator = new ImpliedVolatility(_symbol, 0.04m); + + for (var i = 0; i < 5; i++) + { + var price = 500m; + var optionPrice = Math.Max(price - 450, 0) * 1.1m; + + indicator.Update(new TradeBar() { Symbol = _symbol, Low = optionPrice, High = optionPrice, Volume = 100, Close = optionPrice, Time = _reference.AddDays(1 + i) }); + indicator.Update(new TradeBar() { Symbol = _underlying, Low = price, High = price, Volume = 100, Close = price, Time = _reference.AddDays(1 + i) }); + } + + Assert.IsTrue(indicator.IsReady); + + indicator.Reset(); + + TestHelper.AssertIndicatorIsInDefaultState(indicator); + } + + [Test] + public override void TimeMovesForward() + { + var indicator = CreateIndicator(); + + for (var i = 10; i > 0; i--) + { + var price = 500m; + var optionPrice = Math.Max(price - 450, 0) * 1.1m; + + indicator.Update(new TradeBar() { Symbol = _symbol, Low = optionPrice, High = optionPrice, Volume = 100, Close = optionPrice, Time = _reference.AddDays(1 + i) }); + indicator.Update(new TradeBar() { Symbol = _underlying, Low = price, High = price, Volume = 100, Close = price, Time = _reference.AddDays(1 + i) }); + } + + Assert.AreEqual(2, indicator.Samples); + } + + [Test] + public override void WarmsUpProperly() + { + var period = 5; + var indicator = new ImpliedVolatility("testImpliedVolatilityIndicator", _symbol, period: period); + var warmUpPeriod = (indicator as IIndicatorWarmUpPeriodProvider)?.WarmUpPeriod; + + if (!warmUpPeriod.HasValue) + { + Assert.Ignore($"{indicator.Name} is not IIndicatorWarmUpPeriodProvider"); + return; + } + + // warmup period is 5 + 1 + for (var i = 1; i <= warmUpPeriod.Value; i++) + { + var time = _reference.AddDays(i); + var price = 500m; + var optionPrice = Math.Max(price - 450, 0) * 1.1m; + + indicator.Update(new TradeBar() { Symbol = _symbol, Low = optionPrice, High = optionPrice, Volume = 100, Close = optionPrice, Time = time }); + + Assert.IsFalse(indicator.IsReady); + + indicator.Update(new TradeBar() { Symbol = _underlying, Low = price, High = price, Volume = 100, Close = price, Time = time }); + + // At least 2 days data for historical daily volatility + if (time <= _reference.AddDays(3)) + { + Assert.IsFalse(indicator.IsReady); + } + else + { + Assert.IsTrue(indicator.IsReady); + } + + } + + Assert.AreEqual(2 * warmUpPeriod.Value, indicator.Samples); + } + + [Test] + public override void AcceptsRenkoBarsAsInput() + { + var indicator = CreateIndicator(); + var firstRenkoConsolidator = new RenkoConsolidator(0.5m); + var secondRenkoConsolidator = new RenkoConsolidator(0.5m); + firstRenkoConsolidator.DataConsolidated += (sender, renkoBar) => + { + Assert.DoesNotThrow(() => indicator.Update(renkoBar)); + }; + + secondRenkoConsolidator.DataConsolidated += (sender, renkoBar) => + { + Assert.DoesNotThrow(() => indicator.Update(renkoBar)); + }; + + for (int i = 1; i <= 300; i++) + { + var price = 550m - i; + var optionPrice = Math.Max(price - 450, 0) * 1.1m; + + var tradeBar1 = new TradeBar(_reference.AddDays(i), _symbol, optionPrice, optionPrice, optionPrice, optionPrice, 150m); + firstRenkoConsolidator.Update(tradeBar1); + var tradeBar2 = new TradeBar(_reference.AddDays(i), _underlying, price, price, price, price, 1200m); + secondRenkoConsolidator.Update(tradeBar2); + } + + Assert.AreNotEqual(0, indicator.Samples); + firstRenkoConsolidator.Dispose(); + secondRenkoConsolidator.Dispose(); + } + + [Test] + public override void AcceptsVolumeRenkoBarsAsInput() + { + var indicator = CreateIndicator(); + var firstVolumeRenkoConsolidator = new VolumeRenkoConsolidator(100); + var secondVolumeRenkoConsolidator = new VolumeRenkoConsolidator(1000); + firstVolumeRenkoConsolidator.DataConsolidated += (sender, renkoBar) => + { + Assert.DoesNotThrow(() => indicator.Update(renkoBar)); + }; + + secondVolumeRenkoConsolidator.DataConsolidated += (sender, renkoBar) => + { + Assert.DoesNotThrow(() => indicator.Update(renkoBar)); + }; + + for (int i = 1; i <= 300; i++) + { + var price = 550m - i; + var optionPrice = Math.Max(price - 450, 0) * 1.1m; + + var tradeBar1 = new TradeBar(_reference.AddDays(i), _symbol, optionPrice, optionPrice, optionPrice, optionPrice, 150m); + firstVolumeRenkoConsolidator.Update(tradeBar1); + var tradeBar2 = new TradeBar(_reference.AddDays(i), _underlying, price, price, price, price, 1200m); + secondVolumeRenkoConsolidator.Update(tradeBar2); + } + + Assert.AreNotEqual(0, indicator.Samples); + firstVolumeRenkoConsolidator.Dispose(); + secondVolumeRenkoConsolidator.Dispose(); + } + + [Test] + public void AcceptsQuoteBarsAsInput() + { + var indicator = CreateIndicator(); + + for (var i = 1; i <= 100; i++) + { + var price = 500m; + var optionPrice = Math.Max(price - 450, 0) * 1.1m; + + indicator.Update(new QuoteBar { + Symbol = _symbol, + Ask = new Bar(optionPrice, optionPrice, optionPrice, optionPrice), + Bid = new Bar(optionPrice, optionPrice, optionPrice, optionPrice), + Time = _reference.AddDays(1 + i) + }); + indicator.Update(new QuoteBar { Symbol = _underlying, Ask = new Bar(price, price, price, price), Time = _reference.AddDays(1 + i) }); + } + + Assert.AreEqual(200, indicator.Samples); + } + + // Not used + protected override string TestFileName => string.Empty; + } + }