为了账号安全,请及时绑定邮箱和手机立即绑定

如何在算法交易中应用强化学习

在《华尔街漫步》这本书里,作者伯顿·G·马尔基尔声称:“一个蒙眼的猴子对着报纸的金融版面乱扔飞镖,所选的股票组合可能会和专家精心挑选的一样好,甚至更好。”

采取行动并因期望的结果而获得奖励的行为是一种巴甫洛夫式的强化方法,这种方法强化了该行为。强化学习(RL)就是指智能体通过学习如何在环境中采取行动来最大化其价值的过程,这个过程是通过机器学习实现的。智能体从其行动的结果中学习,无需通过特定任务的规则进行明确编程。

任何RL算法的目的都是找到一种最大化价值的策略(π)。

其中 γ (0 ≤ γ ≤ 1) 是用于调整未来奖励重要性的折扣因子,t 是时间步,R 是每一步的回报值。简单来说,在强化学习里,策略是指在状态 s 时选择行动 a 的概率。

我们将采用的算法是Q-Learning,这是一种不依赖环境模型的强化学习算法,其目标是通过离散状态的动作的Q值来间接学习策略。在我们的情况下,这种方法很有用,因为它不需要对错综复杂的资本市场进行建模。

我们通过贝尔曼方程来估计Q值并找出最佳策略等等。

以下为:

  • r: 从状态 s 到状态 st+1 的转换后得到的奖励。
  • γ: 未来奖励的折扣系数 (0≤γ≤1)。
  • st+1: 在状态 s 采取行动 a 后的下一个状态。
  • at+1: 下一个状态中的可能动作。

这些Q值被存储在Q表中,并且通过迭代进行更新。代理使用Q表从当前状态查询所有可能动作的Q值,选择具有最高Q值的那个动作。这种方法在有限空间中效果不错,但在随机环境中,拥有无限组合的情况会变得困难。我们将使用引导过程和神经网络作为近似器来解决这个问题。

本文中设计的智能代理借鉴了Thibaut和Damien Ernst (2021) 的论文中的想法。

环境配置和笔记本电脑准备

我们将使用Tensorflow的TA-Agents框架(TA-Agents framework)。首先,我们需要导入所有需要的库。

# 导入numpy库,这是一个用于处理数字的工具包
import numpy as np
# 导入数学库,用于基本数学运算
import math
# 导入shutil库,用于文件操作
import shutil
# 导入yfinance库,用于获取股票数据
import yfinance as yf
# 导入pandas库,用于数据分析
import pandas as pd
# 导入statsmodels库,用于统计建模
import statsmodels as sm
# 导入statsmodels工具模块,提供常数添加功能
from statsmodels.tools.tools import add_constant
# 导入线性模型模块,用于线性回归分析
from statsmodels.regression.linear_model import OLS
# 导入matplotlib.pyplot库,用于绘图
import matplotlib.pyplot as plt
# 导入matplotlib.dates库,用于日期处理
import matplotlib.dates as mdates
# 导入datetime库,用于日期处理
from datetime import datetime
# 导入scipy.stats库,用于统计分析
from scipy.stats import skew, kurtosis
# 导入ta.utils模块,用于数据预处理
from ta.utils import dropna
# 导入ta.trend模块,用于趋势分析
from ta.trend import MACD, EMAIndicator
# 导入ta.volatility模块,用于波动性分析
from ta.volatility import AverageTrueRange
# 导入tensorflow库,用于机器学习
import tensorflow as tf
# 导入tf_agents.train库,用于学习代理
from tf_agents.train import learner
# 导入tf_agents.specs库,用于定义张量规格
from tf_agents.specs import array_spec, tensor_spec
# 导入tf_agents.trajectories库,用于时间步处理
from tf_agents.trajectories import time_step as ts
# 导入tf_agents.utils库,用于通用工具
from tf_agents.utils import common
# 导入tf_agents.agents.dqn模块,用于深度Q网络代理
from tf_agents.agents.dqn import dqn_agent
# 导入tf_agents.drivers库,用于驱动代理学习
from tf_agents.drivers import py_driver
# 导入tf_agents.environments库,用于定义环境
from tf_agents.environments import py_environment, tf_py_environment, utils
# 导入tf_agents.networks库,用于定义网络结构
from tf_agents.networks import sequential
# 导入tf_agents.policies库,用于定义策略
from tf_agents.policies import py_tf_eager_policy, policy_saver, random_tf_policy
# 导入tf_agents.train.utils库,用于策略学习
from tf_agents.train.utils import strategy_utils
# 导入reverb库,用于回放缓冲区
import reverb
# 导入tf_agents.replay_buffers库,用于回放缓冲区
from tf_agents.replay_buffers import reverb_replay_buffer, reverb_utils
# 导入tqdm库,用于进度条显示
from tqdm import tqdm

训练一个智能代理需要大量的时间和计算资源,为此目的我们将配置一个GPU配置策略,以便更快地完成训练。

    gpus = tf.config.experimental.list_physical_devices('GPU')  
    for gpu in gpus:  
        tf.config.experimental.set_memory_growth(gpu, True)  
    strategy = strategy_utils.get_strategy(tpu=False, use_gpu=True)  
    print(f"使用的CPU或GPU: {gpus}")
市场行情

像大多数一样,我们将从yfinance拉取市场数据,这个平台提供我们需要的数据。

    # 指数和指标  
    RATES_INDEX = "^FVX"        # 5年期国债收益率  
    VOLATILITY_INDEX = "^VIX"   # CBOE波动率指数  
    SMALLCAP_INDEX = "^RUT"     # 罗素2000指数  
    GOLD_FUTURES = "GC=F"         # 黄金期货  
    OIL_FUTURES = "CL=F"        # 原油期货  
    MARKET = "^SPX"             # 标普500指数  
    TICKER_SYMBOLS = [TARGET, RATES_INDEX, VOLATILITY_INDEX, SMALLCAP_INDEX, GOLD_FUTURES, MARKET, OIL_FUTURES]  
    INTERVAL = "1d"  

    def get_tickerdata(tickers_symbols, start=START_DATE, end=END_DATE, interval=INTERVAL, data_dir=DATA_DIR):  
        tickers = {}  
        earliest_end = datetime.strptime(end, '%Y-%m-%d')  
        latest_start = datetime.strptime(start, '%Y-%m-%d')  
        os.makedirs(DATA_DIR, exist_ok=True)  
        for symbol in tickers_symbols:  
            cached_file_path = f"{data_dir}/{symbol}-{start}-{end}-{interval}.parquet"  
            try:  
                if os.path.exists(cached_file_path):  
                    df = pd.read_parquet(cached_file_path)  
                    df.index = pd.to_datetime(df.index)  
                    assert not df.empty  
                else:  
                    df = yf.download(  
                        symbol,  
                        start=START_DATE,  
                        end=END_DATE,  
                        progress=False,  
                        interval=INTERVAL,  
                    )  
                    assert not df.empty  
                    df.to_parquet(cached_file_path, index=True, compression='snappy')  
                min_date = df.index.min()  
                max_date = df.index.max()  
                nan_count = df["Close"].isnull().sum()  
                skewness = round(skew(df["Close"].dropna()), 2)  
                kurt = round(kurtosis(df["Close"].dropna()), 2)  
                outliers_count = (df["Close"] > df["Close"].mean() + (3 * df["Close"].std())).sum()  
                print(  
                    f"{symbol} => 最小日期: {min_date}, 最大日期: {max_date}, 峰度: {kurt}, 偏度: {skewness}, 异常值数量: {outliers_count}, 缺失值数量: {nan_count}"  
                )  
                tickers[symbol] = df.copy()  
                if min_date > latest_start:  
                    latest_start = min_date  
                if max_date < earliest_end:  
                    earliest_end = max_date  
            except Exception as e:  
                print(f"Error with {symbol}: {e}")  
        return tickers, latest_start, earliest_end  
    tickers, latest_start, earliest_end = get_tickerdata(TICKER_SYMBOLS)  
    stock_df = tickers[TARGET].copy()  
    stock_df.tail(5)
观察和状态空间

原来的论文使用标准的HLOC(High, Low, Open, Close,即最高价、最低价、开盘价、收盘价)和交易量作为代理的环境状态的观察值,他们将这些数据处理为:

  • 价格回报:在时间步长 t 和它的前一步 -1 之间计算的标准百分比收益。
  • 价格差价:当前时间步长的最高价减去最低价。

他们还对状态空间进行了最小-最大标准化处理。因为我们投资组合的回报是一个奖励信号,所以在“Price Raw”中,我们保持收盘价不变,以便我们能够计算回报,而回报不会包含在状态空间中。

我们将通过增加更多技术及宏观经济指标来扩展这个空间,从而使其功能更丰富。

    MACRO_FEATURES = [RATES_INDEX, VOLATILITY_INDEX, MARKET, GOLD_FUTURES, OIL_FUTURES]  
    TA_FEATURES = ['MACD', 'MACD_HIST', 'MACD_SIG', 'ATR', 'EMA_SHORT', 'EMA_MID', 'EMA_LONG']  
    HLOC_FEATURES = ["收盘价", "最高价", "最低价", "开盘价", "成交量"]  
    FEATURES = ['价格收益率', '价格变动', '收盘价', '成交量']  
    TARGET_FEATURE = "原始价格"
