自动刷新改用 tail update 与 scrollToPosition 恢复视窗,避免 setData 后跳到最右;拆分 chart_tv 模块并扩展 analyze/recent API。同步威科夫分析、pipeline 增量构建及相关策略与配置。 Co-authored-by: Cursor <cursoragent@cursor.com>
247 lines
9.4 KiB
Python
247 lines
9.4 KiB
Python
#!/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()
|