TradingGym · BACKTEST LOOP

TradingGym 回测:README 的示例照抄会报错,正确入口是 reset()

README 的 Backtesting 段落用 env_id='backtest_v1' 却调用 env.backtest();本机实测直接抛 AttributeError: 'trading_env' object has no attribute 'backtest'——那个方法只存在于 training_v0.py。真实的日间循环靠 reset() 推进日期、靠 backtest_done 收尾。这页给出可运行版本与账本读法。

  • 实测报错hasattr(env,'backtest') == False,而 backtest_done 属性存在
  • 正确路径reset() 取一个新交易日 → step() 跑到 done → 读 env.info
  • 5 天实测:随机策略 -9.300 / +3.800 / +1.700(三个种子),双均线 +1.300

TradingGym 日间回测循环有哪四个环节?

reset()取第 N 个交易日(内部推进 date_record
step(action)循环直到 done
env.info当日逐 tick 账本(DataFrame)
backtest_done是否还有下一天
依据 backtest_v1.py:72-84, 186-280 与本机实测报错绘制的回测循环示意(非官方流程图)。注意 backtest_v1 没有 backtest() 方法,不要照 README 的写法循环。

TradingGym · 先说坑

TradingGym 为什么照 README 跑回测会报错:两个独立原因

这不是版本差异问题,而是官方示例本身没跟上实现。本机把 README 的写法原样跑了一遍,报错原文如下。

原因一:方法不存在

AttributeError: 'trading_env' object has no attribute 'backtest'
hasattr(env2, 'backtest')     -> False
hasattr(env2, 'backtest_done') -> True

README 用 env_id='backtest_v1',但 def backtest(self) 只写在 training_v0.py:442。也就是说「回测方法」只在最老的那一支里存在。

原因二:取数实现过时

AttributeError: 'Series' object has no attribute 'as_matrix'

即使绕开原因一,backtest_v1reset() 仍在调用 pandas 1.0 起就移除的 as_matrix()backtest_v1.py:91,93)。所以必须先加两行兼容补丁。

README 示例 vs 实际可运行写法(逐项对照)
环节README 写法实际行为正确写法注意点
取一个交易日未说明env.reset(),之后每次再 reset() 会推进到下一交易日reset 不是「重跑同一天」,而是「换一天」
驱动单日循环while not env.backtest_done: state = env.backtest()AttributeError(无该方法)while not done: obs, reward, done, info = env.step(action)单日 done 与「全部跑完」是两件事
判断是否继续env.backtest_done属性存在且可用单日循环结束后判断 env.backtest_done为 True 时说明没有下一个交易日了
读取结果输出 transaction_details 表格并画图v0 系才有一次性交易明细env.info(v1 的逐 tick 账本)两个环境的结果结构完全不同
交易详情形态每次成交追加一行(step/transact/price/share/...)v0:transact_n 明细;v1:整日数组 + joinv1 用 env.info['position_variation'] != 0 筛出交易点v1 没有「成交列表」这种对象
数据要求未提索引索引非整数会取错切片df = df.reset_index(drop=True)见「数据契约」页

TradingGym · 正确写法

TradingGym 日间回测循环怎么写?(本站实测通过)

下面三段合起来就是一个完整回测:建环境 → 逐日循环 → 收集账本。代码与本站跑出 -9.300/+3.800/+1.700 那三组数字所用的脚本一致。

第 1 步 · 建环境(含兼容补丁与 MA 特征)

import numpy as np, pandas as pd, trading_env

if not hasattr(pd.Series, "as_matrix"):            # 老代码在新 pandas 上取数所需的补丁
    pd.Series.as_matrix = lambda self: self.to_numpy()
    pd.DataFrame.as_matrix = lambda self: self.to_numpy()

df = pd.read_csv('trading_env/test/data/SGXTWsample.csv', index_col=0, parse_dates=['datetime'])
df = df.reset_index(drop=True)                     # 索引契约:必须是 0..N 的整数
df['MA'] = df['Price'].rolling(20).mean()
df = df.iloc[20:].copy()                           # 裁掉 MA 未定义的头部,避免 NaN 进观测

