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