Overall Statistics |
Total Trades 1770 Average Win 0.43% Average Loss -0.20% Compounding Annual Return -4.672% Drawdown 59.100% Expectancy -0.987 Net Profit -34.947% Sharpe Ratio -0.004 Probabilistic Sharpe Ratio 0.046% Loss Rate 100% Win Rate 0% Profit-Loss Ratio 2.09 Alpha 0 Beta 0 Annual Standard Deviation 0.28 Annual Variance 0.078 Information Ratio -0.004 Tracking Error 0.28 Treynor Ratio 0 Total Fees $5364.05 |
using System; using System.Collections.Generic; using System.Linq; using QuantConnect.Brokerages; using QuantConnect.Data; using QuantConnect.Data.Market; using QuantConnect.Data.UniverseSelection; using QuantConnect.Indicators; using QuantConnect.Orders; using QuantConnect.Orders.Fees; using QuantConnect.Orders.Fills; using QuantConnect.Parameters; using QuantConnect.Securities; using QuantConnect.Securities.Option; namespace QuantConnect.Algorithm.CSharp { public class NNNHedge : QCAlgorithm { // Parameters private const decimal MonthlyNOI = 35000m; private const int NumOfPayments = 165; private const double DiscountRate = 0.07 / 12; private const decimal ResidualUnderlyingValue = 0.50m; private const int TradedMaDays = 5; private const int StartingCash = 300000; private const decimal MaxPercentDailyVolume = 0.25m; private const int MinDaysToAccumulate = 5; private const int MinExpiryDays = 90; private const int MaxExpiryDays = 3*365; private const int LiquidateExpiryDays = 60; private const int AggLiquidateExpiryDays = 45; private const decimal MaxAskPrice = 0.15m; private const decimal MaxAbsoluteSpread = 0.05m; private const decimal MaxPercentSpread = 0.80m; private const int MaxOptionOrdersPerPeriod = 20; private const int MinContractsPerOrder = 5; [Parameter("ema-period")] private int EmaPeriod = 252; [Parameter("hdd-cutoff-mult")] private decimal HddCutoffMultiple = 3.0m; // WBA // QuantConnect data starts 6/1/2010. Ticker change from WAG to WBA 1/2015 private static readonly DateTime Start = new DateTime(2013, 1, 1); //new DateTime(2011, 10, 7); //new DateTime(2010, 6, 1); // Missing data 4/30/11 - 10/5/11 private static readonly DateTime End = new DateTime(2020, 12, 15); // private static readonly DateTime Start = new DateTime(2019, 1, 1); // private static readonly DateTime End = Start.AddMonths(1); private static readonly string UnderlyingTicker = "WBA"; // AAPL // private static readonly DateTime Start = new DateTime(2014, 6, 6); // private static readonly DateTime End = new DateTime(2014, 6, 9); // private static readonly string UnderlyingTicker = "AAPL"; // GOOG // private static readonly DateTime Start = new DateTime(2015, 12, 24); // private static readonly DateTime End = Start; // private static readonly string UnderlyingTicker = "GOOG"; private static readonly Symbol OptionSymbol = QuantConnect.Symbol.Create(UnderlyingTicker, SecurityType.Option, Market.USA); private static readonly Symbol UnderlyingSymbol = QuantConnect.Symbol.Create(UnderlyingTicker, SecurityType.Equity, Market.USA); private static readonly TimeSpan TradeMaTimeSpan = TimeSpan.FromDays(TradedMaDays); private static readonly TimeSpan CyclePeriodTimeSpan = TimeSpan.FromMinutes(60); private static readonly decimal NumPeriodsPerTradingDay = ((decimal)TimeSpan.FromHours(6).Ticks) / CyclePeriodTimeSpan.Ticks; // SOD doesn't start till ~9:40 and want to end before close private static readonly decimal MaxHedgePerPeriod = Decimal.One / (NumPeriodsPerTradingDay * MinDaysToAccumulate); //Don't accumulate faster than spread over a month private readonly Dictionary<DateTime, decimal> discountedMonthlyIncome = new Dictionary<DateTime, decimal>(); private readonly Dictionary<Symbol, decimal> averageVolume = new Dictionary<Symbol, decimal>(); private readonly Dictionary<int, OrderTicket> knownOrders = new Dictionary<int, OrderTicket>(); private readonly Dictionary<DateTime, decimal> currentHedgeNotionalByExpiry = new Dictionary<DateTime, decimal>(); private readonly Dictionary<Symbol, decimal> tradedVolume = new Dictionary<Symbol, decimal>(); private decimal targetHedgeNotional = Decimal.Zero; private decimal currentHedgeNotional = Decimal.Zero; private DateTime noiScheduleDate = DateTime.MinValue; private DateTime minUnwindDate; private DateTime aggUnwindDate; private DateTime minExpiryDate; private DateTime maxExpiryDate; private bool isActive = false; private ExponentialMovingAverage hddMedianEma; private ExponentialMovingAverage hddAverageEma; public override void Initialize() { Log($"Param, StartDate = {Start.ToShortDateString()}"); Log($"Param, EndDate = {End.ToShortDateString()}"); Log($"Param, MinExpiryDays = {MinExpiryDays}"); Log($"Param, MaxExpiryDays = {MaxExpiryDays}"); Log($"Param, MaxPercentDailyVolume = {MaxPercentDailyVolume}"); Log($"Param, StartingCash = {StartingCash}"); Log($"Param, MinDaysToAccumulate = {MinDaysToAccumulate}"); Log($"Param, LiquidateExpiryDays = {LiquidateExpiryDays}"); Log($"Param, AggLiquidateExpiryDays = {AggLiquidateExpiryDays}"); Log($"Param, MonthlyNOI = {MonthlyNOI}"); Log($"Param, NumOfPayments = {NumOfPayments}"); Log($"Param, DiscountRate = {DiscountRate * 12}"); Log($"Param, ResidualUnderlyingValue = {ResidualUnderlyingValue}"); Log($"Param, MaxAskPrice = {MaxAskPrice}"); Log($"Param, MaxAbsoluteSpread = {MaxAbsoluteSpread}"); Log($"Param, MaxPercentSpread = {MaxPercentSpread}"); Log($"Param, MaxOptionOrdersPerPeriod = {MaxOptionOrdersPerPeriod}"); Log($"Param, MinContractsPerOrder = {MinContractsPerOrder}"); Log($"Param, EmaPeriod = {EmaPeriod}"); Log($"Param, HddCutoffMultiple = {HddCutoffMultiple}"); Log("EOD, Hedge, Target, Exp Days, Exp Soon, Port Val, Cash, Fees, Hold Cost, Hold Val, Profit, Unrealized"); hddMedianEma = new ExponentialMovingAverage("HD/$ Median EMA", EmaPeriod, ExponentialMovingAverage.SmoothingFactorDefault(EmaPeriod)); hddAverageEma = new ExponentialMovingAverage("HD/$ Average EMA", EmaPeriod, ExponentialMovingAverage.SmoothingFactorDefault(EmaPeriod)); decimal prevIncome = 0.0m; int period = 1; DateTime startDate = new DateTime(Start.Year, Start.Month, 1); DateTime currentDay = startDate.AddMonths(NumOfPayments); while (currentDay >= startDate) { discountedMonthlyIncome[currentDay] = MonthlyNOI / Convert.ToDecimal(Math.Pow((1 + DiscountRate), period)) + prevIncome; prevIncome = discountedMonthlyIncome[currentDay]; currentDay = currentDay.AddMonths(-1); period++; } DateTime warmupDatartDate = TradingCalendar.GetDaysByType(TradingDayType.BusinessDay, Start.AddDays(-(EmaPeriod*370/250)), Start) .Reverse().Take(EmaPeriod + 5).Last().Date; SetStartDate(warmupDatartDate); SetEndDate(End); SetCash(StartingCash); SetWarmup(TimeSpan.FromDays(TradedMaDays)); SetBenchmark(d => StartingCash); SetBrokerageModel(BrokerageName.InteractiveBrokersBrokerage); CustomFillModel fillModel = new CustomFillModel(this); SetSecurityInitializer(security => security.SetFillModel(fillModel)); AddEquity(UnderlyingSymbol, Resolution.Daily, Market.USA, false); Option option = AddOption(UnderlyingSymbol, Resolution.Minute, Market.USA, false); option.SetFilter(u => u.PutsOnly() .Strikes(Int32.MaxValue, 0) //ATM or OTM ); option.PriceModel = OptionPriceModels.BaroneAdesiWhaley(); // optionContract.PriceModel = new QLOptionPriceModel(process => new BaroneAdesiWhaleyApproximationEngine(process), // _underlyingVolEstimator, // _riskFreeRateEstimator, // _dividendYieldEstimator); DefaultOrderProperties.TimeInForce = TimeInForce.Day; //Cancel all orders at end of day Schedule.On(DateRules.EveryDay(UnderlyingSymbol), TimeRules.AfterMarketOpen(UnderlyingSymbol, -1), AtStartOfDayHandler); // 9:29 AM Schedule.On(DateRules.EveryDay(UnderlyingSymbol), TimeRules.BeforeMarketClose(UnderlyingSymbol, 1), AtEndOfDayHandler); // 3:59 PM Schedule.On(DateRules.EveryDay(UnderlyingSymbol), TimeRules.Every(CyclePeriodTimeSpan), PeriodicCycleHandler); } private void PeriodicCycleHandler() { if (!isActive) return; // Cancel all previous orders foreach (var ticket in Transactions.GetOpenOrderTickets()) { ticket.Cancel(); } // Figure out current hedge state CalculateHedgeNotional(); OptionChain chain; if (!CurrentSlice.OptionChains.TryGetValue(OptionSymbol, out chain)) { Error($"No option contract chain for {OptionSymbol}"); return; } decimal hedgeNeeded = targetHedgeNotional - currentHedgeNotional; //Check for those needing unwinding and reduce notional by those expiring soon decimal minExpiryHedgeNotional = Decimal.Zero; Dictionary<Symbol,decimal> unwindLevels = new Dictionary<Symbol, decimal>(); foreach (Option option in GetUnderMinimumExpiration()) { //Regardless if we unwind or not, pretend these positions don't count and aim to replace them decimal securityHedgeNotional = CalculateSecurityHedgeNotional(option.Symbol, Portfolio[option.Symbol].Quantity + GetTradedQuantity(option.Symbol)); decimal unwindLevel; if (!unwindLevels.TryGetValue(option.Symbol, out unwindLevel)) { unwindLevel = Math.Min(1.0m, (minUnwindDate - option.Expiry).Ticks / (decimal) (minUnwindDate - aggUnwindDate).Ticks); unwindLevels.Add(option.Symbol, unwindLevel); } minExpiryHedgeNotional += unwindLevel * securityHedgeNotional; } // If there is something that qualifies for rolling and we have enough hedge already if (minExpiryHedgeNotional > Decimal.Zero && hedgeNeeded < Decimal.Zero) { decimal liquidationAttemptedHedgeNotional = Decimal.Zero; foreach (Option option in GetUnderMinimumExpiration().OrderByDescending(opt => Portfolio[opt.Symbol].HoldingsValue)) { // Only liquidate if we haven't already attempted to liquidate enough to get us to neutral hedge if (liquidationAttemptedHedgeNotional < hedgeNeeded || option.BidPrice <= 0.01m) { continue; } OptionContract optionContract; if (!chain.Contracts.TryGetValue(option.Symbol, out optionContract)) { // Debug($"Unable to unwind {optionContract.Symbol} for min exp, not in chain"); continue; } decimal prctOfSpread = Math.Max(unwindLevels[option.Symbol], 0.5m); decimal sentQty = SendLimitOrder(optionContract, -Portfolio[option.Symbol].Quantity, prctOfSpread, "MinExp Unwind", $"{hedgeNeeded:F1}"); if (sentQty < Decimal.Zero) { liquidationAttemptedHedgeNotional += CalculateSecurityHedgeNotional(option.Symbol, sentQty); } } } //Now check what we need to do hedgeNeeded += minExpiryHedgeNotional; //Add what is going to expire or be rolled soon decimal prctHedgeNeeded = hedgeNeeded / targetHedgeNotional; if (prctHedgeNeeded > Decimal.Zero && hddAverageEma.IsReady) { decimal periodHedge = Math.Min(hedgeNeeded, targetHedgeNotional * MaxHedgePerPeriod); IEnumerable<Tuple<OptionContract, decimal>> accumulateRank = GetAccumulationRank(chain); // decimal medianEma = hddMedianEma.IsReady ? hddMedianEma.Current.Value : Math.Max(hddMedianEma.Current.Value, 12750m); // decimal averageEma = hddAverageEma.IsReady ? hddAverageEma.Current.Value: Math.Max(hddAverageEma.Current.Value, 22750m); decimal medianEma = hddMedianEma.Current.Value; decimal averageEma = hddAverageEma.Current.Value; decimal minHdd = averageEma * HddCutoffMultiple; IEnumerable<Tuple<OptionContract,decimal>> selected = accumulateRank .Where(t => t.Item2 >= minHdd) .Take(MaxOptionOrdersPerPeriod); decimal[] hedgeDayValues = selected.Select(tuple => tuple.Item2).ToArray(); int numOptions = hedgeDayValues.Length; decimal sum = hedgeDayValues.Sum(); using (IEnumerator<Tuple<OptionContract, decimal>> enumerator = selected.GetEnumerator()) { while (enumerator.MoveNext() && periodHedge > Decimal.Zero && numOptions > 0) { OptionContract optionContract = enumerator.Current.Item1; decimal hedgeDayValue = enumerator.Current.Item2; Option option = Securities[optionContract.Symbol] as Option; if (option != null) { decimal perContractHedgeNotional = option.ContractMultiplier * (option.StrikePrice - ResidualUnderlyingValue); decimal targetQty = Math.Max(MinContractsPerOrder, Math.Ceiling((periodHedge * (hedgeDayValue/sum)) / perContractHedgeNotional)); decimal prctOfSpread = 0.0m; //prctHedgeNeeded > 0.5m ? 1.0m : (prctHedgeNeeded * 2m); decimal sentQty = SendLimitOrder(optionContract, targetQty, prctOfSpread, "Accumulate", $"{hedgeNeeded:F1}, {medianEma:F1}, {averageEma:F1}, {minHdd:F1}, {hedgeDayValue:F1}, {sum:F1}" ); if (sentQty > Decimal.Zero) { periodHedge -= sentQty * perContractHedgeNotional; numOptions--; } } else { Error($"No optionContract found for {optionContract.Symbol}"); } } } } else if (prctHedgeNeeded < -0.05m) //dispose { foreach (Option option in GetDisposeRanking()) { if (hedgeNeeded >= Decimal.Zero) break; OptionContract optionContract; if (!chain.Contracts.TryGetValue(option.Symbol, out optionContract)) { Debug($"Unable to unwind {option.Symbol} for dispose, not in chain"); continue; } decimal perContractHedgeNotional = option.ContractMultiplier * (option.StrikePrice - ResidualUnderlyingValue); decimal targetQty = Math.Max(Math.Floor(hedgeNeeded / perContractHedgeNotional), -Portfolio[option.Symbol].Quantity); if (targetQty <= -Decimal.One) { const decimal prctOfSpread = 0.0m; //0.5m; decimal sentQty = SendLimitOrder(optionContract, targetQty, prctOfSpread, "Dispose", $"{hedgeNeeded:F1}" ); hedgeNeeded -= sentQty * perContractHedgeNotional; } } } } private decimal GetTradedQuantity(Symbol symbol) { decimal traded; return tradedVolume.TryGetValue(symbol, out traded) ? traded : Decimal.Zero; } private IOrderedEnumerable<Tuple<OptionContract, decimal>> GetAccumulationRank(OptionChain chain) { IEnumerable<Tuple<OptionContract, decimal>> selected; // decimal maxAskPrice = 0.00375m * underlyingLastPrice; // decimal maxSpread = maxAskPrice / 3; selected = chain .Where(oc => (oc.Expiry >= minExpiryDate && oc.Expiry <= maxExpiryDate)) .Where(oc => oc.Strike > ResidualUnderlyingValue /*&& oc.Strike <= 0.9m * oc.UnderlyingLastPrice*/) .Where(oc => oc.BidPrice > Decimal.Zero && oc.AskPrice > Decimal.Zero && oc.AskPrice > oc.BidPrice) .Where(oc => /*oc.AskPrice <= maxAskPrice /*MaxAskPrice#1# &&*/ ( /*(oc.AskPrice - oc.BidPrice) < maxSpread /*MaxAbsoluteSpread#1# ||*/ ((oc.AskPrice - oc.BidPrice) / oc.AskPrice) <= MaxPercentSpread)) .Select(oc => Tuple.Create(oc, HedgeValueRank(oc))); // } return selected.OrderByDescending(t => t.Item2); } private decimal HedgeValueRank(OptionContract optionContract) { return ((optionContract.Strike - ResidualUnderlyingValue) * (decimal) (optionContract.Expiry - CurrentSlice.Time).TotalDays) / optionContract.AskPrice; } private decimal GetWeightedExpirationDays() { return currentHedgeNotionalByExpiry .Sum(kvp => Convert.ToDecimal((kvp.Key - CurrentSlice.Time).TotalDays) * (kvp.Value / currentHedgeNotional)); } private IEnumerable<Option> GetUnderMinimumExpiration() { // Dispose of closest expiries first and then by ones furthest OTM return Portfolio.Values .Where(sh => sh.Invested) .Select(sh => Securities[sh.Symbol] as Option) .Where(opt => opt != null) //TODO log .Where(option => option.Expiry <= minUnwindDate); } private IOrderedEnumerable<Option> GetDisposeRanking() { // Dispose of closest expiries first and then by ones furthest OTM return Portfolio.Values .Where(sh => sh.Invested) .Select(sh => Securities[sh.Symbol] as Option) .Where(opt => opt != null) //TODO log .Where(option => option.Expiry > minUnwindDate) .OrderBy(o => o.Expiry) .ThenByDescending(o => o.Underlying.Price - o.StrikePrice); } public override void OnData(Slice slice) { if (!isActive) return; DateTime cutoff = UtcTime.AddMinutes(-5); // Don't update faster than 5 minutes IOrderedEnumerable<OrderTicket> orderTickets = Transactions.GetOpenOrderTickets().Where(t => t.Time < cutoff).OrderBy(t => t.Time); // OrderTicket ticket = orderTickets.First(); foreach (var ticket in orderTickets) { LimitOrder limitOrder = Transactions.GetOrderById(ticket.OrderId) as LimitOrder; if (limitOrder == null) { continue; } decimal prevPrice = limitOrder.LimitPrice; QuoteBar quoteBar; if (!slice.QuoteBars.TryGetValue(ticket.Symbol, out quoteBar) || quoteBar.Bid == null || quoteBar.Ask == null || quoteBar.Bid.Close > quoteBar.Ask.Close) { // Debug($"No or missing quote bars for {ticket.Symbol} @ {slice.Time}"); continue; } decimal currentSpread = quoteBar.Ask.Close - quoteBar.Bid.Close; decimal updatedPrice; if (limitOrder.Direction == OrderDirection.Buy) { if (prevPrice >= quoteBar.Ask.Close /*|| prevPrice >= MaxAskPrice*/) { // Debug($"Order ({ticket.OrderId}) to buy {ticket.Symbol} @ {prevPrice} is >= current ask {quoteBar.Ask.Close} but hasn't executed, time = {slice.Time}"); continue; //Doesn't need update, not sure why it hasn't executed } prevPrice = Math.Max(prevPrice, quoteBar.Bid.Close); decimal bumpUp = Math.Max(0.01m, Math.Round(0.2m * currentSpread, 2)); // Increase by 10% of spread or 0.01, whichever greater // updatedPrice = Math.Min(MaxAskPrice, prevPrice + bumpUp); updatedPrice = prevPrice + bumpUp; OrderResponse orderResponse = ticket.UpdateLimitPrice(updatedPrice, ticket.Tag + $", {updatedPrice}"); if (orderResponse.IsSuccess) { // Debug($"Updated order ({ticket.OrderId}) price to buy {ticket.Symbol} @ {prevPrice} to {updatedPrice}, bid = {quoteBar.Bid.Close}, ask = {quoteBar.Ask.Close}, time = {slice.Time}"); // return; //Do one per cycle } else { Error($"Unable to update order ({ticket.OrderId}) price to buy {ticket.Symbol} @ {prevPrice} to {updatedPrice}, response = {orderResponse.ErrorMessage}, bid = {quoteBar.Bid.Close}, ask = {quoteBar.Ask.Close}, time = {slice.Time}"); } } else { if (prevPrice <= quoteBar.Bid.Close) { // Debug($"Order ({ticket.OrderId}) to sell {ticket.Symbol} @ {prevPrice} is <= current bid {quoteBar.Bid.Close} but hasn't executed, time = {slice.Time}"); continue; //Doesn't need update, not sure why it hasn't executed } prevPrice = Math.Min(prevPrice, quoteBar.Ask.Close); decimal bumpDown = Math.Max(0.01m, Math.Round(0.1m * currentSpread, 2)); // Increase by 10% of spread or 0.01, whichever greater updatedPrice = prevPrice - bumpDown; OrderResponse orderResponse = ticket.UpdateLimitPrice(updatedPrice, ticket.Tag + $", {updatedPrice}"); if (orderResponse.IsSuccess) { // Debug($"Updated order ({ticket.OrderId}) price to sell {ticket.Symbol} @ {prevPrice} to {updatedPrice}, bid = {quoteBar.Bid.Close}, ask = {quoteBar.Ask.Close}, time = {slice.Time}"); // return; //Do one per cycle } else { Error($"Unable to update order ({ticket.OrderId}) price to sell {ticket.Symbol} @ {prevPrice} to {updatedPrice}, response = {orderResponse.ErrorMessage}, bid = {quoteBar.Bid.Close}, ask = {quoteBar.Ask.Close}, time = {slice.Time}"); } } } } private void AtStartOfDayHandler() { isActive = !IsWarmingUp && CurrentSlice.Time.Date >= Start; minExpiryDate = CurrentSlice.Time.AddDays(MinExpiryDays); maxExpiryDate = CurrentSlice.Time.AddDays(MaxExpiryDays); minUnwindDate = CurrentSlice.Time.Date.AddDays(LiquidateExpiryDays); aggUnwindDate = CurrentSlice.Time.Date.AddDays(AggLiquidateExpiryDays); // Entered new month, recalibrate on new target notional if (isActive) { if (CurrentSlice.Time.Year != noiScheduleDate.Year || CurrentSlice.Time.Month != noiScheduleDate.Month) { noiScheduleDate = new DateTime(CurrentSlice.Time.Year, CurrentSlice.Time.Month, 1); if (!discountedMonthlyIncome.TryGetValue(noiScheduleDate, out targetHedgeNotional)) { Error($"Failed to lookup NOI schedule for {noiScheduleDate.ToShortDateString()}"); Quit(); // Debug($"New month {noiScheduleDate.ToShortDateString()} -> {targetHedgeNotional:N}"); } } foreach (var symbolGroup in History(TradeMaTimeSpan) .SelectMany(s => s.Bars.Values) .GroupBy(bar => bar.Symbol)) { SetAverageVolume(symbolGroup.Key, symbolGroup.Average(bar => bar.Volume) / TradedMaDays); } // Check if any positions are expired but somehow left over IEnumerable<Option> expiredPos = Portfolio.Where(kvp => kvp.Value.Invested) .Select(kvp => Securities[kvp.Key] as Option) .Where(opt => opt != null) .Where(opt => opt.Expiry.Date < CurrentSlice.Time.Date); if (expiredPos.Any()) { string msg = string.Join(", ", expiredPos.Select(option => $"{option.Symbol}, {Portfolio[option.Symbol].Quantity}, {Securities[option.Symbol].Price}")); Error(msg); Quit("There are expired holdings"); } // Check if there are any short positions which should not happen IEnumerable<SecurityHolding> shortPos = Portfolio.Values.Where(sh => sh.Invested && sh.IsShort); foreach (SecurityHolding securityHolding in shortPos.Where(sh => sh.Type == SecurityType.Equity)) { Log($"Liquidating exercised short stock {securityHolding.Quantity} {securityHolding.NetProfit}"); OrderTicket orderTicket = MarketOrder(securityHolding.Symbol, -securityHolding.Quantity, true, "Liquidate Exercised"); if (orderTicket.Status != OrderStatus.Invalid) { knownOrders[orderTicket.OrderId] = orderTicket; } } if (shortPos.Any(sh => sh.Type != SecurityType.Equity)) { Error(string.Join(", ", shortPos.Select(sh => $"{sh.Symbol}:{sh.Quantity}"))); Quit("There are short positions"); } // Log(string.Join(", ", Portfolio.Values.Where(sh => sh.Invested).OrderBy(sh => sh.Symbol).Select(sh => $"{sh.Symbol}:{sh.Quantity}"))); } } private decimal SendLimitOrder(OptionContract optionContract, decimal targetQty, decimal prctOfSpread, string type, string addlTags = "") { if (targetQty == Decimal.Zero || !IsMarketOpen(optionContract.Symbol)) { // Debug($"{optionContract.Symbol} - Market is not open"); return Decimal.Zero; } decimal traded = GetTradedQuantity(optionContract.Symbol); decimal qty = targetQty; decimal avgVolume = Decimal.Zero; // Only restrict volume if accumulating, otherwise take what market is offering if (qty > Decimal.Zero) // Going long { if(traded > Decimal.Zero) { if (!averageVolume.TryGetValue(optionContract.Symbol, out avgVolume) || avgVolume == Decimal.Zero) { // Debug($"{optionContract.Symbol} - No volume"); return Decimal.Zero; } // Adjust quantity to be % of avg volume less what has already traded qty = Math.Min(qty, Math.Ceiling(avgVolume * MaxPercentDailyVolume) - traded); if (qty <= Decimal.Zero) { // Debug($"{optionContract.Symbol} - No more quantity {volume}, {traded}, {maxQty}, {targetQty}"); return Decimal.Zero; } } if (qty >= Decimal.One) { Option optSec = Securities[optionContract.Symbol] as Option; if (optSec != null) { GetMaximumOrderQuantityResult buyingPower = optSec.BuyingPowerModel.GetMaximumOrderQuantityForTargetBuyingPower(Portfolio, optSec, qty); if (buyingPower.Quantity < Decimal.One) { // Debug($"NNN: Not enough buying power to purchase {qty} of {optionContract.Symbol} - {buyingPower.Reason}"); return Decimal.Zero; } else if(qty > buyingPower.Quantity) { // Reduce amount based on available margin Debug($"Reducing quantity from {qty} to {buyingPower.Quantity} because of margin requirements"); qty = Math.Min(buyingPower.Quantity, qty); } } } } decimal bidPrice = optionContract.BidPrice; decimal askPrice = optionContract.AskPrice; if (askPrice < bidPrice || bidPrice <= Decimal.Zero || askPrice <= Decimal.Zero) { QuoteBar quoteBar; if (!CurrentSlice.QuoteBars.TryGetValue(optionContract.Symbol, out quoteBar) || quoteBar?.Ask == null || quoteBar?.Bid == null || quoteBar.Ask.Close <= Decimal.Zero || quoteBar.Bid.Close <= Decimal.Zero || quoteBar.Ask.Close <= quoteBar.Bid.Close) { // Debug($"{optionContract.Symbol} - No quote bar or Ask/Bid bar"); return Decimal.Zero; } else { bidPrice = quoteBar.Bid.Close; askPrice = quoteBar.Ask.Close; Debug($"{optionContract.Symbol} - Bad option contact prices, using quote bars [${quoteBar.Bid.Close}, ${quoteBar.Ask.Close}]"); } } // rawPrice = qty > 0 ? bidPrice : askPrice; decimal spread = optionContract.AskPrice - optionContract.BidPrice; decimal spreadToPay = Math.Max(0.0m, prctOfSpread) * spread; decimal rawPrice = qty > 0 ? optionContract.BidPrice + spreadToPay : optionContract.AskPrice - spreadToPay; decimal price = Math.Round(rawPrice, 2); if(price <= Decimal.Zero) return Decimal.Zero; string sentTag = $"{type}, {targetQty}, {price}, {bidPrice}, {askPrice}, {prctOfSpread:F1}, {targetHedgeNotional:F1}, " + $"{currentHedgeNotional:F1}, {Portfolio[optionContract.Symbol].Quantity}, {traded}, {avgVolume}, " + $"{optionContract.UnderlyingLastPrice}, {optionContract.ImpliedVolatility:F6}, {optionContract.TheoreticalPrice:F6}, " + $"{optionContract.Greeks.Delta:F6}, {optionContract.Greeks.Vega:F6}, {optionContract.Greeks.Theta:F4}, " + $"{optionContract.Greeks.Gamma:F4}"; if(!string.IsNullOrEmpty(addlTags)) sentTag += $", {addlTags}"; OrderTicket orderTicket = LimitOrder(optionContract.Symbol, qty, price, sentTag); if (orderTicket.Status == OrderStatus.Invalid) { Debug( $"Received invalid order - {orderTicket.OrderId}, {optionContract.Symbol}, {(optionContract.Strike / optionContract.UnderlyingLastPrice):P}, " + $"{qty}, {price}, {bidPrice}, {askPrice}, {prctOfSpread:P}, {bidPrice}, {askPrice}, {orderTicket.GetMostRecentOrderRequest().Response}"); return Decimal.Zero; } // Debug($"Sending order - {orderTicket.OrderId}, {optionContract.Symbol}, {(optionContract.StrikePrice / optionContract.Underlying.Price):P}, " + // $"{qty}, {price}, {spread}, {prctOfSpread:P}, {quoteBar.Bid?.Close}, {quoteBar.Ask?.Close}, {rawPrice.SmartRounding()}"); knownOrders[orderTicket.OrderId] = orderTicket; return qty; } private void AtEndOfDayHandler() { isActive = false; CalculateHedgeNotional(); decimal weightedHedgeDays = GetWeightedExpirationDays(); if (weightedHedgeDays < Decimal.Zero) { string data = string.Join(",", currentHedgeNotionalByExpiry.Select(kvp => $"{kvp.Key}={kvp.Value.SmartRounding()}")); Error($"{data}, {CurrentSlice.Time}, {currentHedgeNotional.SmartRounding()}"); Quit("Negative weighted hedge expiration days"); } decimal hedgeNotionalExpiringSoon = GetUnderMinimumExpiration().Select(opt => CalculateSecurityHedgeNotional(opt.Symbol, Portfolio[opt.Symbol].Quantity)).Sum(); Log( $"EOD, {currentHedgeNotional:F1}, {targetHedgeNotional:F1}, {weightedHedgeDays:F1}, {hedgeNotionalExpiringSoon:F1}, {Portfolio.TotalPortfolioValue:F1}, {Portfolio.Cash:F1}, " + $"{Portfolio.TotalFees:F1}, {Portfolio.TotalAbsoluteHoldingsCost:F1}, {Portfolio.TotalHoldingsValue:F1}, {Portfolio.TotalProfit:F1}, {Portfolio.TotalUnrealizedProfit:F1}" ); Plot("Hedge", "Current", currentHedgeNotional); Plot("Hedge", "Target", targetHedgeNotional); Plot("Expiry", "Weighted Days", weightedHedgeDays); OptionChain chain; if (!CurrentSlice.OptionChains.TryGetValue(OptionSymbol, out chain)) { Error($"EOD: No option contract chain for {OptionSymbol}"); } else { IEnumerable<decimal> rank = chain .Where(oc => oc.Expiry >= minExpiryDate && oc.Expiry <= maxExpiryDate) .Where(oc => oc.BidPrice > Decimal.Zero && oc.AskPrice > Decimal.Zero && oc.AskPrice > oc.BidPrice) .Select(HedgeValueRank) .OrderByDescending(d => d); decimal[] hedgeDayValues = rank.ToArray(); if (hedgeDayValues.Length > 0) { decimal medianEma = hddMedianEma.Current.Value; decimal averageEma = hddAverageEma.Current.Value; decimal max = hedgeDayValues.First(); decimal min = hedgeDayValues.Last(); decimal median = hedgeDayValues[hedgeDayValues.Length / 2]; decimal average = hedgeDayValues.Sum() / hedgeDayValues.Length; hddMedianEma.Update(CurrentSlice.Time, median); hddAverageEma.Update(CurrentSlice.Time, average); Log($"HDD, {hedgeDayValues.Length}, {max:F1}, {min:F1}, {median:F1}, {average:F1}, {medianEma:F1}, {averageEma:F1}, {hddAverageEma.IsReady}"); Plot("HD/$", "Median", median); Plot("HD/$", "Average", average); Plot("HD/$", "Median EMA", medianEma); Plot("HD/$", "Average EMA", averageEma); Plot("HD/$", "Max", max); Plot("HD/$", "Min", min); } else { Log($"HDD, {hedgeDayValues.Length}, N/A, N/A, N/A, N/A, N/A, N/A, {hddAverageEma.IsReady}"); } } currentHedgeNotional = Decimal.Zero; minUnwindDate = DateTime.MaxValue; // aggUnwindDate = DateTime.MaxValue; averageVolume.Clear(); tradedVolume.Clear(); currentHedgeNotionalByExpiry.Clear(); } // private void SetCurrentExpiries(OptionChain chain) // { // var surroundingExpiries = chain // .Select(x => x.Expiry) // .Distinct() // .ToDictionary(expiry => (expiry-pivotDate).TotalDays) // .Where(kvp => (kvp.Key > 0 || (kvp.Key + TargetExpiryPivot) > MinExpiryDays)) // //Don't roll to next expiry unless at least a month out to minimize transaction costs // // .Where(kvp => kvp.Key > 30) // .OrderBy(kvp => Math.Abs(kvp.Key)) // .ToLookup(kvp => kvp.Key < 0); // // int[] daysAway = new int[] {-1, -1}; // // DateTime earlierExp = surroundingExpiries[true].Select(kvp => kvp.Value).FirstOrDefault(); // if (earlierExp != default(DateTime)) // { // if (pivotDate < earlierExp) // { // Error( // $"Earlier expiration {earlierExp.ToShortDateString()} is after pivot {pivotDate.ToShortDateString()}" // ); // } // else // { // expirations[0] = earlierExp; // daysAway[0] = (pivotDate - earlierExp).Days; // } // } // // DateTime laterExp = surroundingExpiries[false].Select(kvp => kvp.Value).FirstOrDefault(); // if (laterExp != default(DateTime)) // { // if (pivotDate > laterExp) // { // Error( // $"Later expiration {laterExp.ToShortDateString()} is after pivot {pivotDate.ToShortDateString()}" // ); // } // else // { // expirations[1] = laterExp; // daysAway[1] = (laterExp - pivotDate).Days; // } // } // // if (daysAway[0] < 0 && daysAway[1] < 0) // { // Error($"No expiries found with pivot @ {pivotDate.ToShortDateString()}"); // } // else if (daysAway[0] < 0) // { // targetHedgeNotionalByExpiry[0] = Decimal.Zero; // targetHedgeNotionalByExpiry[1] = 0.75m * targetHedgeNotional; // } // else if (daysAway[1] < 0) // { // targetHedgeNotionalByExpiry[0] = 0.25m * targetHedgeNotional; // targetHedgeNotionalByExpiry[1] = Decimal.Zero; // } // else // { // decimal totalDays = Convert.ToDecimal(daysAway[0] + daysAway[1]); // targetHedgeNotionalByExpiry[0] = (Convert.ToDecimal(daysAway[1]) / totalDays) * targetHedgeNotional; // targetHedgeNotionalByExpiry[1] = (Convert.ToDecimal(daysAway[0]) / totalDays) * targetHedgeNotional; // } // } public override void OnOrderEvent(OrderEvent orderEvent) { switch (orderEvent.Status) { case OrderStatus.Filled: case OrderStatus.PartiallyFilled: if (tradedVolume.ContainsKey(orderEvent.Symbol)) tradedVolume[orderEvent.Symbol] += orderEvent.AbsoluteFillQuantity; else tradedVolume.Add(orderEvent.Symbol, orderEvent.AbsoluteFillQuantity); if (orderEvent.Status == OrderStatus.Filled) RemoveKnownOrder(orderEvent); // Debug($"Order filled - {orderEvent.Symbol}, {orderEvent.OrderId}, {orderEvent.Id}, {orderEvent.FillQuantity}, {orderEvent.FillPrice.SmartRounding()}, {orderEvent.OrderFee.Value.Amount}, {orderEvent.Message}, {orderEvent.Status}"); break; case OrderStatus.Invalid: //Usually not enough margin RemoveKnownOrder(orderEvent); // Debug($"Invalid order - {orderEvent.Symbol}, {orderEvent.OrderId}, {orderEvent.Id}, {orderEvent.Quantity}, {orderEvent.Message}"); break; case OrderStatus.Canceled: RemoveKnownOrder(orderEvent); // Log($"Canceled order - {orderEvent.Symbol}, {orderEvent.OrderId}, {orderEvent.Id}"); break; case OrderStatus.Submitted: case OrderStatus.UpdateSubmitted: case OrderStatus.New: case OrderStatus.None: break; case OrderStatus.CancelPending: // Log($"Cancel pending - {orderEvent.Symbol}, {orderEvent.OrderId}, {orderEvent.Id}"); break; default: Error($"Unhandled order status on order event - {orderEvent.Status}"); break; } } public override void OnSecuritiesChanged(SecurityChanges changes) { foreach (var change in changes.AddedSecurities) { // Only print options price if (change.Symbol == UnderlyingSymbol) continue; IEnumerable<TradeBar> history = History(change.Symbol, TradeMaTimeSpan); if (history.Any()) { SetAverageVolume(change.Symbol, history.Sum(bar => bar.Volume) / TradedMaDays); } } } private void SetAverageVolume(Symbol symbol, decimal avgVolume) { if(averageVolume.ContainsKey(symbol)) averageVolume[symbol] = avgVolume; else averageVolume.Add(symbol, avgVolume); } private void RemoveKnownOrder(OrderEvent orderEvent) { if (knownOrders.ContainsKey(orderEvent.OrderId)) knownOrders.Remove(orderEvent.OrderId); else if(orderEvent.IsAssignment) // Shouldn't happen Error($"Option assigned - {orderEvent.Symbol}, {orderEvent.FillQuantity}, {orderEvent.FillPrice.SmartRounding()}"); else { OrderTicket ticket = Transactions.GetOrderTicket(orderEvent.OrderId); if (ticket.OrderType == OrderType.OptionExercise && orderEvent.FillQuantity != Decimal.Zero) { // decimal underlyingPrice = Securities[UnderlyingSymbol].Price; // if() // { // Log( // $"Option exercised - {orderEvent.Symbol}, {orderEvent.FillQuantity}, {orderEvent.FillPrice}, {underlyingPrice.SmartRounding()}"); // } } else if(orderEvent.FillQuantity != Decimal.Zero) { Error($"Received order event for unknown order - {ticket.OrderId}, {orderEvent.Symbol}, {ticket?.OrderType}, {ticket?.Quantity}, " + $"{orderEvent.FillPrice.SmartRounding()}, {orderEvent.Message}, {orderEvent.Status}, {ticket?.OrderType}"); } } } private void UpdateExpiryHedgeNotional(DateTime expiry, decimal hedgeNotional) { decimal currentExpiry; currentHedgeNotionalByExpiry.TryGetValue(expiry, out currentExpiry); currentHedgeNotionalByExpiry[expiry] = currentExpiry + hedgeNotional; } private void CalculateHedgeNotional() { currentHedgeNotional = Decimal.Zero; currentHedgeNotionalByExpiry.Clear(); foreach (var kvp in Portfolio.Where(kvp => kvp.Value.Invested)) { decimal secNotional = CalculateSecurityHedgeNotional(kvp.Key, kvp.Value.Quantity); var security = Securities[kvp.Key] as Option; if (security != null) UpdateExpiryHedgeNotional(security.Expiry, secNotional); // else // Error($"{kvp.Key} is missing from security registry"); //Will be reported in CalculateSecurityHedgeNotional currentHedgeNotional += secNotional; } } private decimal CalculateSecurityHedgeNotional(Symbol symbol, decimal quantity) { Option optionSecurity = Securities[symbol] as Option; if (optionSecurity != null) { var secNotionalHedge = quantity * optionSecurity.ContractMultiplier * (optionSecurity.StrikePrice - ResidualUnderlyingValue); // Debug($"Notional Hedge - {symbol} {quantity} {optionContract.StrikePrice} {secNotionalHedge}"); return secNotionalHedge; } else { Error($"{symbol} is missing from security registry"); return Decimal.Zero; } } internal class CustomFillModel : ImmediateFillModel { private readonly QCAlgorithm _algorithm; private readonly Dictionary<int, decimal> _absoluteRemainingByOrderId = new Dictionary<int, decimal>(); public CustomFillModel(QCAlgorithm algorithm) { _algorithm = algorithm; } public override OrderEvent LimitFill(Security asset, LimitOrder order) { DateTime utcTime = asset.LocalTime.ConvertToUtc(asset.Exchange.TimeZone); OrderEvent fill = new OrderEvent(order, utcTime, OrderFee.Zero); try { if (order.Status == OrderStatus.Canceled || !IsExchangeOpen(asset, false)) return fill; // Only fill once a time slice if (order.LastFillTime != null && utcTime <= order.LastFillTime) return fill; Prices prices = GetPrices(asset, order.Direction); QuoteBar quoteBar = asset.Cache.GetData<QuoteBar>(); DateTime pricesEndTime = prices.EndTime.ConvertToUtc(asset.Exchange.TimeZone); // do not fill again on a previous quote if (order.LastFillTime != null && pricesEndTime <= order.LastFillTime) return fill; decimal absoluteRemaining; if (!_absoluteRemainingByOrderId.TryGetValue(order.Id, out absoluteRemaining)) { absoluteRemaining = order.AbsoluteQuantity; } switch (order.Direction) { case OrderDirection.Buy: if (prices.Low <= order.LimitPrice) { fill.FillPrice = Math.Min(prices.High, order.LimitPrice); fill.FillQuantity = Math.Min(absoluteRemaining, quoteBar?.LastAskSize ?? absoluteRemaining); } break; case OrderDirection.Sell: if (prices.High >= order.LimitPrice) { fill.FillPrice = Math.Max(prices.Low, order.LimitPrice); fill.FillQuantity = -Math.Min(absoluteRemaining, quoteBar?.LastBidSize ?? absoluteRemaining); } break; } decimal absoluteFillQuantity = Math.Abs(fill.FillQuantity); if (absoluteFillQuantity > Decimal.Zero) { if (absoluteRemaining == absoluteFillQuantity) { fill.Status = OrderStatus.Filled; _absoluteRemainingByOrderId.Remove(order.Id); } else { fill.Status = OrderStatus.PartiallyFilled; absoluteRemaining -= absoluteFillQuantity; _absoluteRemainingByOrderId[order.Id] = absoluteRemaining; } // _algorithm.Debug( // $"Model Filled - {fill.Symbol}, {fill.FillQuantity}, {fill.FillPrice}, {fill.Quantity}, {orderTicket?.Quantity}, {absoluteRemaining}, {quoteBar.Time.ToShortTimeString()}, {quoteBar}" // ); } } catch (Exception e) { _algorithm.Error($"Exception in CustomFillModel for {order.Id}: {e.StackTrace}"); } return fill; } } } }