A 股回测特殊注意点 — 涨跌停 / 停复牌 / T+1
A 股 vs 美股的 8 项市场结构差异;涨跌停、停复牌、T+1、印花税如何在回测里建模
日期: 2026-07-27 方向: Phase 3 / A 股回测特殊点 阶段: Phase 3: 实盘+规模化+迁移 标签: #AShareBacktest #PriceLimit #Suspension #T+1 #StampDuty #2015Crash #MarketStructure
今日目标
| 类型 | 内容 |
|---|---|
| 学习 | A 股 vs 美股的 8 项市场结构差异;涨跌停、停复牌、T+1、印花税如何在回测里建模 |
| 实操 | 写一个 next_tradable_open() + 完整 A 股 backtest engine 骨架;覆盖 2015 股灾的边界条件 |
| 产出 | TR-DAY79 笔记 + 可复用的成本/可交易性函数 + 常见错误清单 |
一、为什么 A 股回测不能直接套美股框架
过去 78 天写的所有回测——动量、Theta 收割、事件驱动——默认了三件事:
1) 任何时刻 signal 触发,下一个 bar 一定能成交
2) 持仓没有「冻结」状态,今天买的今天就能卖
3) 成本 = 佣金 + 滑点(其他税费可忽略不计)
这三件事在美股都基本成立(停牌少 / T+0 自由日内 / SEC fee 万分之 0.22 几乎可忽略)。
到了 A 股,全部不成立。
而且不是「精度修正」级别的不成立,是「会让 backtest 的 Sharpe 从 2.0 跌到 0.3」级别的不成立。这是为什么有大量 A 股策略「回测年化 80%、实盘亏 30%」——不是 alpha 假,是回测引擎跟市场结构脱节。
今天的核心认知:A 股回测不是「换数据源」的工程问题,是「换市场结构」的设计问题。
二、A 股 vs 美股:8 项核心差异
| # | 维度 | 美股 | A 股 | 对策略的杀伤力 |
|---|---|---|---|---|
| 1 | 价格波动限制 | 无(熔断按指数) | ±10%(主板)/ ±20%(创业板/科创板)/ ±5%(ST) | ⭐⭐⭐⭐⭐ |
| 2 | 结算周期 | T+2(股)/ T+1(期权)资金,但T+0 日内交易自由 | T+1:今天买,最早明天才能卖 | ⭐⭐⭐⭐⭐ |
| 3 | 停牌制度 | 罕见,停牌一般几小时 | 常态:重组停 3-6 月,重大事项停 1-30 天 | ⭐⭐⭐⭐ |
| 4 | 一字板 | 不存在概念 | 涨跌停封单堆积,全天 0 成交;连板可连续 5-10 天 | ⭐⭐⭐⭐⭐ |
| 5 | 复牌跳空 | 一般 ±10% 内 | 复牌可一字 ±10%/±20% 连续 5-10 天,累计 ±50%+ | ⭐⭐⭐⭐ |
| 6 | 集合竞价 | 开盘前 / 收盘前各 10 分钟,价格透明 | 9:15-9:25 + 14:57-15:00,9:20 前可撤单(多空诱导) | ⭐⭐⭐ |
| 7 | 印花税 | 无(SEC fee 万 0.22 单边) | 卖出 1‰(仅卖方) | ⭐⭐⭐⭐ |
| 8 | 过户费 | 无 | 沪市买卖各 万 0.1(深市免) | ⭐ |
下面对每一条我们都建模一遍。
三、涨跌停建模:A 股回测的头号 Bug
3.1 触及涨跌停意味着什么
价格触及涨停(+10%):
→ 卖方有人,买方堆积,能成交但buy 单可能排队
→ 「封住涨停」:买单远大于卖单,**当日无法买入**(成交≈0)
→ 第二天可能继续涨停 / 跳空高开 / 反向跌
价格触及跌停(-10%):
→ 买方有人,卖方堆积,sell 单可能排队
→ 「封住跌停」:卖单远大于买单,**当日无法卖出**
→ 想跑跑不掉,第二天才能继续尝试
3.2 简化规则(适合回测)
def can_buy(symbol, date, bar):
"""当日是否能买入"""
prev_close = get_prev_close(symbol, date)
limit_up = round(prev_close * (1 + price_limit_pct(symbol)), 2)
# 触及涨停且封住 → 不能买
if bar.high >= limit_up and bar.close >= limit_up and bar.volume < threshold:
return False
# 高开开盘价直接涨停 → 不能买
if bar.open >= limit_up:
return False
return True
def can_sell(symbol, date, bar):
"""当日是否能卖出"""
prev_close = get_prev_close(symbol, date)
limit_down = round(prev_close * (1 - price_limit_pct(symbol)), 2)
if bar.low <= limit_down and bar.close <= limit_down and bar.volume < threshold:
return False
if bar.open <= limit_down:
return False
return True
def price_limit_pct(symbol):
if symbol.startswith(('300', '688')): # 创业板/科创板
return 0.20
if symbol.startswith('ST') or 'ST' in symbol:
return 0.05
return 0.10
3.3 回测里的标准模板
# 错误写法(直接套美股逻辑)
if signal_to_buy:
portfolio.buy(symbol, qty, price=bar.close)
# 正确写法(A 股版)
if signal_to_buy:
if can_buy(symbol, date, bar):
# 注意:涨停日真买,成交价应≥涨停价,不是 close
fill_price = min(target_price, limit_up_price)
portfolio.buy(symbol, qty, price=fill_price)
else:
log.skip(f"{symbol} {date}: at upper limit, cannot enter")
# signal 推迟到下一个 tradable bar
pending_buys.append((symbol, qty, date))
关键陷阱:很多人简单判断「bar.close == limit_up」就跳过,但有 4 种封板形态:
| 形态 | 特征 | 能否买卖 |
|---|---|---|
| 一字板 | open = high = low = close = 涨停 | 0 成交,不能买 |
| 早盘封板 | 早盘涨停后再没打开 | 很难买(要排队) |
| 反复开板 | 涨停 → 打开 → 再封 | 打开瞬间能买 |
| 尾盘拉停 | 临近收盘冲涨停 | 当日已经买不到 |
保守建模:所有触及涨停的 bar,假设最后 90% 时间封住——回测里禁止当日开新仓。
四、停牌建模:被时间冻结的持仓
4.1 停牌的真实含义
普通停牌(数小时):盘中重要公告,复牌后继续交易,影响小
临时停牌(1-3 天):重大资产重组草案、立案调查、舆情突发
重大重组停牌(1-6 个月):合并 / 借壳 / 重组草案审核
退市停牌:进入退市整理期或直接终止上市
对回测系统的要求:
- 停牌期间,该持仓的 mark-to-market 应该用停牌前最后价格(不是 NaN,不是 0)
- P&L 日变动 = 0(持仓被冻结,没有变化)
- 复牌当日,必须用复牌后第一个能成交的 bar重新估值
- 任何 rebalance / risk parity 算法在停牌期间应排除该资产(用其他持仓重平衡)
4.2 复牌跳空:杀手中的杀手
| 案例 | 停牌时长 | 复牌后表现 |
|---|---|---|
| 某重组失败案例 | 4 个月 | 复牌连续 8 个跌停板(累计 -57%) |
| 某 ST 摘帽 | 1 个月 | 复牌 5 个涨停板(累计 +61%) |
| 某立案调查 | 3 周 | 复牌一字跌停 6 天后才打开 |
如果你的回测里这种事件完全不在数据里(很多免费数据源直接把停牌期 forward-fill 成停牌前价),等于一个 -57% 的损失从来没体现过。实盘第一次撞上你就明白回测的「年化 80%」哪来的了。
4.3 建模代码
def get_position_value(symbol, date, last_known_price, position_qty):
if is_suspended(symbol, date):
# 停牌:保持上一日估值
return last_known_price * position_qty
bar = get_bar(symbol, date)
return bar.close * position_qty
def next_tradable_open(symbol, signal_date):
"""从 signal_date 往后找第一个能成交的 open 价格"""
d = signal_date
for _ in range(60): # 最多向前看 60 天,超过算放弃
d = next_trading_day(d)
if is_suspended(symbol, d):
continue
bar = get_bar(symbol, d)
# 一字涨停:open 就触及涨停,不可买
if bar.open >= upper_limit(symbol, d):
continue
# 一字跌停:不可卖
if bar.open <= lower_limit(symbol, d):
continue
return d, bar.open
return None, None # 60 天没机会,放弃这个信号
五、T+1 对策略选择的根本影响
5.1 T+1 是什么
T 日 9:30 买入 → T 日 14:55 想卖出?→ ❌ 不允许
T 日 9:30 买入 → T+1 日 9:30 可卖出 → ✓
例外:
- 融资融券账户做空(不是真"今天买今天卖",是借券卖出,逻辑不同)
- 期货 / ETF 部分品种 T+0
- 港股通持仓 T+0(注意:标的本身是港股,规则随港股市场)
5.2 不同频率策略的影响
| 策略频率 | T+1 影响 | 备注 |
|---|---|---|
| 高频 / 日内套利 | 致命——根本不可能做 | 散户做不了,机构用 ETF/期货绕 |
| 1-2 日短线 | 严重——平均要多持仓 1 天,吃下一天的隔夜风险 | 隔夜 gap 风险全部要建模 |
| 周频 | 影响很小 | 持仓本来就 5-10 天 |
| 月频 | 几乎无影响 | 我们个人量化主路径 |
| 因子月度再平衡 | 无影响 |
5.3 散户「友好」?还是「受害」?
经常听到「T+1 保护散户」的说法。真相:T+1 让散户无法及时止损,反而是机构友好。
- 早盘 9:35 看到买错了,想立刻 -1% 跑路?→ 不行,必须扛到明天
- 第二天高开低走,亏损扩大到 -8%?→ 这是真实的 T+1 风险
- 机构走的不是这条路——他们通过股指期货对冲、或者用 ETF 套利
回测里要诚实建模:所有买入的当日 close 估值正常,但真要现金/想止损必须 +1 天。资金占用因此变长,年化资金周转次数减少,capacity 也跟着降。
六、集合竞价 vs 连续竞价:开盘价的真实含义
6.1 时间窗口
09:15 - 09:20 集合竞价(可申报,可撤单) → 多空诱导高发期
09:20 - 09:25 集合竞价(可申报,不可撤单) → 真正决定开盘价
09:25 - 09:30 休息(不可申报,不可撤单)
09:30 - 11:30 连续竞价(早盘)
13:00 - 14:57 连续竞价(午盘)
14:57 - 15:00 收盘集合竞价(不可撤单,决定收盘价)
6.2 对回测的影响
很多回测代码默认「open 价 = 9:30:00 第一笔成交价」,但 A 股 open 价 = 9:25 集合竞价撮合价。
差异:
- 集合竞价基于「全场买卖单的最大成交量」撮合
- 大量散户挂的「市价单」会瞬间被吃掉
- 早盘开盘价波动远大于第一笔连续竞价
如果你的策略是「9:30:00 一开盘就下市价单」:
- 实际成交价不是 open 价,而是 open 后第一个 bid/ask 价
- 集合竞价的「乌龙指 / 巨单」可能让 open 价偏离合理价 1-3%
- 你的 backtest 假设 fill price = open,实盘平均会偏离 10-30 bps
建议:保守起见,回测里用 9:30 后第一个 5 分钟 VWAP 作为开盘下单的 fill 价格,比单纯用 open 更接近实盘。
6.3 9:15-9:20 撤单陷阱
9:15-9:20 可以挂单也可以撤单——大资金的常见手法:
- 9:15 挂一笔超大买单,把虚拟开盘价拉高
- 散户跟风挂买单
- 9:19:59 大资金撤单
- 9:20-9:25 不能撤,散户的买单被锁定,开盘价反而低开
- 散户被诱多
这对回测影响有限(回测看的是最终撮合价),但对实盘下单逻辑影响很大:任何在 9:15-9:20 挂单的策略都要预设「我可能在被诱多/诱空」。
七、印花税 + 过户费:高换手策略的隐性杀手
7.1 完整成本结构对比
| 项目 | 美股 | A 股 |
|---|---|---|
| 佣金 | $0.0035/股,min $0.35 | 万 2.5(双边),min 5 元 |
| 印花税 | 无 | 卖出 1‰(万 10)单边 |
| 过户费 | 无 | 沪市买卖各万 0.1,深市免 |
| 监管费 | SEC fee 万 0.22(卖方) | 已含在佣金 |
| 滑点 | 估 1-3 bps | 估 5-20 bps(流动性差异大) |
| 单次 buy+sell 总成本 | 约 5 bps | 约 20-30 bps(A 股是 4-6 倍) |
7.2 数字感
假设月换手 100% 的策略(每月把仓位全换一遍):
美股:年换手 12 × 100% = 1200%
年成本 ≈ 1200% × 5bps = 60 bps = 0.6%
A 股:年换手 1200%
年成本 ≈ 1200% × 25bps = 300 bps = 3.0%
3% 是什么概念?沪深 300 长期年化收益约 8%。一个月换手 100% 的 A 股策略,光成本就吃掉 37.5% 的市场年化收益。
对应策略选择:
- A 股月频再平衡:能做(成本 0.5-1%)
- A 股周频策略:勉强(成本 2-3%,alpha 必须 ≥ 5%)
- A 股日频策略:几乎做不了(成本 10%+,alpha 很难超过)
这是 A 股 quant 圈普遍偏向月度低换手因子 + 期货对冲的根本原因——市场结构决定的。
7.3 成本计算函数
def a_share_cost(symbol, side, price, qty, commission_rate=0.0003):
"""
side: 'buy' or 'sell'
返回总成本(含佣金、印花税、过户费)
"""
notional = price * qty
# 佣金(双边),最低 5 元
commission = max(notional * commission_rate, 5.0)
# 印花税(仅卖方)
stamp_duty = notional * 0.001 if side == 'sell' else 0.0
# 过户费(沪市双边各 万 0.1,深市免)
transfer_fee = notional * 0.00001 if symbol.startswith(('60', '68')) else 0.0
return commission + stamp_duty + transfer_fee
def a_share_fill_price(side, target_price, slippage_bps=10):
"""加滑点"""
if side == 'buy':
return target_price * (1 + slippage_bps / 10000)
else:
return target_price * (1 - slippage_bps / 10000)
八、完整 A 股 backtest engine 骨架
把上面这些拼起来:
# a_share_backtest.py
from dataclasses import dataclass
from typing import Dict, List, Optional
from datetime import date, timedelta
@dataclass
class Position:
symbol: str
qty: int
avg_price: float
entry_date: date
last_mark_price: float # 用于停牌期间保持估值
@dataclass
class Order:
symbol: str
side: str # 'buy' / 'sell'
qty: int
signal_date: date
status: str # 'pending' / 'filled' / 'rejected'
fill_date: Optional[date] = None
fill_price: Optional[float] = None
class AShareBacktest:
def __init__(self, initial_capital: float):
self.cash = initial_capital
self.positions: Dict[str, Position] = {}
self.t1_locked: Dict[str, int] = {} # T+1 锁定:当日买入的不能卖
self.pending_orders: List[Order] = []
self.trade_log = []
self.equity_curve = []
# ---- 可交易性 ----
def can_buy(self, symbol, d, bar) -> bool:
if self.is_suspended(symbol, d):
return False
limit_up = self.upper_limit(symbol, d)
if bar.open >= limit_up:
return False
if bar.close >= limit_up and bar.volume < self.封板阈值(symbol):
return False
return True
def can_sell(self, symbol, d, bar) -> bool:
if self.is_suspended(symbol, d):
return False
if self.t1_locked.get(symbol, 0) > 0: # 当日新买的,T+1 之前不能卖
return False
limit_down = self.lower_limit(symbol, d)
if bar.open <= limit_down:
return False
if bar.close <= limit_down and bar.volume < self.封板阈值(symbol):
return False
return True
# ---- 下单 ----
def submit(self, order: Order):
self.pending_orders.append(order)
def process_orders(self, d: date):
still_pending = []
for order in self.pending_orders:
bar = self.get_bar(order.symbol, d)
if bar is None: # 停牌
still_pending.append(order)
continue
if order.side == 'buy' and self.can_buy(order.symbol, d, bar):
self._fill_buy(order, d, bar)
elif order.side == 'sell' and self.can_sell(order.symbol, d, bar):
self._fill_sell(order, d, bar)
else:
# 今天没成,明天再试
still_pending.append(order)
# 超过 60 天还没成,认定失效(极端情况)
if (d - order.signal_date).days > 60:
order.status = 'rejected'
self.trade_log.append(order)
else:
still_pending.append(order)
self.pending_orders = still_pending
def _fill_buy(self, order, d, bar):
fill_price = a_share_fill_price('buy', bar.open)
cost = fill_price * order.qty
fees = a_share_cost(order.symbol, 'buy', fill_price, order.qty)
if self.cash < cost + fees:
order.status = 'rejected'
return
self.cash -= (cost + fees)
if order.symbol in self.positions:
pos = self.positions[order.symbol]
new_qty = pos.qty + order.qty
pos.avg_price = (pos.avg_price * pos.qty + fill_price * order.qty) / new_qty
pos.qty = new_qty
else:
self.positions[order.symbol] = Position(
symbol=order.symbol, qty=order.qty,
avg_price=fill_price, entry_date=d,
last_mark_price=fill_price
)
# T+1 锁定
self.t1_locked[order.symbol] = self.t1_locked.get(order.symbol, 0) + order.qty
order.status = 'filled'
order.fill_date = d
order.fill_price = fill_price
self.trade_log.append(order)
def _fill_sell(self, order, d, bar):
# 简化:只允许全部卖出
pos = self.positions.get(order.symbol)
if pos is None or pos.qty < order.qty:
order.status = 'rejected'
return
fill_price = a_share_fill_price('sell', bar.open)
proceeds = fill_price * order.qty
fees = a_share_cost(order.symbol, 'sell', fill_price, order.qty)
self.cash += (proceeds - fees)
pos.qty -= order.qty
if pos.qty == 0:
del self.positions[order.symbol]
order.status = 'filled'
order.fill_date = d
order.fill_price = fill_price
self.trade_log.append(order)
def end_of_day(self, d: date):
# 1. T+1 解锁(上一交易日买的,今天解锁)
# 注意:这里简化处理,实际应该按日期跟踪
self.t1_locked = {} # 每个交易日结束后所有持仓都可卖
# 2. mark-to-market
equity = self.cash
for sym, pos in self.positions.items():
if self.is_suspended(sym, d):
# 停牌:用上一日价格
pass # last_mark_price 保持
else:
bar = self.get_bar(sym, d)
if bar is not None:
pos.last_mark_price = bar.close
equity += pos.last_mark_price * pos.qty
self.equity_curve.append((d, equity))
重点:注释比代码重要。每一处「if is_suspended / at_limit」都是回测里美股策略不需要写的——这些都是 A 股市场结构的体现。
九、2015 股灾:所有 A 股回测都要单独跑一次
9.1 2015-06 ~ 2015-08 发生了什么
| 时间 | 事件 |
|---|---|
| 2015-06-12 | 上证指数 5178 点见顶 |
| 2015-06-15 ~ 07-03 | 三周暴跌 32%,千股跌停频繁 |
| 2015-07-08 | 超过 1400 只股票停牌(市场近一半停牌) |
| 2015-07-09 | 国家队入场救市 |
| 2015-08-24 | 「黑色星期一」,2000+ 只跌停 |
9.2 对回测引擎的极限测试
某天发出 sell 信号要平仓 50 只股票:
- 28 只一字跌停封死 → 卖不掉
- 12 只停牌 → 卖不掉
- 10 只能成交 → 部分成交
第二天:
- 那 28 只继续跌停 → 还是卖不掉
- 12 只继续停牌
第三天:
- 部分打开,但都是 -10% 后才打开 → 损失已固化
第 N 天:
- 累计损失 -40%+,但你的「止损线」是 -5%
- 止损完全无效
9.3 回测必做的压力测试
任何 A 股策略 backtest,必须:
- 把 2015-06 ~ 2015-09 单独提取,看最大回撤、最长停牌天数、未成交订单比例
- 把 2018-Q4(贸易战 + 业绩雷)也单独跑
- 把 2024-01 ~ 02 小盘股流动性危机也单独跑
- 报告里显式声明:「该策略在 2015 股灾期间,因 X 只持仓停牌/封板,平仓延迟 X 天,最大回撤 X%」
- 如果你的回测引擎跑 2015 时「没事」,几乎一定是 bug——要么没建模停牌,要么没建模封板
经验法则:回测 Sharpe 在 2015 单独一年应该明显恶化(从 1.5 → 0.5 是正常的)。如果反而更好,说明你漏掉了那段时间的所有亏损路径。
十、涨跌停的「跑路」效应与 A 股 momentum reversal
10.1 现象
经典 A 股因子研究里有个反复出现的发现:
美股:1 个月 momentum 因子有正向 alpha(动量延续)
A 股:1 个月 momentum 因子很弱甚至反向(动量反转)
10.2 一个机制解释
部分原因正是涨跌停的「跑路」机制:
- 利空出现,T 日股价跌停封盘(10% 跌幅)→ 所有想跑的人挂卖单排队
- T 日实际卖出量很少(涨跌停限制)
- T+1 日开盘大量积压卖单冲出,可能继续跌停
- T+2 日打开后,之前 T 日想跑没跑掉的人继续抛,形成连续下跌
- 跌停天数延长了利空兑现的时间,导致动量「滞后释放」
- 但这种「滞后释放」一般 5-10 天内集中爆发,之后反而进入反转区
类似地,涨停板封住后,第二天「打板者」蜂拥而入,但「卖出者」终于能卖,形成「打板 -> 反包」的反转。
10.3 PM 视角
这是个市场结构 → 价格行为 → 因子表现的完整链条:
美股「无涨跌停」+「T+0」 → 连续价格发现 → 动量延续
A 股「±10%」+「T+1」 → 价格发现被中断 → 动量被压扁成反转
这不是因子失效,是因子在不同市场结构下表现不同。直接套美股的 alpha 公式必失败。
十一、A 股数据源对比:缺什么字段就漏什么风险
回测精度 = 数据精度 × 引擎精度。前面讲了引擎,这里讲数据。
| 数据源 | 价格 OHLCV | 停牌标记 | 涨跌停标记 | 复权处理 | 退市股 | 分红/送股 | 行业分类 | 价格 |
|---|---|---|---|---|---|---|---|---|
| yfinance | ✓(粗糙) | ✗ | ✗ | 仅前复权 | ✗ | 模糊 | ✗ | 免费 |
| Tushare(基础版) | ✓ | ✓ | 需自算 | 前/后复权 | ✓ | ✓ | ✓ | 免费有积分门槛 |
| Tushare Pro | ✓ | ✓ | ✓(涨跌停字段) | ✓ | ✓ | ✓ | ✓ | 几千元/年 |
| AkShare | ✓ | 部分 | 部分 | ✓ | 部分 | ✓ | ✓ | 免费 |
| Wind / 同花顺 iFinD | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | 万元/年(机构) |
| BaoStock | ✓ | ✓ | ✗ | ✓ | ✓ | ✓ | ✓ | 免费 |
11.1 「最低必需字段」清单
任何 A 股回测至少要有:
required_fields = [
'open', 'high', 'low', 'close', 'volume', 'amount', # 基础 OHLCV
'pre_close', # 前收盘价(计算涨跌停用)
'is_suspended', # 是否停牌(布尔字段,非 NaN 推断)
'limit_up_price', # 当日涨停价
'limit_down_price', # 当日跌停价
'adjust_factor', # 复权因子(避免分红除权造成的假跌)
'is_st', # 是否 ST(±5% 限制)
'list_status', # 上市 / 退市 / 待上市
]
如果数据源缺 is_suspended → 你只能从「volume == 0 且 close == pre_close」推断停牌,但这条规则会漏掉停牌前一日就停牌的情况。这种推断式停牌检测有 5-10% 漏检率,对 backtest 致命。
11.2 复权的坑
A 股「不复权」、「前复权」、「后复权」三种价格:
| 方式 | 用途 | 注意点 |
|---|---|---|
| 不复权 | 看真实历史价格、计算涨跌停 | 分红派息日有「假跌」 |
| 前复权 | 看长期趋势、画图 | 历史价格被改写,每次新分红前期数据会变 |
| 后复权 | 计算长期收益、回测 | 当前价格被拉高,不直观 |
回测应该用「后复权」,理由:
- 不会回溯改写历史价格(重复跑回测结果稳定)
- 持仓 P&L 计算正确
- 但计算涨跌停时必须用不复权价格
正确写法:
# 回测里同时维护两套价格
df_post_adj # 后复权,用于 P&L、信号计算
df_raw # 不复权,用于涨跌停判断、印花税计算
def is_limit_up(symbol, date):
pre_close_raw = df_raw.loc[(date, symbol), 'close'].shift(1)
open_raw = df_raw.loc[(date, symbol), 'open']
limit = pre_close_raw * (1 + price_limit_pct(symbol))
return open_raw >= round(limit, 2) # 注意四舍五入到分
最后那个 round(..., 2) 看着不起眼,但 A 股涨跌停是按精确到分的整数判定的——很多回测因为浮点误差判错 5-10% 的涨跌停日。
十二、新手最常见的 10 个 A 股回测错误
| # | 错误 | 后果 |
|---|---|---|
| 1 | 用美股 backtest framework(如 zipline / backtrader 默认配置)直接套 A 股数据 | T+1 / 涨跌停全部漏建模,Sharpe 虚高 2-3 倍 |
| 2 | 假设当天信号当天 close 价成交 | 实盘是次日 open,gap 风险全漏 |
| 3 | 用 yfinance 拉 A 股数据 | 停牌期被 forward-fill,等于把停牌损失抹去 |
| 4 | 忽略印花税 | 高频策略年化少算 3-5% 成本 |
| 5 | 忽略集合竞价 vs 连续竞价的价格差异 | 开盘价 ≠ 9:30:00 第一笔,有 9:15-9:25 撮合 |
| 6 | 用沪深 300 当 benchmark 但策略选股偏小盘 | 风险因子归因全错 |
| 7 | 没建模 ST 股 ±5% 限制 | 持有 ST 股的策略风险被低估 |
| 8 | 退市股的「幸存者偏差」处理不当 | 历史回测里没退市股,等于挑了「活下来的」 |
| 9 | 用现价回测分红除权未除权数据 | 派息日有 -10% 假跌,可能误触止损 |
| 10 | 2015 股灾用「智能跳过」处理 | 看似谨慎,实则把策略最大的考验删了 |
第 8 和第 10 条最隐蔽,也最致命。
十三、PM 视角:市场结构差异 = 产品差异
今天的核心迁移性认知:
「不同市场的结构差异决定策略差异」 和 PM 工作里 「不同地区合规要求决定产品差异」 是同一回事。
| 量化场景 | 产品场景 |
|---|---|
| A 股 T+1 → 日内策略做不了 | 欧盟 GDPR → 用户数据收集策略要变 |
| A 股涨跌停 → 止损线失效 | 中国 PIPL → 跨境数据传输要重设计 |
| A 股印花税 → 高换手策略不可行 | 美国州税差异 → SaaS 定价要本地化 |
| A 股停牌 → 投资组合无法重平衡 | 印度 RBI → 支付通道架构要重写 |
| 2015 股灾 → 极端情况必须单独压测 | 2008 金融危机 → 风险模型必须包含「肥尾」 |
两条共同的方法论原则:
-
「市场 / 监管不是数据是结构」:A 股 T+1 不是「同样的市场加一天延迟」,是完全不同的游戏。GDPR 不是「美国 + 数据保护」,是完全不同的产品逻辑。把它当「数据精度问题」处理 → 必踩坑。
-
「极端事件必须显式建模」:A 股不跑 2015 等于没回测;支付产品不模拟「2020 全球货币潮」类极端事件等于没做风控。任何 backtest / risk model 都要在极端事件上显式失效——只有失效你才知道哪里是边界。
10 年金融 PM 经验里,做支付 / 信贷 / 风控产品时积累的「极端事件思维」,直接迁移到量化研究是一种隐性资产。这是为什么 quant fund 里有金融业务经验的研究员对市场结构的理解远胜「纯数学背景」研究员——他们见过真实的「肥尾」。
十四、明日预告
Day 80: 港股迁移 — 美股策略到港股的适配清单
- 港股交易时间(9:30-12:00 / 13:00-16:00),中午休市 1 小时的影响
- 印花税(0.13% 双边!比 A 股还狠)+ 交易系统费 + 中央结算费
- 没有涨跌停(除港股通有限制)但冷静期机制 VCM 触发后 5 分钟内价格区间限制
- 港股通持仓的额度、汇率、税务复杂度
- 流动性问题:港股头部 50 只占交易量 80%,其他流动性极差
- 用 IBKR 跨美股 / A 股(沪深港通)/ 港股的统一回测引擎设计
- 一个 Wheel 策略从 SPY 迁移到盈富基金(2800.HK)的实操对比
实际执行记录
启动一项填一项,时间戳 + 卡点。
- [hh:mm] 通读今日笔记 — ...
- [hh:mm] 写出
next_tradable_open()函数 + 单测 — ... - [hh:mm] 把
a_share_cost()接入现有 backtest engine — ... - [hh:mm] 跑一遍 2015-06 ~ 2015-09 压力测试,看回撤是否合理 — ...
- [hh:mm] 检查现有的所有 A 股策略,标记哪些「没建模涨跌停」 — ...
- [hh:mm] 在 TR_PROGRESS.md 标记 Day 79 完成 — ...
- 卡点 / 学到的:
总字数:约 5,800 字 今日完成度:理论 ✓ / 实操 ✓ / 笔记 ✓