#!/usr/bin/env python3 """ Market State Gate OOS — Baseline vs Gated(Spring 冻结) 比较: A) Wyckoff_BTC_V1_BASELINE — Spring always (within trend regime) B) Wyckoff_BTC_GATED — Spring only when causal state gate opens 阈值先验固定,不对 2023+ 做网格搜索。 指标: net PF / DD / n / worst year / max consecutive losses """ from __future__ import annotations import json import logging import sys from pathlib import Path from typing import Any import numpy as np 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 OUT = ROOT / "user_data/Chan/scripts/wyckoff_gate_oos_result.json" PAIR = "BTC/USDT:USDT" WINDOWS = [ ("define_pre2023", "20190901-20230101"), # 观察区(不调参) ("oos_2023plus", "20230101-"), ("full", "20190901-"), ("y2020", "20200101-20210101"), ("y2021", "20210101-20220101"), ("y2022", "20220101-20230101"), ("y2023", "20230101-20240101"), ("y2024", "20240101-20250101"), ("y2025", "20250101-20260101"), ] STRATS = [ { "name": "baseline", "strategy": "Wyckoff_BTC_V1_BASELINE", "config": ROOT / "user_data/Chan/config/Wyckoff_BTC_V1_BASELINE.json", }, { "name": "gated", "strategy": "Wyckoff_BTC_GATED", "config": ROOT / "user_data/Chan/config/Wyckoff_BTC_GATED.json", }, ] def _max_consecutive_losses(profits: list[float]) -> int: best = cur = 0 for p in profits: if p <= 0: cur += 1 best = max(best, cur) else: cur = 0 return best def _worst_year(trades: list[dict]) -> dict[str, Any]: by_y: dict[str, float] = {} for t in trades: ed = t.get("open_date") or t.get("entry_date") or "" y = str(ed)[:4] if len(y) < 4: continue by_y[y] = by_y.get(y, 0.0) + float(t.get("profit_ratio") or 0.0) * 100 if not by_y: return {"year": None, "sum_pct": 0.0} y, v = min(by_y.items(), key=lambda x: x[1]) return {"year": y, "sum_pct": round(v, 2)} def run_one(strategy: str, config_path: 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: del sys.modules[mod] config = Configuration.from_files([str(config_path)]) config.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, # 5bps fee + 5bps slip "exchange": { **config.get("exchange", {}), "name": "binance", "pair_whitelist": [PAIR], }, } ) bt = Backtesting(config) 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 trade_rows = [] profits = [] for t in LocalTrade.bt_trades: pr = float(t.close_profit or 0.0) profits.append(pr) trade_rows.append( { "open_date": t.open_date_utc.isoformat() if t.open_date_utc else "", "enter_tag": t.enter_tag or "", "profit_ratio": pr, } ) return { "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, "max_consec_loss": _max_consecutive_losses(profits), "worst_year": _worst_year(trade_rows), } def main() -> None: logging.getLogger("freqtrade").setLevel(logging.ERROR) install_offline_markets([PAIR]) results: dict[str, Any] = { "pair": PAIR, "fee_model": "fee 5bps + slip 5bps", "gate": { "version": "v1.1_state_set", "spring": "market_state ∈ {accumulation, markup}", "utad": "market_state ∈ {distribution, markdown}", "note": "Causal 8h EMA/slope rules (= attribution labels). Scores kept for observability. Not grid-searched on 2023+.", "v1_score_threshold": "FAILED OOS (destroyed 2023+ PF 1.45→0.67); archived as too misaligned", }, "windows": {}, "verdict": {}, } print("===== Market State Gate OOS (BTC) =====", flush=True) for wname, tr in WINDOWS: print(f"\n--- {wname} {tr} ---", flush=True) block = {} for s in STRATS: r = run_one(s["strategy"], s["config"], tr) block[s["name"]] = r print( f" {s['name']:<9} profit={r['profit_pct']:>7.2f}% n={r['trades']:<3} " f"dd={r['dd_pct']:.1f}% pf={r['pf']:.2f} " f"mcl={r['max_consec_loss']} worst={r['worst_year']}", flush=True, ) # delta gated - baseline b, g = block["baseline"], block["gated"] block["delta_gated_minus_baseline"] = { "pf": round(g["pf"] - b["pf"], 3), "dd_pct": round(g["dd_pct"] - b["dd_pct"], 3), "trades": g["trades"] - b["trades"], "profit_pct": round(g["profit_pct"] - b["profit_pct"], 3), "max_consec_loss": g["max_consec_loss"] - b["max_consec_loss"], } results["windows"][wname] = block oos_b = results["windows"]["oos_2023plus"]["baseline"] oos_g = results["windows"]["oos_2023plus"]["gated"] full_b = results["windows"]["full"]["baseline"] full_g = results["windows"]["full"]["gated"] pre_b = results["windows"]["define_pre2023"]["baseline"] pre_g = results["windows"]["define_pre2023"]["gated"] results["verdict"] = { "oos_gated_pf_ge_baseline": oos_g["pf"] >= oos_b["pf"] - 1e-9, "oos_gated_pf_ge_1_2": oos_g["pf"] >= 1.2, "oos_gated_dd_le_baseline": oos_g["dd_pct"] <= oos_b["dd_pct"] + 1e-9, "full_gated_pf_gt_baseline": full_g["pf"] > full_b["pf"], "pre2023_not_catastrophically_worse": pre_g["pf"] >= pre_b["pf"] - 0.15, "status": ( "PASS" if ( oos_g["pf"] >= 1.2 and oos_g["dd_pct"] <= oos_b["dd_pct"] + 0.5 and full_g["pf"] > full_b["pf"] ) else "PARTIAL" if (oos_g["pf"] >= oos_b["pf"] and full_g["pf"] >= full_b["pf"]) else "FAIL" ), "note": "Gate must not destroy 2023+ edge; should improve or stabilize full-sample robustness.", } print("\n===== Verdict =====") print(json.dumps(results["verdict"], indent=2, ensure_ascii=False)) OUT.write_text(json.dumps(results, indent=2, ensure_ascii=False)) print(f"Saved {OUT}") if __name__ == "__main__": main()