Quant Trading for Programmers 04: How A-Share Trading Rules Change Code
· 27 min read · Views --
Last updated on

Quant Trading for Programmers 04: How A-Share Trading Rules Change Code

Author: Alex Xiang


Quant Trading for Programmers 04: How A-Share Trading Rules Change Code

The previous article mapped core concepts into ZiQuant models: a stock universe is not a position, a factor is not a strategy, and a signal is not an order.

This article continues downward: how do A-share trading rules change code? Many backtests look beautiful only because they assume the market always fills orders, positions can be split arbitrarily, same-day buys can be sold the same day, and trading has no cost. In A-shares, these assumptions systematically overestimate strategies.

So trading rules cannot stay in the article. They must enter code.

Cover image for article 4 in Quant Trading for Programmers

Board Lots Of 100 Shares

Common A-share buy orders use board lots of 100 shares. If you want to buy 258 shares, the program cannot pretend that 258 shares were bought. It should first round down to 200.

ZiQuant adds 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

Corresponding test:

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

This function is small, but it decides all later buy quantities. The more basic a rule is, the more it deserves a test.

Trading Fees Are Not Rounding Noise

High-turnover strategies are especially sensitive to missing fees.

ZiQuant first implements a simplified fee model: commission, transfer fee, and sell-side stamp duty. Broker rates may differ and should be configurable in production. But in backtesting and paper trading, fees must not default to zero.

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),
    )

A 20,000 CNY buy and sell have different fees:

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

If a strategy trades frequently, fees continuously erode return. Later backtest evaluation will write fees into every BacktestTrade.

Order Checks Should Come Before Strategy Explanation

A strategy producing a candidate does not mean the order can be executed.

Buys must check price, board lot, and cash. Sells must check available position. ZiQuant’s minimal order check is:

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)

Example:

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

Rejection reasons should be structured:

low_cash = check_a_share_order(side="buy", price=10.0, shares=300, available_cash=1000)
assert low_cash.reason == "insufficient_cash"

Paper trading and backtesting should both preserve these reasons. The system should not only say “it did not buy”. It should explain why it did not buy.

T+1 Changes Sellable Quantity

Common A-share stocks are T+1. Shares bought today usually cannot be sold today.

This affects two places.

In backtesting, if a position is bought today and then a stop-loss condition triggers a sell on the same day, filling that sell is wrong. In paper trading, if the user paper-buys today, the system cannot count those shares as sellable today.

The current trading_rules first handles board lots, cash, and position count. T+1 will enter PaperPosition and order flow in later paper-trading articles: positions must distinguish total shares from sellable shares, and orders must record buy dates.

For now, keep this principle: sell checks cannot only read total position. They need sellable quantity.

Limit-Up And Limit-Down Change Execution Assumptions

When a stock is limit-up, buys may not fill. When it is limit-down, sells may not fill.

ZiQuant’s backtest service already has a helper like _limit_state() that uses previous close and current close to identify prices near limit-up or limit-down:

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

Later daily backtests will use it for two cases:

  • Limit-up: skip buy, record limit_up.
  • Limit-down: skip sell, record limit_down.

This is still a simplified model. Real order books are more complex, but simplifying is not the same as ignoring. Making the system know that not every price is executable is much better than pretending liquidity is infinite.

Suspensions, ST Names, And Delisting Risk Belong In Universe Filters

Suspended stocks cannot trade normally. ST names and delisting-risk names also should not casually enter the default strategy candidates.

This information belongs in stock master data’s metadata_json or later explicit fields. Stock-universe rebuilding should filter them before the strategy runs, rather than removing them temporarily after signals are generated.

When we build the public A-share 500 universe later, we will continue this work: exclude ST, delisted names, low liquidity, too-short listing history, or insufficient data.

Runnable Foundation Check

A-share rules should run as commands, not stay as text. The current project uses this command to reproduce the foundation capabilities from articles 01-08:

uv run python -m scripts.chapter_examples foundation-check

The output for this chapter is:

A-share trading-rule foundation-check output

In the example, 258 shares are normalized to 200, and buy and sell fees differ because selling includes stamp duty. This small output proves that later backtests and paper trading will treat A-share rules as testable code, not comments.

Hands-On Task

The hands-on task is already in the project: add app/trading_rules.py and tests/test_trading_rules.py.

Run:

cd ~/projects/zi-quant-platform
uv run pytest tests/test_trading_rules.py

If you clone this chapter from 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

Full regression:

uv run pytest

Once tests pass, the minimal A-share order rules have a reproducible foundation. The next step is not complex strategy. It is the data layer.

Chapter Update And Repository

This chapter adds:

  • A-share board-lot, fee, and order-check logic.
  • app/trading_rules.py.
  • tests/test_trading_rules.py, covering buy, sell, insufficient cash, insufficient position, and related cases.

Repository:

https://github.com/ax2/zi-quant-platform

Code for this chapter:

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

Summary

A-share trading rules change code.

Board lots of 100 shares change order quantity. Trading fees change return. T+1 changes sellable quantity. Limit-up and limit-down change execution assumptions. Suspensions and ST names change the stock universe. They are not “business background”. They are code constraints.

The next article enters the data layer. Before strategies, we build data: data-source abstraction, market data, financial reports, data quality, coverage, and source records.