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