[Python] 详解使用动态规划求解最大回撤并绘图

前情提要

最近股市行情很好啊,我那点儿钱买的基金也小赚了一点点。在这时,我忽然又想起来了之前我写的最大回撤率的文章,当时为了省事儿,求最大回撤率的时候直接遍历了数组两次,耗时O(2n)。我想优化一下求最大回撤率的算法,于是有了这一篇文章。

另外,我翻了翻人家求最大回撤率的博客,求的都是最大回撤,于是最大回撤和最大回撤率就在我脑海里回荡,他们俩什么关系? 但是,我没发现确切描述最大回撤和最大回撤率的区别的人。他们俩的区别,这个问题在我脑海里绕了两天,今天终于被我整明白了。那就是,在基金中,一般找到最大回撤,就相当于找到了最大回撤率,因为基金的净值波动的数值并不大。然而,最大回撤≠最大回撤率。证明过程会放在文中描述。

一. 最大回撤和最大回撤率

有一个数组,求其中两个数 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)

用图来表示就是:

列子(1)
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

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)

成功绘图。

  • 5
    点赞
  • 15
    收藏
    觉得还不错? 一键收藏
  • 4
    评论
评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值