Quant Trading for Programmers 10: Run The First Minimal Backtest Loop
· 24 min read · Views --
Last updated on

Quant Trading for Programmers 10: Run The First Minimal Backtest Loop

Author: Alex Xiang


Quant Trading for Programmers 10: Run The First Minimal Backtest Loop

The previous articles can finally be connected.

Article 4 has A-share order rules. Article 8 has cleaned K-lines. Article 9 has factor signals. Article 10 builds the first minimal backtest loop: buy when buy_watch appears, sell when risk_watch appears, and record cash, positions, trades, and the equity curve along the way.

Cover image for article 10 in Quant Trading for Programmers

This Is Not A Full Backtest Engine

Start with the boundary.

This article does not build an event-driven engine. It does not handle matching queues, minute bars, slippage models, or multi-symbol portfolio optimization. It is only an explainable minimal loop.

It needs to prove several things:

  • Cleaned K-lines can enter factor calculation.
  • Factor signals can drive buy and sell actions.
  • Buys pass through A-share board-lot and cash checks.
  • Sells calculate fees.
  • Every day has an equity-curve record.
  • Backtest results can be reproduced by tests.

It is useful to state a few boundaries clearly. Backtesting is not simply “run a strategy on historical data”. At minimum, it separates signal generation, order constraints, execution assumptions, and account accounting. This article assumes daily close-price execution, not minute-level matching. It handles a single-symbol position, not portfolio-level cash sharing. But it already includes A-share board lots and fees, so it avoids obviously non-executable trades.

Backtest Result Objects

Chapter 10 adds app/mini_backtest.py.

The trade object:

@dataclass(frozen=True)
class MiniBacktestTrade:
    trade_date: date
    side: str
    price: float
    shares: int
    amount: float
    fee: float
    reason: str

The backtest result object:

@dataclass(frozen=True)
class MiniBacktestResult:
    symbol: str
    initial_cash: float
    final_equity: float
    cash: float
    shares: int
    total_return: float
    max_drawdown: float
    trades: tuple[MiniBacktestTrade, ...] = field(default_factory=tuple)
    equity_curve: tuple[dict[str, object], ...] = field(default_factory=tuple)

reason matters. A trade is not only buy or sell. We also need to know why it happened. Later strategy diagnostics will depend on this field.

Buying Uses A-Share Rules

The buy logic does not deduct cash directly. It reuses check_a_share_order() from article 4:

check = check_a_share_order(
    side="buy",
    price=bar.close,
    shares=raw_shares,
    available_cash=cash,
)
if check.accepted:
    cash = round(cash - check.amount - check.fee.total, 2)
    shares += check.normalized_shares

This handles board lots of 100 shares, fees, and insufficient cash.

If backtesting bypasses trading rules, it may buy 37 shares or drive cash negative. The final return may look good, but it would not be executable in A-shares.

Selling Also Deducts Fees

Selling currently liquidates the position and calculates sell-side fees:

fee = estimate_a_share_fee(round(bar.close * shares, 2), "sell")
amount = round(bar.close * shares, 2)
cash = round(cash + amount - fee.total, 2)

The article 4 sell-side fee includes commission, transfer fee, and stamp duty. Reusing it avoids writing one set of rules for backtesting and another for paper trading.

Record The Equity Curve Every Day

Whether there is a trade or not, equity is recorded:

equity = round(cash + shares * bar.close, 2)
equity_curve.append({
    "trade_date": bar.trade_date.isoformat(),
    "cash": cash,
    "shares": shares,
    "equity": equity,
    "signal": factor.signal,
})

Backtesting is not only about final return. The equity curve tells us what happened along the path, and max drawdown is calculated from it:

def _max_drawdown(equity_values: list[float]) -> float:
    peak = None
    worst = 0.0
    for value in equity_values:
        peak = value if peak is None else max(peak, value)
        if peak:
            worst = min(worst, value / peak - 1)
    return round(worst, 6)

Real Run Screenshot

Article 10 can directly run the current mainline integrated example:

uv run python -m scripts.chapter_examples factor-backtest --source sample

The screenshot below comes from the minimal-backtest section of the same command:

Real run screenshot for article 10 minimal backtest

The output shows 000001.SZ starting with 100000, ending with final equity of 105031.28, total return around 5.03%, max drawdown around -2.58%, and 1 buy trade. The return number is not the point. The point is that the signal has become real account fields: buy date, shares, price, reason, cash, positions, and equity curve can all be reproduced by code.

Test The First Loop

Chapter 10 tests cover four scenarios.

With no data, cash stays unchanged:

result = run_signal_backtest("600519.SH", [], initial_cash=100000)
assert result.final_equity == 100000
assert result.trades == ()

A stable uptrend produces a buy:

closes = [10 + index * 0.1 for index in range(40)]
result = run_signal_backtest("600519.SH", _bars("600519.SH", closes), initial_cash=100000)
assert result.trades[0].side == "buy"
assert result.trades[0].shares % 100 == 0

A rise followed by a fall produces buy and sell:

assert [trade.side for trade in result.trades] == ["buy", "sell"]
assert result.shares == 0

A mismatched symbol should not trade. This prevents multi-symbol backtests from accidentally using another stock’s bars for the current symbol.

Hands-On Task

Clone the chapter 10 code:

git clone https://github.com/ax2/zi-quant-platform.git
cd zi-quant-platform
git checkout chapter-10
uv sync --extra dev
uv run pytest

Run only the backtest tests:

uv run pytest tests/test_mini_backtest.py

The full chapter 10 test suite passes with 168 passed, still with only the existing FastAPI deprecation warning.

Stage Review For Articles 6-10

The second group of five articles completes the loop from data to a minimal backtest.

Article 6 turns PostgreSQL schema and Alembic migrations into checkable objects, so platform table structure does not depend only on manual review.

Article 7 breaks A-share stock-universe construction into pure functions, handling symbol normalization, ST/delisting filters, deduplication, size limits, and source summaries.

Article 8 cleans raw K-lines into unified CleanMarketBar objects, explicitly handling dates, numbers, OHLC consistency, duplicate rows, and volume units.

Article 9 calculates return, moving averages, momentum, and volatility on clean K-lines, then generates the first explainable signals.

Article 10 connects those signals with A-share order rules and runs buy, sell, cash, positions, fees, equity curve, and max drawdown.

This group does not jump to “strategy-return optimization”. It first fills in the runnable foundation. The third group will continue with multi-symbol backtests, universe traversal, backtest metrics, strategy parameters, and result persistence.

Chapter Update And Repository

This chapter adds:

  • app/mini_backtest.py.
  • A connection between article 8 market-data cleaning, article 9 factor signals, and article 4 A-share order rules.
  • tests/test_mini_backtest.py, covering empty data, buys, sells, and symbol isolation.
  • A real current-mainline screenshot showing how article 9 signals enter the article 10 backtest.
  • Backtest-boundary notes on signal generation, order constraints, execution assumptions, and account accounting.
  • A stage review for articles 6-10.

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-10
uv sync --extra dev
uv run pytest tests/test_mini_backtest.py

Summary

The first backtest loop does not need to be complex, but it must be real.

It must respect the stock universe, data cleaning, factor windows, A-share board lots, fees, cash, and positions. Only after these basic constraints are in code does it make sense to talk about strategy optimization.