Lean  $LEAN_TAG$
DrawdownCollection.cs
1 /*
2  * QUANTCONNECT.COM - Democratizing Finance, Empowering Individuals.
3  * Lean Algorithmic Trading Engine v2.0. Copyright 2014 QuantConnect Corporation.
4  *
5  * Licensed under the Apache License, Version 2.0 (the "License");
6  * you may not use this file except in compliance with the License.
7  * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
8  *
9  * Unless required by applicable law or agreed to in writing, software
10  * distributed under the License is distributed on an "AS IS" BASIS,
11  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12  * See the License for the specific language governing permissions and
13  * limitations under the License.
14 */
15 
16 using Deedle;
17 using QuantConnect.Packets;
18 using System;
19 using System.Collections.Generic;
20 using System.Linq;
21 
22 namespace QuantConnect.Report
23 {
24  /// <summary>
25  /// Collection of drawdowns for the given period marked by start and end date
26  /// </summary>
27  public class DrawdownCollection
28  {
29  /// <summary>
30  /// Starting time of the drawdown collection
31  /// </summary>
32  public DateTime Start { get; private set; }
33 
34  /// <summary>
35  /// Ending time of the drawdown collection
36  /// </summary>
37  public DateTime End { get; private set; }
38 
39  /// <summary>
40  /// Number of periods to take into consideration for the top N drawdown periods.
41  /// This will be the number of items contained in the <see cref="Drawdowns"/> collection.
42  /// </summary>
43  public int Periods { get; private set; }
44 
45  /// <summary>
46  /// Worst drawdowns encountered
47  /// </summary>
48  public List<DrawdownPeriod> Drawdowns { get; private set; }
49 
50  /// <summary>
51  /// Creates an instance with a default collection (no items) and the top N worst drawdowns
52  /// </summary>
53  /// <param name="periods"></param>
54  public DrawdownCollection(int periods)
55  {
56  Drawdowns = new List<DrawdownPeriod>();
57  Periods = periods;
58  }
59 
60  /// <summary>
61  /// Creates an instance from the given drawdowns and the top N worst drawdowns
62  /// </summary>
63  /// <param name="strategySeries">Equity curve with both live and backtesting merged</param>
64  /// <param name="periods">Periods this collection contains</param>
65  public DrawdownCollection(Series<DateTime, double> strategySeries, int periods)
66  {
67  var drawdowns = GetDrawdownPeriods(strategySeries, periods).ToList();
68 
69  Periods = periods;
70  Start = strategySeries.IsEmpty ? DateTime.MinValue : strategySeries.FirstKey();
71  End = strategySeries.IsEmpty ? DateTime.MaxValue : strategySeries.LastKey();
72  Drawdowns = drawdowns.OrderByDescending(x => x.PeakToTrough)
73  .Take(Periods)
74  .ToList();
75  }
76 
77  /// <summary>
78  /// Generate a new instance of DrawdownCollection from backtest and live <see cref="Result"/> derived instances
79  /// </summary>
80  /// <param name="backtestResult">Backtest result packet</param>
81  /// <param name="liveResult">Live result packet</param>
82  /// <param name="periods">Top N drawdown periods to get</param>
83  /// <returns>DrawdownCollection instance</returns>
84  public static DrawdownCollection FromResult(BacktestResult backtestResult = null, LiveResult liveResult = null, int periods = 5)
85  {
86  return new DrawdownCollection(NormalizeResults(backtestResult, liveResult), periods);
87  }
88 
89  /// <summary>
90  /// Normalizes the Series used to calculate the drawdown plots and charts
91  /// </summary>
92  /// <param name="backtestResult">Backtest result packet</param>
93  /// <param name="liveResult">Live result packet</param>
94  /// <returns></returns>
95  public static Series<DateTime, double> NormalizeResults(BacktestResult backtestResult, LiveResult liveResult)
96  {
97  var backtestPoints = ResultsUtil.EquityPoints(backtestResult);
98  var livePoints = ResultsUtil.EquityPoints(liveResult);
99 
100  if (backtestPoints.Count < 2 && livePoints.Count < 2)
101  {
102  return new Series<DateTime, double>(new DateTime[] { }, new double[] { });
103  }
104 
105  var startingEquity = backtestPoints.Count == 0 ? livePoints.First().Value : backtestPoints.First().Value;
106 
107  // Note: these calculations are *incorrect* for getting the cumulative returns. However, since we're just
108  // trying to normalize these two series with each other, it's a good candidate for it since the original
109  // values can easily be recalculated from this point
110  var backtestSeries = new Series<DateTime, double>(backtestPoints).PercentChange().Where(kvp => !double.IsInfinity(kvp.Value)).CumulativeSum();
111  var liveSeries = new Series<DateTime, double>(livePoints).PercentChange().Where(kvp => !double.IsInfinity(kvp.Value)).CumulativeSum();
112 
113  // Get the last key of the backtest series if our series is empty to avoid issues with empty frames
114  var firstLiveKey = liveSeries.IsEmpty ? backtestSeries.LastKey().AddDays(1) : liveSeries.FirstKey();
115 
116  // Add the final non-overlapping point of the backtest equity curve to the entire live series to keep continuity.
117  if (!backtestSeries.IsEmpty)
118  {
119  var filtered = backtestSeries.Where(kvp => kvp.Key < firstLiveKey);
120  liveSeries = filtered.IsEmpty ? liveSeries : liveSeries + filtered.LastValue();
121  }
122 
123  // Prefer the live values as we don't care about backtest once we've deployed into live.
124  // All in all, this is a normalized equity curve, though it's been normalized
125  // so that there are no discontinuous jumps in equity value if we only used equity cash
126  // to add the last value of the backtest series to the live series.
127  //
128  // Pandas equivalent:
129  //
130  // ```
131  // pd.concat([backtestSeries, liveSeries], axis=1).fillna(method='ffill').dropna().diff().add(1).cumprod().mul(startingEquity)
132  // ```
133  return backtestSeries.Merge(liveSeries, UnionBehavior.PreferRight)
134  .FillMissing(Direction.Forward)
135  .DropMissing()
136  .Diff(1)
137  .SelectValues(x => x + 1)
138  .CumulativeProduct()
139  .SelectValues(x => x * startingEquity);
140  }
141 
142  /// <summary>
143  /// Gets the underwater plot for the provided curve.
144  /// Data is expected to be the concatenated output of <see cref="ResultsUtil.EquityPoints"/>.
145  /// </summary>
146  /// <param name="curve">Equity curve</param>
147  /// <returns></returns>
149  {
150  if (curve.IsEmpty)
151  {
152  return curve;
153  }
154 
155  var returns = curve / curve.FirstValue();
156  var cumulativeMax = returns.CumulativeMax();
157 
158  return (1 - (returns / cumulativeMax)) * -1;
159  }
160 
161  /// <summary>
162  /// Gets all the data associated with the underwater plot and everything used to generate it.
163  /// Note that you should instead use <see cref="GetUnderwater(Series{DateTime, double})"/> if you
164  /// want to just generate an underwater plot. This is internally used to get the top N worst drawdown periods.
165  /// </summary>
166  /// <param name="curve">Equity curve</param>
167  /// <returns>Frame containing the following keys: "returns", "cumulativeMax", "drawdown"</returns>
168  public static Frame<DateTime, string> GetUnderwaterFrame(Series<DateTime, double> curve)
169  {
170  var frame = Frame.CreateEmpty<DateTime, string>();
171  if (curve.IsEmpty)
172  {
173  return frame;
174  }
175 
176  var returns = curve / curve.FirstValue();
177  var cumulativeMax = returns.CumulativeMax();
178  var drawdown = 1 - (returns / cumulativeMax);
179 
180  frame.AddColumn("returns", returns);
181  frame.AddColumn("cumulativeMax", cumulativeMax);
182  frame.AddColumn("drawdown", drawdown);
183 
184  return frame;
185  }
186 
187  /// <summary>
188  /// Gets the top N worst drawdowns and associated statistics.
189  /// Returns a Frame with the following keys: "duration", "cumulativeMax", "drawdown"
190  /// </summary>
191  /// <param name="curve">Equity curve</param>
192  /// <param name="periods">Top N worst periods. If this is greater than the results, we retrieve all the items instead</param>
193  /// <returns>Frame with the following keys: "duration", "cumulativeMax", "drawdown"</returns>
194  public static Frame<DateTime, string> GetTopWorstDrawdowns(Series<DateTime, double> curve, int periods)
195  {
196  var frame = Frame.CreateEmpty<DateTime, string>();
197  if (curve.IsEmpty)
198  {
199  return frame;
200  }
201 
202  var returns = curve / curve.FirstValue();
203  var cumulativeMax = returns.CumulativeMax();
204  var drawdown = 1 - (returns / cumulativeMax);
205 
206  var groups = cumulativeMax.GroupBy(kvp => kvp.Value);
207  // In order, the items are: date, duration, cumulative max, max drawdown
208  var drawdownGroups = new List<Tuple<DateTime, double, double, double>>();
209 
210  foreach (var group in groups.Values)
211  {
212  var firstDate = group.SortByKey().FirstKey();
213  var lastDate = group.SortByKey().LastKey();
214 
215  var cumulativeMaxGroup = cumulativeMax.Between(firstDate, lastDate);
216  var drawdownGroup = drawdown.Between(firstDate, lastDate);
217  var drawdownGroupMax = drawdownGroup.Values.Max();
218 
219  var drawdownMax = drawdownGroup.Where(kvp => kvp.Value == drawdownGroupMax);
220 
221  drawdownGroups.Add(new Tuple<DateTime, double, double, double>(
222  drawdownMax.FirstKey(),
223  group.ValueCount,
224  cumulativeMaxGroup.FirstValue(),
225  drawdownMax.FirstValue()
226  ));
227  }
228 
229  var drawdowns = new Series<DateTime, double>(drawdownGroups.Select(x => x.Item1), drawdownGroups.Select(x => x.Item4));
230  // Sort by negative drawdown value (in ascending order), which leaves it sorted in descending order 😮
231  var sortedDrawdowns = drawdowns.SortBy(x => -x);
232  // Only get the most we're allowed to take so that we don't overflow trying to get more drawdown items than exist
233  var periodsToTake = periods < sortedDrawdowns.ValueCount ? periods : sortedDrawdowns.ValueCount;
234 
235  // Again, in order, the items are: date (Item1), duration (Item2), cumulative max (Item3), max drawdown (Item4).
236  var topDrawdowns = new Series<DateTime, double>(sortedDrawdowns.Keys.Take(periodsToTake), sortedDrawdowns.Values.Take(periodsToTake));
237  var topDurations = new Series<DateTime, double>(topDrawdowns.Keys.OrderBy(x => x), drawdownGroups.Where(t => topDrawdowns.Keys.Contains(t.Item1)).OrderBy(x => x.Item1).Select(x => x.Item2));
238  var topCumulativeMax = new Series<DateTime, double>(topDrawdowns.Keys.OrderBy(x => x), drawdownGroups.Where(t => topDrawdowns.Keys.Contains(t.Item1)).OrderBy(x => x.Item1).Select(x => x.Item3));
239 
240  frame.AddColumn("duration", topDurations);
241  frame.AddColumn("cumulativeMax", topCumulativeMax);
242  frame.AddColumn("drawdown", topDrawdowns);
243 
244  return frame;
245  }
246 
247  /// <summary>
248  /// Gets the given drawdown periods from the equity curve and the set periods
249  /// </summary>
250  /// <param name="curve">Equity curve</param>
251  /// <param name="periods">Top N drawdown periods to get</param>
252  /// <returns>Enumerable of DrawdownPeriod</returns>
253  public static IEnumerable<DrawdownPeriod> GetDrawdownPeriods(Series<DateTime, double> curve, int periods = 5)
254  {
255  var frame = GetUnderwaterFrame(curve);
256  var topDrawdowns = GetTopWorstDrawdowns(curve, periods);
257 
258  for (var i = 1; i <= topDrawdowns.RowCount; i++)
259  {
260  var data = DrawdownGroup(frame, topDrawdowns["cumulativeMax"].GetAt(i - 1));
261 
262  // Tuple is as follows: Start (Item1: DateTime), End (Item2: DateTime), Max Drawdown (Item3: double)
263  yield return new DrawdownPeriod(data.Item1, data.Item2, data.Item3);
264  }
265  }
266 
267  private static Tuple<DateTime, DateTime, double> DrawdownGroup(Frame<DateTime, string> frame, double groupMax)
268  {
269  var drawdownAfter = frame["cumulativeMax"].Where(kvp => kvp.Value > groupMax);
270  var drawdownGroup = frame["cumulativeMax"].Where(kvp => kvp.Value == groupMax);
271  var groupDrawdown = frame["drawdown"].Realign(drawdownGroup.Keys).Max();
272 
273  var groupStart = drawdownGroup.FirstKey();
274  // Get the start of the next period if it exists. That is when the drawdown period has officially ended.
275  // We do this to extend the drawdown period enough so that missing values don't stop it early.
276  var groupEnd = drawdownAfter.IsEmpty ? drawdownGroup.LastKey() : drawdownAfter.FirstKey();
277 
278  return new Tuple<DateTime, DateTime, double>(groupStart, groupEnd, groupDrawdown);
279  }
280  }
281 }