FEATURES = ['Price', 'MA']
env = trading_env.make(env_id='backtest_v1', obs_data_len=64, step_len=32, df=df,
                       fee=0.0, max_position=1, deal_col_name='Price', feature_names=FEATURES)

把 MA 直接算进 DataFrame 是最省事的做法,但要注意 feature_names 必须与实际列名完全一致。

预期输出:无报错;env.obs_return 在 reset 后形状为 (64, 10)(2 个特征 + 8 个附加列)。

第 2 步 · 日间循环(关键在这里)

np.random.seed(0)
days, env.reset()
while True:
    done = False
    while not done:                                # 单日:跑完这一天的所有 step
        obs = env.obs_return                       # 当前观测(含持仓列)
        action = int(np.random.randint(3))         # 0=不动 1=做多 2=做空
        obs, reward, done, info = env.step(action)
    det = env.info.copy()                          # 当日逐 tick 账本
    days.append(det)
    if env.backtest_done:                          # 没有下一个交易日了
        break
    env.reset()                                    # 推进到下一个交易日

这段循环在任何中文教程里都还找不到:reset() 承担「换一天」的职责,而 backtest_done 只在最后一个交易日跑完后才为 True。

预期输出:5 个交易日全部跑完;len(days) == 5,每日 len(det) 等于该日 tick 数(8,550 / 10,451 / 14,071 / 10,182 / 15,543)。

第 3 步 · 从账本算结果(不要用 step 返回值加总)

for det in days:
    px, pos = det['Price'].to_numpy(float), det['position'].to_numpy(float)
    env_reward  = det['reward'].sum()                       # 与环境账本一致
    site_pnl    = ((px[1:] - px[:-1]) * pos[:-1]).sum()     # 独立重算,应相等
    trades      = int((det['position_variation'] != 0).sum())
    bh          = px[-1] - px[0]                            # Buy&Hold 对照(1 份敞口)
    print(det['datetime'].iloc[0].date(), trades, round(env_reward,3), round(site_pnl,3), round(bh,3))

本站用这套算法核对过:env.info['reward'].sum() 与「逐 tick 增量盈亏 × 持仓」的独立重算完全吻合,这也是判断「结果有没有算错」最直接的检查。

预期输出:每行打印一个交易日的日期、笔数、环境账本收益、独立重算收益与 Buy&Hold 收益;前两者应一致。

step() 返回的 reward 直接累加会得到偏大的数值(本站实测 5 天 -19.500 vs 账本 -9.300),原因是它等于「观测窗口内 reward 数组之和」。详细对账见「奖励与基准」页。

TradingGym · 账本

TradingGym env.info 里有什么:逐 tick 账本的列说明

env.info 是当日行情表与交易明细 join 后的结果,行数等于当日 tick 数。做结果分析时它比 step() 返回值可靠得多。

env.info 的列(v1 账本;原始行情列会一并保留)
含义取值/单位怎么用注意点
position逐 tick 的持仓量整数,正为多、负为空重建净值、统计持仓时长它才是「敞口」的真相
position_variation该 tick 的持仓变化量整数(±1 等)!= 0 即交易点,可数笔数一笔平仓 + 一笔开仓可能落在同一 tick
entry_cover动作类型标签1 新开仓 / 2 加仓 / -1 平仓 / -2 减仓区分开平仓与加减仓v0 系没有这一列
price逐 tick 成交价deal_col_name 同单位与 position 一起重建盈亏
price_mean当前持仓的均价价格单位看浮盈浮亏的基准加仓会改变它
reward增量盈亏(已实现+浮动)价格单位可直接累加得到区间总盈亏本站实测与独立重算完全一致
reward_fluctuant浮动盈亏水平值(并含每 tick 持仓费)价格单位观察瞬时浮盈浮亏不能累加:单日实测 +28.1 vs 真实 -0.5
reward_makereal已实现盈亏标记/累加0/1 与数值混用区分是否含平仓结算reward 口径交织,分析时优先用 reward
原始行情列datetime/Price/Volume/serial_number原样保留按时间对齐外部数据列名与你的输入一致

