#!/usr/bin/env python3 """ Phase3 — Evidence Expansion(不改 Spring 规则) 目标: 将样本从 N=20 推向 N>=50 手段: - 多品种外部验证(本地有数据的 pair) - 分开统计 SPRING_LONG / UTAD_SHORT - 同一净成本模型(fee+slip) - 不引入 LPS、不扫参 用法: .venv/bin/python user_data/Chan/scripts/wyckoff_phase3_evidence.py 缺 4h/8h 时从 1h resample(离线,不依赖 API)。 BTC 若无 2019 更早数据,脚本会标明 gap,不伪造历史。 """ from __future__ import annotations import json import logging import sys from pathlib import Path from typing import Any, Optional import pandas as pd ROOT = Path(__file__).resolve().parents[3] sys.path.insert(0, str(ROOT)) from user_data.Chan.scripts.wyckoff_tf_grid import install_offline_markets # noqa: E402 DATADIR = ROOT / "user_data/data/binance/futures" STRAT = "Wyckoff_BTC_V1_BASELINE" CONFIG = ROOT / "user_data/Chan/config/Wyckoff_BTC_V1_BASELINE.json" OUT = ROOT / "user_data/Chan/scripts/wyckoff_phase3_evidence_result.json" # 候选外部验证(规则冻结;用本地最长可用历史) CANDIDATES = [ {"pair": "BTC/USDT:USDT", "file": "BTC_USDT_USDT", "timerange": "20190901-"}, {"pair": "ETH/USDT:USDT", "file": "ETH_USDT_USDT", "timerange": "20191101-"}, {"pair": "SOL/USDT:USDT", "file": "SOL_USDT_USDT", "timerange": "20200901-"}, ] MIN_1H_BARS = 4000 # ~ema200@8h 需要足够历史;过短 skip def ensure_tf(file_stub: str, tf: str, source_tf: str = "1h") -> bool: """从更细周期 resample 生成 tf feather;已存在则跳过。""" out = DATADIR / f"{file_stub}-{tf}-futures.feather" src = DATADIR / f"{file_stub}-{source_tf}-futures.feather" if out.exists(): return True if not src.exists(): return False df = pd.read_feather(src) df["date"] = pd.to_datetime(df["date"], utc=True) df = df.set_index("date").sort_index() rule = tf.replace("m", "min") if tf.endswith("m") else tf ohlc = df.resample(rule).agg( {"open": "first", "high": "max", "low": "min", "close": "last", "volume": "sum"} ).dropna(subset=["open", "close"]) ohlc = ohlc.reset_index() ohlc.to_feather(out) print(f" resampled {out.name} n={len(ohlc)}", flush=True) return True def pair_ready(file_stub: str) -> tuple[bool, str]: p1 = DATADIR / f"{file_stub}-1h-futures.feather" if not p1.exists(): return False, "missing 1h" df = pd.read_feather(p1) n = len(df) if n < MIN_1H_BARS: return False, f"1h bars={n} < {MIN_1H_BARS} (insufficient for 8h ema200)" ok4 = ensure_tf(file_stub, "4h") ok8 = ensure_tf(file_stub, "8h") if not (ok4 and ok8): return False, "cannot build 4h/8h" return True, f"1h={n}" def run_bt(pair: str, timerange: str, fee: float = 0.0005, extra: float = 0.0) -> dict[str, Any]: from freqtrade.configuration import Configuration from freqtrade.enums import RunMode from freqtrade.optimize.backtesting import Backtesting import freqtrade.optimize.optimize_reports.bt_output as bt_output bt_output.show_backtest_results = lambda *a, **k: None # type: ignore for mod in list(sys.modules): if "Wyckoff_BTC" in mod: del sys.modules[mod] config = Configuration.from_files([str(CONFIG)]) config.update( { "strategy": STRAT, "strategy_path": str(ROOT / "user_data/Chan/strategies"), "timerange": timerange, "timeframe": "1h", "export": "none", "runmode": RunMode.BACKTEST, "datadir": ROOT / "user_data/data/binance", "user_data_dir": ROOT / "user_data", "enable_protections": False, "fee": fee + extra, "exchange": { **config.get("exchange", {}), "pair_whitelist": [pair], "name": config.get("exchange", {}).get("name", "binance"), }, } ) bt = Backtesting(config) bt.start() st = bt.results["strategy"].get(STRAT) or list(bt.results["strategy"].values())[0] profit = st.get("profit_total_pct") if profit is None: profit = float(st.get("profit_total") or 0) * 100 # 按 enter_tag 拆分(freqtrade 可能是 dict 或 list[dict]) by_tag: dict[str, dict[str, Any]] = {} trades = st.get("trades") or [] tag_stats = st.get("results_per_enter_tag") or {} items = [] if isinstance(tag_stats, dict): items = list(tag_stats.items()) elif isinstance(tag_stats, list): items = [ (x.get("key") or x.get("enter_tag") or x.get("tag") or "unknown", x) for x in tag_stats if isinstance(x, dict) ] if items: for tag, info in items: if not isinstance(info, dict): continue by_tag[str(tag)] = { "trades": int(info.get("trades") or info.get("total_trades") or 0), "profit_pct": float( info.get("profit_total_pct") if info.get("profit_total_pct") is not None else (float(info.get("profit_total") or 0) * 100) ), "pf": float(info.get("profit_factor") or 0), } elif trades: from collections import defaultdict agg: dict[str, list] = defaultdict(list) for t in trades: tag = t.get("enter_tag") or "unknown" agg[tag].append(float(t.get("profit_ratio") or 0)) for tag, profits in agg.items(): wins = [p for p in profits if p > 0] losses = [-p for p in profits if p <= 0] gross_win = sum(wins) gross_loss = sum(losses) pf = (gross_win / gross_loss) if gross_loss > 0 else (999.0 if gross_win > 0 else 0.0) by_tag[tag] = { "trades": len(profits), "profit_pct": sum(profits) * 100, "pf": float(pf), } return { "pair": pair, "timerange": timerange, "profit_pct": float(profit), "trades": int(st.get("total_trades") or 0), "dd_pct": float(st.get("max_drawdown_account") or 0) * 100, "pf": float(st.get("profit_factor") or 0), "winrate": float(st.get("winrate") or 0) * 100, "fee_used": config["fee"], "by_setup": by_tag, } def main() -> None: logging.getLogger("freqtrade").setLevel(logging.ERROR) install_offline_markets([c["pair"] for c in CANDIDATES]) results: dict[str, Any] = { "phase": "Phase3 Evidence Expansion", "strategy": STRAT, "rule": "frozen Spring-only; no LPS; no param change", "pairs": {}, "skipped": {}, "notes": [], } # BTC 历史缺口说明 btc_1h = DATADIR / "BTC_USDT_USDT-1h-futures.feather" if btc_1h.exists(): d0 = pd.read_feather(btc_1h)["date"].min() results["notes"].append( f"BTC local 1h starts {d0}; 2019-2022 not in datadir — download separately for deeper N" ) print("===== Phase3: prepare TF data =====", flush=True) run_list = [] for c in CANDIDATES: ok, msg = pair_ready(c["file"]) if ok: print(f" READY {c['pair']}: {msg}", flush=True) run_list.append(c) else: print(f" SKIP {c['pair']}: {msg}", flush=True) results["skipped"][c["pair"]] = msg print("\n===== Phase3: backtests (fee 5bps, then fee+slip) =====", flush=True) total_n = 0 spring_n = 0 utad_n = 0 for c in run_list: print(f"\n--- {c['pair']} ---", flush=True) base = run_bt(c["pair"], c["timerange"], fee=0.0005, extra=0.0) mid = run_bt(c["pair"], c["timerange"], fee=0.0005, extra=0.0005) block = {"base_fee": base, "net_mid": mid} results["pairs"][c["pair"]] = block total_n += base["trades"] for tag, info in base.get("by_setup", {}).items(): if "SPRING" in tag: spring_n += info["trades"] if "UTAD" in tag: utad_n += info["trades"] print( f" fee5bps profit={base['profit_pct']:.2f}% n={base['trades']} " f"dd={base['dd_pct']:.1f}% pf={base['pf']:.2f}", flush=True, ) print( f" net_mid profit={mid['profit_pct']:.2f}% n={mid['trades']} " f"pf={mid['pf']:.2f}", flush=True, ) print(f" by_setup {base.get('by_setup')}", flush=True) results["aggregate"] = { "pairs_tested": len(run_list), "total_trades": total_n, "spring_long_trades": spring_n, "utad_short_trades": utad_n, "target_n": 50, "target_met": total_n >= 50, "next": ( "目标 N>=50 已达成 — 再看跨品种 net PF 是否仍>1.3" if total_n >= 50 else "继续补历史数据(BTC 2019+)或更多品种 1h/4h/8h" ), } print("\n===== Aggregate =====") print(json.dumps(results["aggregate"], ensure_ascii=False, indent=2)) OUT.write_text(json.dumps(results, indent=2, ensure_ascii=False)) print(f"\nSaved {OUT}") if __name__ == "__main__": main()