程序员量化交易实战 04:A 股交易规则对程序的影响
原创 · 约 17 分钟阅读 · 阅读 --
Last updated on

程序员量化交易实战 04:A 股交易规则对程序的影响

作者: Alex Xiang


程序员量化交易实战 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

这个函数很小,但它决定后面所有买入数量。越基础的规则越应该有测试。

A 股规则如何进入程序

交易费用不是小数点误差

高换手策略最怕漏算费用。

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.pytests/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 会改变股票池。它们都不是“业务背景”,而是代码约束。

下一篇进入数据层。策略之前先做数据:数据源抽象、行情数据、财报数据、数据质量、覆盖率和来源记录。