Files
Chan/strategies/mms_stats.py
T
jackyu66gitandCursor 8ee11317d3 fix(web): 自动刷新保留 K 线视窗;威科夫与图表增量更新
自动刷新改用 tail update 与 scrollToPosition 恢复视窗,避免 setData 后跳到最右;拆分 chart_tv 模块并扩展 analyze/recent API。同步威科夫分析、pipeline 增量构建及相关策略与配置。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 22:57:43 +08:00

247 lines
9.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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()