TradingGym · 策略接口

TradingGym 策略怎么写:一个 choice_action 就够了

TradingGym 不规定策略基类,它只是「每步给你观测、你回一个动作」。README 里用 ChoiceAction 这个名字,实际只是一个普通类。下面三段分别是随机、规则(均线交叉)与自定义逻辑。

随机策略(基准用)

class RandomAgent:
    def __init__(self, seed=0):
        self.rng = np.random.RandomState(seed)
    def choice_action(self, obs):
        return int(self.rng.randint(3))

注意用自己的 RandomState,因为环境内部用的是全局 np.random,两者混用会让「换种子复现」失效。

预期输出:动作序列可复现;本站三个种子对应 5 天账本收益 -9.300 / +3.800 / +1.700。

规则策略(双均线交叉)

def ma_action(obs):                 # obs[:, 0]=Price, obs[:, 1]=MA
    if obs[-1][0] > obs[-1][1] and obs[-2][0] <= obs[-2][1]:
        return 1                    # 上穿 → 做多方向
    if obs[-1][0] < obs[-1][1] and obs[-2][0] >= obs[-2][1]:
        return 2                    # 下穿 → 做空方向
    return 0

规则策略也能用这套环境——这正是很多人误以为「TradingGym 只能做强化学习」的地方。判据只用窗口内已出现的 tick,不含未来数据。

预期输出:本站实测 5 天 132 笔交易,账本 +1.300(fee=0),Buy&Hold 同期 +1.700。

用观测里的持仓列做风控

def risk_aware_action(obs, max_pos=1):
    position = obs[-1][8]           # 附加列第 1 列就是 position
    if position >= max_pos:
        return 0                    # 已达上限就不再加仓(否则会被环境改写为不动)
    return 1 if obs[-1][0] > obs[-1][1] else 0

v1 的观测自带持仓信息(return_transaction=True),因此策略可以直接读到自己的仓位,不必在外部维护状态。

预期输出:持仓不超过上限;若不判断上限,动作会被环境静默改写为 0,看起来像「策略没响应」。

TradingGym · 结果落地

TradingGym 回测结果怎么保存成可核对的表格与指标?

回测最容易出错的地方是「中间结果丢了」。下面这段把账本落盘成 CSV 并计算常用指标,便于事后逐 tick 复核。

落盘账本(保留全部列)

all_days = []
for i, det in enumerate(days, 1):
    d = det.copy()
    d['trading_day'] = i
    all_days.append(d)
ledger = pd.concat(all_days)
ledger.to_csv('ledger.csv', index=True)          # 保留原始索引,便于回溯 tick
print(ledger[['trading_day','position','reward','Price']].tail())

保留逐 tick 账本后,任何「收益率异常」的质疑都可以回到具体 tick 上核对。

预期输出:ledger.csv 行数 = 5 个交易日 tick 数之和(58,797 行)。

算指标(用可累加的那一列)

equity = ledger.groupby('trading_day')['reward'].sum().cumsum()
peak   = equity.cummax()
dd     = (equity - peak).min()                    # 最大回撤(价格单位)
total  = equity.iloc[-1]
print('total=%.3f  maxDD=%.3f  days=%d' % (total, dd, len(equity)))

reward 而不是 reward_fluctuant,否则回撤会被放大到无意义的量级。

预期输出:随机策略 seed 0 的 total-9.300

加成本、加基准,再判断「有没有效」

for fee in (0.0, 0.1):
    env = make_env(fee=fee)                        # 仅改 fee,其余参数不变
    tot = run_backtest(env, agent)                 # 复用第 2 步的循环
    print('fee=%.1f  账本收益=%.3f  笔数=%d' % (fee, tot['env'], tot['trades']))

至少要同时报告:成本前后两版结果 + 一个基准(Buy&Hold 或均线)+ 多个随机种子。只有一版的收益率不足以支撑结论。

预期输出:seed 0 在 fee 0 → 0.1 时,账本收益由 -9.300 变 -49.400。

TradingGym · 实跑对照

TradingGym 各策略实测结果如何?同一段数据、同一套口径

