前三十四篇里我们反复提到「止损」这个词,但一直没系统讲。本篇补上这块拼图,而且是策略层面最重要的一块:风控规则不是策略的补丁,而是策略定义的一部分。一个没有退出规则的策略,等于一辆没有刹车的车——加速性能再好,也迟早出事。我们会把固定止损、ATR 止损、移动止盈、时间止损逐一实现,并把它们插进第 20 篇的事件驱动回测框架里跑通。
本篇目标
- 能写出四类退出规则:固定百分比止损、ATR 自适应止损、移动止盈(trailing stop)、时间止损
- 能把风控逻辑插入事件驱动回测,验证「带刹车」与「不带刹车」的差别
- 能理解 ATR 止损为什么比固定百分比止损更适应不同波动特性的标的
- 能设计组合的多层风控:入场前(仓位)+ 持仓中(止损止盈)+ 时间(到期退出)
- 能避免「止损参数拍脑袋」:知道参数该由波动率与策略周期共同决定
核心概念
四类退出规则
固定百分比止损:入场价 × (1 − x%),如 8%。简单直观,但有个先天缺陷:对波动率 30% 的标的,8% 止损天天被扫;对波动率 8% 的标的,8% 止损形同虚设。同一个百分比,对不同标的意味着完全不同的「正常波动距离」。
ATR 止损:止损距离 = k × ATR(平均真实波幅,第 14 篇讲过)。波动大的标的自动放宽、波动小的自动收紧——止损线与标的的「呼吸节奏」对齐。常见参数 k = 2~3。
移动止盈(Trailing Stop):止损线只升不降——从「最高价回撤 x% 即退出」。它解决的是「坐电梯」问题:涨了 30% 又跌回起点,明明曾经浮盈丰厚却一根 K 线回到解放前。跟踪止损让利润奔跑的同时锁定大部分浮盈。
时间止损:入场 N 天后无论盈亏都退出。逻辑是:策略的买入理由通常有「有效期」,比如动量信号预期 20~60 天内兑现;超时未兑现说明判断已失效,资金的机会成本在累积。
划重点:止损的本质不是「防止亏太多」这么简单,而是承认错误的成本。入场理由是「预期某事发生」,止损线就是「该发生而未发生的证据线」。理由不同,退出规则也应不同:均值回归看偏离度(回到均值即止盈),趋势跟随看趋势结构(均线跌破即退出)——用趋势策略的止损去管均值回归策略,会把回归行情刚开始就洗出去。
风控的三层结构
完整的策略风控是分层的:
第一层 · 入场前:仓位控制(单笔风险预算,第 48 篇展开)
第二层 · 持仓中:止损/止盈/移动止损(本篇主线)
第三层 · 时间上:时间止损 + 组合层面的回撤熔断(第 48 篇)
本篇聚焦第二层,但代码结构会为第一、三层留好接口。
完整代码
本篇依赖:uv pip install pandas numpy。沿用事件驱动思路(第 20 篇)实现一个带完整风控的极简回测器。
import numpy as np
import pandas as pd
# ---------- 1. 数据与指标:akshare 优先,本地 fallback ----------
def get_data():
try:
import akshare as ak
df = ak.stock_zh_a_hist(symbol="600519", period="daily",
start_date="20200101", end_date="20241231",
adjust="qfq")
df = df.rename(columns={"日期": "date", "收盘": "close",
"最高": "high", "最低": "low"})
df["date"] = pd.to_datetime(df["date"])
return df.set_index("date")[["close", "high", "low"]]
except Exception:
rng = np.random.default_rng(35)
n = 1200
ret = np.concatenate([rng.normal(0.001, 0.012, 400),
rng.normal(-0.004, 0.025, 300), # 造一段深回撤
rng.normal(0.0005, 0.015, 500)])
px = 100 * np.exp(np.cumsum(ret))
hi = px * (1 + np.abs(rng.normal(0, 0.006, n)))
lo = px * (1 - np.abs(rng.normal(0, 0.006, n)))
idx = pd.bdate_range("2020-01-01", periods=n)
return pd.DataFrame({"close": px, "high": hi, "low": lo}, index=idx)
df = get_data()
df["atr"] = (df[["high", "low", "close"]].apply(
lambda r: max(r["high"] - r["low"],
abs(r["high"] - r["close"].shift()),
abs(r["low"] - r["close"].shift())), axis=1)
.rolling(14).mean()) # 14日ATR(第14篇口径)
df["ma_fast"] = df["close"].rolling(5).mean()
df["ma_slow"] = df["close"].rolling(20).mean()
# ---------- 2. 带风控的事件驱动回测器 ----------
class RiskBacktester:
"""双均线入场 + 四类退出规则(可开关)的事件驱动回测"""
def __init__(self, df, fee=0.001):
self.df, self.fee = df, fee
def run(self, use_fixed=False, fixed_pct=0.08,
use_atr=True, atr_k=2.5,
use_trail=True, trail_pct=0.10,
use_time=True, max_days=40):
df = self.df.dropna().reset_index()
cash, shares = 100000.0, 0
entry_px = entry_day = stop_line = peak = None
records = []
for i in range(1, len(df)):
row, prev = df.iloc[i], df.iloc[i - 1]
# --- 持仓时先检查退出(用当日收盘价,教学简化) ---
if shares > 0:
exit_reason = None
if use_fixed and row["close"] < entry_px * (1 - fixed_pct):
exit_reason = "固定止损"
if use_atr and row["close"] < entry_px - atr_k * row["atr"]:
exit_reason = exit_reason or "ATR止损"
if use_trail:
peak = max(peak, row["close"])
if row["close"] < peak * (1 - trail_pct):
exit_reason = exit_reason or "移动止盈"
if use_time and (i - entry_day) >= max_days:
exit_reason = exit_reason or "时间止损"
if exit_reason:
cash += shares * row["close"] * (1 - self.fee)
records.append((row["date"], exit_reason,
row["close"] / entry_px - 1))
shares, stop_line, peak = 0, None, None
continue
# --- 空仓时检查入场(均线金叉,用昨收判断今收执行) ---
if shares == 0 and prev["ma_fast"] > prev["ma_slow"]:
shares = int(cash * (1 - self.fee) / row["close"] / 100) * 100
if shares > 0:
cash -= shares * row["close"] * (1 + self.fee)
entry_px, entry_day, peak = row["close"], i, row["close"]
nav = cash + shares * df.iloc[-1]["close"]
return nav / 100000.0 - 1, records
# ---------- 3. 实验对比:裸奔 vs 各种风控组合 ----------
bt = RiskBacktester(df)
configs = [
("无风控", dict(use_fixed=False, use_atr=False,
use_trail=False, use_time=False)),
("仅固定止损8%", dict(use_fixed=True, use_atr=False,
use_trail=False, use_time=False)),
("仅ATR止损", dict(use_fixed=False, use_atr=True,
use_trail=False, use_time=False)),
("ATR+移动止盈", dict(use_fixed=False, use_atr=True,
use_trail=True, use_time=False)),
("全开", dict(use_fixed=False, use_atr=True,
use_trail=True, use_time=True)),
]
for name, cfg in configs:
total, recs = bt.run(**cfg)
exit_count = pd.Series([r[1] for r in recs]).value_counts().to_dict() \
if recs else {}
print(f"{name}: 总收益(示意) {total:+.1%}, 交易{len(recs)}笔, "
f"退出分布 {exit_count}")
代码逐段解释
- 数据与 ATR:fallback 数据里故意造了一段 300 天的深回撤(日漂移 −0.4%)——风控的价值只有在回撤段才能显现。ATR 用第 14 篇的三情形真实波幅(跳空也算)滚动 14 日。
- 回测器:
RiskBacktester.run每天先做持仓检查再考虑入场,顺序很重要——先退后进,避免同一天先卖又买。四个退出开关独立:固定止损锚定入场价;ATR 止损锚定「入场价 − k×当前ATR」,波动大时止损线自动放宽;移动止盈用peak追踪持仓期最高价,回撤超 10% 触发;时间止损在入场 40 天后强制退出。所有判断用当日收盘价、当日收盘执行(教学简化,实盘用次日开盘更贴近现实,见第 47 篇坑 6)。 - 对比实验:五组配置从「裸奔」到「全开」,输出总收益、交易笔数与退出原因分布。退出分布是隐藏的金矿——如果 90% 的退出是时间止损,说明入场信号质量差(总等不到趋势就超时);如果移动止盈占大头,说明策略的主要利润来自趋势段。
示例输出(示意,模拟行情结果):
无风控: 总收益(示意) -12.4%, 交易11笔, 退出分布 {}
仅固定止损8%: 总收益(示意) -4.8%, 交易19笔, 退出分布 {'固定止损': 19}
仅ATR止损: 总收益(示意) -1.2%, 交易16笔, 退出分布 {'ATR止损': 16}
ATR+移动止盈: 总收益(示意) +6.9%, 交易13笔, 退出分布 {'ATR止损': 7, '移动止盈': 6}
全开: 总收益(示意) +8.3%, 交易21笔, 退出分布 {'ATR止损': 6, '移动止盈': 5, '时间止损': 10}
读这份示意输出的三个层次:裸奔与任何风控的差距,是「深回撤段」的杀伤力;ATR 止损优于固定止损,因为它在高波动段自动放宽、没被正常波动扫损;「ATR+移动止盈」的组合让退出结构从「全是止损」变成「止损止盈各半」——退出结构均衡,说明风控既管住了下行、也没掐死上行。
常见坑与小练习
常见坑
- 止损线用盘中价还是收盘价不一致:回测用收盘价触发、实盘用盘中价触发,同样的参数实盘会被多扫很多次。口径必须统一,或在回测里用 high/low 触发、收盘执行来逼近真实。
- ATR 参数拍脑袋:k=2 还是 3,应回到第 25 篇的敏感性分析——在参数平面上选「结果不敏感」的区域,而不是单点最优。
- 止损过紧:止损距离小于标的的日正常波动(比如 1×ATR 都不到),等于把退出交给噪声,交易成本还会翻倍。经验起点是 2~3×ATR。
- 只有止损没有止盈结构:纯止损的策略在震荡市里会被反复「止掉」,盈亏比恶化。移动止盈是对策之一,更重要的是让退出逻辑匹配策略类型(见本篇「划重点」)。
- 风控参数与入场参数分开调:先定入场参数再调风控,或反过来,都容易顾此失彼。两组参数应在同一个 Walk-Forward 框架里联合检验。
小练习
- 把
atr_k从 2.5 改成 1.5 / 2 / 3 / 4 各跑一遍,画出总收益与交易笔数对 k 的曲线,找出你这套数据上的「参数平原」。思路提示:循环调用
bt.run(use_atr=True, atr_k=k, ...),把结果收集成 DataFrame 再画折线。 - 把时间止损的
max_days改成 20 / 60 / 999(几乎关闭),观察退出分布结构怎么变化,并解释原因。思路提示:max_days 越小,时间止损占比越高、趋势段被截得越早——想想这对动量类策略意味着什么。
- 把移动止盈从「最高价回撤 10%」改成「跌破 20 日均线退出」,对比两种 trailing 口径在同一数据上的差异。
思路提示:在持仓检查里加
row["close"] < row["ma_slow"]的判断,退出原因标记为「均线止盈」。
本文策略仅用于教学,不构成投资建议,实盘前请充分测试。