/* * 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 Deedle; using QuantConnect.Packets; using System; using System.Collections.Generic; using System.Linq; namespace QuantConnect.Report { /// /// Collection of drawdowns for the given period marked by start and end date /// public class DrawdownCollection { /// /// Starting time of the drawdown collection /// public DateTime Start { get; private set; } /// /// Ending time of the drawdown collection /// public DateTime End { get; private set; } /// /// Number of periods to take into consideration for the top N drawdown periods. /// This will be the number of items contained in the collection. /// public int Periods { get; private set; } /// /// Worst drawdowns encountered /// public List Drawdowns { get; private set; } /// /// Creates an instance with a default collection (no items) and the top N worst drawdowns /// /// public DrawdownCollection(int periods) { Drawdowns = new List(); Periods = periods; } /// /// Creates an instance from the given drawdowns and the top N worst drawdowns /// /// Equity curve with both live and backtesting merged /// Periods this collection contains public DrawdownCollection(Series strategySeries, int periods) { var drawdowns = GetDrawdownPeriods(strategySeries, periods).ToList(); Periods = periods; Start = strategySeries.IsEmpty ? DateTime.MinValue : strategySeries.FirstKey(); End = strategySeries.IsEmpty ? DateTime.MaxValue : strategySeries.LastKey(); Drawdowns = drawdowns.OrderByDescending(x => x.PeakToTrough) .Take(Periods) .ToList(); } /// /// Generate a new instance of DrawdownCollection from backtest and live derived instances /// /// Backtest result packet /// Live result packet /// Top N drawdown periods to get /// DrawdownCollection instance public static DrawdownCollection FromResult(BacktestResult backtestResult = null, LiveResult liveResult = null, int periods = 5) { return new DrawdownCollection(NormalizeResults(backtestResult, liveResult), periods); } /// /// Normalizes the Series used to calculate the drawdown plots and charts /// /// Backtest result packet /// Live result packet /// public static Series NormalizeResults(BacktestResult backtestResult, LiveResult liveResult) { var backtestPoints = ResultsUtil.EquityPoints(backtestResult); var livePoints = ResultsUtil.EquityPoints(liveResult); if (backtestPoints.Count < 2 && livePoints.Count < 2) { return new Series(new DateTime[] { }, new double[] { }); } var startingEquity = backtestPoints.Count == 0 ? livePoints.First().Value : backtestPoints.First().Value; // Note: these calculations are *incorrect* for getting the cumulative returns. However, since we're just // trying to normalize these two series with each other, it's a good candidate for it since the original // values can easily be recalculated from this point var backtestSeries = new Series(backtestPoints).PercentChange().Where(kvp => !double.IsInfinity(kvp.Value)).CumulativeSum(); var liveSeries = new Series(livePoints).PercentChange().Where(kvp => !double.IsInfinity(kvp.Value)).CumulativeSum(); // Get the last key of the backtest series if our series is empty to avoid issues with empty frames var firstLiveKey = liveSeries.IsEmpty ? backtestSeries.LastKey().AddDays(1) : liveSeries.FirstKey(); // Add the final non-overlapping point of the backtest equity curve to the entire live series to keep continuity. if (!backtestSeries.IsEmpty) { var filtered = backtestSeries.Where(kvp => kvp.Key < firstLiveKey); liveSeries = filtered.IsEmpty ? liveSeries : liveSeries + filtered.LastValue(); } // Prefer the live values as we don't care about backtest once we've deployed into live. // All in all, this is a normalized equity curve, though it's been normalized // so that there are no discontinuous jumps in equity value if we only used equity cash // to add the last value of the backtest series to the live series. // // Pandas equivalent: // // ``` // pd.concat([backtestSeries, liveSeries], axis=1).fillna(method='ffill').dropna().diff().add(1).cumprod().mul(startingEquity) // ``` return backtestSeries.Merge(liveSeries, UnionBehavior.PreferRight) .FillMissing(Direction.Forward) .DropMissing() .Diff(1) .SelectValues(x => x + 1) .CumulativeProduct() .SelectValues(x => x * startingEquity); } /// /// Gets the underwater plot for the provided curve. /// Data is expected to be the concatenated output of . /// /// Equity curve /// public static Series GetUnderwater(Series curve) { if (curve.IsEmpty) { return curve; } var returns = curve / curve.FirstValue(); var cumulativeMax = returns.CumulativeMax(); return (1 - (returns / cumulativeMax)) * -1; } /// /// Gets all the data associated with the underwater plot and everything used to generate it. /// Note that you should instead use if you /// want to just generate an underwater plot. This is internally used to get the top N worst drawdown periods. /// /// Equity curve /// Frame containing the following keys: "returns", "cumulativeMax", "drawdown" public static Frame GetUnderwaterFrame(Series curve) { var frame = Frame.CreateEmpty(); if (curve.IsEmpty) { return frame; } var returns = curve / curve.FirstValue(); var cumulativeMax = returns.CumulativeMax(); var drawdown = 1 - (returns / cumulativeMax); frame.AddColumn("returns", returns); frame.AddColumn("cumulativeMax", cumulativeMax); frame.AddColumn("drawdown", drawdown); return frame; } /// /// Gets the top N worst drawdowns and associated statistics. /// Returns a Frame with the following keys: "duration", "cumulativeMax", "drawdown" /// /// Equity curve /// Top N worst periods. If this is greater than the results, we retrieve all the items instead /// Frame with the following keys: "duration", "cumulativeMax", "drawdown" public static Frame GetTopWorstDrawdowns(Series curve, int periods) { var frame = Frame.CreateEmpty(); if (curve.IsEmpty) { return frame; } var returns = curve / curve.FirstValue(); var cumulativeMax = returns.CumulativeMax(); var drawdown = 1 - (returns / cumulativeMax); var groups = cumulativeMax.GroupBy(kvp => kvp.Value); // In order, the items are: date, duration, cumulative max, max drawdown var drawdownGroups = new List>(); foreach (var group in groups.Values) { var firstDate = group.SortByKey().FirstKey(); var lastDate = group.SortByKey().LastKey(); var cumulativeMaxGroup = cumulativeMax.Between(firstDate, lastDate); var drawdownGroup = drawdown.Between(firstDate, lastDate); var drawdownGroupMax = drawdownGroup.Values.Max(); var drawdownMax = drawdownGroup.Where(kvp => kvp.Value == drawdownGroupMax); drawdownGroups.Add(new Tuple( drawdownMax.FirstKey(), group.ValueCount, cumulativeMaxGroup.FirstValue(), drawdownMax.FirstValue() )); } var drawdowns = new Series(drawdownGroups.Select(x => x.Item1), drawdownGroups.Select(x => x.Item4)); // Sort by negative drawdown value (in ascending order), which leaves it sorted in descending order 😮 var sortedDrawdowns = drawdowns.SortBy(x => -x); // Only get the most we're allowed to take so that we don't overflow trying to get more drawdown items than exist var periodsToTake = periods < sortedDrawdowns.ValueCount ? periods : sortedDrawdowns.ValueCount; // Again, in order, the items are: date (Item1), duration (Item2), cumulative max (Item3), max drawdown (Item4). var topDrawdowns = new Series(sortedDrawdowns.Keys.Take(periodsToTake), sortedDrawdowns.Values.Take(periodsToTake)); var topDurations = new Series(topDrawdowns.Keys.OrderBy(x => x), drawdownGroups.Where(t => topDrawdowns.Keys.Contains(t.Item1)).OrderBy(x => x.Item1).Select(x => x.Item2)); var topCumulativeMax = new Series(topDrawdowns.Keys.OrderBy(x => x), drawdownGroups.Where(t => topDrawdowns.Keys.Contains(t.Item1)).OrderBy(x => x.Item1).Select(x => x.Item3)); frame.AddColumn("duration", topDurations); frame.AddColumn("cumulativeMax", topCumulativeMax); frame.AddColumn("drawdown", topDrawdowns); return frame; } /// /// Gets the given drawdown periods from the equity curve and the set periods /// /// Equity curve /// Top N drawdown periods to get /// Enumerable of DrawdownPeriod public static IEnumerable GetDrawdownPeriods(Series curve, int periods = 5) { var frame = GetUnderwaterFrame(curve); var topDrawdowns = GetTopWorstDrawdowns(curve, periods); for (var i = 1; i <= topDrawdowns.RowCount; i++) { var data = DrawdownGroup(frame, topDrawdowns["cumulativeMax"].GetAt(i - 1)); // Tuple is as follows: Start (Item1: DateTime), End (Item2: DateTime), Max Drawdown (Item3: double) yield return new DrawdownPeriod(data.Item1, data.Item2, data.Item3); } } private static Tuple DrawdownGroup(Frame frame, double groupMax) { var drawdownAfter = frame["cumulativeMax"].Where(kvp => kvp.Value > groupMax); var drawdownGroup = frame["cumulativeMax"].Where(kvp => kvp.Value == groupMax); var groupDrawdown = frame["drawdown"].Realign(drawdownGroup.Keys).Max(); var groupStart = drawdownGroup.FirstKey(); // Get the start of the next period if it exists. That is when the drawdown period has officially ended. // We do this to extend the drawdown period enough so that missing values don't stop it early. var groupEnd = drawdownAfter.IsEmpty ? drawdownGroup.LastKey() : drawdownAfter.FirstKey(); return new Tuple(groupStart, groupEnd, groupDrawdown); } } }