fix(web): 自动刷新保留 K 线视窗;威科夫与图表增量更新

自动刷新改用 tail update 与 scrollToPosition 恢复视窗,避免 setData 后跳到最右;拆分 chart_tv 模块并扩展 analyze/recent API。同步威科夫分析、pipeline 增量构建及相关策略与配置。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
jackyu66git
2026-08-25 22:57:43 +08:00
co-authored by Cursor
parent 1e60ab3bfa
commit 8ee11317d3
104 changed files with 21452 additions and 4988 deletions
+246
View File
@@ -0,0 +1,246 @@
#!/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()