程序员量化交易实战 04:A 股交易规则对程序的影响
程序员量化交易实战 04:A 股交易规则对程序的影响
古董级程序员,大厂出来后一直在创业公司,现在仍活跃在一线做 AI 相关的开发。这个专栏会把一个 A 股量化平台从 0 到 1 拆开写:数据、策略、回测、模拟盘、提醒和生产化,尽量用真实代码和真实运行结果说话。更完整的更新也会同步到微信公众号「字与码」。
上一篇把核心概念落到了 ZiQuant 的模型里:股票池不是持仓,因子不是策略,信号不是订单。
这一篇继续往下走:A 股交易规则会怎样改变程序。很多回测看起来漂亮,只是因为它默认市场永远能成交、订单可以随便拆、当天买入当天卖出、交易没有费用。这些假设放到 A 股里,会系统性高估策略。
所以交易规则不能只写在文章里。它们必须进入代码。

100 股整数手
A 股普通买入按 100 股整数手处理。你想买 258 股,程序不能假装真的买到了 258 股,应该先向下规整成 200 股。
ZiQuant 这次新增了 app/trading_rules.py:
def normalize_a_share_lot(shares: int, lot_size: int = 100) -> int:
if shares <= 0 or lot_size <= 0:
return 0
return shares // lot_size * lot_size
对应测试:
def test_normalize_a_share_lot_rounds_down_to_board_lot():
assert normalize_a_share_lot(99) == 0
assert normalize_a_share_lot(100) == 100
assert normalize_a_share_lot(258) == 200
assert normalize_a_share_lot(-100) == 0
这个函数很小,但它决定后面所有买入数量。越基础的规则越应该有测试。

交易费用不是小数点误差
高换手策略最怕漏算费用。
ZiQuant 先实现一个简化费用模型:佣金、过户费、卖出印花税。不同券商费率会有差异,生产使用时应配置化;但在回测和模拟盘里,费用绝不能默认为 0。
def estimate_a_share_fee(amount: float, side: OrderSide) -> AShareFeeBreakdown:
if amount <= 0:
return AShareFeeBreakdown(commission=0.0, transfer_fee=0.0, stamp_tax=0.0, total=0.0)
commission = max(amount * 0.0003, 5.0)
transfer_fee = amount * 0.00001
stamp_tax = amount * 0.0005 if side == "sell" else 0.0
total = commission + transfer_fee + stamp_tax
return AShareFeeBreakdown(
commission=round(commission, 2),
transfer_fee=round(transfer_fee, 2),
stamp_tax=round(stamp_tax, 2),
total=round(total, 2),
)
20,000 元买入和卖出的费用不同:
buy = estimate_a_share_fee(20_000, "buy")
sell = estimate_a_share_fee(20_000, "sell")
print(buy.total, sell.total) # 6.2 16.2
如果一个策略频繁买卖,费用会持续侵蚀收益。后面做回测评价时,我们会把费用写入每一笔 BacktestTrade。
订单检查应该先于策略解释
策略给出候选,不代表订单能成交。
买入要检查价格、整数手、现金;卖出要检查可卖持仓。ZiQuant 的最小订单检查如下:
def check_a_share_order(
*,
side: OrderSide,
price: float,
shares: int,
available_cash: float | None = None,
available_shares: int | None = None,
lot_size: int = 100,
) -> AShareOrderCheck:
normalized = normalize_a_share_lot(shares, lot_size=lot_size)
if price <= 0:
return AShareOrderCheck(False, normalized, 0.0, estimate_a_share_fee(0, side), "invalid_price")
if normalized <= 0:
return AShareOrderCheck(False, 0, 0.0, estimate_a_share_fee(0, side), "lot_size_not_met")
amount = round(price * normalized, 2)
fee = estimate_a_share_fee(amount, side)
if side == "buy" and available_cash is not None and amount + fee.total > available_cash:
return AShareOrderCheck(False, normalized, amount, fee, "insufficient_cash")
if side == "sell" and available_shares is not None and normalized > normalize_a_share_lot(available_shares, lot_size=lot_size):
return AShareOrderCheck(False, normalized, amount, fee, "insufficient_position")
return AShareOrderCheck(True, normalized, amount, fee)
使用方式:
check = check_a_share_order(side="buy", price=10.0, shares=258, available_cash=2500)
assert check.accepted is True
assert check.normalized_shares == 200
assert check.amount == 2000
拒单原因也要结构化:
low_cash = check_a_share_order(side="buy", price=10.0, shares=300, available_cash=1000)
assert low_cash.reason == "insufficient_cash"
以后模拟盘和回测都应该保留这些原因。系统不应该只说“没买成”,而要能说清楚为什么没买成。

