前情提要
最近股市行情很好啊,我那点儿钱买的基金也小赚了一点点。在这时,我忽然又想起来了之前我写的最大回撤率的文章,当时为了省事儿,求最大回撤率的时候直接遍历了数组两次,耗时O(2n)。我想优化一下求最大回撤率的算法,于是有了这一篇文章。
另外,我翻了翻人家求最大回撤率的博客,求的都是最大回撤,于是最大回撤和最大回撤率就在我脑海里回荡,他们俩什么关系? 但是,我没发现确切描述最大回撤和最大回撤率的区别的人。他们俩的区别,这个问题在我脑海里绕了两天,今天终于被我整明白了。那就是,在基金中,一般找到最大回撤,就相当于找到了最大回撤率,因为基金的净值波动的数值并不大。然而,最大回撤≠最大回撤率。证明过程会放在文中描述。
[Python] 详解使用动态规划求解最大回撤并绘图
一. 最大回撤和最大回撤率
有一个数组,求其中两个数 x, y ,满足 x 的索引小于 y 的索引:
- 最大回撤:使得
x - y
最大。 - 最大回撤率:使得
(x - y) / x
最大。
没错,最大回撤率,多除以了一个最高点 x 的值。那么他们俩会不会有不同呢?
1. 最大回撤率
最大回撤率,来源于百度百科:
1.回撤用来衡量该私募产品的抗风险能力。
回撤的意思,是指在某一段时期内产品净值从最高点开始回落到最低点的幅度
最大回撤率,不一定是(最高点净值-最低点净值)/最高点时的净值,也许它会出现在其中某一段的回落。
公式可以这样表达:
D为某一天的净值,i为某一天,j为i后的某一天,Di为第i天的产品净值,Dj则是Di后面某一天的净值
drawdown就是最大回撤率
drawdown=max( ( Di - Dj ) / Di ),其实就是对每一个净值进行回撤率求值,然后找出最大的。可以使用程序实现。
2. 最大回撤
首先,我们要理解什么是最大回撤。
最大回撤:有一个数组,求其中两个数 x , y,满足 x 的索引小于 y 的索引,使得
x -y
最大。 下面举例几种情况:[1,2,3,4,5,6,7,8,9]: 最大回撤是-1。 (1) [9,8,7,6,5,4,3,2,1]: 最大回撤是8,对应的x=9,y=1。 (2) [3,7,2,6,4,1,9,8,5]: 最大回撤是6,对应的x=7,y=1。 (3) [2,3,5,2,4,1,9,2,6]: 最大回撤是7,对应的x=9,y=2。 (4) [9,2,5,1,8,5,9,2,1]: 最大回撤是8,对应的x=9,y=1。 (5) [8,2,5,1,8,5,9,2,1]: 最大回撤是8,对应的x=9,y=1。 (6)
用图来表示就是:
3. 最大回撤和最大回撤率的区别
我告诉你,当然不同!最大回撤≠最大回撤率。然而,在基金中,一般找到最大回撤,就相当于找到了最大回撤率,因为基金的净值波动的数值并不大。
看图!!!
但是,你见过基金有这么大的净值差吗?
…
…
…
最大回撤和最大回撤率建模:
你如果能证明以下:
一条曲线 y = f(x) 上有两个点 (x0, y0) 和 (x1, y1),且两个点都在第一象限,其中 x1 > x0 ,y0 - y1 > 0,是否满足当 y0 - y1 最大时,(y0 - y1) / y0 最大?
那么就可以说明,最大回撤=最大回撤率。
二、求解最大回撤(率)
我们求解最大回撤,都是为了求解最大回撤率。既然基金中不存在那么大的净值差,我们只要直接求解最大回撤就行了。
0. 先贴出最终版代码和测试用例:
最终代码是基于求解最大回撤的最低点和最高点的方式的代码,如果看官要看动态规划,可以直接跳到 3. 动态规划——换种思考方式。在此处算法中,最大回撤是负的也可以。实际上,最大回撤一般都是正值。
def _withdraw_with_high_low(arr):
""" 传入一个数组,返回最大回撤和对应的最高点索引、最低点索引 """
_dp = 0 # 使用 _dp 表示 i 点的最大回撤
i_high = 0 # 遍历时,0 ~ i - 1 中最高的点的索引,注意是索引
# 全局最大回撤和对应的最高点和最低点的索引,注意是索引
g_withdraw, g_high, g_low = float('-inf'), -1, -1
for i in range(1, len(arr)):
# 注意:此处求的是
if arr[i_high] < arr[i-1]: # 若 0 ~ i - 1 中最高的点小于当前点
i_high = i-1 # 0 ~ i - 1 中最高的点的索引
_dp = arr[i_high] - arr[i] # _dp 表示 i 点的最大回撤
if _dp > g_withdraw: # 找到新的最大回撤,更新三个值
g_withdraw = _dp
g_high = i_high
g_low = i
return g_withdraw, g_high, g_low
test_case_s = [
[1, 1, 1, 1, 1, 1, 1, 1, 1],
[9, 8, 7, 6, 5, 4, 3, 2, 1], # range(9, 0, -1)
[3, 7, 2, 6, 4, 1, 9, 8, 5],
[2, 3, 5, 2, 4, 1, 9, 2, 6],
[9, 2, 5, 1, 8, 5, 9, 2, 1],
[8, 2, 5, 1, 8, 5, 9, 2, 1],
[9, 3, 5, 2, 8, 5, 8, 3, 1], # 最大回撤率的不同
[8, 2, 5, 1, 8, 5, 9, 3, 2],
[0, 2, 3, 4, 5, 6, 7, 9, 10],
[0, 4, 6, 8, 12, 14, 19, 24, 28], # 负值
]
for test_case in test_case_s:
print(_withdraw_with_high_low(test_case))
(0, 0, 1) # 直线,回撤率0
(8, 0, 8) # 上升线
(6, 1, 5)
(7, 6, 7)
(8, 0, 3)
(8, 6, 8)
(8, 0, 8)
(7, 0, 3)
(8, 0, 8)
(-1, 1, 2) # 回撤率是负的
(-2, 1, 2) # 回撤率是负的
1. 最直接的思考方式——循环两次
循环两次,第一次寻找最低点,第二次寻找最低点前的最高点。但这样实际上是错误,例子(4)就是很好的解释。哔哔一句,以前我就是这样想的,以为最大回撤就是先找到最低点再找到最高点,泪目。
2. 暴力枚举——找出所有情况
求出所有 i,j( i < j )
且0 <= i, j <= n-1
两点之间的差值,寻找出其中最大的差值,就是最大回撤。例如下图,假设数组大小为4,则取a0-a1、a0-a1、…、a2-a3 中的最大值,就是最大回撤:
def _force(arr):
max_withdraw = float('-inf') # 负无穷
n = len(arr)
for i in range(n):
for j in range(i+1, n):
diff = arr[i] - arr[j]
max_withdraw = max(max_withdraw, diff)
return max_withdraw
for test_case in test_case_s:
print(_force(test_case), end=' ')
0 8 6 7 8 8 8 7 -1 -2
这种方法找出最大回撤肯定是没问题的,但是时间复杂度是O(n^2)。
3. 动态规划——换种思考方式
再看一遍最大回撤的题意,有一个数组,求其中两个数x,y,满足x的索引小于y的索引,使得 x-y 最大。
现在我们假设 dp[i] 表示 [0, i-1] 中最高的点到 i 的差值,dp[i] 中最大的值就是最大回撤,就是 max(dp[i]),0 <= i <= n-1
。为什么呢?看下面的图之后,你就理解了:
而dp[i] = max(dp[i-1] + diff, diif)
,diff = arr[i-1] - arr[i]
,dp[0] = -1
。
下面证明:dp[i] = max(dp[i-1] + diff, diif)
令 diff[i] = a[i-1] - a[i] ,即,diff1 = a0 - a1, diff2 = a1 - a2 ...
dp[0] = -1 # 仅有一个点时,无意义
dp[1] = a0 - a1
dp[2] = max(a0 - a2, a1 - a2),而 a0 - a2 = (a0 - a1) + (a1 - a2) = dp[1] + a1 - a2
= max(dp[1] + a1 - a2, a1 - a2)
= max(dp[1] + diff2, diff2)
dp[3]:
a0 - a3 = (a0 - a2) + (a2 - a3) = dp[1] + diff2 + diif3
a1 - a3 = (a1 - a2) + (a2 - a3) = dff2 + diff3
a2 - a3 = diif3
dp[3] = max(a0 - a3, a1 - a3, a2 - a3)
= max(dp[1] + diff2 + diif3, dff2 + diff3, diff3) ,提出 diff3
= max(max(dp[1] + diff2, diff2) + diff3, diff3)
= max(dp[2] + diif3, diif3)
依次类推:
dp[i] = max(dp[i-1] + diff, diif),diff = arr[i-1] - arr[i]
写成代码:
def _dp_withdraw(arr):
dp = [0] * len(arr)
dp[0] = -1 # 或者 float('-inf'),因为仅一个点时无意义
for i in range(1, len(arr)):
diff = arr[i-1] - arr[i]
dp[i] = max(dp[i-1] + diff, diff)
return max(dp)
在循环迭代的时候,我们只使用到了 dp[i] 和 dp[i - 1],也就是说整个数组参与迭代的只需要两个数,稍加修改,我们还能改成一个数:
def _dp_withdraw(arr):
_dp = -1 # 或者 float('-inf')
max_withdraw = _dp
for i in range(1, len(arr)):
diff = arr[i-1] - arr[i]
_dp = max(_dp + diff, diff)
max_withdraw = max(max_withdraw, _dp)
return max_withdraw
至此,我们已经把时间复杂度降到了O(n),把空间复杂度将到了O(1)。接下来我们还要做的事,就是求解最大回撤的最低点和最高点。
4. 求解最大回撤的最低点和最高点——另辟蹊径
dp[i] 表示 [0, i-1] 中最高的点到 i 的差值,可以理解 dp[i] 对应最大回撤的最低点就是 i ,我们把这个称为 low[i],low[i] = i 。我们只要求出 dp[i] 对应最大回撤的最高点即可。而这个最高点,就是 [0, i-1] 中的最大值。假设dp[i] 对应最大回撤的最高点为 high[i],其索引为 high_index[i],则 high[i] = max(arr[j]),high_index[i] = j,0 <= j <= i - 1。想一想很容易想通:
dp[1] = a0 - a1
☆ high[1] = a0,high_index = 0
dp[2] = max(a0 - a2, a1 - a2)
☆ high[2] = max(a0, a1),high_index[2] = 0 / 1
dp[3] = max(a0 - a3, a1 - a3, a2 - a3)
☆ high[3] = max(a0, a1, a2),high_index[3] = 0 / 1 / 2
... ...
整理一下,假设每个点的最大回撤值、最大回撤对应的最低点和索引、最高点和索引分别是 dp[i]、low[i]、low_index[i]、high[i],high_index[i]:
dp[i] = max(dp[i-1] + diff, diif),diff = arr[i-1] - arr[i]
low[i] = arr[i],low_index[i] = i
high[i] = max(arr[j]),high_index[i] = j,0 <= j < i # 注意注意! j 属于 [0, i) 左闭右开
不清楚你有没有发现,我们其实可以不通过 dp[i] 数组,就可以直接找到 i 点最大回撤的最高点和最低点了了:
low[i] = arr[i],low_index[i] = i
high[i] = max(arr[j]),high_index[i] = j,0 <= j < i
而 i 点的最大回撤恰恰是:
dp[i] = high[i] - low[i]
我们要求的是:
max(dp[i]) 0 <= i <= n - 1
写成代码:
def _withdraw_with_high_low(arr):
dp = [0] * len(arr) # dp[i]存储i的最大回撤
high = [-1] * len(arr) # 存储i点的 0 ~ i - 1 中最高的点的索引,注意是索引
g_max = 0 # 遍历时,0 ~ i - 1 中最高的点的索引,注意是索引
g_withdraw = float('-inf') # 全局最大回撤
g_high, g_low = 0, 0 # 全局最大回撤对应的最高点和最低点索引,注意是索引
for i in range(1, len(arr)):
if arr[g_max] < arr[i-1]:
g_max = i-1 # 0 ~ i - 1 中最高的点的索引
dp[i] = arr[g_max] - arr[i]
high[i] = g_max
if dp[i] > g_withdraw: # 找到新的最大回撤
g_withdraw = dp[i]
g_high = high[i]
g_low = i
return g_withdraw, g_high, g_low # 返回最大回撤和对应的最高点索引、最低点索引
5. 代码关键点
我们很需要注意的就是循环之中:
for i in range(1, len(arr)):
if arr[g_max] < arr[i - 1]: # 此处是 i - 1,而不是 i
g_max = i - 1 # 此处是 i - 1,而不是 i
# 还记得我们前面讲的:
# ☆ high[1] = a0,high_index = 0
# ☆ high[2] = max(a0, a1),high_index[2] = 0 / 1
# ☆ high[3] = max(a0, a1, a2),high_index[3] = 0 / 1 / 2
# ...
# ☆ high[i-1] = max(a0, a1, a2, ..., ai-2),high_index[i-1] = 0 / 1 / 2 / ... / i - 2
# 所以high_index是从0开始,最终到n-2,而不会取到n-1, 属于 [0, n-1) 左开右闭区间
6. 代码优化
代码中存在很多地方可以优化,循环中high数组和dp数组都只用到了high[i]和dp[i],可以使用一个变量替代:
def _withdraw_with_high_low(arr):
i_high = 0 # 遍历时,0 ~ i - 1 中最高的点的索引,注意是索引
g_withdraw = float('-inf') # 全局最大回撤
g_high, g_low = 0, 0 # 全局最大回撤对应的最高点和最低点索引,注意是索引
_dp, _high = 0, -1 # dp数组和high数组变成了单个变量
for i in range(1, len(arr)):
if arr[i_high] < arr[i-1]: # 若 0 ~ i - 1 中最高的点小于当前点
i_high = i-1 # 0 ~ i - 1 中最高的点的索引
_dp = arr[i_high] - arr[i]
_high = i_high
if _dp > g_withdraw: # 找到新的最大回撤
g_withdraw = _dp
g_high = _high
g_low = i
return g_withdraw, g_high, g_low
high[i]只是作为了中间变量来传递,所以 high 数组可以不用了:
def _withdraw_with_high_low(arr):
""" 传入一个数组,返回最大回撤和对应的最高点索引、最低点索引 """
_dp = 0 # 使用 _dp 表示 i 点的最大回撤
i_high = 0 # 遍历时,0 ~ i - 1 中最高的点的索引,注意是索引
# 全局最大回撤和对应的最高点和最低点的索引,注意是索引
g_withdraw, g_high, g_low = float('-inf'), -1, -1
for i in range(1, len(arr)):
if arr[i_high] < arr[i-1]: # 若 0 ~ i - 1 中最高的点小于当前点
i_high = i-1 # 0 ~ i - 1 中最高的点的索引
_dp = arr[i_high] - arr[i] # _dp 表示 i 点的最大回撤
if _dp > g_withdraw: # 找到新的最大回撤,更新三个值
g_withdraw = _dp
g_high = i_high
g_low = i
return g_withdraw, g_high, g_low
以上,就是最终版的代码了。时间复杂度降到了O(n),空间复杂度也降到了O(1)。
要注意的是,如果没有最大回撤,也就是曲线一直是上升的,最大回撤会是负值。
三、绘制折线图
1. 安装matplotlib模块
使用了matplotlib模块:
pip install matplotlib
2. 调用绘图函数
import matplotlib.pyplot as plt
def draw_trend_and_withdraw(xs, ys, title, max_x, max_y, show_max_str, min_x, min_y, show_min_str,
withdraw=None, withdraw_x=None, withdraw_y=None):
""" 根据数据绘制折线走势图 """
plt.rcParams['font.sans-serif'] = ['SimHei']
plt.rcParams['axes.unicode_minus'] = False
plt.plot(xs, ys) # 根据数据绘制折线走势图
plt.title(title)
plt.scatter(min_x, min_y, color='r') # 标记最低点
plt.scatter(max_x, max_y, color='r') # 标记最高点
plt.annotate(show_min_str, xytext=(min_x, min_y), xy=(min_x, min_y)) # 标记提示
plt.annotate(show_max_str, xytext=(max_x, max_y), xy=(max_x, max_y)) # 标记提示
plt.plot([min_x, max_x], [min_y, max_y], color='b', linestyle='--') # 连接最低净值点和最高净值点
if withdraw_x is None or withdraw_y is None:
plt.annotate(f' {withdraw}', xytext=((max_x + min_x) / 2, (max_y + min_y) / 2), xy=((max_x + min_x) / 2, (max_y + min_y) / 2)) # 标记提示
else:
plt.annotate(f' {withdraw}', xytext=(withdraw_x, withdraw_y), xy=(withdraw_x, withdraw_y)) # 标记提示
plt.show()
调用代码:
for test_case in test_case_s:
arr = test_case
max_withdraw, _max, _min = _withdraw_with_high_low(test_case)
draw_trend_and_withdraw(list(range(0, 9)), arr, f'{arr}', _max, arr[_max], f'最高点索引:{_max}',
_min, arr[_min], f'最低点索引:{_min}', max_withdraw)
成功绘图。