Implementation code 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 个单测,见仓库说明。")