技术面

这些是我们之前文章中提到的技术分析特性,可以被我们的网络利用。

MACD(指数移动平均线的收敛和发散)是通过12天和26天的指数移动平均线计算得出的,其与9日信号线的差异形成了MACD柱状图。为了衡量波动幅度,平均真实范围(ATR)是基于14日周期计算得出的。

此外,计算出三个指数移动平均线(分别是12日、26日和200日),以帮助识别股票收盘价的短期内、中期内和长期内的趋势。

macd = MACD(close=stock_df["Close"], window_slow=26, window_fast=12, window_sign=9, fillna=True)  # MACD 是移动平均收敛/发散指标
stock_df['MACD'] = macd.macd()  # 获取MACD值
stock_df['MACD_HIST'] = macd.macd_diff()  # 获取MACD历史数据
stock_df['MACD_SIG'] = macd.macd_signal()  # 获取MACD信号线
atr = AverageTrueRange(stock_df["High"], stock_df["Low"], stock_df["Close"], window=14, fillna=True)  # 计算平均真实波动范围
stock_df['ATR'] = atr.average_true_range()  # 将ATR添加到数据框
ema = EMAIndicator(stock_df["Close"], window=12, fillna=True)  # 计算短期EMA
stock_df['EMA_SHORT'] = ema.ema_indicator()  # 添加短期EMA到数据框
ema = EMAIndicator(stock_df["Close"], window=26, fillna=True)  # 计算中期EMA
stock_df['EMA_MID'] = ema.ema_indicator()  # 添加中期EMA到数据框
ema = EMAIndicator(stock_df["Close"], window=200, fillna=True)  # 计算长期EMA
stock_df['EMA_LONG'] = ema.ema_indicator()  # 添加长期EMA到数据框
stock_df.tail(5)  # 显示数据框最后5行
宏观经济信号

这些是宏观特征,比如。它们的变化将被我们的网络监测到。如我们在之前的文章中提到:

  • 5年期国债收益率代表利率水平,同时也体现了投资者的风险偏好。
  • CBOE波动率指数,它作为市场恐惧的信号。
  • 罗素2000指数可以反映投机和增长意愿(或者你可以选择纳斯达克指数,它主要由科技股构成)。
  • 黄金期货可以反映投资者对市场的信心。
  • 原油期货是全球宏观经济的一个重要信号,影响着大多数行业。
  • 标准普尔500指数可以反映美国市场的整体情绪。

我们将利用每个信号的结果来更新状态空间。

# 计算收盘价的变化百分比并填充缺失值为0
stock_df[VOLATILITY_INDEX] = tickers[VOLATILITY_INDEX]["Close"].pct_change().fillna(0)  
# 计算收盘价的变化百分比并填充缺失值为0
stock_df[RATES_INDEX] = tickers[RATES_INDEX]["Close"].pct_change().fillna(0)  
# 计算收盘价的变化百分比并填充缺失值为0
stock_df[SMALLCAP_INDEX] = tickers[SMALLCAP_INDEX]["Close"].pct_change().fillna(0)  
# 计算收盘价的变化百分比并填充缺失值为0
stock_df[GOLD_FUTURES] = tickers[GOLD_FUTURES]["Close"].pct_change().fillna(0)  
# 计算收盘价的变化百分比并填充缺失值为0
stock_df[OIL_FUTURES] = tickers[OIL_FUTURES]["Close"].pct_change().fillna(0)  
# 计算收盘价的变化百分比并填充缺失值为0
stock_df[MARKET] = tickers[MARKET]["Close"].pct_change().fillna(0)

总的来说,这导致了状态空间的增强:

问题定义

这里有个警告,这一节会用一组正式的符号定义环境和动作——如果你只想看代码和结果,也可以直接跳过。

通过Q-训练,我们来教导一个条件反射式代理进行交易。我们的目标是通过一系列交互来获得最大回报,这意味着我们需要计算所有可能路径的预期回报。

在每个时间步 t:

  1. 使用 f(.) 观察环境状态 st 和历史地图。
  2. 历史 ht 中的观察 ot,包括之前的行动 a_t-1、观察 o_t-1 及其回报 r_t-1。在我们的实验中,我们将把这些编码成网络的特征。
  3. 执行动作 a_t,它可以是:持有,做多,做空
  4. 以 γt 折扣计算回报 r_t。γ 是折现因子,用以防止代理仅考虑当前收益(忽略更好的未来收益)的短期选择。

π(at|ht) 对数量 Q 进行操作,at = Qt。正 Q 表示持有(long),负 Q 表示做空(short),Q 为 0 时则不进行任何操作。本文将交替使用策略 π(at|ht) 和 Q 值 Q(at,st),因为 Q 将定义购买的数量。

行动及其回报

在强化学习中,一个核心概念是奖励设计。比如,我们来看看时间 t 时的动作空间 A。

操作 Long,t 被设定为最大化买入的回报,基于我们的流动性状况 vc_t(即我们投资组合的价值 v 和剩余现金 c),在价格 p 购买股份(考虑交易成本 C),如果我们还没有持有的情况下操作。

操作 Short,t 的目标是将部分股票转为收益(Short,t是指沽空操作)(因此,我们的 v_c 初始值会是负数。)

注意,-2n 表示要卖出两倍的数量,意味着不仅平掉原有的多头仓位,并且要卖出相同数量的股票,因为做空是反向操作,我们需要将这个数量取反来正确反映持有的仓位。如果我们一开始没有持有任何股票,那么 -2(0) 就不会有任何效果,除了反映空头仓位的数量。

做空是有风险的,我们需要为代理人设定限制,因为做空可能带来无限的损失。鉴于我们的投资组合不能为负,我们需要设定一些限制条件。

  1. 现金价值 vc_t 需要足够大,以便能回到中性状态 n_t=0。
  2. 为了返回到 0,我们需要调整由于市场波动 ε 引起的成本 C(比如滑点和价差)。
  3. 我们重新定义允许的动作范围,以确保我们总能回到中性状态。

我们要尊重这个

动作空间 A 重新定义为 N_t 在做空和做多边界 N- 和 N+ 之间的可接受值:

上边界 N+ 为:如下

并且$N-$的下界是(无论是从多头仓位(其中$\Delta t$为正)退出,还是逆转空头仓位并承担两倍的成本($\Delta t$为负)):

投资组合价值随时间变化的 delta t 表示一段时间内投资组合价值的变化量。

代理的目标

论文中提到,他们将百分比收益用作奖励信号,将其范围限制在-1到1之间,并用γ来调整。

在实验4到6中,我们将奖励函数改为年度化夏普比率(时间窗口从N天到最多252个交易日),并让代理学会生成最优夏普比率。

即投资组合的平均回报(R平均),减去无风险利率(Rf,截至撰写之时为4.5%)后,再除以投资组合在N个时间窗口内的波动率(σ)。

瑞典
瑞典文本要求基于源文本,源文本是“交易环境”,对应的中文翻译为:
瑞典
交易环境

