★ 代表実装
双均线趋势跟踪 · 生产级参考实现(含回测引擎/成本/风控/测试)
python
【这是什么】
双均线趋势跟踪的**生产级参考实现**(完整项目含数据层、回测引擎、绩效指标、报告、CLI 与 24 个单元测试)。
本页展示核心可读节选:配置校验 → 指标 → 无前视信号 → 事件驱动引擎(含成本/风控)。
【工程上做对的关键点】
1. 严格无前视:t 日收盘产生信号,t+1 日**开盘**成交;并有专门的回归测试守住这条契约。
2. 真实成本:佣金(含最低 5 元)+ 卖出印花税 + 滑点,逐笔计提。
3. 真实成交假设:整手 100 股、现金约束(永不透支)、跳空低开按开盘价止损。
4. 风控:ATR 移动止损 + 止损后等信号归零再入场 + 波动率目标仓位。
5. 换手控制:再平衡阈值(rebalance band)——加入后交易从 133 笔降到 31 笔,手续费降 17%,收益不变。
【真实数据实测(沪深300ETF sh510300,2016-06-29 ~ 2026-10-16,2500 根)】
累计 +8.18% | 年化 +0.80% | 年化波动 6.48% | 最大回撤 -20.65%
夏普 0.15 | 卡玛 0.04 | 31 笔交易 | 胜率 48.4% | 盈亏比 1.21 | 平均仓位 17.7%
诚实结论:**扣费后基本不赚钱**。但用仅 17.7% 的平均仓位换来正收益,风险调整后并不差。
参数扫描显示夏普在 -0.33 ~ 0.50 之间跳——提醒:能挑出好参数不等于策略有效。
【参考项目与出处(仅借鉴设计思路,本实现为独立编写,未复制任何源码)】
- backtrader https://github.com/mementum/backtrader (事件驱动分层/撮合与成本建模)
- zipline-reloaded https://github.com/stefan-jansen/zipline-reloaded (next-bar 执行、禁用未来函数)
- vectorbt https://github.com/polakowo/vectorbt (向量化信号 + 事件撮合的取舍)
- bt https://github.com/pmorissette/bt (回测编排抽象)
- pyalgotrade https://github.com/gbeced/pyalgotrade (海龟/ATR 头寸与止损)
- ta-lib-python https://github.com/TA-Lib/ta-lib-python (ATR / Wilder 平滑口径)
- empyrical https://github.com/quantopian/empyrical (夏普/索提诺/回撤口径)
- ffn https://github.com/pmorissette/ffn (绩效汇总口径)
- pyfolio https://github.com/quantopian/pyfolio (报告产物组织)
- vn.py https://github.com/vnpy/vnpy (国内实盘框架的工程组织)
- qlib https://github.com/microsoft/qlib (研究工作流化)
理论:海龟交易法则(ATR/Unit 头寸法);Moreira & Muir (2017) "Volatility-Managed Portfolios"。
注:以上项目各有不同开源许可,若需复制其源码请先核对许可条款。
【免责声明】教学与工程示范用途,不构成投资建议;历史回测不代表未来收益。
⛓ main
登录后可投票
実装コード python
""" 双均线趋势跟踪 · 生产级核心实现(节选) ============================================================ 本文件是完整项目的「核心可读节选」,完整版另含:数据层(多源抓取/校验)、 绩效指标、报告产物、CLI、24 个单元测试、参数敏感性扫描。 完整项目结构: config.py 参数集中管理 + 启动即校验(fail-fast) data.py 数据校验 / CSV / 多源在线抓取 / 可复现合成数据 indicators.py SMA / EMA / ATR(Wilder) / 已实现波动率 strategy.py 信号生成(严格无前视) engine.py 事件驱动引擎:次日开盘成交 + 成本 + ATR 移动止损 metrics.py 绩效指标(夏普/索提诺/最大回撤/卡玛…) report.py 报告与产物(equity/trades/summary/config) cli.py 命令行:fetch / backtest / demo tests/ 24 个单测(含无前视回归测试) 实测(沪深300ETF sh510300,2016-06-29 ~ 2026-10-16,2500 根真实日线): 累计 +8.18% | 年化 +0.80% | 年化波动 6.48% | 最大回撤 -20.65% 夏普 0.15 | 31 笔交易 | 胜率 48.4% | 盈亏比 1.21 | 平均仓位 17.7% 结论:扣费后基本不赚钱——但这正是"先做对工程,再谈 alpha"的第一课。 参考项目与出处(仅借鉴设计思路,代码为独立实现,未复制任何第三方源码): backtrader https://github.com/mementum/backtrader (事件驱动分层/撮合与成本建模) zipline-reloaded https://github.com/stefan-jansen/zipline-reloaded (next-bar 执行、禁用未来函数) vectorbt https://github.com/polakowo/vectorbt (向量化信号 + 事件撮合的取舍) bt https://github.com/pmorissette/bt (回测编排抽象) pyalgotrade https://github.com/gbeced/pyalgotrade (海龟/ATR 头寸与止损的教学式实现) ta-lib-python https://github.com/TA-Lib/ta-lib-python (ATR / Wilder 平滑口径) empyrical https://github.com/quantopian/empyrical (夏普/索提诺/回撤口径) ffn https://github.com/pmorissette/ffn (绩效汇总口径) pyfolio https://github.com/quantopian/pyfolio (报告产物组织) vn.py https://github.com/vnpy/vnpy (国内实盘框架的工程组织) qlib https://github.com/microsoft/qlib (研究工作流化) 理论:海龟交易法则(ATR/Unit 头寸法);Moreira & Muir (2017) Volatility-Managed Portfolios。 """ from __future__ import annotations import json import math from dataclasses import asdict, dataclass, field from typing import List, Optional import numpy as np import pandas as pd # ============================================================ # 1. 配置:集中管理 + 启动即校验 # ============================================================ @dataclass class StrategyConfig: fast_window: int = 20 # 快线窗口 slow_window: int = 60 # 慢线窗口 atr_window: int = 20 # ATR 窗口(止损用) atr_stop_mult: float = 3.0 # 移动止损 = 最高价 - N × ATR vol_window: int = 60 # 波动率估计窗口 vol_target: float = 0.15 # 年化波动率目标 max_exposure: float = 1.0 # 最大仓位比例 rebalance_threshold: float = 0.05 # 再平衡阈值(降换手) allow_short: bool = False def validate(self) -> None: if self.slow_window <= self.fast_window: raise ValueError("slow_window 必须大于 fast_window") if not (0 < self.vol_target <= 5): raise ValueError("vol_target 应在 (0, 5] 之间") if not (0 < self.max_exposure <= 1.0): raise ValueError("max_exposure 应在 (0, 1] 之间") if not (0 <= self.rebalance_threshold <= 1): raise ValueError("rebalance_threshold 应在 [0, 1] 之间") @dataclass class CostConfig: commission_rate: float = 0.00025 # 佣金(双边),万 2.5 min_commission: float = 5.0 # 单笔最低佣金 stamp_tax_rate: float = 0.0005 # 印花税(仅卖出) slippage_bps: float = 2.0 # 滑点(基点) @dataclass class BacktestConfig: initial_cash: float = 1_000_000.0 lot_size: int = 100 # A 股 1 手 = 100 股 annual_days: int = 252 # ============================================================ # 2. 指标(全部因果:t 时刻只用 t 及之前的数据) # ============================================================ def sma(series: pd.Series, window: int) -> pd.Series: """简单移动平均。窗口不足时为 NaN,不释放"半截均线"。""" return series.rolling(window=window, min_periods=window).mean() def true_range(df: pd.DataFrame) -> pd.Series: prev_close = df["close"].shift(1) tr = pd.concat([ df["high"] - df["low"], (df["high"] - prev_close).abs(), (df["low"] - prev_close).abs(), ], axis=1).max(axis=1) if len(tr): tr.iloc[0] = df["high"].iloc[0] - df["low"].iloc[0] return tr def atr(df: pd.DataFrame, window: int = 20) -> pd.Series: """平均真实波幅(Wilder 平滑,alpha = 1/window)。""" return true_range(df).ewm(alpha=1.0 / window, adjust=False, min_periods=window).mean() def realized_vol(close: pd.Series, window: int = 60, annual_days: int = 252) -> pd.Series: log_ret = np.log(close / close.shift(1)) return log_ret.rolling(window=window, min_periods=window).std() * math.sqrt(annual_days) # ============================================================ # 3. 信号:t 日收盘产生目标仓位,t+1 开盘执行(严格无前视) # ============================================================ def build_signals(df: pd.DataFrame, cfg: StrategyConfig, annual_days: int = 252) -> pd.DataFrame: cfg.validate() out = df.copy().reset_index(drop=True) close = out["close"] out["fast"] = sma(close, cfg.fast_window) out["slow"] = sma(close, cfg.slow_window) out["atr"] = atr(out, cfg.atr_window) out["vol"] = realized_vol(close, cfg.vol_window, annual_days=annual_days) # (1) 趋势方向:快线在慢线之上做多 direction = np.where(out["fast"] > out["slow"], 1.0, 0.0) if cfg.allow_short: direction = np.where(out["fast"] > out["slow"], 1.0, np.where(out["fast"] < out["slow"], -1.0, 0.0)) out["signal"] = direction # (2) 波动率目标仓位:低波动加仓、高波动减仓 with np.errstate(divide="ignore", invalid="ignore"): lev = cfg.vol_target / out["vol"] lev = lev.replace([np.inf, -np.inf], np.nan).clip(lower=0.0, upper=cfg.max_exposure).fillna(0.0) out["exposure"] = (out["signal"] * lev).clip(-cfg.max_exposure, cfg.max_exposure) # (3) 指标未就绪区间强制空仓 ready = out["fast"].notna() & out["slow"].notna() & out["atr"].notna() & out["vol"].notna() out["target_exposure"] = out["exposure"].where(ready, 0.0).astype(float) return out # ============================================================ # 4. 回测引擎(核心循环,节选) # - t+1 开盘成交;整手;现金约束;佣金+印花税+滑点 # - ATR 移动止损;跳空低开按开盘价成交;止损后等信号归零才再入场 # - 再平衡阈值:目标与当前仓位偏离超过阈值才调仓 # ============================================================ @dataclass class Trade: entry_date: str entry_price: float exit_date: str exit_price: float shares: int fees: float gross_pnl: float net_pnl: float ret: float hold_days: int reason: str # signal / stop / end class BacktestEngine: def __init__(self, s_cfg: StrategyConfig, c_cfg: CostConfig, b_cfg: BacktestConfig): s_cfg.validate() self.s, self.c, self.b = s_cfg, c_cfg, b_cfg def run(self, df: pd.DataFrame): s, c, b = self.s, self.c, self.b sig = build_signals(df, s, annual_days=b.annual_days) slip, lot = c.slippage_bps / 10_000.0, b.lot_size cash, shares, entry_price, entry_fees, entry_index = float(b.initial_cash), 0, 0.0, 0.0, -1 stop_price: Optional[float] = None reentry_blocked = False pending_target = 0.0 peak = cash trades: List[Trade] = [] rows = [] def do_buy(px, qty, i): nonlocal cash, shares, entry_price, entry_fees, entry_index value = px * qty fee = max(value * c.commission_rate, c.min_commission) cash -= (value + fee) if shares == 0: entry_price, entry_fees, entry_index = px, fee, i else: # 加仓:摊入加权成本 total = entry_price * shares + entry_fees entry_price = (total + value + fee) / (shares + qty) entry_fees = 0.0 shares += qty def do_sell(px, qty, i, reason): nonlocal cash, shares, entry_fees value = px * qty fee = max(value * c.commission_rate, c.min_commission) + value * c.stamp_tax_rate cash += (value - fee) gross = (px - entry_price) * qty cost_basis = entry_price * qty + entry_fees trades.append(Trade( str(df["date"].iloc[entry_index])[:10], round(entry_price, 6), str(df["date"].iloc[i])[:10], round(px, 6), qty, round(fee + entry_fees, 2), round(gross, 2), round(gross - fee - entry_fees, 2), round((gross - fee - entry_fees) / cost_basis, 6) if cost_basis > 0 else 0.0, i - entry_index if entry_index >= 0 else 0, reason)) shares -= qty if shares == 0: entry_fees = 0.0 for i in range(len(df)): row = df.iloc[i] o, h, l, cl = float(row["open"]), float(row["high"]), float(row["low"]), float(row["close"]) # (1) 开盘:执行上一日收盘给出的目标 if pending_target != 0.0 or shares > 0: equity_open = cash + shares * o if pending_target == 0.0: rebalance = shares > 0 else: cur = (shares * o / equity_open) if equity_open > 0 else 0.0 rebalance = abs(pending_target - cur) >= s.rebalance_threshold if rebalance: target_value = equity_open * pending_target px_ref = o * (1 + slip) if target_value >= shares * o else o * (1 - slip) desired = int(np.floor(target_value / max(px_ref, 1e-9) / lot) * lot) delta = desired - shares if delta > 0: usable = max(cash / (1.0 + c.commission_rate) - c.min_commission, 0.0) qty = min(delta, int(np.floor(usable / (o * (1 + slip)) / lot) * lot)) if qty > 0: do_buy(o * (1 + slip), qty, i) elif delta < 0: qty = min(-delta, shares) if qty > 0: do_sell(o * (1 - slip), qty, i, "signal") # (2) 盘中:ATR 移动止损(跳空低开按开盘价成交) if shares > 0 and stop_price is not None and l <= stop_price: do_sell(min(o, stop_price) * (1 - slip), shares, i, "stop") stop_price, reentry_blocked = None, True # (3) 收盘:估值 equity = cash + shares * cl peak = max(peak, equity) rows.append({"date": str(row["date"])[:10], "cash": round(cash, 2), "shares": shares, "position_value": round(shares * cl, 2), "equity": round(equity, 2), "exposure": round(shares * cl / equity, 6) if equity > 0 else 0.0, "drawdown": round(equity / peak - 1.0, 6) if peak > 0 else 0.0}) # (4) 收盘后:更新移动止损 + 生成下一日目标(止损后需等信号归零) a = float(sig["atr"].iloc[i]) if pd.notna(sig["atr"].iloc[i]) else np.nan if shares > 0 and not np.isnan(a) and a > 0: cand = h - s.atr_stop_mult * a stop_price = cand if stop_price is None else max(stop_price, cand) elif shares == 0: stop_price = None raw = float(sig["target_exposure"].iloc[i]) if raw == 0.0: reentry_blocked = False pending_target = 0.0 if (reentry_blocked and raw > 0) else raw # (5) 收尾:按最后收盘价清仓,统一口径 if shares > 0: do_sell(float(df.iloc[-1]["close"]) * (1 - slip), shares, len(df) - 1, "end") rows[-1].update({"cash": round(cash, 2), "shares": 0, "position_value": 0.0, "equity": round(cash, 2)}) return pd.DataFrame(rows), pd.DataFrame([asdict(t) for t in trades]) # ============================================================ # 5. 用法 # ============================================================ if __name__ == "__main__": # df = load_csv("data/510300_daily.csv") # 见完整项目 dualma/data.py # eq, tr = BacktestEngine(StrategyConfig(), CostConfig(), BacktestConfig()).run(df) # m = compute_metrics(eq, tr) # 见完整项目 dualma/metrics.py print("完整项目:fetch / backtest / demo 三个命令 + 24 个单测,见仓库说明。")