#!/usr/bin/env python3 """ Soft-score Gate — 窄实验(研究纪律) 1) 仅在 pre_2023 比较少数 Gate 形式并选定阈值 2) 锁定后评估 2023+ / full 3) 禁止全样本扫参;判定不要求超过 baseline PF 候选: - state_set - soft_sum: state_set & (accum+markup) >= q q ∈ {80,100,120,140} - soft_bad_cap: state_set & max(bad) <= q q ∈ {40,50,60} Fit 目标(pre_2023): n>=5 前提下优先更低 DD,其次更高 PF(非收益最大化) OOS 通过: - 2023+ PF >= 1.2 - full DD 明显低于 baseline(<= baseline_dd * 0.7 或绝对差 >= 5pp) - pre_2023 n >= 5(非极低样本偶然) - 标签不漂移:gated 入场中 state∈{accumulation,markup}|UTAD镜像 比例 >= 0.95 """ from __future__ import annotations import json import logging import re import sys from pathlib import Path from typing import Any, Optional 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 STRAT_PATH = ROOT / "user_data/Chan/strategies/Wyckoff_BTC_GATED.py" BASE_CFG = ROOT / "user_data/Chan/config/Wyckoff_BTC_V1_BASELINE.json" GATE_CFG = ROOT / "user_data/Chan/config/Wyckoff_BTC_GATED.json" OUT = ROOT / "user_data/Chan/scripts/wyckoff_soft_gate_oos_result.json" PAIR = "BTC/USDT:USDT" FIT_TR = "20190901-20230101" OOS_TR = "20230101-" FULL_TR = "20190901-" CANDIDATES: list[dict[str, Any]] = [ {"mode": "state_set", "q_sum": 100.0, "q_bad": 55.0}, {"mode": "soft_sum", "q_sum": 80.0, "q_bad": 55.0}, {"mode": "soft_sum", "q_sum": 100.0, "q_bad": 55.0}, {"mode": "soft_sum", "q_sum": 120.0, "q_bad": 55.0}, {"mode": "soft_sum", "q_sum": 140.0, "q_bad": 55.0}, {"mode": "soft_bad_cap", "q_sum": 100.0, "q_bad": 40.0}, {"mode": "soft_bad_cap", "q_sum": 100.0, "q_bad": 50.0}, {"mode": "soft_bad_cap", "q_sum": 100.0, "q_bad": 60.0}, ] def set_gate(mode: str, q_sum: float, q_bad: float) -> None: text = STRAT_PATH.read_text() text2, n1 = re.subn( r'^(\tgate_mode: str = )".*"', rf'\g<1>"{mode}"', text, count=1, flags=re.M, ) text2, n2 = re.subn( r'^(\tgate_q_sum: float = )[0-9.]+', rf"\g<1>{float(q_sum)}", text2, count=1, flags=re.M, ) text2, n3 = re.subn( r'^(\tgate_q_bad: float = )[0-9.]+', rf"\g<1>{float(q_bad)}", text2, count=1, flags=re.M, ) if min(n1, n2, n3) < 1: raise RuntimeError(f"failed patching gate attrs n=({n1},{n2},{n3})") STRAT_PATH.write_text(text2) pyc = STRAT_PATH.parent / "__pycache__" if pyc.is_dir(): for p in pyc.glob("Wyckoff_BTC_GATED*.pyc"): p.unlink(missing_ok=True) def run_bt(strategy: str, config: Path, timerange: str) -> dict[str, Any]: from freqtrade.configuration import Configuration from freqtrade.enums import RunMode from freqtrade.optimize.backtesting import Backtesting from freqtrade.persistence import LocalTrade 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 or "market_state" in mod: del sys.modules[mod] cfg = Configuration.from_files([str(config)]) cfg.update( { "strategy": strategy, "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": 0.0010, "exchange": { **cfg.get("exchange", {}), "name": "binance", "pair_whitelist": [PAIR], }, } ) bt = Backtesting(cfg) bt.start() st = bt.results["strategy"].get(strategy) 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 profits = [float(t.close_profit or 0.0) for t in LocalTrade.bt_trades] wins = [p for p in profits if p > 0] losses = [p for p in profits if p <= 0] avg_win = float(sum(wins) / len(wins)) if wins else 0.0 avg_loss = float(sum(losses) / len(losses)) if losses else 0.0 expectancy = float(sum(profits) / len(profits)) if profits else 0.0 # 标签漂移:用原生 8h 因果状态(不依赖 analyzed 缓存窗口) label_ok_rate = None try: import pandas as pd from engine.market_state import compute_market_state_8h h8 = pd.read_feather(ROOT / "user_data/data/binance/futures/BTC_USDT_USDT-8h-futures.feather") h8["date"] = pd.to_datetime(h8["date"], utc=True) h8 = compute_market_state_8h(h8).set_index("date").sort_index() ok = tot = 0 for t in LocalTrade.bt_trades: ed = pd.Timestamp(t.open_date_utc) if ed.tzinfo is None: ed = ed.tz_localize("UTC") idx = h8.index.get_indexer([ed], method="ffill")[0] if idx < 0: continue stt = str(h8.iloc[idx]["market_state"]) tag = t.enter_tag or "" if "SPRING" in tag: ok += int(stt in ("accumulation", "markup")) elif "UTAD" in tag: ok += int(stt in ("distribution", "markdown")) else: ok += 1 tot += 1 label_ok_rate = (ok / tot) if tot else None except Exception: label_ok_rate = None return { "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, "expectancy": expectancy, "avg_win": avg_win, "avg_loss": avg_loss, "label_ok_rate": label_ok_rate, } def fit_score(m: dict[str, Any]) -> tuple: """pre_2023 选择:n>=5;DD 越低越好;PF 次之;n 再之。""" n = m["trades"] if n < 5: return (0, 999.0, 0.0, 0) # invalid return (1, m["dd_pct"], -m["pf"], -n) def main() -> None: logging.getLogger("freqtrade").setLevel(logging.ERROR) install_offline_markets([PAIR]) orig = STRAT_PATH.read_text() results: dict[str, Any] = { "discipline": "fit on pre_2023 only; lock; test 2023+/full; no full-sample sweep", "baseline": {}, "candidates_fit_pre2023": [], "locked": None, "oos": {}, "verdict": {}, } try: print("===== Baseline (reference) =====", flush=True) for name, tr in [("pre_2023", FIT_TR), ("oos_2023plus", OOS_TR), ("full", FULL_TR)]: r = run_bt("Wyckoff_BTC_V1_BASELINE", BASE_CFG, tr) results["baseline"][name] = r print( f" baseline {name:<12} n={r['trades']:<3} pf={r['pf']:.2f} " f"dd={r['dd_pct']:.1f}% exp={r['expectancy']*100:.2f}%", flush=True, ) print("\n===== Fit soft gates on pre_2023 only =====", flush=True) fit_rows = [] for c in CANDIDATES: set_gate(c["mode"], c["q_sum"], c["q_bad"]) r = run_bt("Wyckoff_BTC_GATED", GATE_CFG, FIT_TR) row = {**c, **r, "valid_n": r["trades"] >= 5} fit_rows.append(row) print( f" {c['mode']:<12} q_sum={c['q_sum']:<5} q_bad={c['q_bad']:<5} " f"n={r['trades']:<3} pf={r['pf']:.2f} dd={r['dd_pct']:.1f}% " f"label_ok={r['label_ok_rate']}", flush=True, ) results["candidates_fit_pre2023"] = fit_rows valid = [x for x in fit_rows if x["valid_n"]] if not valid: raise RuntimeError("no candidate with n>=5 on pre_2023") locked = sorted(valid, key=fit_score)[0] results["locked"] = { "mode": locked["mode"], "q_sum": locked["q_sum"], "q_bad": locked["q_bad"], "pre_2023": { k: locked[k] for k in ( "trades", "pf", "dd_pct", "profit_pct", "expectancy", "avg_win", "avg_loss", "label_ok_rate", ) }, } print( f"\nLOCKED (pre_2023): mode={locked['mode']} q_sum={locked['q_sum']} " f"q_bad={locked['q_bad']} n={locked['trades']} pf={locked['pf']:.2f} " f"dd={locked['dd_pct']:.1f}%", flush=True, ) set_gate(locked["mode"], locked["q_sum"], locked["q_bad"]) print("\n===== Locked gate → OOS / full =====", flush=True) for name, tr in [("pre_2023", FIT_TR), ("oos_2023plus", OOS_TR), ("full", FULL_TR)]: r = run_bt("Wyckoff_BTC_GATED", GATE_CFG, tr) results["oos"][name] = r print( f" gated {name:<12} n={r['trades']:<3} pf={r['pf']:.2f} " f"dd={r['dd_pct']:.1f}% exp={r['expectancy']*100:.2f}% " f"avgW={r['avg_win']*100:.2f}% avgL={r['avg_loss']*100:.2f}% " f"label_ok={r['label_ok_rate']}", flush=True, ) b_full = results["baseline"]["full"] b_oos = results["baseline"]["oos_2023plus"] g_pre = results["oos"]["pre_2023"] g_oos = results["oos"]["oos_2023plus"] g_full = results["oos"]["full"] dd_ok = (g_full["dd_pct"] <= b_full["dd_pct"] * 0.7) or ( (b_full["dd_pct"] - g_full["dd_pct"]) >= 5.0 ) label_ok = (g_oos.get("label_ok_rate") is None) or (g_oos["label_ok_rate"] >= 0.95) results["verdict"] = { "oos_pf_ge_1_2": g_oos["pf"] >= 1.2, "full_dd_clearly_below_baseline": dd_ok, "pre2023_n_ge_5": g_pre["trades"] >= 5, "label_no_drift": label_ok, "oos_pf": g_oos["pf"], "oos_n": g_oos["trades"], "full_dd_gated": g_full["dd_pct"], "full_dd_baseline": b_full["dd_pct"], "baseline_oos_pf": b_oos["pf"], "status": ( "PASS" if ( g_oos["pf"] >= 1.2 and dd_ok and g_pre["trades"] >= 5 and label_ok ) else "FAIL" ), "note": "Success = domain control (PF floor + DD cut), not beating baseline PF.", } print("\n===== Verdict =====") print(json.dumps(results["verdict"], indent=2, ensure_ascii=False)) finally: # 恢复默认 state_set,避免污染 live 默认 STRAT_PATH.write_text(orig) print("\nRestored Wyckoff_BTC_GATED.py defaults", flush=True) OUT.write_text(json.dumps(results, indent=2, ensure_ascii=False)) print(f"Saved {OUT}") if __name__ == "__main__": main()