Quant Trading for Programmers 04: How A-Share Trading Rules Change Code
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.

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:

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.
Follow ZiCode on WeChat
If this post was useful, you can follow later updates on WeChat as well.
X / Twitter
Follow @ax2_zicode
Faster technical notes, short thoughts, and new-post alerts are posted on X.
More in this column
- Quant Trading for Programmers 08: Clean Raw K-Lines Into Trustworthy Market Bars
- Quant Trading for Programmers 07: Build A Clean A-Share Stock Universe First
- Quant Trading for Programmers 06: Make The Database Schema Explicit First
- Quant Trading for Programmers 05: Data Matters More Than Strategy
- Quant Trading for Programmers 03: Core Concepts In Quant Trading
- Quant Trading for Programmers 02: Build The Python Project Structure From Scratch
- Quant Trading for Programmers 01: Why Programmers Are Well Suited To Quant Trading