全部实验口径一致:backtest_v1obs_data_len=64step_len=32max_position=1,跑官方样例数据的前 5 个交易日(SGX TW tick)。

实测结果(单位:价格点;样例价格量级约 277 点)
策略fee交易笔数账本收益Buy&Hold 对照需要留意的点
do nothing000.000+1.100零持仓必须严格为 0,用来验证账本没有虚增
always long010+1.000+1.100与 Buy&Hold 差 0.1,来自撮合价位置而不是算错
双均线(20 tick)0132+1.300+1.700(MA 头部裁剪后)规则策略可用,但并未跑赢简单持有
随机策略 seed 00802-9.300+1.100基线较差,可用来看清成本与方差
随机策略 seed 10830+3.800+1.100换种子由亏转盈:单次结果不可信的直接证据
随机策略 seed 20812+1.700+1.100三种子跨度 13.1 点,方差大于策略差异
随机策略 seed 00.1802-49.400+1.100成本 40.1 点,几乎是价格波动量级的数倍

样本内就是全部样本

这 5 天是唯一可用样例,且既是训练集又是测试集。任何「策略有效」的结论都必须在更长、独立的数据上重做。

成本项主导短周期

800 多笔交易下,fee=0.1 让结果变化几十点,而同期 Buy&Hold 只有 1.1 点。短周期实验必须把成本放在显著位置。

规则策略同样要报成本

双均线 132 笔交易时,成本使结果由 +1.300 变 -5.300——交易频率低不等于成本可以忽略。

TradingGym · FAQ

TradingGym 回测常见问题是什么?

TradingGym 怎么做回测?官方有现成函数吗?

没有可直接用的高层回测函数。README 展示的 env.backtest() 在当前版本不存在(只写在 training_v0.py:442),本机实测报 AttributeError。可用的是 backtest_v1 环境的 reset() + step() 循环,单日跑完读 env.info,用 backtest_done 判断是否还有下一日。写法见本页第 2 节,以官方源码为准。

为什么我按 README 写 while 循环报「没有 backtest 属性」?

因为 README 的循环体 state = env.backtest() 依赖一个只在 training_v0 里存在的方法。改用 step() 循环即可;如果确实要跑 v0 的回测路径,就得同时接受它需要 as_matrix 补丁才能在当前 pandas 上取数这一事实。

一天跑完怎么进入下一天?

再次调用 env.reset()backtest_v1.reset() 内部会调 _choice_section() 推进 date_record,取到下一个交易日;当所有交易日跑完后,env.backtest_done 变为 True。注意 reset() 的语义是「换一天」而不是「重跑同一天」,这对随机抽日的 v0 环境尤其容易误解。

回测结果在哪里拿?transaction_details 是什么?

v1 用 env.info:当日行情表与交易明细 join 后的逐 tick 账本,行数 = 当日 tick 数,含 position/price_mean/reward 等列。README 里的 transaction_details 是 v0 的一次性成交明细(步骤/时间/价格/份额等),v1 里没有对应对象,需要用 position_variation != 0 自己筛交易点。

为什么回测跑得很快、几百步就结束了?

因为单日步数 ≈ (当日 tick 数 − obs_data_len) / step_len,用 64/32 时一天大约 250–480 步;如果某天 tick 数少于 obs_data_len + step_len,第一个 step 之后就会 done。官方样例最短的交易日只有 8,550 根 tick,最长的有 25,617 根,所以每天步数差异明显。

回测收益和 step 返回的 reward 累加值不一样,哪个对?

用账本。env.info['reward'].sum() 与「逐 tick 增量盈亏 × 持仓」的独立重算完全一致(本站实测单日 -0.5000 对 -0.5000);而 step() 返回的是观测窗口内 reward 之和(窗口重叠),5 天累加为 -19.500 而账本为 -9.300。做净值曲线一律用账本列,细节见「奖励与基准」页。

下一步该怎么做?把结果做成可信的净值与基准对照

回测能跑只是起点。同一策略换随机种子可以从 -9.3 变 +3.8 点,所以净值重建、基准对照与多随机种子复现才是结论成立的前提。