#!/usr/bin/env python3 """ BTC Maker Micro Scalper — 回测结果统计 重点指标:Net Expectancy(不是胜率) E = 胜率×平均盈利 - 失败率×平均亏损 - 手续费 - 滑点 用法: python user_data/Chan/strategies/mms_stats.py python user_data/Chan/strategies/mms_stats.py --file user_data/backtest_results/xxx.zip python user_data/Chan/strategies/mms_stats.py --slippage 0.00005 """ from __future__ import annotations import argparse import json import zipfile from pathlib import Path from typing import Any import numpy as np import pandas as pd def _latest_backtest(results_dir: Path) -> Path | None: zips = sorted(results_dir.glob("backtest-result-*.zip"), key=lambda p: p.stat().st_mtime) return zips[-1] if zips else None def _load_trades(path: Path) -> tuple[pd.DataFrame, dict[str, Any]]: meta: dict[str, Any] = {} if path.suffix == ".zip": with zipfile.ZipFile(path, "r") as zf: names = zf.namelist() # prefer meta + trades json inside zip trade_name = next((n for n in names if n.endswith(".json") and "meta" not in n), None) meta_name = next((n for n in names if n.endswith(".meta.json")), None) if meta_name: meta = json.loads(zf.read(meta_name)) if not trade_name: raise FileNotFoundError(f"No trades json in {path}") payload = json.loads(zf.read(trade_name)) else: payload = json.loads(path.read_text()) # Freqtrade formats: {"strategy": {"BTC_...": {"trades": [...]}}} # or flat list / {"trades": [...]} trades = None if isinstance(payload, list): trades = payload elif isinstance(payload, dict): if "trades" in payload: trades = payload["trades"] elif isinstance(payload.get("strategy"), dict): # freqtrade zip: {"strategy": {"BTC_Maker_Micro_Scalper": {"trades": [...]}}} for name, v in payload["strategy"].items(): if isinstance(v, dict) and "trades" in v: trades = v["trades"] meta.setdefault("strategy", name) break if trades is None: for _k, v in payload.items(): if isinstance(v, dict) and "trades" in v: trades = v["trades"] meta.setdefault("strategy", _k) break if trades is None: raise ValueError(f"Cannot parse trades from {path}") df = pd.DataFrame(trades) return df, meta def summarize(df: pd.DataFrame, fee_rate: float = 0.00016, slippage: float = 0.0) -> dict[str, Any]: if df.empty: return {"error": "no trades"} # profit_ratio is net of fees in freqtrade; also keep absolute profit_col = "profit_ratio" if "profit_ratio" in df.columns else "close_profit" profits = df[profit_col].astype(float) wins = profits[profits > 0] losses = profits[profits <= 0] n = len(profits) win_rate = len(wins) / n if n else 0.0 loss_rate = 1.0 - win_rate avg_win = float(wins.mean()) if len(wins) else 0.0 avg_loss = float(losses.mean()) if len(losses) else 0.0 # negative or 0 avg_loss_abs = abs(avg_loss) gross_profit = float(wins.sum()) if len(wins) else 0.0 gross_loss = float((-losses).sum()) if len(losses) else 0.0 profit_factor = (gross_profit / gross_loss) if gross_loss > 0 else float("inf") # 手续费:freqtrade 的 profit 已扣费;这里单独估算双边 maker 占比 # 每笔双边 fee ≈ 2 * fee_rate(相对名义) fee_per_trade = 2.0 * fee_rate total_fee_est = n * fee_per_trade # 滑点假设(每边) slip_per_trade = 2.0 * slippage total_slip_est = n * slip_per_trade # Net Expectancy(每笔期望,比率) # E = WR*avg_win - LR*avg_loss_abs - fee - slip expectancy = win_rate * avg_win - loss_rate * avg_loss_abs - fee_per_trade - slip_per_trade # 注意:若 profit_ratio 已含手续费,上式 fee 会双重扣除。 # 提供两个版本: # 1) E_raw:用毛期望再减 fee/slip(假设 profit 含 fee → 用 E_from_net) # 2) E_from_net:直接用已实现平均利润(已含 fee)再减额外滑点假设 e_from_net = float(profits.mean()) - slip_per_trade # 最大回撤(权益曲线,相对) equity = (1.0 + profits).cumprod() peak = equity.cummax() dd = (equity - peak) / peak max_dd = float(dd.min()) if len(dd) else 0.0 # 持仓时间 hold_min = None if "open_date" in df.columns and "close_date" in df.columns: od = pd.to_datetime(df["open_date"], utc=True) cd = pd.to_datetime(df["close_date"], utc=True) hold_min = float(((cd - od).dt.total_seconds() / 60.0).mean()) # Maker 成交率:若有 order_type / is_short 等字段无法直接得,默认限价策略按 100% 标注 maker_rate = 1.0 if "exit_reason" in df.columns: # 无法精确时保持 1.0;实盘可从策略 _maker_fills 导出 pass # 手续费占毛利 fee_share = None if "fee_open" in df.columns and "fee_close" in df.columns: fees = df["fee_open"].astype(float).fillna(0) + df["fee_close"].astype(float).fillna(0) abs_pnl = df.get("profit_abs", profits).astype(float).abs().sum() fee_share = float(fees.sum() / abs_pnl) if abs_pnl else None total_fee_est = float(fees.sum()) return { "total_trades": n, "win_rate": win_rate, "avg_win": avg_win, "avg_loss": avg_loss, "profit_factor": profit_factor, "max_drawdown": max_dd, "fee_est_total_ratio_units": total_fee_est, "fee_share_of_abs_pnl": fee_share, "maker_fill_rate_assumed": maker_rate, "avg_hold_minutes": hold_min, "net_expectancy_from_realized": e_from_net, "net_expectancy_formula_rebuild": expectancy, "total_profit_ratio_sum": float(profits.sum()), "avg_profit": float(profits.mean()), "slippage_assumed_per_side": slippage, "note": ( "优先看 net_expectancy_from_realized(已含 freqtrade 手续费)。" "net_expectancy_formula_rebuild 会再减一遍 fee,仅作分解参考。" ), } def print_report(stats: dict[str, Any], source: str) -> None: print("=" * 60) print("BTC Maker Micro Scalper — Backtest Stats") print(f"source: {source}") print("=" * 60) if "error" in stats: print(stats["error"]) return def pct(x): return f"{x * 100:.4f}%" if x is not None else "n/a" print(f"总交易次数 : {stats['total_trades']}") print(f"胜率 : {pct(stats['win_rate'])} (勿作为主指标)") print(f"平均盈利 : {pct(stats['avg_win'])}") print(f"平均亏损 : {pct(stats['avg_loss'])}") print(f"Profit Factor : {stats['profit_factor']:.4f}") print(f"最大回撤 : {pct(stats['max_drawdown'])}") print(f"手续费占比(abs pnl) : {stats['fee_share_of_abs_pnl']}") print(f"Maker成交率(假设) : {pct(stats['maker_fill_rate_assumed'])}") print(f"平均持仓时间(分钟) : {stats['avg_hold_minutes']}") print("-" * 60) print(f"Net Expectancy/笔 : {pct(stats['net_expectancy_from_realized'])} ★主指标") print(f"公式重建 E(参考) : {pct(stats['net_expectancy_formula_rebuild'])}") print(f"累计收益(比率和) : {pct(stats['total_profit_ratio_sum'])}") print(f"平均单笔 : {pct(stats['avg_profit'])}") print("-" * 60) print(stats["note"]) print("=" * 60) def export_equity_csv(df: pd.DataFrame, out: Path) -> None: if df.empty or "profit_ratio" not in df.columns: return profits = df["profit_ratio"].astype(float) equity = (1.0 + profits).cumprod() out_df = pd.DataFrame({ "close_date": df.get("close_date"), "profit_ratio": profits, "equity": equity, }) out.parent.mkdir(parents=True, exist_ok=True) out_df.to_csv(out, index=False) print(f"净收益曲线已导出: {out}") def main(): ap = argparse.ArgumentParser() ap.add_argument("--file", type=str, default=None, help="backtest zip/json path") ap.add_argument("--slippage", type=float, default=0.0, help="per-side slippage ratio") ap.add_argument("--fee", type=float, default=0.00016, help="per-side maker fee ratio") ap.add_argument( "--equity-out", type=str, default="user_data/plot/mms_equity.csv", help="equity curve csv", ) args = ap.parse_args() root = Path(__file__).resolve().parents[3] # freqtrade root results_dir = root / "user_data" / "backtest_results" path = Path(args.file) if args.file else _latest_backtest(results_dir) if path is None or not path.exists(): print("未找到回测结果。请先运行 backtesting,或用 --file 指定。") print( "示例:\n" " freqtrade backtesting -c ./user_data/Chan/config/BTC_Maker_Micro_Scalper.json \\\n" " --strategy BTC_Maker_Micro_Scalper --strategy-path ./user_data/Chan/strategies \\\n" " --timerange=20260101- --fee 0.00016 --enable-protections" ) return df, meta = _load_trades(path) stats = summarize(df, fee_rate=args.fee, slippage=args.slippage) print_report(stats, str(path)) if meta: print(f"meta keys: {list(meta.keys())[:8]}") export_equity_csv(df, root / args.equity_out) if __name__ == "__main__": main()