Overall Statistics |
Total Trades 18 Average Win 31.04% Average Loss -21.55% Compounding Annual Return 19.283% Drawdown 11.300% Expectancy 0.220 Net Profit 7.600% Sharpe Ratio 1.226 Probabilistic Sharpe Ratio 55.576% Loss Rate 50% Win Rate 50% Profit-Loss Ratio 1.44 Alpha 0.13 Beta -0.037 Annual Standard Deviation 0.112 Annual Variance 0.012 Information Ratio 1.422 Tracking Error 0.236 Treynor Ratio -3.743 Total Fees $180.00 Estimated Strategy Capacity $3000.00 Lowest Capacity Asset SPY 31Y46M3K287DY|SPY R735QTJ8XC9X Portfolio Turnover 3.37% |
#region imports using System; using System.Collections.Generic; using System.Linq; using QuantConnect.Brokerages; using QuantConnect.Util; using QuantConnect.Indicators; using QuantConnect.Data; using QuantConnect.Data.Market; using QuantConnect.Data.UniverseSelection; using QuantConnect.Orders; using QuantConnect.Securities; using QuantConnect.Securities.Option; using QuantConnect.Orders.Fees; #endregion // sell to open a credit spread during the market’s close for a net credit, // then buy to close the spread the next day for a net debit. // Expiry should be 3 or 5 DTE // We place trades based on the price action of daily candles. Check the daily candle at 3:20 p.m. to see the momentum. The candle’s body should be full, with no large wicks. To be considered a momentum candle, the body must be at least 3/4 full. Please avoid this strategy if the score is 2/4. If a doji or spinning top candle forms, avoid. // Ichimoku cloud to filter out counter-trend trades. // Open a 0.14 delta spread. For a bear call spread, we open above the candle’s opening price, and for a bull put spread, we open below it. There is less probability that it will break the opening price if it is a momentum candle. // Exit — Try to capture 50% premium atleast. // At market open, the volatility will be high due to power hours. If it’s in our favor. Exit it. Else wait atleast 10:30 to 11 for volatility to decrease. // If you want to hold longer and get more juice, try exiting with a TTM squeeze fade out. But, as usual, I prefer to be conservative. So my trades are very mechanical, and I like to close with a 50% premium captured. namespace QuantConnect.Algorithm.CSharp.Options { public partial class SellingCreditSpreads : QCAlgorithm { string[] tickers = new string[] { "SPY" }; // TODO not used, remove? decimal maxPercentagePerContract = 1m; Resolution resolution = Resolution.Minute; int minExpirationDays = 1; int maxExpirationDays = 3; int minStrike = -20; int maxStrike = 1; decimal minPrice = 0m; decimal maxPrice = 0m; Dictionary<Symbol, Stock> stocks = new Dictionary<Symbol, Stock>(); public override void Initialize() { SetCash(2500); SetStartDate(2022, 1, 1); SetEndDate(2022, 6, 1); //SetBrokerageModel(BrokerageName.InteractiveBrokersBrokerage); SetBrokerageModel(BrokerageName.QuantConnectBrokerage); AddStocks(this.tickers); UniverseSettings.DataNormalizationMode = DataNormalizationMode.Raw; SetSecurityInitializer(x => x.SetDataNormalizationMode(DataNormalizationMode.Raw)); } void CancelOpenOrders() { var openOrders = this.Transactions.GetOpenOrders(); foreach (var order in openOrders) { this.Transactions.CancelOrder(order.Id); } } void RemoveAllConsolidators(Symbol symbol) { foreach (var subscription in this.SubscriptionManager.Subscriptions.Where(x => x.Symbol == symbol)) { foreach (var consolidator in subscription.Consolidators) { this.SubscriptionManager.RemoveConsolidator(symbol, consolidator); } subscription.Consolidators.Clear(); } } // Subscribe to stocks void AddStocks(string[] tickers) { foreach (string ticker in tickers) { Stock stock = new Stock(ticker, this.resolution, this, this.maxPercentagePerContract, this.minExpirationDays, this.maxExpirationDays, this.minStrike, this.maxStrike, this.minPrice, this.maxPrice); this.stocks.TryAdd(stock.stockSymbol, stock); } } public override void OnData(Slice slice) { if (Time > new DateTime(Time.Year, Time.Month, Time.Day, 15, 20, 0) && Time < new DateTime(Time.Year, Time.Month, Time.Day, 15, 59, 0)) { this.TryEnter(slice); } if (Time >= new DateTime(Time.Year, Time.Month, Time.Day, 9, 30, 0)) { this.TryExit(slice); } } private void TryEnter(Slice slice) { // CancelOpenOrders(); foreach (KeyValuePair<Symbol, Stock> entry in this.stocks) { Stock stock = entry.Value; stock.TryEnter(slice); } } private void TryExit(Slice slice) { // CancelOpenOrders(); foreach (KeyValuePair<Symbol, Stock> entry in this.stocks) { Stock stock = entry.Value; stock.TryExit(slice); } } } class Stock { decimal minPrice; decimal maxPrice; public Symbol stockSymbol; public Symbol optionSymbol; SellingCreditSpreads algorithm; Indicators indicators; decimal percentagePerStock; decimal maxPercentagePerContract; int minExpirationDays; int maxExpirationDays; int minStrike; int maxStrike; bool investedPuts = false; bool investedCalls = false; public int contracts; public DateTime expirationDate; public DateTime createdDate; public OptionContract shortLeg; public OptionContract longLeg; decimal distanceToInsurance = 0.01m; // percent of strike price public readonly decimal minDistanceBetweenPutsCalls = 0.02M; // percent of strike price public readonly decimal maxDistanceBetweenPutsCalls = 0.07M; // percent of strike price private readonly decimal safeDistanceToMaxBet = 0.001M; public bool DecideIfClosing(Slice slice) { bool closing = false; if(this.CalculatePnlPercentagePerShare(slice) >= 0.5m) // Use closeInDays // if ((this.algorithm.Time - this.createdDate).TotalDays >= this.closeInDays) { closing = true; this.Log("Capturing 50% pnl."); } // close before expiration if ((this.expirationDate.Date - this.algorithm.Time.Date).TotalDays <= 0) { // just before market close if (this.algorithm.Time.TimeOfDay >= new TimeSpan(15, 0, 0)) { closing = true; } } return closing; } public TradeBar GetTodayPrices() { IEnumerable<TradeBar> history = this.algorithm.History("SPY", 1, Resolution.Daily); return history.ElementAt(0); } public bool DecideIfOpening(Slice slice) { bool opening = false; TradeBar today = GetTodayPrices(); // opening = today.Close > today.Open && today.High <= today.Close && today.Low >= today.Open; opening = ((today.Close - today.Open)/(today.High - today.Low) > 0.7m) && ((today.High - today.Close)/(today.Close - today.Open) < 0.2m) && ((today.Open - today.Low)/(today.Close - today.Open) < 0.2m); // do not open if yesturday was not a business day // if (!this.IsDayBeforeBusinessDay()) // { // this.Log("Not entering: yesturday was not a business day."); // return false; // } return opening; } public void Log(string message) { this.algorithm.Debug(message); } public decimal CalculateCurrentPremiumPerShare(Slice slice) { decimal shortLegLastPrice = this.algorithm.Securities[this.shortLeg.Symbol].AskPrice; decimal longLegLastPrice = this.algorithm.Securities[this.longLeg.Symbol].BidPrice; return shortLegLastPrice - longLegLastPrice; } public decimal CalculateInitialPremiumPerShare() { return this.averageFillPriceShort - this.averageFillPriceLong; } public decimal CalculatePnlPercentagePerShare(Slice slice) { decimal initialPremium = CalculateInitialPremiumPerShare(); decimal currentPremium = CalculateCurrentPremiumPerShare(slice); decimal result = 0; if(initialPremium != 0) { result = (initialPremium - currentPremium)/initialPremium; } return result; } public decimal CalculateMaxLoss() { return Math.Abs(this.shortLeg.Strike - this.longLeg.Strike); } public void SetInvestedPuts(bool investedPuts) { this.investedPuts = investedPuts; } public void SetInvestedCalls(bool investedCalls) { this.investedCalls = investedCalls; } bool IsDayBeforeExpirationBusinessDay(DateTime expirationDate) { return this.algorithm.TradingCalendar.GetTradingDay(expirationDate.AddDays(-1)).BusinessDay; } bool IsDayBeforeBusinessDay() { return this.algorithm.TradingCalendar.GetTradingDay(this.algorithm.Time.AddDays(-1)).BusinessDay; } bool AreAllDaysBeforeExpirationBusinessDays(DateTime expirationDate) { int numberOfBusinessDaysUntilExpiration = this.algorithm.TradingCalendar.GetDaysByType(TradingDayType.BusinessDay, this.algorithm.Time, expirationDate).Count(); int numberOfDaysUntilExpiration = (expirationDate - this.algorithm.Time).Days; // Log("numberOfBusinessDaysUntilExpiration=" + numberOfBusinessDaysUntilExpiration + ", numberOfDaysUntilExpiration" + numberOfDaysUntilExpiration); return numberOfBusinessDaysUntilExpiration == numberOfDaysUntilExpiration + 2; } bool IsNextDayBusinessDay() { return this.algorithm.TradingCalendar.GetTradingDay(this.algorithm.Time.AddDays(1)).BusinessDay; } public Stock(string underlyingTicker, Resolution resolution, SellingCreditSpreads algorithm, decimal maxPercentagePerContract, int minExpirationDays, int maxExpirationDays, int minStrike, int maxStrike, decimal minPrice, decimal maxPrice) { this.algorithm = algorithm; this.createdDate = algorithm.Time; this.stockSymbol = this.algorithm.AddEquity(underlyingTicker, resolution).Symbol; var option = this.algorithm.AddOption(underlyingTicker, Resolution.Minute); option.PriceModel = OptionPriceModels.CrankNicolsonFD(); option.SetFilter(universe => from symbol in universe.Strikes(this.minStrike, this.maxStrike).WeeklysOnly().Expiration(TimeSpan.FromDays(this.minExpirationDays), TimeSpan.FromDays(this.maxExpirationDays)) select symbol); this.optionSymbol = option.Symbol; this.indicators = new Indicators(algorithm, this.stockSymbol, resolution); this.maxPercentagePerContract = maxPercentagePerContract; this.algorithm.Securities[this.stockSymbol].SetDataNormalizationMode(DataNormalizationMode.Raw); this.minExpirationDays = minExpirationDays; this.maxExpirationDays = maxExpirationDays; this.minStrike = minStrike; this.maxStrike = maxStrike; this.minPrice = minPrice; this.maxPrice = maxPrice; } bool IsTradable(Symbol symbol) { Security security; if (this.algorithm.Securities.TryGetValue(symbol, out security)) { return security.IsTradable; } return false; } bool IsTradable() { return IsTradable(this.optionSymbol); } int CalculateNumberOfContracts() { var percentagePerContract = this.maxPercentagePerContract; int numberOfContracts = (int)((this.algorithm.Portfolio.MarginRemaining) / (100m * (this.shortLeg.Strike - this.longLeg.Strike))); return numberOfContracts; } public OptionContract FindContractWithDelta(Slice slice, OptionRight right) { TradeBar today = GetTodayPrices(); OptionContract foundContract = null; OptionChain chain; if (slice.OptionChains.TryGetValue(this.optionSymbol, out chain)) { if (right == OptionRight.Put) { foundContract = ( from contract in chain .OrderByDescending(contract => (Math.Abs(contract.Greeks.Delta))) where Math.Abs(contract.Greeks.Delta) <= 0.14m where contract.Right == right where contract.Strike < contract.UnderlyingLastPrice where contract.Strike < today.Open select contract ).FirstOrDefault(); } else { foundContract = ( from contract in chain .OrderByDescending(contract => (contract.OpenInterest * contract.LastPrice)) where contract.Right == right where contract.Strike > contract.UnderlyingLastPrice select contract ).FirstOrDefault(); } } return foundContract; } // find put contract with max dollar bet: Open Interest * LastPrice, with strike below/above underlying price public OptionContract FindContractWithMaxDollarBet(Slice slice, OptionRight right) { OptionContract foundContract = null; OptionChain chain; if (slice.OptionChains.TryGetValue(this.optionSymbol, out chain)) { if (right == OptionRight.Put) { foundContract = ( from contract in chain .OrderByDescending(contract => (contract.OpenInterest * contract.LastPrice)) where contract.Right == right where contract.Strike < contract.UnderlyingLastPrice select contract ).FirstOrDefault(); } else { foundContract = ( from contract in chain .OrderByDescending(contract => (contract.OpenInterest * contract.LastPrice)) where contract.Right == right where contract.Strike > contract.UnderlyingLastPrice select contract ).FirstOrDefault(); } } return foundContract; } /// <summary> /// Finds contract that has a distance from mainContract. /// Examples: insurance contract, less risky contract. /// </summary> /// <param name="slice"></param>Option chains to find contract in. /// <param name="mainContract"></param>Main contract distance from which is specified. /// <param name="distance"></param>Distance from main contract, percent of strike price. /// <returns>Found contract or null.</returns> private OptionContract FindContractWithDistance(Slice slice, OptionContract mainContract, decimal distance) { OptionContract foundContract = null; OptionChain chain; if (slice.OptionChains.TryGetValue(this.optionSymbol, out chain)) { if (mainContract.Right == OptionRight.Put) { // first contract below strike of main contract foundContract = ( from contract in chain .OrderByDescending(contract => contract.Strike) where contract.Right == mainContract.Right where contract.Expiry == mainContract.Expiry where contract.Strike < mainContract.Strike select contract ).FirstOrDefault(); } else { // first contract above strike of main contract foundContract = ( from contract in chain .OrderByDescending(contract => contract.Strike) where contract.Right == mainContract.Right where contract.Expiry == mainContract.Expiry where contract.Strike > mainContract.Strike select contract ).LastOrDefault(); } } return foundContract; } public OptionRight GetOppositeRight(OptionRight right) { if (right == OptionRight.Put) { return OptionRight.Call; } else { return OptionRight.Put; } } public decimal CalculateDistanceBetweenPutsCalls(Decimal strike1, Decimal strike2) { decimal distanceBetweenPutsCalls = Math.Abs(strike1 - strike2); // Log("distanceBetweenPutsCalls=[" + distanceBetweenPutsCalls + "]"); return distanceBetweenPutsCalls; } public OptionContract FindContractWithMaxDollarBetAndGoodDistanceFromOppositeContract(Slice slice, OptionRight right) { OptionContract maxBet = this.FindContractWithMaxDollarBet(slice, right); Log("Contract WithMaxDollarBet=[" + maxBet.Strike + "]"); OptionContract oppositeBet = this.FindContractWithMaxDollarBet(slice, GetOppositeRight(maxBet.Right)); decimal distanceBetweenPutsCalls = CalculateDistanceBetweenPutsCalls(oppositeBet.Strike, maxBet.Strike); if (distanceBetweenPutsCalls < maxBet.UnderlyingLastPrice * this.minDistanceBetweenPutsCalls) { Log("distanceBetweenPutsCalls is too small=[" + distanceBetweenPutsCalls + "]"); return null; } else if (distanceBetweenPutsCalls > maxBet.UnderlyingLastPrice * this.maxDistanceBetweenPutsCalls) { Log("distanceBetweenPutsCalls is too big=[" + distanceBetweenPutsCalls + "]"); return null; } // do not use max bet, use safer bet than safeDistanceToMaxBet less risky return this.FindContractWithDistance(slice, maxBet, this.safeDistanceToMaxBet); } public void TryEnter(Slice slice) { if (!this.IsInvested()) { if (this.algorithm.Securities[this.stockSymbol].Exchange.ExchangeOpen) { // this.indicators.CalculateFromHistory(); if (DecideIfOpening(slice)) { this.shortLeg = this.FindContractWithDelta(slice, OptionRight.Put); if (this.shortLeg != null) { // do not open if not all days before expiration are business days if (!this.IsNextDayBusinessDay()) { this.Log("Not entering: tomorrow is not business day."); return; } // find insurance contract this.longLeg = this.FindContractWithDistance(slice, this.shortLeg, this.distanceToInsurance); if (this.longLeg != null) { // TODO if (IsTradable(this.shortLeg.Symbol)) { // this.contracts = CalculateNumberOfContracts(); this.contracts = 20; if (this.contracts > 0) { Open(); this.expirationDate = shortLeg.Expiry; } else { Log("Not enough cash"); } } } } } } } } public void TryExit(Slice slice) { if (this.IsInvested()) { if (DecideIfClosing(slice)) { if (this.algorithm.Securities[this.stockSymbol].Exchange.ExchangeOpen) { Close(); } } } } decimal averageFillPriceLong; decimal averageFillPriceShort; public void Open() { string tag = "Opening"; { decimal feePerOrder = 0.5m * this.contracts; this.algorithm.Securities[this.shortLeg.Symbol].FeeModel = new ConstantFeeModel(feePerOrder); this.algorithm.Securities[this.longLeg.Symbol].FeeModel = new ConstantFeeModel(feePerOrder); var optionStrategy = OptionStrategies.BullPutSpread(this.optionSymbol, this.shortLeg.Strike, this.longLeg.Strike, this.longLeg.Expiry); var tickets = this.algorithm.Buy(optionStrategy, this.contracts, true, tag); foreach (var ticket in tickets) { if (ticket.Status != OrderStatus.Filled) { throw new Exception("Failed to fill order=[" + ticket.Symbol + "], [" + ticket.Quantity + "]"); } if(ticket.Quantity > 0) { this.averageFillPriceLong = ticket.AverageFillPrice; } else { this.averageFillPriceShort = ticket.AverageFillPrice; } } } Log("================================= Opened spread=[" + shortLeg.Strike + ", " + longLeg.Strike + "]"); this.SetInvestedCalls(true); this.SetInvestedPuts(true); } public void Close() { string tag = "Closing"; { decimal feePerOrder = 0.5m * this.contracts; this.algorithm.Securities[this.shortLeg.Symbol].FeeModel = new ConstantFeeModel(feePerOrder); this.algorithm.Securities[this.longLeg.Symbol].FeeModel = new ConstantFeeModel(feePerOrder); var optionStrategy = OptionStrategies.BullPutSpread(this.optionSymbol, this.shortLeg.Strike, this.longLeg.Strike, this.longLeg.Expiry); var tickets = this.algorithm.Sell(optionStrategy, this.contracts, true, tag); foreach (var ticket in tickets) { if (ticket.Status != OrderStatus.Filled) { throw new Exception("Failed to fill order=[" + ticket.Symbol + "], [" + ticket.Quantity + "]"); } } } this.SetInvestedCalls(false); this.SetInvestedPuts(false); Log("================================= Closed spread=[" + shortLeg.Strike + ", " + longLeg.Strike + "]"); } public bool ShouldExit() { return true; } public bool ShouldSellPut() { return this.indicators.ShouldSellPut(); } public bool ShouldSellCall() { return this.indicators.ShouldSellCall(); } private bool IsInvested() { return this.investedPuts; // TODO && this.investedCalls; } public bool ShouldBuy() { return this.percentagePerStock <= 0 && this.indicators.ShouldBuy() && this.IsTradable(); } public bool ShouldSell() { return this.percentagePerStock >= 0 && this.indicators.ShouldSell() && this.IsTradable(); } public bool ShouldShort() { return this.percentagePerStock >= 0 && this.indicators.ShouldShort() && this.IsTradable(); } public bool ShouldCover() { return this.percentagePerStock <= 0 && this.indicators.ShouldCover() && this.IsTradable(); } public bool AreIndicatorsReady() { return this.indicators.IsReady(); } public bool UpdateIndicators(TradeBar tradeBar) { return this.indicators.Update(tradeBar); } public void ResetIndicators() { this.indicators.Reset(); } } public class Indicators { QCAlgorithm algorithm; Symbol symbol; decimal price; MovingAverageConvergenceDivergence macd; ExponentialMovingAverage ema20; ExponentialMovingAverage ema50; AroonOscillator aroon; // Keep history for 15 days RollingWindow<AroonOscillatorState> aroonHistory = new RollingWindow<AroonOscillatorState>(15); Resolution resolution; public Indicators(QCAlgorithm algorithm, Symbol symbol, Resolution resolution) { this.algorithm = algorithm; this.symbol = symbol; this.resolution = resolution; this.macd = new MovingAverageConvergenceDivergence(12, 26, 9, MovingAverageType.Wilders); this.ema20 = new ExponentialMovingAverage((int)(3 * 6.5 * 60)); this.ema50 = new ExponentialMovingAverage(50); ; this.aroon = new AroonOscillator(20, 20); } public void Reset() { this.macd.Reset(); this.ema20.Reset(); this.ema50.Reset(); this.aroon.Reset(); } public bool ShouldBuy() { if (IsReady()) { if (IsMacdInUptrend() && IsEma20AboveEma50()) { if (this.price > this.ema20) { if (HasAroonCrossedUp() && IsAroonAbove50()) { return true; } } } } return false; } public bool ShouldCover() { if (IsReady()) { if (IsMacdInUptrend() || IsEma20AboveEma50()) { if (this.price > this.ema20) { if (HasAroonCrossedUp() && IsAroonAbove50()) { return true; } } } } return false; } public bool ShouldSell() { if (IsReady()) { if (!IsMacdInUptrend() || !IsEma20AboveEma50()) { if (this.price < this.ema20) { if (HasAroonCrossedDown() && IsAroonAbove50()) { return true; } } } } return false; } public bool ShouldShort() { if (IsReady()) { if (!IsMacdInUptrend() && !IsEma20AboveEma50()) { // if (this.price < this.ema20) { if (HasAroonCrossedDown() && IsAroonAbove50()) { return true; } } } } return false; } public void CalculateFromHistory() { this.Reset(); IEnumerable<TradeBar> history = this.algorithm.History(this.symbol, 100, this.resolution); foreach (TradeBar tradeBar in history) { this.Update(tradeBar); } } public bool Update(TradeBar tradeBar) { DateTime time = tradeBar.EndTime; this.price = tradeBar.Close; bool result = this.macd.Update(time, price) && this.ema20.Update(time, price) && this.ema50.Update(time, price) && this.aroon.Update(tradeBar); SaveAroonHistory(); return result; } public bool IsReady() { return (this.macd.IsReady && this.ema20.IsReady && this.ema50.IsReady && this.aroon.IsReady && this.aroonHistory.IsReady); } public bool IsMacdInUptrend() { return (this.macd > this.macd.Signal); } public bool IsEma20AboveEma50() { return (this.ema20 > this.ema50); } public bool HasAroonCrossedUp() { // cross up between 2 days ago and today return this.aroon.AroonUp.Current.Value > this.aroon.AroonDown.Current.Value && this.aroonHistory[2].Up < this.aroonHistory[2].Down; } public bool HasAroonCrossedDown() { // cross down between 2 days ago and today return this.aroon.AroonUp.Current.Value < this.aroon.AroonDown.Current.Value && this.aroonHistory[2].Up > this.aroonHistory[2].Down; } public bool IsAroonAbove50() { return this.aroon.AroonUp.Current.Value > 55 && this.aroon.AroonDown.Current.Value > 53; } public void SaveAroonHistory() { this.aroonHistory.Add(new AroonOscillatorState(this.aroon)); } internal bool ShouldSellPut() { return this.IsReady() && IsMacdInUptrend(); } internal bool ShouldSellCall() { return this.IsReady() && !this.IsEma20AboveEma50(); } } // class to hold the current state of a AroonOscillator instance public class AroonOscillatorState { public readonly decimal Up; public readonly decimal Down; public AroonOscillatorState(AroonOscillator aroon) { Up = aroon.AroonUp.Current.Value; Down = aroon.AroonDown.Current.Value; } } }