Quant Trading for Programmers 03: Core Concepts In Quant Trading
· 35 min read · Views --
Last updated on

Quant Trading for Programmers 03: Core Concepts In Quant Trading

Author: Alex Xiang


Quant Trading for Programmers 03: Core Concepts In Quant Trading

The first two articles did two things: put quant trading back into an engineering context, then build the Python project skeleton.

From this article onward, we need a shared vocabulary. Many terms in a quant system sound familiar, but if they do not map to code objects, they quickly get mixed together. A stock universe is not a position. A factor is not a strategy. A signal is not an order.

This article has a simple goal: translate the concepts that will keep appearing later into ZiQuant models, fields, and functions.

Cover image for article 3 in Quant Trading for Programmers

A Stock Is Not Just A String

The first common programming mistake is treating a stock as a string.

600519.SH is of course a stock symbol, but a runnable platform also needs its name, market, sector, lot size, and metadata. ZiQuant stores this master data in the Stock table:

class Stock(Base):
    __tablename__ = "zi_quant_stocks"

    symbol = mapped_column(String(16), primary_key=True)
    name = mapped_column(String(80), index=True)
    market = mapped_column(String(8), index=True)
    sector = mapped_column(String(80), index=True)
    lot_size = mapped_column(Integer, default=100)
    metadata_json = mapped_column(JSONB, default=dict)

lot_size=100 is not decoration. Common A-share buy orders use board lots of 100 shares. Later order checks, backtest execution, and paper trading will all use it.

Stock master data answers “what is this symbol?” It does not answer “what is the price today?” or “should we buy it?”

A Stock Universe Is The Strategy’s Running Scope

A stock universe is not a holding.

A stock universe is the range of stocks that a strategy is allowed to observe and select. For example, a public A-share 500 universe is a candidate set used by strategies, factor refresh jobs, and recommendation APIs. A holding is a stock already bought by an account.

ZiQuant expresses this with StockPool and StockPoolMember:

class StockPool(Base):
    __tablename__ = "zi_quant_stock_pools"

    name = mapped_column(String(120), index=True)
    visibility = mapped_column(Enum(Visibility), default=Visibility.private)
    refresh_strategy_id = mapped_column(UUID(as_uuid=True), nullable=True)


class StockPoolMember(Base):
    __tablename__ = "zi_quant_stock_pool_members"

    pool_id = mapped_column(ForeignKey("zi_quant_stock_pools.id"))
    symbol = mapped_column(ForeignKey("zi_quant_stocks.symbol"))
    weight = mapped_column(Float, nullable=True)
    reason = mapped_column(Text, default="")

This design lets public universes, user-private universes, and strategy-rebuilt universes coexist. Later, when we build the 500-stock A-share universe, this layer will continue to evolve.

K-Lines Are Market Facts, Not Strategy Conclusions

Market data usually enters the system as K-lines. In a daily system, the most common record is the daily bar: open, high, low, close, volume, and amount.

ZiQuant’s MarketBar table is designed like this:

class MarketBar(Base):
    __tablename__ = "zi_quant_market_bars"

    symbol = mapped_column(String(16), index=True)
    trade_date = mapped_column(Date, index=True)
    frequency = mapped_column(String(16), default="1d", index=True)
    open = mapped_column(Float)
    high = mapped_column(Float)
    low = mapped_column(Float)
    close = mapped_column(Float)
    volume = mapped_column(Float, default=0)
    amount = mapped_column(Float, default=0)
    source = mapped_column(String(40), default="unknown", index=True)
    payload = mapped_column(JSONB, default=dict)

Notice source and payload.

source records whether the data came from QVeris, Eastmoney, Tushare, iFinD, or fallback. payload keeps vendor raw fields or cleaning details. When data issues need investigation later, these two fields are critical.

Market data is a factual record. It tells us what happened on a day. It does not directly say whether we should buy.

Adjusted Prices Need A Clear Policy Early

A-shares have dividends, bonus shares, and split-like events. If price series do not handle adjustments, many factors will be polluted by historical price jumps.

For example, after an ex-rights event, a stock may go from 100 to 50. That does not mean it really fell 50%. If a strategy calculates 20-day momentum directly from raw prices, it may misread the move.

In the early stage of this series, we first keep the fields and sources ready. When real market data is integrated later, we will define forward-adjusted, backward-adjusted, and unadjusted price policies clearly. The engineering principle is already fixed: one market-bar table must not mix different price adjustment policies without marking them. The vendor’s adjustment method must enter payload or a dedicated field.

A Factor Is Not A Strategy

A factor is sortable evidence, not a complete decision.

20-day momentum can be a factor. ROE can be a factor. Volatility can be a factor. Financial-report quality can be a factor. But “buy the top 20 momentum names with equal weights, rebalance weekly, and sell when stop-loss is triggered” is closer to a strategy.

ZiQuant uses FactorDefinition to describe factor definitions and FactorValue to store one stock’s factor value on one day:

class FactorValue(Base):
    __tablename__ = "zi_quant_factor_values"

    factor_id = mapped_column(ForeignKey("zi_quant_factor_definitions.id"))
    symbol = mapped_column(String(16), index=True)
    trade_date = mapped_column(Date, index=True)
    value = mapped_column(Float)
    payload = mapped_column(JSONB, default=dict)