T+1 会影响可卖数量
A 股普通股票是 T+1。今天买入的股票,通常不能今天卖出。
这会影响两个地方。
回测里,如果当天买入后又因为止损条件触发卖出,直接成交就是错的。模拟盘里,如果用户今天纸面买入,系统也不能把这部分数量计入当天可卖。
当前 trading_rules 先处理整数手、现金和持仓数量。T+1 会在后续模拟盘文章里进入 PaperPosition 和订单流水:持仓要区分总股数和可卖股数,订单要记录买入日期。
现在先把原则写下:卖出检查不能只看持仓总数,还要看可卖数量。
涨跌停会影响成交假设
涨停时,买入不一定能成交;跌停时,卖出不一定能成交。
ZiQuant 的回测服务里已经有 _limit_state() 这样的辅助函数,用前收盘价和当日收盘价判断是否接近涨跌停:
def _limit_state(previous_close, close, limit_up_pct, limit_down_pct):
if not previous_close:
return None
change = close / previous_close - 1
if change >= limit_up_pct:
return "limit_up"
if change <= -limit_down_pct:
return "limit_down"
return None
后面日频回测会用它处理两类情况:
- 涨停:跳过买入,记录
limit_up。 - 跌停:跳过卖出,记录
limit_down。
这仍然是简化模型。真实盘口会更复杂,但简化不等于忽略。先让系统知道“不是所有价格都能成交”,比假装无限流动性可靠得多。
停牌、ST 和退市要进入股票池过滤
停牌股票不能正常交易,ST 股票和退市风险股票也不应该随便进入默认策略候选。
这些信息最适合放在股票主数据的 metadata_json 或后续明确字段里。股票池重建时应该过滤它们,而不是等到策略输出后再临时剔除。
后面做公共 A 股 500 股票池时,我们会继续完善这部分:排除 ST、退市、流动性差、上市时间过短或数据不足的股票。
本篇实战任务
这一篇的实战任务已经进入项目:新增 app/trading_rules.py 和 tests/test_trading_rules.py。
运行:
cd /home/alex/work/yswx/zi-quant-platform
uv run pytest tests/test_trading_rules.py
如果从 GitHub 拉取这一章对应代码:
git clone https://github.com/ax2/zi-quant-platform.git
cd zi-quant-platform
git checkout chapter-04
uv sync --extra dev
uv run pytest tests/test_trading_rules.py
完整回归:
uv run pytest
测试通过后,说明最小 A 股订单规则已经有了可复现基础。下一步不是马上写复杂策略,而是先把数据层做好。
本章更新与代码仓库
本章更新内容:
- 新增 A 股整数手、费用和订单检查逻辑。
- 新增
app/trading_rules.py。 - 新增
tests/test_trading_rules.py,覆盖买入、卖出、现金不足和持仓不足等场景。
代码仓库:
https://github.com/ax2/zi-quant-platform
本章代码:
git clone https://github.com/ax2/zi-quant-platform.git
cd zi-quant-platform
git checkout chapter-04
uv sync --extra dev
uv run pytest tests/test_trading_rules.py
本篇小结
A 股交易规则会改变程序。
100 股整数手会改变下单数量,交易费用会改变收益,T+1 会改变可卖数量,涨跌停会改变成交假设,停牌和 ST 会改变股票池。它们都不是“业务背景”,而是代码约束。
下一篇进入数据层。策略之前先做数据:数据源抽象、行情数据、财报数据、数据质量、覆盖率和来源记录。