程序员量化交易实战 03:量化交易中的核心概念
程序员量化交易实战 03:量化交易中的核心概念
古董级程序员,大厂出来后一直在创业公司,现在仍活跃在一线做 AI 相关的开发。这个专栏会把一个 A 股量化平台从 0 到 1 拆开写:数据、策略、回测、模拟盘、提醒和生产化,尽量用真实代码和真实运行结果说话。更完整的更新也会同步到微信公众号「字与码」。
前两篇做了两件事:先把量化交易放回工程语境,再把 Python 项目骨架搭起来。
从这一篇开始,我们要统一词汇。量化系统里很多词看着熟悉,但如果不落到代码对象,很快就会混在一起:股票池和持仓不是一回事,因子和策略不是一回事,信号和订单也不是一回事。
这一篇的目标很简单:把后面会反复出现的概念翻译成 ZiQuant 里的模型、字段和函数。

股票不是一个字符串
程序里最容易犯的第一个错误,是把股票当成一个字符串。
600519.SH 当然是股票代码,但一个可运行的平台还需要知道它的名称、市场、行业、交易单位和元数据。ZiQuant 里用 Stock 表保存这些主数据:
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 不是装饰字段。A 股普通买入按 100 股整数手处理,后面订单检查、回测成交和模拟盘都会用到它。
股票主数据解决的是“这个标的是什么”。它不回答“今天多少钱”,也不回答“该不该买”。
股票池是策略运行范围
股票池不是持仓。
股票池表示策略允许观察和选择的范围。比如公共 A 股 500 股票池,是给策略、因子刷新和推荐接口使用的候选集合;持仓则是某个账户已经买入的股票。
ZiQuant 里用 StockPool 和 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="")
这层设计的好处是:公共股票池、用户私有股票池、按策略重建的股票池可以共存。后面我们做 500 只 A 股股票池时,会继续完善这部分。

K 线是行情事实,不是策略结论
行情数据通常会以 K 线形式进入系统。日频系统里,最常用的是日 K:开盘价、最高价、最低价、收盘价、成交量、成交额。
ZiQuant 的 MarketBar 表是这样设计的:
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)
注意两个字段:source 和 payload。
source 用来记录数据来自 QVeris、东方财富、Tushare、同花顺,还是 fallback。payload 用来保留供应商原始字段或清洗信息。后面排查数据问题时,这两个字段会救命。
行情数据只是事实记录。它告诉我们某天发生了什么,不直接告诉我们该不该买。
复权要尽早想清楚
A 股会分红、送股、拆股。价格序列如果不处理复权,很多因子会被历史价格跳变污染。
例如一只股票除权后,价格从 100 变成 50,并不代表真实跌了 50%。如果策略直接用原始价格计算 20 日动量,就可能误判。
本系列前期会先把字段和来源留好,后面接入真实行情时再明确前复权、后复权和不复权的口径。这里先定一个工程原则:同一张行情表不能混入不同价格口径而不标记。供应商返回的复权方式,要进入 payload 或单独字段。
因子不是策略
因子是一个可排序的证据,不是完整决策。
20 日动量可以是因子,ROE 可以是因子,波动率可以是因子,财报质量分也可以是因子。但“动量前 20 名等权买入、每周调仓、跌破止损卖出”才更接近策略。
ZiQuant 里用 FactorDefinition 描述因子定义,用 FactorValue 保存某日某股票的因子值:
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)
当前项目里已经有一个早期的 compute_factor(),会把 MACD、RSI、动量、波动率和质量分合成一个 FactorRow:
row = compute_factor(stock)
print(row.symbol, row.momentum, row.volatility, row.quality, row.score)
这还不是最终策略,只是后续推荐、回测和模拟盘会消费的证据层。
信号不是订单
信号是策略输出的观察结论,订单是账户动作。
这个区分很重要。我们会用 BUY_WATCH、HOLD_WATCH、RISK_WATCH 这样的名字,而不是直接叫 BUY、SELL。原因很简单:系统做的是研究和模拟观察,不做真实下单。
一个信号至少应该包含:
- 股票代码。
- 信号类型。
- 置信度或排序分。
- 触发原因。
- 风控提示。
- 数据来源和交易日期。
类似这样的结构比一句“建议买入”可靠得多:
signal = {
"symbol": "600519.SH",
"signal": "BUY_WATCH",
"score": 0.73,
"reasons": ["20日动量靠前", "财报质量分较高", "波动率未超限"],
"risk": {"max_position_pct": 0.10, "paper_trading_only": True},
}
后面推荐工作台、飞书提醒和昨日复盘都会围绕这种结构展开。
持仓、订单和账户要分开
模拟盘里至少有三类对象。
账户是 PaperPortfolio:现金、关联策略、配置。
持仓是 PaperPosition:某只股票持有多少股,平均成本是多少,已实现盈亏是多少。
订单是 PaperOrder:某次纸面买入或卖出的价格、数量、费用、状态和原因。
如果把这三者混成一张表,后面净值曲线、风险检查、复盘都会很痛苦。比如持仓只表示当前状态,订单才是历史流水;账户现金变化也应该能追到具体订单。
回测是一次实验记录
回测不是“跑一个函数得到收益率”。它应该是一条实验记录。
ZiQuant 用 BacktestRun 保存回测参数、区间、初始资金、最终权益和指标:
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)
交易明细则放在 BacktestTrade。这样做是为了以后回答:这次回测为什么赚钱,费用花了多少,哪些股票贡献了收益,哪些交易被涨跌停或成交量限制拒绝。

胜率不等于赚钱
很多人喜欢问策略胜率多少。胜率当然可以看,但它不是唯一指标。
一个策略可能 90% 的交易都赚小钱,剩下 10% 一次亏掉全部利润。另一个策略可能胜率只有 45%,但亏损很小、盈利交易更大,整体仍然赚钱。
所以我们后面会一起看总收益、最大回撤、Sharpe、交易次数、样本外表现和基准对比。单个指标都不够,组合起来才接近策略健康状况。
本篇实战任务
这一篇不要求新增复杂功能,但要确认当前项目里核心表已经存在。
运行测试:
cd /home/alex/work/yswx/zi-quant-platform
uv run pytest tests/test_services.py -k schema
如果从 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
已有测试会检查关键表名:
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
如果你从零实现,也可以先补这类测试。它不验证业务正确性,但能保证工程骨架里已经有承载后续业务的对象。
本章更新与代码仓库
本章更新内容:
- 梳理股票、股票池、K 线、因子、策略、回测和模拟盘这些核心概念。
- 对应 ZiQuant 里的核心 ORM 表和测试入口。
- 明确回测应该是一条可追溯的实验记录,而不是一次函数调用。
代码仓库:
https://github.com/ax2/zi-quant-platform
本章代码:
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
本篇小结
这一篇做的是概念对齐。
股票主数据、股票池、K 线、因子、信号、持仓、订单、回测和指标,都应该有清晰边界。边界清楚,后面写数据同步、因子计算和策略回测才不会互相污染。
下一篇进入 A 股交易规则:100 股整数手、T+1、涨跌停、停牌、ST、手续费和印花税。这些规则会直接进入订单检查、回测成交和模拟盘风控。