正确写法
df = df.reset_index(drop=True) # 索引变成 0..N-1
assert list(df.index[:3]) == [0, 1, 2]
官方样例 CSV 用 pd.read_csv(..., index_col=0) 读取:第一列恰好是 0..N 的整数,所以能直接跑。这不是巧合,而是这份数据被整理过。
TradingGym · DATA CONTRACT
TradingGym 不下载数据、不做复权、不管交易规则,它只接受一份你整理好的 DataFrame。这份表必须通过 make() 里的四个断言,并且索引必须是 0..N 的整数——这两条决定了你是十分钟跑通,还是卡在KeyError 里。
env_id 合法 · deal_col_name 在列 · serial_number 必须在列 · 每个特征列存在0..N 整数,否则 iloc 切片会错位serial_number/Price/Ask_price/Bid_price/Updown 等make() 四个断言,缺列立即报错==0 切出每日首根 tickiloc[label:label] 的前提trading_env/__init__.py:13-17 的断言与官方样例数据实读绘制的数据契约示意(非官方流程图)。四道检查任一不过,都会在 make() 或 reset() 阶段报错,而不是在训练时。TradingGym · 列要求
make() 只检查四件事,但真正跑起来还依赖几列。下表按「谁要求它、缺了报什么」写,避免你把官方样例的列名当成唯一答案。
| 列 | 谁要求它 | 缺了会怎样 | 适用场景 | 注意点 |
|---|---|---|---|---|
serial_number | make() 第 3 个断言,硬性要求 | AssertionError,环境根本建不起来 | 所有环境 | 必须按交易日重置编号,否则分日逻辑失效 |
deal_col_name(如 Price) | make() 第 2 个断言 | AssertionError | 所有环境,决定盈亏计算口径 | 用收盘价还是成交价,要与你回测假设一致 |
feature_names 里的每一列 | make() 第 4 个断言,逐列检查 | 任一列不存在即 AssertionError | 所有环境,构成观测 | 列名大小写敏感;观测里会按你给的顺序排列 |
datetime | 非断言,但回测/日志与账本都靠它 | 账本时间列缺失,逐日推进无法核对 | 逐日回测、结果留痕 | 建议解析成真正的时间类型,不要留字符串 |
Volume | 非断言,官方样例与教程默认特征 | 若列入 feature_names 则断言失败 | 价量类特征 | tick 数据里常是累计量,是否需要差分由你决定 |
买卖盘列(Ask_price/Bid_price 等) | 非断言 | 不报错,但你没有盘口特征可用 | tick 级微观结构研究 | 没有这些列时不要把它们写进 feature_names |
Updown/Updown_Cum | 非断言,官方样例自带 | 不报错 | 期货等带涨跌字段的品种 | A 股日线没有这两列,直接不传即可 |
position、position_variation、entry_cover、price、price_mean、reward_fluctuant、reward_makereal、reward),这些列不需要你提供,由环境自己生成。TradingGym · 分日机制
环境用 df[df['serial_number'] == 0].index 找出每个交易日的起点,再据此切分。所以 serial_number 不是「行号」,而是每个交易日内从 0 开始的连续编号。
| 交易日(环境口径) | tick 数 | 起始时间 | 结束时间 | 跨几个日历日期 | 注意点 |
|---|---|---|---|---|---|
| 第 1 日 | 8,550 | 2013-04-09 10:45:03 | 2013-04-10 01:59:39 | 2 | 首根 tick 必须标 0,否则这一天被整体跳过 |
| 第 2 日 | 10,451 | 2013-04-10 08:45:00 | 2013-04-11 01:59:56 | 2 | 编号要重置,不能跨日累加 |
| 第 3 日 | 14,071 | 2013-04-11 08:45:00 | 2013-04-12 01:59:12 | 2 | 夜盘尾巴落在次日凌晨 |
| 第 4 日 | 10,182 | 2013-04-12 08:45:00 | 2013-04-13 01:59:55 | 2 | 次日凌晨那段属于本交易日 |
| 第 5–7 日 | 15,543 / 15,329 / 13,316 | 均 08:45:00 | 次日 01:59 左右 | 2 | 单日 tick 数在 8.5k–25.6k 之间波动 |
| 第 8 日 | 25,617 | 2013-04-19 08:45:00 | 2013-04-20 01:59:57 | 2 | 最长的一个交易日(占比最大) |
| 整表 | 113,059 | 2013-04-09 起 | 2013-04-20 止 | 11 个日历日期 → 8 个交易日 | 行数 ≠ 可用天数:每天还要够 obs_data_len + step_len 根才能走完 episode |
按 groupby(dt.date) 切 | 会切出 11 天 | — | — | — | 其中 3 天只有 283 / 422 / 202 根(夜盘尾巴),会得到与官方不一致的回测划分 |
2013-04-09 08:45 开的那个交易日一直跑到 2013-04-10 01:59。所以「按日历日期分组」与「按交易日起点分组」结果不同——前者 11 天、后者 8 天。生成 serial_number 时必须用交易日起点(例如「时间早于当日 08:45 的 tick 归到前一交易日」),或直接沿用数据里已有的 serial_number。serial_number 的最小写法(自有数据的通用做法):先按时间排序,再把「交易日期变化」当成新一天:df['serial_number'] = df.groupby(df['datetime'].dt.date).cumcount()。日线数据每天只有 1 根,编号恒为 0。TradingGym · 索引契约
这不是风格问题。源码里取出「每日起点」后,用的是 self.df.iloc[begin_point:end_point]——把索引标签当位置用。只要索引不是从 0 开始的连续整数,切片就会静默取错数据。
df = df.reset_index(drop=True) # 索引变成 0..N-1
assert list(df.index[:3]) == [0, 1, 2]
官方样例 CSV 用 pd.read_csv(..., index_col=0) 读取:第一列恰好是 0..N 的整数,所以能直接跑。这不是巧合,而是这份数据被整理过。
df = pd.read_csv('mydata.csv', parse_dates=['datetime'])
df = df.set_index('datetime') # 索引变成时间戳
env = trading_env.make(..., df=df) # 不报错,但取的是错位的行
用时间戳做索引时,.index[label] 返回的是 Timestamp,传给 iloc 会报 TypeError;如果索引是像 1000, 2000, ... 这样的整数,则不会报错但取到的是错的区间——后者更危险。
| 索引形态 | 能否用 | 现象 | 适用场景 | 注意点 |
|---|---|---|---|---|
RangeIndex 0..N-1 | 能用 | 切片正确 | 默认推荐 | 官方样例就是这种;读完 CSV 后 reset_index(drop=True) 最稳 |
| 时间戳索引 | 不能用 | iloc 收到 Timestamp 报 TypeError | — | 想按时间查数据,另存一列 datetime 即可 |
| 从 1 开始 | 不能直接使用 | 每日起始偏移一行,账本与真实交易日错开 | — | 表现为「第一天少了第一根 tick」,很难察觉 |
| 非连续整数(如按股票代码) | 不能用 | 不报错但区间错位,回测结果无意义 | — | 多标的数据务必先按单一标的切片再 reset_index |
| 多标的混在一张表 | 不能用 | 不同标的的 tick 被当成同一天序列 | — | TradingGym 不含标的维度,必须一只标的一份 df、一个环境 |
按日期做 MultiIndex | 不能用 | 断言与索引逻辑都不兼容 | — | 先把层级压平(reset_index()) |
TradingGym · 适配模板
下面三段代码把自有数据整理成可用的形态。注意变量含义:obs_data_len 是观测窗口根数,step_len 是每次 step 前进的根数——日线数据一天只有 1 根,所以要按「多天滚动窗口」来理解。
df = pd.read_csv('tick.csv', parse_dates=['datetime']).sort_values('datetime')
df = df.reset_index(drop=True) # 索引契约
df['serial_number'] = df.groupby(df['datetime'].dt.date).cumcount() # 每日从 0 编号
features = ['Price', 'Volume', 'Ask_price', 'Bid_price']
assert {'serial_number', 'Price', *features} <= set(df.columns)
env = trading_env.make('backtest_v1', obs_data_len=64, step_len=32, df=df,
fee=0.0, max_position=1, deal_col_name='Price', feature_names=features)
要点:先排序、再 reset_index、最后生成 serial_number。顺序反了会导致编号与行序不一致。
预期输出:make() 不报错;若列名拼错会立刻抛 AssertionError。
df = pd.read_csv('daily.csv', parse_dates=['datetime']).sort_values('datetime')
df = df.reset_index(drop=True)
df['serial_number'] = 0 # 每天只有 1 根,编号恒为 0 → 一天=一根 tick
df['Price'] = df['close']
features = ['Price', 'open', 'high', 'low', 'volume']
env = trading_env.make('backtest_v1', obs_data_len=20, step_len=5, df=df, fee=0.0,
max_position=1, deal_col_name='Price', feature_names=features)
要点:日线下 obs_data_len=20 表示「看过去 20 个交易日」,step_len=5 表示每 5 个交易日决策一次。此时 serial_number=0 意味着整张表被当成「一个超长的交易日」。
预期输出:单日 episode 长度 ≈ (len(df) - obs_data_len) / step_len 步,全部合并在一个 episode 里。
df = ak.stock_zh_a_hist(symbol='600519', period='daily', adjust='hfq') # 需自备数据源
df = df.rename(columns={'日期':'datetime','收盘':'close','成交量':'volume'})
df['datetime'] = pd.to_datetime(df['datetime'])
df['serial_number'] = 0
df['Price'] = df['close']
df = df.reset_index(drop=True)
features = ['Price', 'volume']
要点:TradingGym 只管「按价格撮合」,不管 T+1、涨跌停、停复牌与最小交易单位。这些规则必须在外层自己加约束(例如用观测里的持仓列判断是否可卖)。
预期输出:环境可以建起来,但所有 A 股特有规则都由你自己保证——这是本项目的能力边界,不是 bug。
TradingGym · 错误对照
数据问题几乎都在 make() 或第一次 reset() 暴露,不会拖到训练后期。按这张表逐行对,比反复读源码快。
| 现象 / 报错 | 原因 | 怎么定位 | 修法 | 注意点 |
|---|---|---|---|---|
AssertionError(无消息) | 四个断言之一失败:env_id 非法 / deal_col_name 不在列 / 无 serial_number / 特征列缺失 | 逐个打印 env_id in available_envs()、deal_col_name in df.columns、'serial_number' in df.columns、set(feature_names) <= set(df.columns) | 补齐缺失列,或改 deal_col_name/feature_names | 断言不带提示信息,必须自己逐项检查 |
KeyError: 'Price' | 列名大小写或命名不符 | 打印 list(df.columns) | 统一列名(df.rename(columns=...)) | 空格与全角字符也会导致不匹配 |
TypeError(iloc 相关) | 索引不是整数(如时间戳) | 打印 type(df.index) | df = df.reset_index(drop=True) | 只在 reset() 阶段暴露 |
| 回测「只跑了一天」 | serial_number 未按日重置,只识别出一个交易日 | 打印 (df['serial_number'] == 0).sum(),应为交易日数 | 按日期 groupby(...).cumcount() 重新编号 | 编号跨日累加时最难察觉 |
| episode 立刻结束 | 当日 tick 数少于 obs_data_len + step_len | 打印每日行数分布 | 减小 obs_data_len/step_len,或补足数据 | 停牌日、半日市都会触发 |
观测里出现 NaN | 特征含缺失值(如 rolling 头部未裁剪) | df[features].isna().sum() | 裁剪头部或 fillna | 本站在双均线实验里直接切掉了 MA 未定义的前 20 行 |
| 结果明显不合理 | 时间未排序 / tick 与 OHLC 混用 / 多标的混表 | 检查 datetime 是否单调递增、每行是否同一标的 | 排序、拆分标的、统一频率 | 多标的混表不报错但结果无意义 |
TradingGym · 自检清单
这份清单对应源码里的真实约束,逐项确认能省掉绝大多数「跑不通」的时间。
0..N-1 的整数(reset_index(drop=True) 过一遍)。serial_number 列,且每天从 0 重新编号。deal_col_name 指向的价格列真实存在,且与你回测假设一致。feature_names 里每一列都存在于 DataFrame 中。datetime 已解析为时间类型且单调递增。NaN(rolling 头部已裁剪)。obs_data_len + step_len。| 自检项 | 不满足的表现 | 发现时机 | 严重度 | 注意点 |
|---|---|---|---|---|
| ① 整数索引 | 切片错位或 TypeError | 首次 reset() | 高(结果不可信) | 静默错位比报错更糟 |
| ② serial_number | 断言失败或只识别 1 天 | make() / reset() | 高 | 两种失败方式分别对应「没这列」与「没重置」 |
| ③ deal_col_name | 断言失败 | make() | 中 | 列存在但语义不符(如用买价算盈亏)不会报错 |
| ④ feature_names | 断言失败 | make() | 中 | 逐列检查,任一缺失即失败 |
| ⑤ datetime | 账本时间列不可用 | 结果分析阶段 | 中 | 不影响运行,影响可复现与对账 |
| ⑥ NaN | 观测异常、reward 出现 NaN 风险 | reset() 之后 | 高 | rolling 指标最容易带入头部 NaN |
| ⑦ 每日行数 | episode 立即结束 | 首个 step | 中 | 停牌/半日市数据尤其常见 |
| ⑧ 单一标的 | 不报错但结果无意义 | 结果分析阶段 | 高 | 项目不含标的维度,必须自己拆 |
TradingGym · FAQ
机制上可以:只要满足四道断言并把索引整理成整数,日线也能建成环境(模板 C 已给出写法)。但要注意三点——官方没有任何 A 股数据源,复权要自己处理;日线每天只有 1 根,serial_number 恒为 0,整个区间会被当成一个长 episode;T+1、涨跌停、停复牌、最小交易单位这些规则它完全不管,必须在外层加约束。以官方源码的实际行为为准。
不一定。只有 serial_number、deal_col_name 与 feature_names 里的列是硬要求。官方样例带 Ask_price/Bid_price 等盘口字段,但如果你不把它们写进 feature_names,缺了也不会报错——只是你没有盘口特征可用。特征怎么选属于研究设计,不是安装问题。
可以,但要保证每日行数 ≥ obs_data_len + step_len。若一天的 bar 数小于这个值,第一个 step() 之后就会直接 done。处理方式是调小窗口参数,或者改为中心化的多日窗口(把 serial_number 统一设 0,把整段区间当成一个交易日)。两种做法改变的是实验设定,需要在报告里写清楚。
是。环境用 df[df['serial_number'] == 0] 找每个交易日的起点,所以 0 的位置决定切分点;跨日累加或从 1 开始都会让切分错位或漏掉第一天。生成方式见本页 BAND 2 的最小写法,仍建议跑完后打印 (df['serial_number'] == 0).sum() 与交易日数对照,以官方实现为准。
先检查两件事:①是不是用了时间戳索引(iloc 会拿到 Timestamp);②serial_number 列里有没有 0。这两条占到数据类报错的绝大多数。其余情况按本页「错误对照表」逐行排查,每一项都给了定位命令。
不可以。TradingGym 的环境没有标的维度,serial_number 只表达「日内第几根」,混表会让不同标的的 tick 被当成同一条时间序列,且不会报错。正确做法是每只标的一份 DataFrame、一个环境实例,最后在环境之外汇总结果。