README 写了什么
在 Algorithms 一节的列表里,simple dqn、policy gradient、actor-critic、A3C with RNN 都标着 WIP(进行中)。仓库的文件树里也没有这些模块——它只有 trading_env/ 包的四个环境与一个自测脚本。
TradingGym · GYMNASIUM / SB3
TradingGym 是「模仿 Gym 风格」,不是 Gym 生态的一部分:全仓库没有 import gym,环境类不继承 gym.Env,observation_space 声明成一维 512 而实际返回二维矩阵,step() 返回四元组。想用 SB3 训练,必须自己写适配层——这页给出可直接复制的写法。
gym.Env · 修正空间 · 转五元组 · 提供 reset(seed=…)gym.Env,四元组返回gym.Env、修正空间、转五元组reset(seed) / 五元组 stepimport gym、observation_space 声明为 np.array([obs_len*feature_len,])、step 返回四值)绘制的适配层示意(非官方流程图)。中间那一层必须由使用者自己实现。TradingGym · 四条硬事实
下面四条都来自源码与实测,任一条都会让「直接传入」失败。先认清这四点,再写适配层就不会白折腾。
| 问题 | 事实 | 源码/实测依据 | 导致的失败 | 适配层怎么处理 |
|---|---|---|---|---|
不是 gym.Env 子类 | 类定义为 class trading_env:,全仓库无 import gym | grep import gym 零命中 | SB3 的 check_env 直接拒绝 | 写一个继承 gym.Env 的包装类 |
| 空间声明与实际不符 | observation_space = np.array([obs_len*feature_len,])(一维 512),实际观测是 (obs_len, 特征数+8) 二维 | backtest_v1.py:41,48;实测 obs 形状 (64,16) | SB3 校验形状不一致时报错 | 按真实形状重建 Box/Discrete 空间 |
action_space 类型不统一 | v1 里是整数 3,v0 里是 np.array([3,]) | backtest_v1.py:41、training_v0.py:38 | 取 action_space.n 时报 AttributeError | 统一声明 gym.spaces.Discrete(3) |
| 返回四元组且无 seed/close | step() 返回 (obs, reward, done, info);reset() 不接收 seed,也没有 close() | backtest_v1.py:280、training_v1.py:82 | Gymnasium 要求五元组与 reset(seed=…) | 转 (obs, reward, terminated, truncated, info),并自己管随机种子 |
| 观测含持仓信息 | return_transaction=True 时观测拼接 8 列持仓/成本/盈亏 | backtest_v1.py:118-128 | 与论文设定的纯行情观测不一致 | 按需裁剪观测维度,并在报告里写明 |
| 无算法实现 | README 里 simple dqn / policy gradient / actor-critic / A3C 全部标 WIP | README + 文件树 | 以为「装完就有模型」 | 算法完全由使用者提供(如 SB3) |
TradingGym · 适配层
这段代码做了四件事:继承 gym.Env、按真实形状声明空间、把四元组转成五元组、统一随机种子入口。它不修改 TradingGym 源码。
pip install gymnasium stable-baselines3
# 另需 TradingGym 本身:git clone https://github.com/Yvictor/TradingGym.git && cd TradingGym && python setup.py install
Gymnasium 与 SB3 是外部依赖,TradingGym 的 setup.py 里没有它们;gym 老库也不需要装(项目本来就不用)。
预期输出:import gymnasium 与 import trading_env 均成功。
import numpy as np, gymnasium as gym, trading_env
class TradingGymEnv(gym.Env):
metadata = {"render_modes": []}
def __init__(self, env_id="backtest_v1", obs_data_len=64, step_len=32, df=None,
fee=0.0, max_position=1, deal_col_name="Price", feature_names=None,
drop_position_info=False):
super().__init__()
self.drop = drop_position_info
self._env = trading_env.make(env_id=env_id, obs_data_len=obs_data_len, step_len=step_len,
df=df, fee=fee, max_position=max_position,
deal_col_name=deal_col_name, feature_names=feature_names)
n_feat = len(feature_names)
n_obs = n_feat if drop_position_info else n_feat + 8 # v1 观测拼接了 8 列附加信息
lower = np.full((obs_data_len, n_obs), -np.inf, dtype=np.float32)
upper = np.full((obs_data_len, n_obs), np.inf, dtype=np.float32)
self.observation_space = gym.spaces.Box(low=lower, high=upper, dtype=np.float32)
self.action_space = gym.spaces.Discrete(3) # 0=不动 1=做多方向 2=做空方向
self._done = False
def _obs(self, obs):
o = np.asarray(obs, dtype=np.float32)
return o[:, :o.shape[1] - 8] if self.drop else o
def reset(self, *, seed=None, options=None):
super().reset(seed=seed)
if seed is not None:
np.random.seed(seed) # TradingGym 内部用全局 np.random 抽日
obs = self._env.reset()
self._done = False
return self._obs(obs), {}
def step(self, action):
obs, reward, done, info = self._env.step(int(action))
terminated = bool(done)
truncated = False # v1 的 done 只有「当日 tick 用尽」一种来源
if terminated and getattr(self._env, "backtest_done", False):
truncated = False
return self._obs(obs), float(reward), terminated, truncated, {"info": getattr(self._env, "info", None)}
def close(self):
self._env = None
要点:①空间按真实观测形状声明;②reset(seed) 里把种子传给全局 np.random,否则抽日不可复现;③done 只映射到 terminated,因为你无法从四元组里区分截断。
预期输出:TradingGymEnv(...) 实例化成功,env.observation_space.shape == (64, 16)。
from stable_baselines3.common.env_checker import check_env
check_env(TradingGymEnv(df=df, feature_names=FEATURES), warn=True)
check_env 会检查空间一致性、reset/step 返回结构、动作合法性。它能抓住「空间声明与实际不符」这类问题——也正是原环境直接传入时最会踩的坑。
预期输出:无异常(若报 observation 不在 space 内,说明维度或 dtype 写错了)。
env.info。训练时的 reward 与评估时的净值是两套口径,不要混用(见「奖励与基准」页)。TradingGym · 接口迁移
TradingGym 用的是 OpenAI Gym 时代的老式接口,网上大量教程也停留在那一代。下表把差异集中在一处,便于你改写别人的示例代码。
obs(obs, info)return obs, {}reset(*, seed=None, options=None)np.random.seed(seed)(obs, reward, done, info)(obs, reward, terminated, truncated, info)terminated = done; truncated = Falsenp.array([3,]) 或整数 3gym.spaces.Discrete(3)action_space.nBoxcheck_env 拦下的点render() 依赖 matplotlib 实时画图render_mode 规范TradingGym · 训练前检查
RL 训练跑了几小时才发现口径错了,是这类项目的常见浪费。下面六项在训前各花一分钟就能确认。
check_env 无告警Boxaction_describe 对应Discrete(3) 且语义为 不动/做多/做空reset(seed) 透传 + 固定 numpy 种子fee 设置与实验目的相符TradingGym · 算法现实
这是最容易被误解的一点:TradingGym 提供的是环境,不是算法。README 里列出的四个算法方向,仓库中都没有对应实现文件。
在 Algorithms 一节的列表里,simple dqn、policy gradient、actor-critic、A3C with RNN 都标着 WIP(进行中)。仓库的文件树里也没有这些模块——它只有 trading_env/ 包的四个环境与一个自测脚本。
一套 RL 算法实现(如 Stable-Baselines3 的 PPO/DQN)、训练循环、评估流程与超参搜索。也就是说,「用 TradingGym 做 RL 交易」这件事的算法部分完全在你这边。
python -m trading_env.test)make() 签名TradingGym · 训练与评估
训练看 reward,评估看账本——这两件事必须分开,否则你会得到「训练曲线很好、回测是负的」这类结论。
from stable_baselines3 import PPO
model = PPO("MlpPolicy", TradingGymEnv(df=df, feature_names=FEATURES), verbose=1, seed=0)
model.learn(total_timesteps=200_000)
这里 PPO 用的 reward 是 step() 的返回值(观测窗口求和)。它适合做优化信号,但不是可直接累计的收益。
预期输出:无报错即可;训练时长与步数取决于你的机器与 total_timesteps。
env = trading_env.make('backtest_v1', 64, 32, df=df, fee=0.0, max_position=1,
deal_col_name='Price', feature_names=FEATURES)
env.reset(); done = False
while not done:
action, _ = model.predict(env.obs_return, deterministic=True)
obs, reward, done, info = env.step(int(action))
det = env.info.copy()
print('账本收益 = %.3f 笔数 = %d' % (det['reward'].sum(), (det['position_variation'] != 0).sum()))
评估阶段绕开适配层,直接用原生环境读账本,可以避免「适配层把 reward 换了口径」导致的误判。
预期输出:打印该交易日的账本收益与交易笔数(口径与「奖励与基准」页一致)。
# 1) 随机策略(多随机种子)
# 2) always long / Buy&Hold
# 3) 双均线规则策略
# 每一组都用同一个评估循环与同样的 fee
本站实测:随机策略三种子在 -9.300 到 +3.800 之间波动,双均线 +1.300,Buy&Hold +1.100。没有这些参照线,单看模型的收益率无法判断好坏。
预期输出:一张含策略、笔数、账本收益、Buy&Hold 的对照表(可参见回测循环页的实测对照表)。
TradingGym · FAQ
不支持开箱即用。它不依赖 gym 或 gymnasium,环境类不继承 gym.Env,step() 返回四元组,reset() 不接收 seed。要接入 Gymnasium/SB3 必须自己写一层适配(本页给了完整代码)。以官方源码为准。
因为原环境的 observation_space 声明为 np.array([obs_data_len * feature_len,])(一维),而 reset()/step() 实际返回二维矩阵 (obs_data_len, 特征数+8)(v1 默认拼接 8 列持仓/成本/盈亏)。适配层里按真实形状重建 Box 即可;也可以用 return_transaction=False 或手动裁剪去掉附加列。
没有。README 的算法列表里 simple dqn、policy gradient、actor-critic、A3C with RNN 全部标注 WIP,仓库中也不存在对应实现文件。算法完全由使用者提供,例如用 Stable-Baselines3。训练脚本、评估脚本同样需要自己写。
TradingGym 的 reset() 不接受 seed,而训练环境内部用全局 np.random 抽取交易日。适配层的 reset(seed=…) 里需要显式调用 np.random.seed(seed) 才能让「同一种子两次跑结果一致」。回测环境 backtest_v1 按日顺序推进,不依赖随机抽日,因此更适合作可复现实验的载体。
把模型动作喂回原生环境(不经适配层),跑完整交易日,然后读 env.info['reward'] 重建净值,并同时输出交易笔数与 Buy&Hold 对照。训练时看到的 reward 是窗口求和,不能直接当收益。
技术上可以(SB3 的 make_vec_env),但要注意两点:①TradingGym 内部使用全局 np.random,多进程/多线程下抽日的可复现性会变差;②每个环境实例需要独立的 DataFrame(项目没有标的维度)。多环境并行属于实验工程问题,本站未做实测,请以自己的运行结果为准。