/*
* 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);
}
}
}