单元 04:完成第一个交易策略
我们已经有了通过检查的收盘价,也能计算每日收益。接下来要把一句交易想法变成程序能够逐日执行的明确规则:价格处于近期平均水平之上时希望持有,否则希望空仓。
本单元先完成策略的决策部分,不急着计算赚亏。你会得到一张包含收盘价、移动平均线、信号和目标持仓的研究表;订单、成交和策略收益将在下一单元接入。
本单元目标
完成本单元后,你将能够:
- 使用滚动窗口计算简单移动平均线;
- 解释窗口预热期为什么没有指标值;
- 把交易想法写成没有歧义的信号规则;
- 把文字信号转换为每日目标持仓;
- 区分指标、信号、目标持仓、订单、成交和实际持仓;
- 说明当日收盘后产生的信号为什么不能获得当日收盘前的收益;
- 用指定日期手工核对均线和信号。
最终产物由下面的命令生成:
uv run python examples/unit04/build_ma_signal.py前置条件
- 已完成单元 03;
- 能解释收盘价、简单收益率和收益区间;
- 已执行
uv sync --locked; - 后续命令均在项目根目录运行。
场景:把趋势判断写成明确规则
“趋势向上就持有”听起来像策略,但程序无法直接运行这句话。什么叫趋势向上?观察多长时间?刚好相等时怎么办?数据不足时又怎么办?
本单元把它写成下面这组完整规则:
| 项目 | 本单元约定 |
|---|---|
| 输入 | DEMO_ETF 的日频收盘价 |
| 指标 | 包含当日在内的最近 3 个交易日收盘价平均值 |
| 做多信号 | 当日收盘价高于 3 日均线 |
| 空仓信号 | 当日收盘价低于或等于 3 日均线 |
| 预热期 | 不足 3 个价格时不产生信号 |
| 目标持仓 | long 对应 1.0,cash 对应 0.0 |
| 决策时间 | 当日收盘价形成后 |
目标持仓 1.0 表示希望将全部策略资金暴露于该资产,0.0 表示希望持有现金。本单元不使用杠杆,也不做空。
这种试图跟随已经出现的价格方向、而不是预测具体目标价格的思路,通常称为趋势跟随(Trend Following)。移动平均线只是表达趋势的一种简单方法,不保证识别正确,也不保证获得收益。
3 日窗口只是教学参数
真实日频策略常观察更长区间,但当前样例只有 10 个交易日。选择 3 日窗口是为了让每个数字都能手工检查,并让短样本出现几次信号变化;它不是从历史表现中挑出的“最佳参数”。
计算简单移动平均线
简单移动平均线(Simple Moving Average,SMA)是最近若干个观测值的算术平均。3 日均线写成:
其中:
:交易日 的收盘价,单位为 CNY/份; 、 :前 1、前 2 个交易日的收盘价,单位相同; :截至交易日 的 3 日平均收盘价,仍为 CNY/份;- 上标
(3)表示窗口包含 3 个交易日,不是三次方。
样例前三个收盘价为 100.80、101.70 和 101.20,因此 2024-01-04 的均线为:
代码使用 pandas 的滚动窗口:
result["sma"] = result["close"].rolling(
window=window,
min_periods=window,
).mean()这个窗口向后观察:当日均线只使用当日和此前的价格,不会读取未来数据。min_periods=window 明确要求凑齐 3 个价格才计算,所以前两行保持缺失。
前两天不是“均线等于 0”,也不是“应该空仓”,而是指标尚未准备好。这段等待足够历史数据的区间称为预热期(Warm-up Period)。
从指标得到信号
指标只是一个数字;信号(Signal)是把这个数字带入规则后得到的判断。本单元的规则可以写成:
均线尚未形成 → warming_up
收盘价 > 3 日均线 → long
收盘价 <= 3 日均线 → cash代码先让所有行保持未知,只对均线已经形成的日期赋值:
result["signal"] = pd.Series(pd.NA, index=result.index, dtype="string")
indicator_is_ready = result["sma"].notna()
result.loc[
indicator_is_ready & (result["close"] > result["sma"]),
"signal",
] = "long"
result.loc[
indicator_is_ready & (result["close"] <= result["sma"]),
"signal",
] = "cash"等于均线时归入 cash,不是金融市场的统一规定,只是为了让规则覆盖所有可能情况。换成其他处理方式也可以,但必须事先写清楚。
手工检查三个日期
| 日期 | 收盘价 | 3 日均线 | 比较 | 信号 |
|---|---|---|---|---|
| 2024-01-04 | 101.20 | 101.2333 | 收盘价低于均线 | cash |
| 2024-01-05 | 102.40 | 101.7667 | 收盘价高于均线 | long |
| 2024-01-09 | 102.60 | 102.7000 | 收盘价低于均线 | cash |
这三行分别覆盖了第一个有效信号、从空仓转为希望持有,以及从希望持有转为空仓。程序结果应与逐项比较完全一致。
把信号转换为目标持仓
文字信号便于阅读,数值持仓便于后续计算。这里使用下面的映射:
result["target_position"] = result["signal"].map(
{"long": 1.0, "cash": 0.0}
)目标持仓(Target Position)表示策略此刻希望达到的资产暴露比例:
| 信号 | target_position | 含义 |
|---|---|---|
warming_up | 缺失 | 信息不足,尚不作决策 |
long | 1.0 | 希望全部策略资金持有资产 |
cash | 0.0 | 希望不持有资产 |
这里的“每日目标持仓”表示均线准备好后,每个交易日都有一个目标状态。即使连续两天都是 long,第二天仍会明确记录 1.0;它不是只在进出场那天才出现的事件。
不要把目标持仓当成实际持仓
到这里,程序只完成了决策。下面这些词处在交易链路的不同位置:
| 概念 | 回答的问题 | 本单元是否生成 |
|---|---|---|
| 指标(Indicator) | 数据计算出了什么数值? | 是,sma |
| 信号(Signal) | 规则现在给出什么判断? | 是,long / cash |
| 目标持仓(Target Position) | 策略希望持有多少? | 是,1.0 / 0.0 |
| 订单(Order) | 要向交易场所提交什么买卖指令? | 否 |
| 成交(Fill / Execution) | 订单实际以什么价格成交了多少? | 否 |
| 实际持仓(Position) | 成交后账户真正持有什么? | 否 |
信号不会自动变成成交。订单可能被延迟、部分成交或无法成交,实际持仓也可能因为资金、最小交易单位和市场规则而偏离目标。
本单元还必须明确一条时间关系:3 日均线包含当日收盘价,所以只有当日收盘价形成后,才能确定当日信号。
交易日 t 收盘价形成
↓
计算 SMA(t)
↓
得到 signal(t)
↓
得到 target_position(t)
↓
等待后续可执行时点因此 target_position(t) 不能获得从 t-1 收盘到 t 收盘这段已经发生的收益。下一单元会明确采用什么成交时点,并将目标持仓转换为实际持仓;在那之前,本单元不会计算策略收益。
常见的时间穿越
如果先用交易日 t 的收盘价计算信号,又声称策略已经按同一个收盘价持有并赚取了当天收益,就使用了决策时尚不知道的信息。这会让回测结果看起来更好,但无法按相同时间顺序执行。
运行完整流程
执行:
uv run python examples/unit04/build_ma_signal.py预期得到:
date close sma signal target_position
2024-01-02 100.8 -- warming_up --
2024-01-03 101.7 -- warming_up --
2024-01-04 101.2 101.2333 cash 0.0
2024-01-05 102.4 101.7667 long 1.0
2024-01-08 103.1 102.2333 long 1.0
2024-01-09 102.6 102.7000 cash 0.0
2024-01-10 104.0 103.2333 long 1.0
2024-01-11 104.8 103.8000 long 1.0
2024-01-12 104.2 104.3333 cash 0.0
2024-01-15 105.5 104.8333 long 1.0
价格观测: 10 个
均线预热: 2 天
目标持有: 5 天
目标空仓: 3 天
目标持仓切换: 5 次
信号明细: outputs/unit04/signals.csv
信号图表: outputs/unit04/sma_signal.png第一次有效决策是 cash,它建立了初始目标,但不计为从另一个已知目标切换。此后 cash → long → cash → long → cash → long,因此共有 5 次目标持仓切换。
signals.csv 包含:
| 字段 | 含义 |
|---|---|
symbol | 证券标识 |
date | 产生指标和信号的交易日 |
close | 当日收盘价,CNY/份 |
sma | 截至当日的 3 日简单移动平均线,CNY/份 |
signal | long、cash 或预热期缺失 |
target_position | 当日收盘后确定的目标资产比例 |
检查价格、均线与目标持仓
打开 outputs/unit04/sma_signal.png。上图展示收盘价与 3 日均线,下图展示每个信号日的目标持仓。
读图时沿着同一个日期上下对照:
- 蓝色收盘价高于橙色均线时,下图目标为
Long; - 蓝色收盘价低于或等于橙色均线时,下图目标为
Cash; - 前两个日期没有完整的 3 日窗口,所以目标持仓也没有值;
- 红线表示目标,而不是已经成交后的实际持仓。
图表能够帮助发现规则实现与直觉不一致,但不能证明策略有效。当前数据只有 10 个交易日,窗口也是为了教学选择的 3 日;这张图只用于检查计算链路。
常见陷阱
把预热期当成空仓信号
预热期表示指标无法计算,空仓表示指标已经形成且规则明确选择 0.0。两者在后续统计和回测中应保持区别。
没有明确窗口是否包含当日
本单元的尾随窗口包含当日收盘价和此前两个交易日。若使用居中窗口,计算会读到未来价格,不适合生成可执行信号。
规则遗漏等于均线的情况
只写“大于时持有、小于时空仓”会让相等时没有定义。本单元明确使用“小于或等于时空仓”。
把信号、目标持仓和实际持仓混为一谈
信号是判断,目标持仓是愿望,实际持仓是成交后的账户状态。忽略中间的订单和成交,会掩盖执行延迟与交易限制。
用同一个收盘价生成信号并成交
最终收盘价形成后才能完成本单元的计算。除非有明确且可实现的订单机制,否则不能假设已经按这个价格成交。
根据这 10 天挑选参数
反复尝试窗口,再保留结果最好的那个,会把样本中的偶然波动当成规律。参数代表研究假设,不是从历史数据中自动找到的预测答案。参数选择和过拟合会在后续单元专门处理。
验证结果
完成标准:
- 3 日均线的前两行保持缺失;
- 2024-01-04 的均线为 101.2333,信号为
cash; - 2024-01-05 的均线为 101.7667,信号为
long; - 均线形成后的 8 天均有明确目标持仓;
- 目标持有 5 天、目标空仓 3 天,并发生 5 次切换;
- 能说明目标持仓为什么尚不能直接乘以当日收益;
- 能按顺序区分指标、信号、目标持仓、订单、成交和实际持仓。
小结
本单元把一条交易想法变成了可检查的决策链路:
按日期排序的收盘价
↓ 3 日滚动平均
指标:SMA
↓ 与当日收盘价比较
信号:long / cash
↓ 映射为资产比例
目标持仓:1.0 / 0.0
↓ 尚未发生
订单 → 成交 → 实际持仓下一单元会继续处理这条链路的后半段:加入信号延迟、实际持仓和交易成本,再把持仓与资产收益连接为可验证的策略净值。
练习
基础练习:手工检查完整窗口
选择 2024-01-10:
- 找出它和此前两个交易日的收盘价;
- 手工计算 3 日均线;
- 比较当日收盘价与均线;
- 写出信号和目标持仓;
- 与
signals.csv对照。
进阶练习:修改窗口但不评价收益
把 window 改为 4,观察:
- 预热期增加到几天;
- 第一个有效信号出现在哪一天;
- 哪些日期的目标持仓发生变化。
这里只比较指标和信号,不根据短样本判断 3 日还是 4 日窗口更好。
延伸练习:写出另一条无歧义规则
尝试用自然语言完整描述“短期均线上穿长期均线时持有”的策略,至少回答:
- 两条均线各使用多少个交易日;
- 窗口是否包含当日;
- 两条均线相等时如何处理;
- 预热期是否产生目标持仓;
- 信号何时产生,最早何时可以提交订单。
先把规则写完整,不需要实现代码或比较收益。