使用TensorFlow的PyEnvironment,我们将给代理程序提供一个符合上述规则的环境:

    class TradingEnv(py_environment.PyEnvironment):  
        """  
        一个用于强化学习的自定义交易环境,兼容于tf_agents。  
    此环境模拟了一个简单的交易场景,可以采取三种行动之一:  
        - 长仓(买入)、短仓(卖出)或持有金融工具,通过交易决策来最大化利润。  
        参数:  
        - data: 包含市场数据的DataFrame对象。  
        - data_dim: 每次观察所使用的数据维度。  
        - money: 开始交易的初始资本。  
        - state_length: 考虑过去状态的观察次数。  
        - transaction_cost: 与交易行为相关的成本。  
        """  
        def __init__(self, data, features = FEATURES, money=CAPITAL, state_length=STATE_LEN, transaction_cost=0, market_costs=TRADE_COSTS_PERCENT, reward_discount=DISCOUNT):  
            super(TradingEnv, self).__init__()  
            assert data is not None  
            self.features = features  
            self.data_dim = len(self.features)  
            self.state_length = state_length  
            self.current_step = self.state_length  
            self.reward_discount = reward_discount  
            self.balance = money  
            self.initial_balance = money  
            self.transaction_cost = transaction_cost  
            self.epsilon = max(market_costs, np.finfo(float).eps) # 存在波动成本,确保不会为零  
            self.total_shares = 0  
            self._episode_ended = False  
            self._batch_size = 1  
            self._action_spec = array_spec.BoundedArraySpec(  
                shape=(), dtype=np.int32, minimum=ACT_SHORT, maximum=ACT_LONG, name='action')  
            self._observation_spec = array_spec.BoundedArraySpec(  
                shape=(self.state_length * self.data_dim, ), dtype=np.float32, name='observation')  
            self.data = self.preprocess_data(data.copy())  
            self.reset()  
        @property  
        def batched(self):  
            return False #True  
        @property  
        def batch_size(self):  
            return None #self._batch_size  
        @batch_size.setter  
        def batch_size(self, size):  
            self._batch_size = size  
        def preprocess_data(self, df):  
            price_raw = df['Close'].copy()  
            # 根据HLOC工程化特征  
            df['Price Returns'] = df['Close'].pct_change().fillna(0)  
            df['Price Delta'] = (df['High'] - df['Low'])  
            df['Close Position'] = abs(df['Close'] - df['Low']) / df['Price Delta'].replace(0, 0.5)  
            for col in [col for col in self.features]:  
                col_min, col_max = df[col].min(), df[col].max()  
                if col_min != col_max:  
                    df[col] = (df[col] - col_min) / (col_max - col_min)  
                else:  
                    df[col] = 0.  
            df = df.ffill().bfill()  
            df[TARGET_FEATURE] = price_raw  
            df['Sharpe'] = 0  
            df['Position'] = 0  
            df['Action'] = ACT_HOLD  
            df['Holdings'] = 0.0  
            df['Cash'] = float(self.balance)  
            df['Money'] = df['Holdings'] + df['Cash']  
            df['Reward'] = 0.0  
            assert not df.isna().any().any()  
            return df  
        def action_spec(self):  
            """提供动作空间的规范。"""  
            return self._action_spec  
        def observation_spec(self):  
            """提供观察空间的规范。"""  
            return self._observation_spec  
        def _reset(self):  
            """重置环境状态并为新回合做准备。"""  
            self.balance = self.initial_balance  
            self.current_step = self.state_length  
            self._episode_ended = False  
            self.total_shares = 0  
            self.data['Reward'] = 0.  
            self.data['Sharpe'] = 0.  
            self.data['Position'] = 0  
            self.data['Action'] = ACT_HOLD  
            self.data['Holdings'] = 0.  
            self.data['Cash']  = float(self.balance)  
            self.data['Money'] = self.data.iloc[0]['Holdings'] + self.data.iloc[0]['Cash']  
            self.data['Returns'] = 0.  
            return ts.restart(self._next_observation())  
        def _next_observation(self):  
            """根据当前步骤和历史长度生成下一个观察值。"""  
            start_idx = max(0, self.current_step - self.state_length + 1)  
            end_idx = self.current_step + 1  
            obs = self.data[self.features].iloc[start_idx:end_idx]  
            # 因为:https://stackoverflow.com/questions/67921084/dqn-agent-issue-with-custom-environment  
            obs_values = obs.values.flatten().astype(np.float32)  
            return obs_values  
        def _step(self, action):  
            """执行交易行为并更新环境的状态。"""  
            if self._episode_ended:  
                return self.reset()  
            self.current_step += 1  
            current_price = self.data.iloc[self.current_step][TARGET_FEATURE]  
            assert not self.data.iloc[self.current_step].isna().any().any()  
            if action == ACT_LONG:  
                self._process_long_position(current_price)  
            elif action == ACT_SHORT:  
                prev_current_price = self.data.iloc[self.current_step - 1][TARGET_FEATURE]  
                self._process_short_position(current_price, prev_current_price)  
            elif action == ACT_HOLD:  
                self._process_hold_position()  
            else:  
              raise Exception(f"无效动作: {action}")  
            self._update_financials()  
            done = self.current_step >= len(self.data) - 1  
            reward = self._calculate_sharpe_reward_signal()  
            # reward = self.data['Returns'][self.current_step]  
            self.data.at[self.data.index[self.current_step], "Reward"] = reward  
            if done:  
                self._episode_ended = True  
                return ts.termination(self._next_observation(), reward)  
            else:  
                return ts.transition(self._next_observation(), reward, discount=self.reward_discount)  
        def _get_lower_bound(self, cash, total_shares, price):  
            """  
            计算作用空间的下界,特别是对于卖空,基于当前现金、持股数量和当前价格。  
            """  
            delta = -cash - total_shares * price * (1 + self.epsilon) * (1 + self.transaction_cost)  
            if delta < 0:  
                lowerBound = delta / (price * (2 * (1 + self.transaction_cost) + (1 + self.epsilon) * (1 + self.transaction_cost)))  
            else:  
                lowerBound = delta / (price * (1 + self.epsilon) * (1 + self.transaction_cost))  
            if np.isinf(lowerBound):  
                assert False  
            return lowerBound  
        def _process_hold_position(self):  
            step_idx = self.data.index[self.current_step]  
            self.data.at[step_idx, "Cash"] = self.data.iloc[self.current_step - 1]["Cash"]  
            self.data.at[step_idx, "Holdings"] = self.data.iloc[self.current_step - 1]["Holdings"]  
            self.data.at[step_idx, "Position"] = self.data.iloc[self.current_step - 1]["Position"]  
            self.data.at[step_idx, "Action"] = ACT_HOLD  
        def _process_long_position(self, current_price):  
            step_idx = self.data.index[self.current_step]  
            self.data.at[step_idx, 'Position'] = 1  
            self.data.at[step_idx, 'Action'] = ACT_LONG  
            if self.data.iloc[self.current_step - 1]['Position'] == 1:  
                # 更多长仓  
                self.data.at[step_idx, 'Cash'] = self.data.iloc[self.current_step - 1]['Cash']  
                self.data.at[step_idx, 'Holdings'] = self.total_shares * current_price  
                self.data.at[step_idx, "Action"] = ACT_HOLD  
            elif self.data.iloc[self.current_step - 1]['Position'] == 0:  
                # 新长仓  
                self.total_shares = math.floor(self.data.iloc[self.current_step - 1]['Cash'] / (current_price * (1 + self.transaction_cost)))  
                self.data.at[step_idx, 'Cash'] = self.data.iloc[self.current_step - 1]['Cash'] - self.total_shares * current_price * (1 + self.transaction_cost)  
                self.data.at[step_idx, 'Holdings'] = self.total_shares * current_price  
            else:  
                # 从短仓变为长仓  
                self.data.at[step_idx, 'Cash'] = self.data.iloc[self.current_step - 1]['Cash'] - self.total_shares * current_price * (1 + self.transaction_cost)  
                self.total_shares = math.floor(self.data.iloc[self.current_step]['Cash'] / (current_price * (1 + self.transaction_cost)))  
                self.data.at[step_idx, 'Cash'] = self.data.iloc[self.current_step]['Cash'] - self.total_shares * current_price * (1 + self.transaction_cost)  
                self.data.at[step_idx, 'Holdings'] = self.total_shares * current_price  
        def _process_short_position(self, current_price, prev_price):  
            """  
            调整处理短仓的逻辑以包含下界计算。  
            """  
            step_idx = self.data.index[self.current_step]  
            self.data.at[step_idx, 'Position'] = -1  
            self.data.at[step_idx, "Action"] = ACT_SHORT  
            if self.data.iloc[self.current_step - 1]['Position'] == -1:  
                # 更多短仓  
                low = self._get_lower_bound(self.data.iloc[self.current_step - 1]['Cash'], -self.total_shares, prev_price)  
                if low <= 0:  
                    self.data.at[step_idx, 'Cash'] = self.data.iloc[self.current_step]["Cash"]  
                    self.data.at[step_idx, 'Holdings'] = -self.total_shares * current_price  
                    self.data.at[step_idx, "Action"] = ACT_HOLD  
                else:  
                    total_sharesToBuy = min(math.floor(low), self.total_shares)  
                    self.total_shares -= total_sharesToBuy  
                    self.data.at[step_idx, 'Cash'] = self.data.iloc[self.current_step]["Cash"] - total_sharesToBuy * current_price * (1 + self.transaction_cost)  
                    self.data.at[step_idx, 'Holdings'] = -self.total_shares * current_price  
            elif self.data.iloc[self.current_step - 1]['Position'] == 0:  
                # 新短仓  
                self.total_shares = math.floor(self.data.iloc[self.current_step]["Cash"] / (current_price * (1 + self.transaction_cost)))  
                self.data.at[step_idx, 'Cash'] = self.data.iloc[self.current_step]["Cash"] + self.total_shares * current_price * (1 - self.transaction_cost)  
                self.data.at[step_idx, 'Holdings'] = -self.total_shares * current_price  
            else:  
                # 从长仓变为短仓  
                self.data.at[step_idx, 'Cash'] = self.data.iloc[self.current_step]["Cash"] + self.total_shares * current_price * (1 - self.transaction_cost)  
                self.total_shares = math.floor(self.data.iloc[self.current_step]["Cash"] / (current_price * (1 + self.transaction_cost)))  
                self.data.at[step_idx, 'Cash'] = self.data.iloc[self.current_step]["Cash"] + self.total_shares * current_price * (1 - self.transaction_cost)  
                self.data.at[step_idx, 'Holdings'] = -self.total_shares * current_price  
        def _update_financials(self):  
            """更新财务指标,包括现金、资金和收益。"""  
            step_idx = self.data.index[self.current_step]  
            self.balance = self.data.iloc[self.current_step]['Cash']  
            self.data.at[step_idx,'Money'] = self.data.iloc[self.current_step]['Holdings'] + self.data.iloc[self.current_step]['Cash']  
            self.data.at[step_idx,'Returns'] = ((self.data.iloc[self.current_step]['Money'] - self.data.iloc[self.current_step - 1]['Money'])) / self.data.iloc[self.current_step - 1]['Money']  
        def _calculate_reward_signal(self, reward_clip=REWARD_CLIP):  
            """  
            计算当前步骤的奖励。在论文中他们使用了%收益。  
            """  
            reward = self.data.iloc[self.current_step]['Returns']  
            return np.clip(reward, -reward_clip, reward_clip)  
        def _calculate_sharpe_reward_signal(self, risk_free_rate=RISK_FREE_RATE, periods_per_year=TRADING_DAYS_YEAR, reward_clip=REWARD_CLIP):  
            """  
            计算截至当前步骤的年化夏普比率。  
            参数:  
            - risk_free_rate (float): 年度无风险利率。它将被调整以匹配收益的周期。  
            - periods_per_year (int): 一年中的周期数(例如,252为每日,12为每月)。  
            返回:  
            - float: 作为奖励的年化夏普比率。  
            """  
            observed_returns = self.data['Returns'].iloc[:self.current_step + 1]  
            period_risk_free_rate = risk_free_rate / periods_per_year  
            excess_returns = observed_returns - period_risk_free_rate  
            rets = np.mean(excess_returns)  
            std_rets = np.std(excess_returns)  
            sr = rets / std_rets if std_rets > 0 else 0  
            annual_sr = sr * np.sqrt(periods_per_year)  
            self.data.at[self.data.index[self.current_step], 'Sharpe'] = annual_sr  
            return np.clip(annual_sr, -reward_clip, reward_clip)  
        def get_trade_data(self):  
            self.data['cReturns'] = np.cumprod(1 + self.data['Returns']) - 1  
            return self.data.iloc[:self.current_step + 1]  

        def render(self, mode='human'):  
            print(f'Step: {self.current_step}, Balance: {self.balance}, Holdings: {self.total_shares}')  
            print(f"交易统计: {self.get_trade_stats()}")