The current project already has an early compute_factor() that combines MACD, RSI, momentum, volatility, and quality into a FactorRow:

row = compute_factor(stock)
print(row.symbol, row.momentum, row.volatility, row.quality, row.score)

This is not the final strategy. It is the evidence layer that later recommendations, backtests, and paper trading will consume.

A Signal Is Not An Order

A signal is the strategy’s observation conclusion. An order is an account action.

This distinction matters. We will use names like BUY_WATCH, HOLD_WATCH, and RISK_WATCH, not simply BUY and SELL. The reason is simple: the system does research and paper observation. It does not submit real orders.

A signal should at least include:

  • Symbol.
  • Signal type.
  • Confidence or ranking score.
  • Triggering reasons.
  • Risk notes.
  • Data source and trade date.

This structure is much more reliable than “recommended buy”:

signal = {
    "symbol": "600519.SH",
    "signal": "BUY_WATCH",
    "score": 0.73,
    "reasons": ["20-day momentum ranks high", "financial quality is strong", "volatility is under limit"],
    "risk": {"max_position_pct": 0.10, "paper_trading_only": True},
}

Later recommendation workbenches, Lark alerts, and previous-day reviews will revolve around this structure.

Separate Positions, Orders, And Accounts

Paper trading has at least three object types.

The account is PaperPortfolio: cash, related strategy, and configuration.

The position is PaperPosition: how many shares of a stock are held, average cost, and realized PnL.

The order is PaperOrder: price, quantity, fee, status, and reason for a paper buy or sell.

If these three are merged into one table, net-value curves, risk checks, and reviews become painful later. A position only represents current state. An order is historical flow. Account cash changes should also be traceable to specific orders.

A Backtest Is An Experiment Record

A backtest is not “run a function and get return”. It should be an experiment record.

ZiQuant uses BacktestRun to store parameters, date range, initial cash, final equity, and metrics:

class BacktestRun(Base):
    __tablename__ = "zi_quant_backtest_runs"

    strategy_id = mapped_column(ForeignKey("zi_quant_strategies.id"), nullable=True)
    stock_pool_id = mapped_column(ForeignKey("zi_quant_stock_pools.id"), nullable=True)
    start_date = mapped_column(Date, index=True)
    end_date = mapped_column(Date, index=True)
    initial_cash = mapped_column(Float, default=100000.0)
    final_equity = mapped_column(Float, default=0)
    metrics = mapped_column(JSONB, default=dict)
    params = mapped_column(JSONB, default=dict)

Trades are stored in BacktestTrade. This lets us answer later: why did this backtest make money, how much was spent on fees, which stocks contributed returns, and which trades were rejected by limit-up/limit-down or volume constraints?

Win Rate Is Not Profit

People often ask about strategy win rate. Win rate is useful, but it is not the only metric.

A strategy may win 90% of trades but make small gains and lose all profits in the remaining 10%. Another strategy may have only a 45% win rate, but if losses are small and winning trades are larger, it may still make money overall.

Later we will read total return, max drawdown, Sharpe, trade count, out-of-sample behavior, and baseline comparison together. No single metric is enough. A combination of metrics gets closer to strategy health.

Runnable Foundation Check

After concepts are aligned, the corresponding objects should be visible in code. 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:

Core concepts foundation-check output

The listed tables are not the complete schema. They are the conceptual path: stocks, market bars, factors, backtests, and paper orders. The later signal path will not turn signal directly into a real order. It will pass through risk checks and land in paper orders.

Hands-On Task

This article does not require complex new features, but it does require confirming that the core tables exist.

Run the schema test:

cd ~/projects/zi-quant-platform
uv run pytest tests/test_services.py -k schema

If you clone this chapter from GitHub:

git clone https://github.com/ax2/zi-quant-platform.git
cd zi-quant-platform
git checkout chapter-03
uv sync --extra dev
uv run pytest tests/test_services.py -k schema

The existing test checks key table names:

def test_schema_contains_core_tables():
    names = table_names()
    assert "zi_quant_stocks" in names
    assert "zi_quant_stock_pools" in names
    assert "zi_quant_market_bars" in names
    assert "zi_quant_backtest_runs" in names
    assert "zi_quant_paper_portfolios" in names

If you implement from scratch, this kind of test is a good start. It does not prove business correctness, but it ensures the engineering skeleton has the objects needed by later capabilities.

Chapter Update And Repository

This chapter adds:

  • A clear mapping for stocks, stock pools, K-lines, factors, strategies, backtests, and paper trading.
  • Corresponding core ORM tables and test entry points in ZiQuant.
  • The rule that a backtest should be a traceable experiment record, not a one-time function call.

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-03
uv sync --extra dev
uv run pytest tests/test_services.py -k schema

Summary

This article aligns concepts.

Stock master data, stock universe, K-lines, factors, signals, positions, orders, backtests, and metrics should all have clear boundaries. With clear boundaries, later data sync, factor calculation, and strategy backtesting will not pollute one another.

The next article enters A-share trading rules: board lots of 100 shares, T+1, limit-up/limit-down, suspensions, ST names, commission, and stamp duty. These rules directly enter order checks, backtest execution, and paper-trading risk control.