我们运行了一些测试来验证设置是否正确,并且结果表明它符合TA-Agents框架的要求。

    train_data = stock_df[stock_df.index < pd.to_datetime(SPLIT_DATE)].copy()  
    test_data = stock_df[stock_df.index >= pd.to_datetime(SPLIT_DATE)].copy()
    train_env = TradingEnv(train_data)  
    utils.validate_py_environment(train_env, episodes=TRAIN_EPISODES // 5)  
    test_env = TradingEnv(test_data)  
    utils.validate_py_environment(test_env, episodes=TRAIN_EPISODES // 5)  
    print(f"TimeStep 规范: {train_env.time_step_spec()}")  
    print(f"Action 规范: {train_env.action_spec()}")  
    print(f"Reward 规范: {train_env.time_step_spec().reward}")
双层深度Q网络架构设计

双层深度Q网络架构设计 (DDQN)

建筑

我们将实现一个双深度Q网络(DDQN)架构。双,因为它改进了标准DQN,解决了Q值过估计的问题,从而避免了学习过程中的不稳定和反复无常。类似于DQN,DDQN使用神经网络来近似动作价值函数Q∗(at∣st)。这使得模型能够通过近似处理在复杂环境中形成的庞大Q表。

在DDQN中,我们使用两个Q网络:在线Q网络和目标Q网络。在线Q网络根据下一个状态来选择动作,而目标Q网络用来评估所选动作的Q值。这两个过程的分离减少了过度估计。目标网络的更新频率比在线网络低,提供更稳定的值估计,从而提高训练过程的稳定性。

DQN与DDQN之间的主要区别在于如何选择动作及其评估其价值。在DQN中,同一个网络用来选择动作,并评估其Q值(即动作的价值)。

这种方法有时可能会把某些动作的价值估计得过高。但在DDQN中,选择动作和评估动作这两件事是分开进行的。在线网络利用argmax来选择动作,而目标网络则评估所选动作的价值:

网络则会尽量减少损失,或类似的损失函数。

就像标准的DQN一样,我们将使用一个回放缓存经验来存储过去的经历,这个回放缓存是一个固定大小的循环缓冲区。这个缓存对于打破连续经验之间的相关性至关重要,因为在训练过程中随机从其中采样可以确保更新不会偏向于最近的经验。两个网络都通过预测的Q值与使用存储经验计算的目标值之间的损失进行训练——从而确保一个稳定的学习过程。

强化学习的流程

图片胜过千言万语;以下的流程图下面将指导我们完成整个训练和更新我们的目标模型的过程,去掉“完成”使句子更加简洁流畅。修改为:
图片胜过千言万语;以下流程图将带我们完成训练和更新我们的目标模型的过程。

步骤是这样的:

  1. 初始化在线Q网络:这个网络用来选择动作,并在训练过程中频繁更新。
  2. 初始化目标Q网络:此网络提供更稳定的Q值估计,并且更新频率较低(每隔一定迭代次数从在线网络复制权重)。
  3. 代理从环境观察当前状态(S_t)。此状态将用于决定下一步动作。
  4. 代理根据当前状态(S_t)来选择动作,采用ε贪婪策略。以概率ε,代理随机选择动作(探索)。其逆1−ε,代理选择在线网络为当前状态(S_t)预测Q值最大的动作。
  5. 采取动作并观察下一个状态在采取所选动作(At)后,代理收到一个奖励(R{t+1})并过渡到下一个状态(S_{t+1})。元组(S_t, At, R{t+1}, S_{t+1})会被存储在回放缓冲区中。
  6. 从回放缓冲区采样一个迷你批次以此打破连续经验之间的关联。每个经验元组包括(S_t, At, R{t+1}, S_{t+1})。
  7. 在下一个状态(S{t+1})选择动作(在线网络的argmax)对于迷你批次中的每个经验,利用在线网络来选择下一个状态(S{t+1})中的最大Q值动作。
  8. 评估Q值(目标网络的max)一旦在线网络选择了动作(A{\text{max}}),目标网络就评估这个动作在下一个状态(S{t+1})的Q值:
  9. 计算损失并更新在线网络在线网络通过最小化预测Q值(对于状态(S_t)的动作(A_t))与目标Q值之间的差异来训练。使用梯度下降更新在线网络的权重以最小化该损失。
  10. 更新目标网络(定期)每隔一段时间,目标网络的权重通过复制在线网络的权重来更新。

下面的 Python 代码会帮我们生成网络。

def create_q_network(env, fc_layer_params=LAYERS, dropout_rate=DROPOUT, l2_reg=L2FACTOR):  
    """  
    创建一个具有dropout和批归一化的Q-Network。  
    参数:  
    - env: 环境实例。  
    - fc_layer_params: 表示每个全连接层单元数的整数元组。  
    - dropout_rate: dropout层的dropout率。  
    - l2_reg: L2正则化因子。  
    返回:  
    - q_net: Q-Network模型。  
    """  
    env = tf_py_environment.TFPyEnvironment(env)  
    action_tensor_spec = tensor_spec.from_spec(env.action_spec())  
    num_actions = action_tensor_spec.maximum - action_tensor_spec.minimum + 1  
    layers = []  
    for num_units in fc_layer_params:  
        layers.append(tf.keras.layers.Dense(  
                                num_units,  
                                activation=None,  
                                kernel_initializer=tf.keras.initializers.VarianceScaling(scale=2.0, mode='fan_in', distribution='truncated_normal'),  
                                kernel_regularizer=tf.keras.regularizers.l2(l2_reg)))  
        # 通过批归一化来减少内部协变量漂移,这有助于改善梯度流动。  
        layers.append(tf.keras.layers.BatchNormalization())  
        layers.append(tf.keras.layers.LeakyReLU())  
        layers.append(tf.keras.layers.Dropout(dropout_rate))  
    q_values_layer = tf.keras.layers.Dense(  
        num_actions,  
        activation=None,  
        kernel_initializer=tf.keras.initializers.GlorotNormal(),  
        bias_initializer=tf.keras.initializers.GlorotNormal(),  
        kernel_regularizer=tf.keras.regularizers.l2(l2_reg))  
    q_net = sequential.Sequential(layers + [q_values_layer])  
    return q_net  
def create_agent(q_net, env,  
                 t_q_net=None,  
                 optimizer=None,  
                 eps=EPSILON_START,  
                 learning_rate=LEARN_RATE,  
                 gradient_clipping=GRAD_CLIP,  
                 weight_decay = ADAM_WEIGHTS,  
                 discount=DISCOUNT):  
    """  
    为给定环境创建一个具有特定配置的DDQN代理。  
    参数:  
    - q_net (tf_agents.networks.Network): 主Q网络。  
    - env (tf_agents.environments.PyEnvironment or tf_agents.environments.TFPyEnvironment):  
      代理将与之交互的环境。如果尚未封装,则应用TFPyEnvironment封装。  
    - t_q_net (tf_agents.networks.Network, 可选): 目标Q网络。  
      如果为None,则不使用目标网络。  
    - optimizer (tf.keras.optimizers.Optimizer, 可选): 训练代理的优化器。  
      如果为None,则使用具有指数衰减学习率的Adam优化器。  
    - eps (float): 用于epsilon-贪婪探索的epsilon值。  
    - learning_rate (float): 指数衰减学习率计划的初始学习率。  
      如果提供了优化器,则忽略这个学习率。  
    - gradient_clipping (float): 梯度裁剪的值。如果为1.,则不应用裁剪。  
    返回:  
    - agent (tf_agents.agents.DqnAgent): 初始化并配置的DDQN代理。  
    """  
    if optimizer is None:  
      optimizer = tf.keras.optimizers.AdamW(  
          learning_rate=learning_rate,  
          weight_decay=weight_decay  
      )  
    env = tf_py_environment.TFPyEnvironment(env)  
    agent = dqn_agent.DqnAgent(  
        env.time_step_spec(),  
        env.action_spec(),  
        gamma=discount,  
        q_network=q_net,  
        target_q_network=t_q_net,  
        target_update_period=TARGET_UPDATE_ITERS,  
        optimizer=optimizer,  
        epsilon_greedy=eps,  
        reward_scale_factor=1,  
        gradient_clipping=gradient_clipping,  
        td_errors_loss_fn=common.element_wise_huber_loss,  
        train_step_counter=tf.compat.v1.train.get_or_create_global_step(),  
        name="Trader"  
    )  
    agent.initialize()  
    print(agent.policy)  
    print(agent.collect_policy)  
    return agent  
with strategy.scope():  
  q_net = create_q_network(train_env)  
  t_q_net = create_q_network(train_env)  
  agent = create_agent(q_net, train_env, t_q_net=t_q_net)

交易操作

使用TensorFlow智能体的框架,训练我们的巴甫洛夫式交易者应该比我们自己搭建更容易。

交易模拟器类将准备所有所需的变量。在此情况下,它将使用DeepMind的Reverb初始化回复存储,并为代理创建收集策略。与评估策略π(at|ht)不同,评估策略用于预测目标Q值,收集器则是通过执行动作及其结果来探索和收集数据以填充记忆。记忆被保存为轨迹τ形式,这些轨迹存储在tensorflow中。这些轨迹是当前观察状态(ot)、采取的动作(at)、收到的奖励(r_t+1)以及随后的观察状态(o_t+1)的集合,形式化为r=(ot-1, at-1, rt, ot, dt),其中如果这是最后一个观察,则dt为真。

为了给我们的代理更多的学习机会,首先让它通过较高的epsilon值进行大量探索,并逐渐降低epsilon值,按照下面的公式所示:

  • ϵ_decayed 是当前步骤的 epsilon 值,
  • ϵ_initial 是训练开始时的初始 epsilon 值,我们将其设置为 1,意味着它仅在开始时探索。
  • ϵ_final 是我们希望代理在部署时利用环境的最终 epsilon 值。
  • step 是训练过程中的当前步骤或迭代次数,decay_steps 是控制衰减率的参数,在我们这里为 1000。随着步骤的增加,衰减将变得越来越小。

我们现在来创建一个模拟器,用来把前面提到的所有内容整合起来吧。

    class TradingSimulator:  
        """  
        一个模拟器类,用于使用TF-Agents和Reverb在交易环境中训练和评估DDQN。  
    Args:  
            env: 用于训练代理的Python环境。  
            eval_env: 用于评估代理的Python环境。  
            agent: 要训练的DDQN。  
            episodes (int): 总的训练集数。  
            batch_size (int): 重放缓冲中批量的大小。  
            collect_steps (int): 每次训练步骤中收集的步数。  
            log_interval (int): 训练期间的日志频率。  
            eval_interval (int): 训练期间的评估频率。  
            global_step (int): 全局步数跟踪器。  
        """  
        def __init__(self,  
                     env,  
                     eval_env,  
                     agent,  
                     episodes=TRAIN_EPISODES, batch_size=BATCH_SIZE,  
                     collect_steps=COLLECT_SIZE,  
                     log_interval=LOG_INTERVALS,  
                     eval_interval=TEST_INTERVALS,  
                     global_step=None):  
            assert env is not None and eval_env is not None and agent is not None  
            self.py_env = env  
            self.env = tf_py_environment.TFPyEnvironment(self.py_env)  
            self.py_eval_env = eval_env  
            self.eval_env = tf_py_environment.TFPyEnvironment(self.py_eval_env)  
            self.agent = agent  
            self.episodes = episodes  
            self.log_interval = log_interval  
            self.eval_interval = eval_interval  
            self.global_step = global_step if global_step is not None else tf.compat.v1.train.get_or_create_global_step()  
            self.batch_size = batch_size  
            self.collect_steps = collect_steps  
            self.replay_max = int(collect_steps * 1.5)  
            self.policy = self.agent.policy  
            self.collect_policy = self.agent.collect_policy  
            self.replay_buffer_signature = tensor_spec.add_outer_dim(  
                tensor_spec.from_spec(self.agent.collect_data_spec)  
            )  
        def init_memory(self, table_name='uniform_table'):  
            """  
            使用Reverb初始化重放内存,设置重放缓冲区和数据集。  
            Args:  
                table_name (str): 重放缓冲区的名称。  
            Returns:  
                用于采样重放数据的数据集和迭代器。  
            """  
            self.table = reverb.Table(  
                table_name,  
                max_size=self.replay_max,  
                sampler=reverb.selectors.Uniform(),  
                remover=reverb.selectors.Fifo(),  
                rate_limiter=reverb.rate_limiters.MinSize(1),  
                signature=self.replay_buffer_signature  
            )  
            self.reverb_server = reverb.Server([self.table])  
            self.replay_buffer = reverb_replay_buffer.ReverbReplayBuffer(  
                self.agent.collect_data_spec,  
                table_name=table_name,  
                sequence_length=None,  
                local_server=self.reverb_server  
            )  
            self.rb_observer = reverb_utils.ReverbAddTrajectoryObserver(self.replay_buffer.py_client, table_name, sequence_length=2)  
            self.dataset = self.replay_buffer.as_dataset(  
                num_parallel_calls=tf.data.AUTOTUNE,  
                sample_batch_size=self.batch_size,  
                num_steps=2  
            ).prefetch(tf.data.AUTOTUNE)  
            return self.dataset, iter(self.dataset)  
        def clear_model_directories(self, directories=[MODELS_PATH, LOGS_PATH]):  
            """  
            通过删除所有文件和目录(除了.zip文件)来清理模型目录。  
            Args:  
                directories (list): 要清理的目录列表。  
            """  
            try:  
                for root, dirs, files in os.walk(directories, topdown=False):  
                    for name in files:  
                        file_path = os.path.join(root, name)  
                        if not file_path.endswith('.zip'):  
                            os.remove(file_path)  
                    for name in dirs:  
                        dir_path = os.path.join(root, name)  
                        shutil.rmtree(dir_path)  
                print(f"已清除{directories}中的所有临时文件和目录")  
            except Exception as e:  
                print(f"清除目录时出错:{e}")  
        def get_q_values(self, time_step):  
            """  
            在给定的时间步获取DDQN网络的Q值和目标Q值。  
            Args:  
                time_step: 需要计算Q值的时间步。  
            Returns:  
                表示在线Q值和目标Q值的numpy数组元组。  
            """  
            batched_time_step = tf.nest.map_structure(lambda t: tf.expand_dims(t, 0), time_step)  
            q_values, _ = self.agent._q_network(batched_time_step.observation, batched_time_step.step_type)  
            best_action = tf.argmax(q_values, axis=-1)  
            target_q_values, _ = self.agent._target_q_network(batched_time_step.observation, batched_time_step.step_type)  
            target_q_values = tf.gather(target_q_values, best_action, axis=-1, batch_dims=1)  
            return q_values.numpy(), target_q_values.numpy()  
        def train(self,  
                  checkpoint_path=MODELS_PATH,  
                  initial_epsilon=EPSILON_START,  
                  final_epsilon=EPSILON_END,  
                  decay_steps=EPSILON_DECAY,  
                  strategy=None,  
                  only_purge_buffer=False):  
            """  
            使用指定的超参数训练DDQN。  
            Args:  
                checkpoint_path (str): 保存检查点的路径。  
                initial_epsilon (float): epsilon-greedy策略的初始epsilon值。  
                final_epsilon (float): epsilon-greedy策略的最终epsilon值。  
                decay_steps (int): epsilon衰减的步数。  
                strategy: 并行执行的分布式策略。  
                only_purge_buffer (bool): 仅清除缓冲区而不训练的标志。  
            Returns:  
                训练期间收集的奖励、损失、Q值和目标Q值元组。  
            """  
            self.init_memory()  
            train_checkpointer = None  
            if checkpoint_path is not None:  
                checkpoint_dir = os.path.join(checkpoint_path, 'checkpoint')  
                train_checkpointer = common.Checkpointer(  
                    ckpt_dir=checkpoint_dir,  
                    max_to_keep=1,  
                    agent=self.agent,  
                    policy=self.agent.policy,  
                    replay_buffer=self.replay_buffer,  
                    global_step=self.global_step  
                )  
                train_checkpointer.initialize_or_restore()  
                print(f'检查点已恢复:步数 {self.global_step.numpy()}')  
                root_dir = os.path.join(checkpoint_path, 'learner')  
            else:  
                temp_dir = tempfile.TemporaryDirectory()  
                root_dir = temp_dir.name  
            agent_learner = learner.Learner(  
                root_dir=root_dir,  
                train_step=self.global_step,  
                agent=self.agent,  
                experience_dataset_fn=lambda: self.dataset,  
                checkpoint_interval=self.eval_interval if self.eval_interval is not None else 1000,  
                use_reverb_v2=False,  
                summary_interval=self.log_interval if self.log_interval is not None else 1000,  
                strategy=strategy,  
                summary_root_dir=LOGS_PATH if self.log_interval is not None else None  
            )  
            collect_driver = py_driver.PyDriver(  
                self.py_env,  
                py_tf_eager_policy.PyTFEagerPolicy(self.collect_policy, use_tf_function=True),  
                [self.rb_observer],  
                max_steps=self.collect_steps  
            )  
            losses, rewards, q_values_list, target_q_values_list = [], [], [], []  
            time_step = self.py_env.reset()  
            print(f"从 {self.global_step.numpy()}步开始到 {self.episodes}步骤的训练")  
            while self.global_step.numpy() < self.episodes:  
                time_step = self.py_env.reset() if time_step.is_last() else time_step  
                time_step, _ = collect_driver.run(time_step)  
                agent_learner.run()  
                # 衰减代理的epsilon。  
                self.collect_policy._epsilon = (  
                    final_epsilon + (initial_epsilon - final_epsilon) * tf.math.exp(-1.0 * tf.cast(self.global_step.numpy(), tf.float32) / decay_steps)  
                )  
                if self.log_interval and self.global_step.numpy() % self.log_interval == 0:  
                    print(f'步骤 = {self.global_step.numpy()} of {self.episodes}: 损失 = {agent_learner.loss().loss.numpy()}')  
                    q_values, t_q_values = self.get_q_values(time_step)  
                    q_values_list.append(q_values)  
                    target_q_values_list.append(t_q_values)  
                if (self.eval_interval and  
                        (self.global_step.numpy() % self.eval_interval == 0  
                            or self.global_step.numpy() == self.episodes - 1)):  
                    total_rewards, avg_rewards = self.eval_metrics(strategy)  
                    rewards.append(np.mean(avg_rewards))  
                    losses.append(agent_learner.run().loss.numpy())  
                    print(f'步骤 = {self.global_step.numpy()} | 平均奖励 = {np.mean(avg_rewards)} | 总回报 = {total_rewards[-1]}')  
                    if train_checkpointer is not None:  
                        train_checkpointer.save(self.global_step)  
            self.rb_observer.close()  
            self.reverb_server.stop()  
            self.global_step.assign(0)  
            if checkpoint_path is not None:  
                policy_dir = os.path.join(checkpoint_path, 'policy')  
                try:  
                    policy_saver.PolicySaver(self.agent.policy).save(policy_dir)  
                except Exception as e:  
                    print(f"保存策略时出错,使用检查点代替:{e}")  
                    train_checkpointer.save(self.global_step)  
                self.zip_directories(checkpoint_path)  
                print("训练完成并保存了策略。")  
                self.clear_model_directories(checkpoint_path)  
                print("训练临时文件已清除。")  
            else:  
                temp_dir.cleanup()  
            return rewards, losses, q_values_list, target_q_values_list  
        def eval_metrics(self, strategy):  
            """  
            使用评估环境评估训练的代理。  
            Args:  
                strategy: 并行执行的分布式策略。  
            Returns:  
                包含总回报和平均奖励的元组。  
            """  
            assert self.policy is not None, f"没有策略,需要先训练。"  
            total_returns_list, episode_avg_rewards_list = [], []  
            with strategy.scope():  
                time_step = self.eval_env.reset()  
                episode_rewards = tf.TensorArray(dtype=tf.float32, size=0, dynamic_size=True)  
                i = 0  
                while not time_step.is_last():  
                    action_step = self.policy.action(time_step)  
                    time_step = self.eval_env.step(action_step.action)  
                    episode_rewards = episode_rewards.write(i, time_step.reward)  
                    i += 1  
                episode_rewards = episode_rewards.stack()  
                total_episode_return = tf.reduce_sum(episode_rewards)  
                episode_avg_return = tf.reduce_mean(episode_rewards)  
                total_returns_list.append(total_episode_return.numpy())  
                episode_avg_rewards_list.append(episode_avg_return.numpy())  
            return np.array(total_returns_list), np.array(episode_avg_rewards_list)  
        def zip_directories(self, directories=MODELS_PATH, output_filename=f'{MODELS_PATH}/model_files'):  
            """  
            将指定目录压缩成一个zip文件。  
            Args:  
                directories (str): 要归档的目录。  
                output_filename (str): 输出归档文件的名称。  
            """  
            archive_path = shutil.make_archive(output_filename, 'zip', root_dir='.', base_dir=directories)  
            print(f"已将{directories}归档到{archive_path}")  
        def load_and_eval_policy(self, policy_path):  
            """  
            从指定路径加载并评估保存的策略。  
            Args:  
                policy_path (str): 保存策略的目录路径。  
            Returns:  
                加载的策略、总奖励和平均回报的元组。  
            """  
            policy_dir = os.path.join(policy_path, 'policy')  
            try:  
                self.policy = tf.saved_model.load(policy_dir)  
            except Exception as e:  
                checkpoint_dir = os.path.join(policy_path, 'checkpoint')  
                train_checkpointer = common.Checkpointer(  
                    ckpt_dir=checkpoint_dir,  
                    agent=self.agent,  
                    policy=self.agent.policy,  
                    replay_buffer=self.replay_buffer,  
                    global_step=self.global_step  
                )  
                status = train_checkpointer.initialize_or_restore()  
                print(f'检查点已恢复:{status}')  
            total_rewards, avg_return = self.eval_metrics(strategy)  
            print(f'平均回报 = {np.mean(avg_return)}, 总回报 = {np.mean(total_rewards)}')  
            return self.policy, total_rewards, avg_return  
        def plot_performance(self, average_rewards, losses, q_values, target_q_values):  
            """  
            绘制包括奖励、损失和Q值在内的性能指标,随训练迭代次数的变化。  
            Args:  
                average_rewards (list): 训练过程中收集的平均奖励。  
                losses (list): 训练过程中收集的损失值。  
                q_values (list): 训练过程中收集的Q值。  
                target_q_values (list): 训练过程中收集的目标Q值。  
            """  
            fig, axs = plt.subplots(1, 3, figsize=(24, 6))  
            episodes_index = np.arange(len(average_rewards)) * self.eval_interval  
            q_values = np.array([np.mean(q_val) if q_val.ndim > 1 else q_val for q_val in q_values]).flatten()  
            target_q_values = np.array([np.mean(tq_val) if tq_val.ndim > 1 else tq_val for tq_val in target_q_values]).flatten()  
            axs[0].set_xlabel('周期')  
            axs[0].set_ylabel('奖励')  
            axs[0].plot(episodes_index, average_rewards, label='平均奖励', color="yellow")  
            axs[0].tick_params(axis='y')  
            axs[0].legend(loc="upper right")  
            axs[0].set_title('平均奖励随迭代次数的变化')  
            axs[1].set_xlabel('周期')  
            axs[1].set_ylabel('损失')  
            axs[1].plot(episodes_index, losses, label='损失', color="red")  
            axs[1].tick_params(axis='y')  
            axs[1].legend(loc="upper right")  
            axs[1].set_title('损失随迭代次数的变化')  
            min_q_len = min(len(q_values), len(target_q_values))  
            q_episodes_index = np.arange(min_q_len) * self.log_interval  
            axs[2].set_xlabel('周期')  
            axs[2].set_ylabel('Q值')  
            axs[2].plot(q_episodes_index, q_values[:min_q_len], label='在线Q值', color="green")  
            axs[2].plot(q_episodes_index, target_q_values[:min_q_len], label='目标Q值', color="blue")  
            axs[2].tick_params(axis='y')  
            axs[2].legend(loc="upper right")  
            axs[2].set_title('网络Q值随迭代次数的变化')  
            fig.suptitle('DDQN性能', fontsize=16)  
            plt.tight_layout(rect=[0, 0, 1, 0.96])  
            plt.show()  
        def plot_eval_trades(self, storage_dir=LOGS_PATH, file_name='backtest'):  
            """  
            绘制代理交易表现的回测结果。  
            Args:  
                storage_dir (str): 保存回测图的目录路径。  
                file_name (str): 保存的文件名。  
            显示回测图,显示买入/卖出信号、累计收益和奖励。  
            """  
            trades_df = self.py_eval_env.get_trade_data()  
            assert len(trades_df) > 1, "评估环境中没有交易。你需要调用eval_metrics。"  
            print(f"策略的累计收益率:{trades_df['cReturns'].iloc[-1]*100.:.02f}%")  
            buy_signals = trades_df[trades_df['Action'] == ACT_LONG]  
            sell_signals = trades_df[trades_df['Action'] == ACT_SHORT]  
            _, axes = plt.subplots(3, 1, figsize=(18, 11), gridspec_kw={'height_ratios': [4, 2, 2]})  
            axes[0].plot(trades_df['Close'], label=f'收盘价', color='蓝色', alpha=0.6, linestyle='--')  
            axes[0].scatter(buy_signals.index, buy_signals['Close'], color='绿色', marker='^', label='买入')  
            axes[0].scatter(sell_signals.index, sell_signals['Close'], color='红色', marker='v', label='卖出')  
            axes[0].set_title(f'收盘价')  
            axes[0].set_ylabel('价格')  
            axes[0].legend()  
            axes[0].grid(True)  
            axes[1].plot(trades_df['cReturns'], label='累计收益', color='紫色')  
            axes[1].set_title('累计收益')  
            axes[1].set_ylabel('累计收益')  
            axes[1].grid(True)  
            axes[1].legend()  
            axes[2].plot(trades_df['Reward'], label='奖励', color='绿色')  
            axes[2].set_title('奖励或惩罚')  
            axes[2].set_ylabel('奖励或惩罚')  
            axes[2].grid(True)  
            axes[2].legend()  
            plt.tight_layout()  
            try:  
                if not os.path.exists(storage_dir):  
                    os.makedirs(storage_dir)  
                file_path = os.path.join(storage_dir, f'{file_name}.png')  
                plt.savefig(file_path)  
            except Exception as e:  
                print(f"无法保存图形 {e}")  
            plt.show()

运行它:

试试这个:

此处的代码或命令保持不变

执行一下这个:

此处的代码或命令保持不变

根据上下文选择最合适的表达方式。

sim = TradingSimulator(  
    env=train_env,  
    eval_env=test_env,  
    agent=agent,  
    collect_steps=len(train_data)  # 收集步骤
)  
rewards, losses, q_values, target_q_values = sim.train(strategy=strategy)  # 训练策略

# 绘制性能图
sim.plot_performance(rewards, losses, q_values, target_q_values)

这将在大约5-10分钟的训练后填充并更新所需的所有指标,并绘制所需的图表。以下的图表来自实验编号6,该实验得到了较差的夏普比率。

从这些图表可以看出,可能有几个因素导致了夏普比率的下降。

  • 在“平均奖励”中,奖励在大约200集后显著下降,达到接近-1的低点。这种急剧下降表明代理并没有持续地学习到有益的行为。这种急剧下降表明策略可能过拟合或无法在不同状态下泛化良好。奖励结构可能没有很好地激励正确的行为,或者学习速率可能过高,从而导致不稳定。
  • 在“迭代中的损失”中,某些点上的损失增加可能表明代理难以收敛到最佳策略。这种损失的波动可能表明存在诸如不恰当的折扣因子、不正确的学习速率或探索不足等问题。
  • “迭代中的网络Q值”显示在线和目标Q值有显著波动。理想情况下,在线Q值应该更平稳地接近目标Q值。这种波动表明Q值估算不稳定,可能导致代理做出不佳的行动选择。可能的原因包括:目标网络更新不够频繁或不及时。

在接下来的实验章节中,你会学到更多东西。

论文代码中,研究人员使用了一个“技巧”来加快网络收敛速度——不仅返回动作和状态,还返回它们的逆。他们可以这样做,因为我们构建的代理其实真的非常关注长仓和短仓。

鉴于我们用TF-Agents框架使我们能够更多地关注网络、算法和功能,因此我们错失了这个机会,因为Learner类抽象了网络访问,因此,仅使用返回的状态(而非逆向状态)来训练网络。

接下来我们得到投资组合的指标:

    def get_trade_metrics(df, risk_free_rate=RISK_FREE_RATE, market_index=None):  
        """  
        根据给定的交易数据,计算交易策略的各种绩效指标。  
        参数:  
            df (pd.DataFrame): 包含至少'Returns'和'Position'列的交易数据数据框。  
            risk_free_rate (float): 计算夏普比率和索提诺比率时使用的无风险利率。  
            market_index (pd.DataFrame, 可选): 用于计算Beta的市场指数数据框,需包含'Close'列。  
        返回:  
            pd.DataFrame: 包含以下计算指标的数据框:  
                - 累计收益率  
                - 年化收益率  
                - 最大收益  
                - 最大亏损  
                - 方差  
                - 标准差  
                - 最大回撤  
                - 最大回撤长度  
                - 夏普比率  
                - 索提诺比率  
                - 交易次数  
                - 每间隔的交易次数  
                - 间隔数量  
                - 收益的偏度  
                - 收益的峰度  
                - Beta  
                - 信息比率  
                - 交易周转  
                - 盈利比 [%]  
        """  
        def calc_annualized_sharpe(rets, risk_free_rate=RISK_FREE_RATE):  
            mean_rets = rets.mean()  
            std_rets = rets.std()  
            sharpe_ratio = 0.  
            if std_rets != 0:  
                sharpe_ratio = (mean_rets - (risk_free_rate / TRADING_DAYS_YEAR)) / std_rets  
                sharpe_ratio *= np.sqrt(TRADING_DAYS_YEAR)  
            return sharpe_ratio  
        def calc_annualized_sortino(returns, risk_free_rate):  
            downside_risk = np.sqrt(((returns[returns < 0])**2).mean()) * np.sqrt(TRADING_DAYS_YEAR)  
            return (returns.mean() * TRADING_DAYS_YEAR - risk_free_rate) / downside_risk  
        variance = df['Returns'].var()  
        sharpe = calc_annualized_sharpe(df['Returns'],  risk_free_rate=risk_free_rate)  
        sortino = calc_annualized_sortino(df['Returns'],  risk_free_rate=risk_free_rate)  
        df['Drawdown'] = (1 + df['Returns']).cumprod().div((1 + df['Returns']).cumprod().cummax()) - 1  
        max_drawdown = df['Drawdown'].min()  
        drawdown_length = (df['Drawdown'] < 0).astype(int).groupby(df['Drawdown'].eq(0).cumsum()).cumsum().max()  
        trades = (df['Position'].diff().ne(0) & df['Position'].ne(0)).sum()  
        beta = None  
        if market_index is not None:  
            market_index['Returns'] = pd.to_numeric(market_index['Close'].pct_change().fillna(0), errors='coerce').fillna(0)  
            y = pd.to_numeric(df['Returns'], errors='coerce').fillna(0)  
            X = add_constant(market_index['Returns'].reset_index(drop=True))  
            y = y.iloc[:len(X)].reset_index(drop=True)  
            X = X.iloc[:len(y)].reset_index(drop=True)  
            model = OLS(y, X).fit()  
            beta = model.params[1]  
        active_return = df['Returns'] - (risk_free_rate / TRADING_DAYS_YEAR)  
        tracking_error = active_return.std()  
        information_ratio = (active_return.mean() / tracking_error) * np.sqrt(TRADING_DAYS_YEAR)  
        trade_churn = trades / len(df)  
        cumulative_return = (np.cumprod(1 + df['Returns']) - 1).iloc[-1] if not df['Returns'].empty else 0  
        annualized_return = (1 + cumulative_return)**(TRADING_DAYS_YEAR / len(df)) - 1 if len(df) > 0 else 0  
        winning_trades = df[df['Returns'] > 0]['Returns']  
        profitability_ratio = (winning_trades.sum() / len(df)) * 100  
        stats_df = pd.DataFrame({  
            "Cumulative Returns": [cumulative_return],  
            "Annualized Returns": [annualized_return],  
            "Maximum Return": [df['Returns'].max()],  
            "Maximum Loss": [df['Returns'].min()],  
            "Variance": [variance],  
            "Standard Deviation": [np.sqrt(variance)],  
            "Maximum Drawdown": [max_drawdown],  
            "Drawdown Length": [drawdown_length],  
            "Sharpe Ratio": [sharpe],  
            "Sortino Ratio": [sortino],  
            "Number of Trades": [trades],  
            "Trades per Interval": [trades / len(df)],  
            "Number of Intervals": [len(df)],  
            "Returns": [df['Returns'].to_numpy()],  
            "Returns Skewness": [skew(df['Returns'].to_numpy())],  
            "Returns Kurtosis": [kurtosis(df['Returns'].to_numpy())],  
            "Beta": [beta],  
            "Information Ratio": [information_ratio],  
            "Trade Churn": [trade_churn],  
            "Profitability Ratio [%]": [profitability_ratio],  
        })  
        return stats_df  
    metrics = get_trade_metrics(test_env.get_trade_data(), market_index=tickers[MARKET])  
    metrics.drop(columns=["Returns"]).T

我们绘制时间线图以看看代理做了什么以及何时完成

    sim.plot_eval_trades()

手动策略推断:

在这里我们探讨如何单独调用策略网络,不借助代理或环境,比如说你想要运行你训练好的网络。

我们将获得最后的状态空间,该空间返回了一个短信号(如上图所示)。我们将得到这个空间,将其展平,以便TF-Agents能够访问,然后扩展其维度,以将其放入批处理中。

批次被传递到在线网络,网络会返回Q-Values。

    trade_data = test_env.get_trade_data()  
    trade_data.reset_index(inplace=True)  
    print(trade_data[trade_data['Action'].isin([ACT_LONG, ACT_SHORT])]['Action'].tail(5))  

    last_trade_step = trade_data[trade_data['Action'].isin([ACT_LONG, ACT_SHORT])].iloc[-1].name  
    start_idx = max(0, last_trade_step - test_env.state_length + 1)  
    end_idx = last_trade_step + 1  
    last_trade_state = trade_data.iloc[start_idx:end_idx][test_env.features].values.flatten().astype(np.float32)  
    batched_observation = np.expand_dims(last_trade_state, axis=0)  
    q_values, _ = agent._q_network(batched_observation)  
    print(q_values)  
    predicted_action = np.argmax(q_values, axis=1)[0]  
    print(f"预测行动: {predicted_action},收益: {trade_data.loc[last_trade_step, 'Returns']},奖励: {trade_data.loc[last_trade_step, 'Reward']}")
    494    0  
    495    1  
    496    0  
    498    1  
    500    0  
    名称: 动作, 数据类型: int64  
    tf.Tensor([[0.675662, -0.640489]], 形状=(1, 2), 数据类型=float32)  
    预测的动作: 0 => 返回值: -0.001299471736657158 和 奖励值: 0.14807098172760885

在我们的情况下,代理人估计一个简短的操作会带来最大价值。

在最后一个实验中,它给我们带来了负回报,但有一个积极奖励!这就是所谓的夏普比率(Sharpe ratio)充当奖励信号。

验证运行次数

由于RL算法本质上是随机的,一般来说,我们希望得到其在所有指标上的性能近似值,以估计其性能波动。

    def validate_agent(trading_simulator, test_env, strategy=None, num_runs=VALIDATION_ITERS, market_index=None):  
        """  
        通过多次训练和评估来验证RL算法,并计算交易相关指标(例如收益和回报)。参数:  
        - trading_simulator: TradingSimulator类的实例。  
        - test_env: 用于评估的训练后环境。  
        - strategy: 用于分布式训练的TensorFlow策略(如有必要)。  
        - num_runs: DDQN训练和评估的运行次数。  
        - market_index: 市场指数数据(例如S&P 500),用于计算贝塔和其他指标。  
        返回值:  
        - metrics_df: 包含聚合指标和交易统计数据均值和标准差的DataFrame。  
        """  
        all_eval_rewards, all_eval_returns, all_trade_metrics = [], [], []  
        for run in tqdm(range(num_runs), desc="Validating Algo..."):  
            trading_simulator.train(checkpoint_path=None,  
                                    strategy=strategy)  
            total_returns, avg_rewards = trading_simulator.eval_metrics(strategy)  
            all_eval_rewards.append(np.mean(avg_rewards))  
            all_eval_returns.append(np.sum(total_returns))  
            trade_data = test_env.get_trade_data()  
            run_trade_metrics = get_trade_metrics(trade_data, market_index=market_index)  
            all_trade_metrics.append(run_trade_metrics.drop(columns=["Returns"]).T)  
        core_metrics_summary = pd.DataFrame({  
            'Metric': ['Eval Rewards', 'Eval Total Return'],  
            'Mean': [np.mean(all_eval_rewards), np.mean(all_eval_returns)],  
            '-/+ Std': [np.std(all_eval_rewards), np.std(all_eval_returns)]  
        })  
        # 聚合所有运行中的交易指标  
        trade_metrics_mean = pd.concat(all_trade_metrics, axis=1).mean(axis=1).to_frame('Mean')  
        trade_metrics_std = pd.concat(all_trade_metrics, axis=1).std(axis=1).to_frame('-/+ Std')  
        trade_metrics_summary = trade_metrics_mean.join(trade_metrics_std).reset_index().rename(columns={'index': 'Metric'})  
        # 将核心指标和交易指标合并到最终DataFrame中  
        combined_metrics = pd.concat([core_metrics_summary, trade_metrics_summary], ignore_index=True)  
        return combined_metrics  

运行以下代码(如下所示,在实验6中):

    val_trading_simulator = TradingSimulator(train_env,  
                                         test_env,  
                                         agent,  
                                         collect_steps=len(train_data),  
                                         log_interval=None,  # 在验证期间禁用这些日志。  
                                         eval_interval=None, # 我们只在完成全部训练后再进行验证。  
                                    )  
    metrics_df = validate_agent(val_trading_simulator,  
                                test_env,  
                                num_runs=VALIDATION_ITERS,  
                                strategy=strategy,  
                                market_index=tickers[MARKET])  
    metrics_df

这些指标的结果如下:

实验1至3:作为奖励

我们的研究问题是这样的:我们能否利用TF-Agents框架(或简称TFA)和一个精心设计的配置来获得更好的性能呢?

为了让我们了解它能做什么,我们在下面每个实验中重复训练和验证10多次,以获得统计显著性的结果。以下是所有这些实验的结果及其标准差。

这篇论文的代码已经5年没有更新了,并使用的是python 3.7版本的PyTorch编写。在Nvidia GeForce RTX 3050上,验证循环跑了4个小时。使用TF-Agents,收敛速度更快,只需要3个小时,不过结果不一样。

在下面的所有部分中,你可以看到实验结果、论文中的TDQN(交易DQN)基准以及简单的买入并持有(B&H)策略。

实验1 — 基础实验

我们的表现跟论文差不多,不过我们与平均值的差异比较大。可惜的是,这篇论文没有公布他们的标准偏差和实验次数。

实验2:技术分析的信号(TA)

使用Pandas-TA库,我们增加了以下几种信号到时间序列数据中:

  • 移动平均收敛散度(MACD)有助于确认趋势。此外,它还可以识别价格背离,这可能预示着潜在的趋势反转。MACD 是通过一个快速的 12 天移动平均线(MA),一个慢速的 26 天移动平均线(MA),以及一个为两者差异的9天指数移动平均线(EMA)来构建的。
  • 平均真实范围(ATR)将指示价格波动及其幅度,这会暗示市场的波动性。它是通过计算14天价格波动的移动平均值来构建的。

结果和实验1一样,这些特征没有起到作用。

实验3:宏观信号

在本次实验中,我们将通过以下时间序列数据向智能体提供其宏观环境。

  • VIX — 当前时期的波动率和恐惧指数(VIX)。
  • 10年期国债收益率 — 反映通胀情况的指标。
  • S&P 500 — 市场风险指标。

虽然平均结果更糟糕,但更有把握了(变异性较小)。

实验4至6:Sharpe作为回报

我们将用Sharpe作为奖励信号,重新运行相同的实验。实验1对应实验4,实验2对应实验5,实验3对应实验6。下面是我们的结果对比,

结果表现不佳,夏普比率并不是一个理想的奖励指标。设计一个好的奖励函数是强化学习中最具挑战性的任务之一。本文先前提到的交易环境图说明了一些原因,比如。

  • 即使投资组合的回报较低或略为负数,但如果波动性(即回报的标准差)也很低,夏普比率依然可以保持正值或比预期更少的负值。本质上,我们的代理因稳定业绩和避免高风险、高波动性交易而获得奖励。
  • 累计回报图可能落后于夏普比率的提升。如果代理在逐步改善其风险调整回报,奖励可能会在回报之前开始提升,因为代理首先降低了波动性,然后持续产生正向回报。
  • 一些较大的正奖励可能反映了代理在较小的变动或较短的时间内实现了较高夏普比率的时期,即使这些时期并未显著影响累计回报。
结论

在本文中,我们采用了 Théate, Thibaut 和 Ernst, Damien (2021) 的 Deep Q-Network (DDQN) 算法,利用了我们的自定义信号和 TensorFlow 的 Agents 框架工具,其中深度 Q 网络 (DDQN) 是指深度 Q 网络 (DDQN)。

我们的代理有能力决定最佳买卖持有策略,并且在模拟环境中表现良好,能够最大化投资组合回报。然而,我们的总体结果并未超越论文中的表现。这可能是因为研究人员针对特定用例对他们特定用例的网络和环境进行了微调,而我们使用的TF-Agents框架抽象了许多用于这种优化所需的低级细节。此外,我们的奖励函数未能有效控制波动和确保盈利,训练过程也不稳定且未能成功收敛——这在DDQN中不应该出现,因为该算法正是专门设计来克服这些强化学习难题的。

一般来说,简单就好,研究者们简化了研究,然而我们的DDQN和框架并没有取得更好的结果。

强化学习以各种形式存在,代表着人工智能中一个既迷人又多用途的分支。这里采用的Q-学习算法是该领域中使用最广泛的方法之一,尽管它在金融应用方面展现潜力,但要取得更好的结果,需要对超参数、环境配置和奖励结构进行细致调整,使之适应不同市场的特点。

参考文献
Github

文章及代码可以在Github上查看。

Kaggle 笔记本在这里可以查看 here

点击此处访问 Google Colab here

瑞典语翻译:
瑞典语中“媒体”的常见表达可以是“传媒”,这样会更加口语化,且符合源文本的格式要求。
传媒

最终翻译:

传媒

所有被使用的媒体(以代码或图像的形式)要么由我拥有,要么通过许可获得,或者属于公共领域并通过知识共享许可使用。

CC 许可与使用

这项作品受以下许可协议约束:在知识共享署名-非商业性使用 4.0 国际许可协议(Creative Commons Attribution-NonCommercial 4.0 International License)下。

点击查看更多内容
TA 点赞

若觉得本文不错,就分享一下吧!

评论

作者其他优质文章

正在加载中
  • 推荐
  • 评论
  • 收藏
  • 共同学习,写下你的评论
感谢您的支持,我会继续努力的~
扫码打赏,你说多少就多少
赞赏金额会直接到老师账户
支付方式
打开微信扫一扫,即可进行扫码打赏哦
今天注册有机会得

100积分直接送

付费专栏免费学

大额优惠券免费领

立即参与 放弃机会
意见反馈 帮助中心 APP下载
官方微信

举报

0/150
提交
取消