fix(web): 自动刷新保留 K 线视窗;威科夫与图表增量更新
自动刷新改用 tail update 与 scrollToPosition 恢复视窗,避免 setData 后跳到最右;拆分 chart_tv 模块并扩展 analyze/recent API。同步威科夫分析、pipeline 增量构建及相关策略与配置。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,221 @@
|
||||
#!/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()
|
||||
@@ -0,0 +1,240 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Gate v1.1 冻结前小范围稳健性确认(不改 Spring / 不调 soft-score)
|
||||
|
||||
在 Baseline SPRING_LONG 全集上:
|
||||
- 按年份、era 切片
|
||||
- 看 blocked 是否仍主要来自 distribution
|
||||
- kept vs blocked 的 PF 关系是否稳定
|
||||
|
||||
range 只作观察桶,不改交易规则。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
sys.path.insert(0, str(ROOT / "user_data/Chan"))
|
||||
|
||||
from engine.market_state import compute_market_state_8h # noqa: E402
|
||||
from user_data.Chan.scripts.wyckoff_tf_grid import install_offline_markets # noqa: E402
|
||||
|
||||
AUDIT = ROOT / "user_data/Chan/scripts/wyckoff_negative_domain_audit_result.json"
|
||||
OUT = ROOT / "user_data/Chan/scripts/wyckoff_gate_robustness_slices_result.json"
|
||||
PAIR = "BTC/USDT:USDT"
|
||||
CFG = ROOT / "user_data/Chan/config/Wyckoff_BTC_V1_BASELINE.json"
|
||||
|
||||
|
||||
def _pf(ps: list[float]) -> float:
|
||||
wins = [p for p in ps if p > 0]
|
||||
losses = [-p for p in ps if p <= 0]
|
||||
gw, gl = sum(wins), sum(losses)
|
||||
if gl <= 0:
|
||||
return 999.0 if gw > 0 else 0.0
|
||||
return gw / gl
|
||||
|
||||
|
||||
def _stats(ps: list[float]) -> dict[str, Any]:
|
||||
if not ps:
|
||||
return {"n": 0, "pf": 0.0, "sum_pct": 0.0, "winrate": 0.0}
|
||||
return {
|
||||
"n": len(ps),
|
||||
"pf": round(_pf(ps), 3),
|
||||
"sum_pct": round(100.0 * float(np.sum(ps)), 2),
|
||||
"winrate": round(100.0 * sum(1 for p in ps if p > 0) / len(ps), 1),
|
||||
}
|
||||
|
||||
|
||||
def load_annotated_springs() -> list[dict[str, Any]]:
|
||||
"""复用 audit 逻辑,产出逐笔 annotated SPRING。"""
|
||||
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]
|
||||
|
||||
cfg = Configuration.from_files([str(CFG)])
|
||||
cfg.update(
|
||||
{
|
||||
"strategy": "Wyckoff_BTC_V1_BASELINE",
|
||||
"strategy_path": str(ROOT / "user_data/Chan/strategies"),
|
||||
"timerange": "20190901-",
|
||||
"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()
|
||||
|
||||
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()
|
||||
|
||||
rows = []
|
||||
for t in LocalTrade.bt_trades:
|
||||
if "SPRING" not in (t.enter_tag or ""):
|
||||
continue
|
||||
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
|
||||
st = h8.iloc[idx]
|
||||
state = str(st["market_state"])
|
||||
rows.append(
|
||||
{
|
||||
"entry_date": ed.isoformat(),
|
||||
"year": str(ed.year),
|
||||
"era": "2023plus" if ed >= pd.Timestamp("2023-01-01", tz="UTC") else "pre_2023",
|
||||
"market_state": state,
|
||||
"allow_spring": bool(st["allow_spring"]),
|
||||
"profit_ratio": float(t.close_profit or 0.0),
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def slice_report(rows: list[dict], key: str) -> dict[str, Any]:
|
||||
out: dict[str, Any] = {}
|
||||
groups: dict[str, list[dict]] = defaultdict(list)
|
||||
for r in rows:
|
||||
groups[str(r[key])].append(r)
|
||||
for k, rs in sorted(groups.items()):
|
||||
kept = [x for x in rs if x["allow_spring"]]
|
||||
blocked = [x for x in rs if not x["allow_spring"]]
|
||||
b_by_state: dict[str, list[float]] = defaultdict(list)
|
||||
for x in blocked:
|
||||
b_by_state[x["market_state"]].append(x["profit_ratio"])
|
||||
blocked_states = {s: _stats(ps) for s, ps in b_by_state.items()}
|
||||
dist_n = blocked_states.get("distribution", {}).get("n", 0)
|
||||
blocked_n = len(blocked)
|
||||
out[k] = {
|
||||
"n_total": len(rs),
|
||||
"kept": _stats([x["profit_ratio"] for x in kept]),
|
||||
"blocked": _stats([x["profit_ratio"] for x in blocked]),
|
||||
"blocked_by_state": blocked_states,
|
||||
"blocked_distribution_share": round(dist_n / blocked_n, 3) if blocked_n else None,
|
||||
"blocked_all_bad": (
|
||||
all(s in ("distribution", "markdown", "range") for s in blocked_states)
|
||||
if blocked_n
|
||||
else True
|
||||
),
|
||||
}
|
||||
return out
|
||||
|
||||
|
||||
def main() -> None:
|
||||
logging.getLogger("freqtrade").setLevel(logging.ERROR)
|
||||
install_offline_markets([PAIR])
|
||||
|
||||
print("===== Annotate SPRING_LONG =====", flush=True)
|
||||
rows = load_annotated_springs()
|
||||
print(f" n={len(rows)}", flush=True)
|
||||
|
||||
by_year = slice_report(rows, "year")
|
||||
by_era = slice_report(rows, "era")
|
||||
|
||||
# 稳定性:有 blocked 的切片里,distribution 是否为第一大来源
|
||||
dist_primary = []
|
||||
for label, block in {**{f"year:{k}": v for k, v in by_year.items()}, **{f"era:{k}": v for k, v in by_era.items()}}.items():
|
||||
bn = block["blocked"]["n"]
|
||||
if bn < 2:
|
||||
continue
|
||||
states = block["blocked_by_state"]
|
||||
top = max(states.items(), key=lambda x: x[1]["n"])[0] if states else None
|
||||
dist_primary.append(
|
||||
{
|
||||
"slice": label,
|
||||
"blocked_n": bn,
|
||||
"top_blocked_state": top,
|
||||
"distribution_share": block["blocked_distribution_share"],
|
||||
"blocked_pf": block["blocked"]["pf"],
|
||||
"kept_pf": block["kept"]["pf"],
|
||||
}
|
||||
)
|
||||
|
||||
n_slices = len(dist_primary)
|
||||
n_dist_top = sum(1 for x in dist_primary if x["top_blocked_state"] == "distribution")
|
||||
n_dist_ge_50 = sum(
|
||||
1 for x in dist_primary if (x["distribution_share"] or 0) >= 0.5
|
||||
)
|
||||
|
||||
result = {
|
||||
"n_spring": len(rows),
|
||||
"by_year": by_year,
|
||||
"by_era": by_era,
|
||||
"slice_summaries": dist_primary,
|
||||
"range_observation_only": {
|
||||
"note": "range 不作交易规则;仅观察 blocked 中的占比与 PF",
|
||||
"blocked_range_global": _stats(
|
||||
[r["profit_ratio"] for r in rows if (not r["allow_spring"] and r["market_state"] == "range")]
|
||||
),
|
||||
},
|
||||
"verdict": {
|
||||
"slices_with_blocked_ge_2": n_slices,
|
||||
"distribution_is_top_blocked_state": n_dist_top,
|
||||
"distribution_share_ge_50pct_slices": n_dist_ge_50,
|
||||
"distribution_attribution_stable": (
|
||||
n_slices > 0 and (n_dist_top / n_slices) >= 0.6
|
||||
),
|
||||
"status": (
|
||||
"PASS"
|
||||
if n_slices > 0 and (n_dist_top / n_slices) >= 0.6
|
||||
else "PARTIAL"
|
||||
if n_dist_ge_50 >= max(1, n_slices // 2)
|
||||
else "FAIL"
|
||||
),
|
||||
"note": "PASS = across year/era slices, blocked mass still led by distribution.",
|
||||
},
|
||||
}
|
||||
|
||||
print("\n===== By year (blocked focus) =====", flush=True)
|
||||
for y, b in by_year.items():
|
||||
print(
|
||||
f" {y}: total={b['n_total']} kept_pf={b['kept']['pf']} "
|
||||
f"blocked_n={b['blocked']['n']} blocked_pf={b['blocked']['pf']} "
|
||||
f"dist_share={b['blocked_distribution_share']} states={list(b['blocked_by_state'])}",
|
||||
flush=True,
|
||||
)
|
||||
print("\n===== By era =====", flush=True)
|
||||
for e, b in by_era.items():
|
||||
print(
|
||||
f" {e}: total={b['n_total']} kept_pf={b['kept']['pf']} "
|
||||
f"blocked_n={b['blocked']['n']} blocked_pf={b['blocked']['pf']} "
|
||||
f"dist_share={b['blocked_distribution_share']} states={list(b['blocked_by_state'])}",
|
||||
flush=True,
|
||||
)
|
||||
print("\n===== Verdict =====", flush=True)
|
||||
print(json.dumps(result["verdict"], indent=2, ensure_ascii=False))
|
||||
|
||||
OUT.write_text(json.dumps(result, indent=2, ensure_ascii=False))
|
||||
print(f"\nSaved {OUT}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,239 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Negative-domain audit
|
||||
|
||||
问题:Gate 拦掉的 Spring,是否集中死在 distribution | markdown | range(结构性错误),
|
||||
而不是偶然删掉赚钱样本?
|
||||
|
||||
方法(Spring 冻结,Gate=state_set):
|
||||
1) 跑 Baseline,取出全部 SPRING_LONG 成交
|
||||
2) 用因果 8h market_state 标注入场时状态
|
||||
3) 按 allow_spring 分成 kept vs blocked
|
||||
4) 比较各域 n / PF / winrate / sum%
|
||||
|
||||
判定:
|
||||
- blocked 主要落在 bad domains
|
||||
- blocked 整体 PF << kept(或明显更差)
|
||||
- kept 域仍以 accumulation|markup 为主
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
sys.path.insert(0, str(ROOT / "user_data/Chan"))
|
||||
|
||||
from engine.market_state import compute_market_state_8h # noqa: E402
|
||||
from user_data.Chan.scripts.wyckoff_tf_grid import install_offline_markets # noqa: E402
|
||||
|
||||
OUT = ROOT / "user_data/Chan/scripts/wyckoff_negative_domain_audit_result.json"
|
||||
PAIR = "BTC/USDT:USDT"
|
||||
CFG = ROOT / "user_data/Chan/config/Wyckoff_BTC_V1_BASELINE.json"
|
||||
GOOD = {"accumulation", "markup"}
|
||||
BAD = {"distribution", "markdown", "range"}
|
||||
|
||||
|
||||
def _pf(ps: list[float]) -> float:
|
||||
wins = [p for p in ps if p > 0]
|
||||
losses = [-p for p in ps if p <= 0]
|
||||
gw, gl = sum(wins), sum(losses)
|
||||
if gl <= 0:
|
||||
return 999.0 if gw > 0 else 0.0
|
||||
return gw / gl
|
||||
|
||||
|
||||
def _stats(ps: list[float]) -> dict[str, Any]:
|
||||
if not ps:
|
||||
return {"n": 0, "pf": 0.0, "winrate": 0.0, "sum_pct": 0.0, "avg_pct": 0.0}
|
||||
return {
|
||||
"n": len(ps),
|
||||
"pf": round(_pf(ps), 3),
|
||||
"winrate": round(100.0 * sum(1 for p in ps if p > 0) / len(ps), 1),
|
||||
"sum_pct": round(100.0 * float(np.sum(ps)), 2),
|
||||
"avg_pct": round(100.0 * float(np.mean(ps)), 2),
|
||||
}
|
||||
|
||||
|
||||
def run_baseline_spring_trades() -> list[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]
|
||||
|
||||
cfg = Configuration.from_files([str(CFG)])
|
||||
cfg.update(
|
||||
{
|
||||
"strategy": "Wyckoff_BTC_V1_BASELINE",
|
||||
"strategy_path": str(ROOT / "user_data/Chan/strategies"),
|
||||
"timerange": "20190901-",
|
||||
"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()
|
||||
rows = []
|
||||
for t in LocalTrade.bt_trades:
|
||||
tag = t.enter_tag or ""
|
||||
if "SPRING" not in tag:
|
||||
continue
|
||||
rows.append(
|
||||
{
|
||||
"entry_date": t.open_date_utc.isoformat() if t.open_date_utc else "",
|
||||
"exit_date": t.close_date_utc.isoformat() if t.close_date_utc else "",
|
||||
"enter_tag": tag,
|
||||
"profit_ratio": float(t.close_profit or 0.0),
|
||||
"era": (
|
||||
"2023plus"
|
||||
if t.open_date_utc and t.open_date_utc >= pd.Timestamp("2023-01-01", tz="UTC")
|
||||
else "pre_2023"
|
||||
),
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def annotate(trades: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
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()
|
||||
|
||||
out = []
|
||||
for t in trades:
|
||||
ed = pd.Timestamp(t["entry_date"])
|
||||
if ed.tzinfo is None:
|
||||
ed = ed.tz_localize("UTC")
|
||||
idx = h8.index.get_indexer([ed], method="ffill")[0]
|
||||
if idx < 0:
|
||||
continue
|
||||
row = h8.iloc[idx]
|
||||
state = str(row["market_state"])
|
||||
allowed = bool(row["allow_spring"])
|
||||
rec = {
|
||||
**t,
|
||||
"market_state": state,
|
||||
"allow_spring": allowed,
|
||||
"domain": "good" if state in GOOD else ("bad" if state in BAD else "other"),
|
||||
"accumulation_score": float(row["accumulation_score"]),
|
||||
"markup_score": float(row["markup_score"]),
|
||||
"distribution_score": float(row["distribution_score"]),
|
||||
"markdown_score": float(row["markdown_score"]),
|
||||
"range_score": float(row["range_score"]),
|
||||
}
|
||||
out.append(rec)
|
||||
return out
|
||||
|
||||
|
||||
def bucket(rows: list[dict], key: str) -> dict[str, Any]:
|
||||
g: dict[str, list[float]] = defaultdict(list)
|
||||
for r in rows:
|
||||
g[str(r[key])].append(float(r["profit_ratio"]))
|
||||
return {k: _stats(v) for k, v in sorted(g.items(), key=lambda x: -len(x[1]))}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
logging.getLogger("freqtrade").setLevel(logging.ERROR)
|
||||
install_offline_markets([PAIR])
|
||||
|
||||
print("===== Baseline SPRING_LONG trades =====", flush=True)
|
||||
raw = run_baseline_spring_trades()
|
||||
print(f" spring trades={len(raw)}", flush=True)
|
||||
rows = annotate(raw)
|
||||
kept = [r for r in rows if r["allow_spring"]]
|
||||
blocked = [r for r in rows if not r["allow_spring"]]
|
||||
|
||||
result: dict[str, Any] = {
|
||||
"pair": PAIR,
|
||||
"fee_model": "fee5bps+slip5bps",
|
||||
"n_spring_total": len(rows),
|
||||
"n_kept": len(kept),
|
||||
"n_blocked": len(blocked),
|
||||
"kept": {
|
||||
"overall": _stats([r["profit_ratio"] for r in kept]),
|
||||
"by_state": bucket(kept, "market_state"),
|
||||
"by_era": bucket(kept, "era"),
|
||||
},
|
||||
"blocked": {
|
||||
"overall": _stats([r["profit_ratio"] for r in blocked]),
|
||||
"by_state": bucket(blocked, "market_state"),
|
||||
"by_era": bucket(blocked, "era"),
|
||||
"by_domain": bucket(blocked, "domain"),
|
||||
},
|
||||
"blocked_share_by_state": {},
|
||||
"verdict": {},
|
||||
}
|
||||
|
||||
# blocked 状态占比
|
||||
if blocked:
|
||||
for st, stt in result["blocked"]["by_state"].items():
|
||||
result["blocked_share_by_state"][st] = round(stt["n"] / len(blocked), 3)
|
||||
|
||||
bad_n = sum(result["blocked"]["by_state"].get(s, {}).get("n", 0) for s in BAD)
|
||||
blocked_bad_share = (bad_n / len(blocked)) if blocked else 0.0
|
||||
kept_good_share = 0.0
|
||||
if kept:
|
||||
kg = sum(1 for r in kept if r["market_state"] in GOOD)
|
||||
kept_good_share = kg / len(kept)
|
||||
|
||||
bk = result["blocked"]["overall"]
|
||||
kp = result["kept"]["overall"]
|
||||
result["verdict"] = {
|
||||
"blocked_mostly_bad_domain": blocked_bad_share >= 0.8,
|
||||
"blocked_bad_share": round(blocked_bad_share, 3),
|
||||
"kept_mostly_good_domain": kept_good_share >= 0.95,
|
||||
"kept_good_share": round(kept_good_share, 3),
|
||||
"blocked_pf_worse_than_kept": bk["pf"] < kp["pf"],
|
||||
"blocked_pf": bk["pf"],
|
||||
"kept_pf": kp["pf"],
|
||||
"status": (
|
||||
"PASS"
|
||||
if (
|
||||
blocked_bad_share >= 0.8
|
||||
and kept_good_share >= 0.95
|
||||
and bk["pf"] < kp["pf"]
|
||||
)
|
||||
else "PARTIAL"
|
||||
if (blocked_bad_share >= 0.7 and bk["pf"] <= kp["pf"])
|
||||
else "FAIL"
|
||||
),
|
||||
"note": "PASS = Gate filters structural bad domains, not random sample deletion.",
|
||||
}
|
||||
|
||||
print("\n===== KEPT (allow_spring) =====", flush=True)
|
||||
print(json.dumps(result["kept"], indent=2, ensure_ascii=False))
|
||||
print("\n===== BLOCKED =====", flush=True)
|
||||
print(json.dumps(result["blocked"], indent=2, ensure_ascii=False))
|
||||
print("\n===== Verdict =====", flush=True)
|
||||
print(json.dumps(result["verdict"], indent=2, ensure_ascii=False))
|
||||
|
||||
OUT.write_text(json.dumps(result, indent=2, ensure_ascii=False))
|
||||
print(f"\nSaved {OUT}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,145 @@
|
||||
#!/usr/bin/env python3
|
||||
"""在最优周期 1h/4h/8h 上扫 ATR 与关键参数。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import itertools
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from user_data.Chan.scripts.wyckoff_tf_grid import ( # noqa: E402
|
||||
STRAT_PATH,
|
||||
install_offline_markets,
|
||||
patch_strategy,
|
||||
run_one,
|
||||
)
|
||||
|
||||
# 参数名 -> (正则匹配赋值行前缀, 候选值列表)
|
||||
PARAM_GRID = {
|
||||
"atr_sl_mult": (
|
||||
r'^(\tatr_sl_mult = DecimalParameter\([^\n]*default=)([0-9.]+)',
|
||||
[1.5, 2.0, 2.5, 3.0],
|
||||
),
|
||||
"vol_spike_mult": (
|
||||
r'^(\tvol_spike_mult = DecimalParameter\([^\n]*default=)([0-9.]+)',
|
||||
[1.2, 1.4, 1.8],
|
||||
),
|
||||
"spring_pierce_pct": (
|
||||
r'^(\tspring_pierce_pct = DecimalParameter\([^\n]*default=)([0-9.]+)',
|
||||
[0.002, 0.004, 0.008],
|
||||
),
|
||||
"range_lookback": (
|
||||
r'^(\trange_lookback = IntParameter\([^\n]*default=)([0-9]+)',
|
||||
[18, 24, 36],
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def set_defaults(text: str, values: dict[str, Any]) -> str:
|
||||
for key, (pat, _) in PARAM_GRID.items():
|
||||
val = values[key]
|
||||
text = re.sub(pat, rf"\g<1>{val}", text, count=1, flags=re.M)
|
||||
return text
|
||||
|
||||
|
||||
def main() -> None:
|
||||
logging.getLogger("freqtrade").setLevel(logging.ERROR)
|
||||
timerange = sys.argv[1] if len(sys.argv) > 1 else "20240101-"
|
||||
install_offline_markets()
|
||||
orig = STRAT_PATH.read_text()
|
||||
|
||||
keys = list(PARAM_GRID.keys())
|
||||
combos = list(itertools.product(*[PARAM_GRID[k][1] for k in keys]))
|
||||
# 全组合太多:改为坐标下降式 — 先基线,再逐参扫描
|
||||
base = {k: PARAM_GRID[k][1][len(PARAM_GRID[k][1]) // 2] for k in keys}
|
||||
# 确保与当前文件接近的中心点
|
||||
base.update(
|
||||
{
|
||||
"atr_sl_mult": 2.0,
|
||||
"vol_spike_mult": 1.4,
|
||||
"spring_pierce_pct": 0.004,
|
||||
"range_lookback": 24,
|
||||
}
|
||||
)
|
||||
|
||||
trials = [dict(base)]
|
||||
for k in keys:
|
||||
for v in PARAM_GRID[k][1]:
|
||||
if v == base[k]:
|
||||
continue
|
||||
t = dict(base)
|
||||
t[k] = v
|
||||
trials.append(t)
|
||||
|
||||
rows = []
|
||||
try:
|
||||
patch_strategy("1h", "4h", "8h")
|
||||
for i, vals in enumerate(trials):
|
||||
text = set_defaults(STRAT_PATH.read_text(), vals)
|
||||
STRAT_PATH.write_text(text)
|
||||
label = ",".join(f"{k}={vals[k]}" for k in keys)
|
||||
print(f"[{i+1}/{len(trials)}] {label}", flush=True)
|
||||
try:
|
||||
res = run_one("1h", timerange)
|
||||
res.update(vals)
|
||||
res["label"] = label
|
||||
res["ok"] = True
|
||||
except Exception as e:
|
||||
res = {"ok": False, "error": str(e), "label": label, **vals}
|
||||
rows.append(res)
|
||||
if res.get("ok"):
|
||||
print(
|
||||
f" -> profit={res['profit_pct']:.2f}% trades={res['trades']} "
|
||||
f"dd={res['dd_pct']:.2f}% pf={res['pf']:.2f}",
|
||||
flush=True,
|
||||
)
|
||||
else:
|
||||
print(f" FAILED {res.get('error')}", flush=True)
|
||||
finally:
|
||||
STRAT_PATH.write_text(orig)
|
||||
|
||||
ok = [r for r in rows if r.get("ok")]
|
||||
ok.sort(key=lambda r: (r["profit_pct"], r["pf"]), reverse=True)
|
||||
print("\n========== PARAM RANKING ==========")
|
||||
for r in ok[:10]:
|
||||
print(
|
||||
f"{r['profit_pct']:>7.2f}% pf={r['pf']:.2f} dd={r['dd_pct']:.1f}% "
|
||||
f"n={r['trades']:<3} {r['label']}"
|
||||
)
|
||||
out = ROOT / "user_data/Chan/scripts/wyckoff_param_grid_result.txt"
|
||||
out.write_text(json.dumps({"timerange": timerange, "rows": rows}, indent=2))
|
||||
print(f"\nSaved {out}")
|
||||
if ok:
|
||||
best = ok[0]
|
||||
print("\nBEST params:", {k: best[k] for k in keys})
|
||||
# 写回最优 default
|
||||
text = set_defaults(orig, {k: best[k] for k in keys})
|
||||
# 保持最优周期
|
||||
text2 = text
|
||||
text2 = re.sub(r'^(\ttimeframe = ).*$', r'\g<1>"1h"', text2, count=1, flags=re.M)
|
||||
text2 = re.sub(
|
||||
r'^(\tstructure_timeframe = ).*$', r'\g<1>"4h"', text2, count=1, flags=re.M
|
||||
)
|
||||
text2 = re.sub(
|
||||
r'^(\tbias_timeframe: Optional\[str\] = ).*$',
|
||||
r'\g<1>"8h"',
|
||||
text2,
|
||||
count=1,
|
||||
flags=re.M,
|
||||
)
|
||||
STRAT_PATH.write_text(text2)
|
||||
print("Wrote best defaults into Wyckoff_BTC.py")
|
||||
# 长周期验证
|
||||
print("\nValidate 20230101- ...", flush=True)
|
||||
res = run_one("1h", "20230101-")
|
||||
print(res)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,206 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Wyckoff Phase2 对比:同一数据 / 同一成本 / 同一 WFO / 同一 Regime
|
||||
|
||||
对比:
|
||||
- Wyckoff_BTC_V1_BASELINE (Spring, range off)
|
||||
- Wyckoff_BTC_LPS (LPS continuation, range off)
|
||||
|
||||
统一看 net PF(fee 计入)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
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
|
||||
|
||||
OUT = ROOT / "user_data/Chan/scripts/wyckoff_phase2_compare_result.json"
|
||||
|
||||
WFO = [
|
||||
("train", "20230101-20250101"),
|
||||
("validate", "20250101-20260101"),
|
||||
("test", "20260101-"),
|
||||
("full", "20230101-"),
|
||||
]
|
||||
|
||||
BRANCHES = [
|
||||
{
|
||||
"name": "Spring_V1",
|
||||
"strategy": "Wyckoff_BTC_V1_BASELINE",
|
||||
"config": ROOT / "user_data/Chan/config/Wyckoff_BTC_V1_BASELINE.json",
|
||||
"target": {"pf": 1.3, "dd": 10.0, "note": "Spring: PF>1.3 DD<10%"},
|
||||
},
|
||||
{
|
||||
"name": "LPS_V2",
|
||||
"strategy": "Wyckoff_BTC_LPS",
|
||||
"config": ROOT / "user_data/Chan/config/Wyckoff_BTC_LPS.json",
|
||||
"target": {"pf": 1.2, "dd": 15.0, "note": "LPS V2: 4h SOS→1h LPS; PF>1.2; ~5-15/yr"},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def run_bt(
|
||||
strategy: str,
|
||||
config_path: Path,
|
||||
timerange: str,
|
||||
*,
|
||||
fee: float = 0.0005,
|
||||
extra_cost: float = 0.0,
|
||||
regime: Optional[str] = None,
|
||||
) -> 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 strategy in mod or "Wyckoff_BTC" in mod:
|
||||
del sys.modules[mod]
|
||||
|
||||
# 可选:临时改 regime_mode(写文件)
|
||||
strat_path = ROOT / "user_data/Chan/strategies" / f"{strategy}.py"
|
||||
orig = None
|
||||
if regime is not None:
|
||||
import re
|
||||
orig = strat_path.read_text()
|
||||
text2, n = re.subn(
|
||||
r'^(\tregime_mode: str = )".*"',
|
||||
rf'\g<1>"{regime}"',
|
||||
orig,
|
||||
count=1,
|
||||
flags=re.M,
|
||||
)
|
||||
if n == 0:
|
||||
raise RuntimeError(f"regime_mode not found in {strategy}")
|
||||
strat_path.write_text(text2)
|
||||
pycache = strat_path.parent / "__pycache__"
|
||||
if pycache.is_dir():
|
||||
for p in pycache.glob(f"{strategy}*.pyc"):
|
||||
p.unlink(missing_ok=True)
|
||||
|
||||
try:
|
||||
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": fee + extra_cost,
|
||||
}
|
||||
)
|
||||
bt = Backtesting(config)
|
||||
loaded = getattr(bt.strategylist[0], "regime_mode", None)
|
||||
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
|
||||
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,
|
||||
"final": float(st.get("final_balance") or 0),
|
||||
"fee_used": config["fee"],
|
||||
"regime_loaded": loaded,
|
||||
}
|
||||
finally:
|
||||
if orig is not None:
|
||||
strat_path.write_text(orig)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
logging.getLogger("freqtrade").setLevel(logging.ERROR)
|
||||
install_offline_markets()
|
||||
results: dict[str, Any] = {"branches": {}}
|
||||
|
||||
for br in BRANCHES:
|
||||
name = br["name"]
|
||||
print(f"\n===== {name} ({br['strategy']}) =====", flush=True)
|
||||
block: dict[str, Any] = {"wfo": {}, "regimes": {}, "cost_stress": {}, "target": br["target"]}
|
||||
|
||||
print("--- WFO ---", flush=True)
|
||||
for wname, tr in WFO:
|
||||
r = run_bt(br["strategy"], br["config"], tr)
|
||||
block["wfo"][wname] = {"timerange": tr, **r}
|
||||
print(
|
||||
f" {wname:<8} profit={r['profit_pct']:>7.2f}% n={r['trades']:<3} "
|
||||
f"dd={r['dd_pct']:.1f}% pf={r['pf']:.2f} wr={r['winrate']:.1f}%",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
print("--- Regime ---", flush=True)
|
||||
for mode in ["trend", "bull", "bear", "range", "all"]:
|
||||
r = run_bt(br["strategy"], br["config"], "20230101-", regime=mode)
|
||||
block["regimes"][mode] = r
|
||||
print(
|
||||
f" {mode:<6} profit={r['profit_pct']:>7.2f}% n={r['trades']:<3} "
|
||||
f"pf={r['pf']:.2f} (loaded={r['regime_loaded']})",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
print("--- Cost (net PF) ---", flush=True)
|
||||
for label, fee, extra in [
|
||||
("fee_5bps", 0.0005, 0.0),
|
||||
("fee_5bps+slip_5bps", 0.0005, 0.0005),
|
||||
("fee_10bps+slip_10bps", 0.0010, 0.0010),
|
||||
]:
|
||||
r = run_bt(br["strategy"], br["config"], "20230101-", fee=fee, extra_cost=extra)
|
||||
block["cost_stress"][label] = r
|
||||
flag = "OK" if r["pf"] >= br["target"]["pf"] else ("WEAK" if r["pf"] >= 1.0 else "FAIL")
|
||||
print(
|
||||
f" {label:<22} profit={r['profit_pct']:>7.2f}% n={r['trades']:<3} "
|
||||
f"pf={r['pf']:.2f} [{flag}]",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
full = block["wfo"]["full"]
|
||||
mid = block["cost_stress"]["fee_5bps+slip_5bps"]
|
||||
years = 3.6 # ~2023→2026.6
|
||||
tpy = full["trades"] / years if years else 0
|
||||
block["verdict"] = {
|
||||
"full_pf": full["pf"],
|
||||
"full_dd": full["dd_pct"],
|
||||
"trades_per_year": tpy,
|
||||
"net_mid_pf": mid["pf"],
|
||||
"target_pf_ok": mid["pf"] >= br["target"]["pf"],
|
||||
"target_dd_ok": full["dd_pct"] <= br["target"]["dd"],
|
||||
}
|
||||
results["branches"][name] = block
|
||||
print(f"Verdict: {json.dumps(block['verdict'], ensure_ascii=False)}", flush=True)
|
||||
|
||||
# 组合粗估:独立回测不可简单相加;只报告各自频率目标
|
||||
s = results["branches"]["Spring_V1"]["verdict"]
|
||||
l = results["branches"]["LPS_V2"]["verdict"]
|
||||
results["portfolio_note"] = {
|
||||
"spring_tpy": s["trades_per_year"],
|
||||
"lps_tpy": l["trades_per_year"],
|
||||
"sum_tpy_approx": s["trades_per_year"] + l["trades_per_year"],
|
||||
"combined_target_tpy": "10-20",
|
||||
"warning": "频率可近似相加;PF/收益不可相加,需另做组合回测;Spring 冻结勿改",
|
||||
}
|
||||
print("\n===== Portfolio note =====")
|
||||
print(json.dumps(results["portfolio_note"], ensure_ascii=False, indent=2))
|
||||
|
||||
OUT.write_text(json.dumps(results, indent=2, ensure_ascii=False))
|
||||
print(f"\nSaved {OUT}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,113 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
LPS V2 单独 Phase2(不改 Spring、不合并组合)
|
||||
|
||||
同一 WFO / Regime / 成本模型。
|
||||
目标: net PF > 1.2;频率约 5-15/year。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from user_data.Chan.scripts.wyckoff_phase2_compare import ( # noqa: E402
|
||||
BRANCHES,
|
||||
WFO,
|
||||
install_offline_markets,
|
||||
run_bt,
|
||||
)
|
||||
|
||||
OUT = ROOT / "user_data/Chan/scripts/wyckoff_lps_v2_phase2_result.json"
|
||||
COMPARE = ROOT / "user_data/Chan/scripts/wyckoff_phase2_compare_result.json"
|
||||
|
||||
|
||||
def main() -> None:
|
||||
logging.getLogger("freqtrade").setLevel(logging.ERROR)
|
||||
install_offline_markets()
|
||||
br = next(b for b in BRANCHES if b["name"] == "LPS_V2")
|
||||
print(f"===== {br['name']} ({br['strategy']}) — LPS-only Phase2 =====", flush=True)
|
||||
block = {"version": "LPS_V2", "wfo": {}, "regimes": {}, "cost_stress": {}, "target": br["target"]}
|
||||
|
||||
print("--- WFO ---", flush=True)
|
||||
for wname, tr in WFO:
|
||||
r = run_bt(br["strategy"], br["config"], tr)
|
||||
block["wfo"][wname] = {"timerange": tr, **r}
|
||||
print(
|
||||
f" {wname:<8} profit={r['profit_pct']:>7.2f}% n={r['trades']:<3} "
|
||||
f"dd={r['dd_pct']:.1f}% pf={r['pf']:.2f} wr={r['winrate']:.1f}%",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
print("--- Regime ---", flush=True)
|
||||
for mode in ["trend", "bull", "bear", "range", "all"]:
|
||||
r = run_bt(br["strategy"], br["config"], "20230101-", regime=mode)
|
||||
block["regimes"][mode] = r
|
||||
print(
|
||||
f" {mode:<6} profit={r['profit_pct']:>7.2f}% n={r['trades']:<3} "
|
||||
f"pf={r['pf']:.2f} (loaded={r['regime_loaded']})",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
print("--- Cost (net PF) ---", flush=True)
|
||||
for label, fee, extra in [
|
||||
("fee_5bps", 0.0005, 0.0),
|
||||
("fee_5bps+slip_5bps", 0.0005, 0.0005),
|
||||
("fee_10bps+slip_10bps", 0.0010, 0.0010),
|
||||
]:
|
||||
r = run_bt(br["strategy"], br["config"], "20230101-", fee=fee, extra_cost=extra)
|
||||
block["cost_stress"][label] = r
|
||||
flag = "OK" if r["pf"] >= br["target"]["pf"] else ("WEAK" if r["pf"] >= 1.0 else "FAIL")
|
||||
print(
|
||||
f" {label:<22} profit={r['profit_pct']:>7.2f}% n={r['trades']:<3} "
|
||||
f"pf={r['pf']:.2f} [{flag}]",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
full = block["wfo"]["full"]
|
||||
mid = block["cost_stress"]["fee_5bps+slip_5bps"]
|
||||
tpy = full["trades"] / 3.6
|
||||
trend_pf = block["regimes"]["trend"]["pf"]
|
||||
range_pf = block["regimes"]["range"]["pf"]
|
||||
block["verdict"] = {
|
||||
"full_pf": full["pf"],
|
||||
"full_dd": full["dd_pct"],
|
||||
"trades_per_year": tpy,
|
||||
"net_mid_pf": mid["pf"],
|
||||
"target_pf_ok": mid["pf"] >= br["target"]["pf"],
|
||||
"target_dd_ok": full["dd_pct"] <= br["target"]["dd"],
|
||||
"freq_ok": 5.0 <= tpy <= 15.0,
|
||||
"regime_logic_ok": trend_pf >= range_pf, # 趋势应不差于横盘
|
||||
"status": "PASS" if (mid["pf"] >= br["target"]["pf"] and full["dd_pct"] <= br["target"]["dd"]) else "FAIL",
|
||||
"hypothesis": "4h native SOS → 1h LPS",
|
||||
}
|
||||
print("\n===== Verdict =====")
|
||||
print(json.dumps(block["verdict"], ensure_ascii=False, indent=2))
|
||||
|
||||
OUT.write_text(json.dumps(block, indent=2, ensure_ascii=False))
|
||||
# 合并进 compare 结果(保留 Spring,覆盖 LPS)
|
||||
if COMPARE.exists():
|
||||
prev = json.loads(COMPARE.read_text())
|
||||
else:
|
||||
prev = {"branches": {}}
|
||||
prev.setdefault("branches", {})["LPS_V2"] = block
|
||||
# 清理旧 LPS_V1 key 的活跃地位,保留作历史可手动看
|
||||
spring = prev["branches"].get("Spring_V1", {}).get("verdict", {})
|
||||
prev["system_status"] = {
|
||||
"spring": "BASELINE FROZEN / PASS + Limited Evidence",
|
||||
"lps": block["verdict"]["status"],
|
||||
"spring_tpy": spring.get("trades_per_year"),
|
||||
"lps_tpy": tpy,
|
||||
"next": "若 LPS PASS → 组合层;否则 Spring-only",
|
||||
}
|
||||
COMPARE.write_text(json.dumps(prev, indent=2, ensure_ascii=False))
|
||||
print(f"\nSaved {OUT}")
|
||||
print("system_status:", json.dumps(prev["system_status"], ensure_ascii=False))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,185 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Wyckoff Phase 2:鲁棒性验证(固定当前参数,不再扫参)
|
||||
|
||||
1) Walk-Forward:Train 2023-2024 / Validate 2025 / Test 2026
|
||||
2) 市场状态拆分:bull / bear / range(8h EMA200 语境)
|
||||
3) 成本压力:抬高手续费 + 滑点后是否仍 PF>1.3
|
||||
"""
|
||||
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 ( # noqa: E402
|
||||
CONFIG_PATH,
|
||||
STRAT_PATH,
|
||||
install_offline_markets,
|
||||
patch_strategy,
|
||||
)
|
||||
|
||||
OUT = ROOT / "user_data/Chan/scripts/wyckoff_phase2_result.json"
|
||||
|
||||
WFO = [
|
||||
("train", "20230101-20250101"),
|
||||
("validate", "20250101-20260101"),
|
||||
("test", "20260101-"),
|
||||
("full", "20230101-"),
|
||||
]
|
||||
|
||||
|
||||
def set_regime(mode: str) -> None:
|
||||
text = STRAT_PATH.read_text()
|
||||
text2, n = re.subn(
|
||||
r'^(\tregime_mode: str = )".*"',
|
||||
rf'\g<1>"{mode}"',
|
||||
text,
|
||||
count=1,
|
||||
flags=re.M,
|
||||
)
|
||||
if n == 0:
|
||||
raise RuntimeError("regime_mode not found in strategy")
|
||||
STRAT_PATH.write_text(text2)
|
||||
# 清掉 bytecode,避免连续切换时读到旧 class 属性
|
||||
pycache = STRAT_PATH.parent / "__pycache__"
|
||||
if pycache.is_dir():
|
||||
for p in pycache.glob("Wyckoff_BTC*.pyc"):
|
||||
p.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def run_bt(
|
||||
timerange: str,
|
||||
*,
|
||||
fee: Optional[float] = None,
|
||||
extra_cost: float = 0.0,
|
||||
regime: Optional[str] = None,
|
||||
) -> 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
|
||||
|
||||
if regime is not None:
|
||||
set_regime(regime)
|
||||
|
||||
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": "Wyckoff_BTC",
|
||||
"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,
|
||||
}
|
||||
)
|
||||
base_fee = 0.0005 if fee is None else fee
|
||||
config["fee"] = base_fee + extra_cost
|
||||
|
||||
bt = Backtesting(config)
|
||||
loaded_regime = getattr(bt.strategylist[0], "regime_mode", None)
|
||||
bt.start()
|
||||
st = bt.results["strategy"].get("Wyckoff_BTC") 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
|
||||
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,
|
||||
"final": float(st.get("final_balance") or 0),
|
||||
"fee_used": config["fee"],
|
||||
"regime_loaded": loaded_regime,
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
logging.getLogger("freqtrade").setLevel(logging.ERROR)
|
||||
install_offline_markets()
|
||||
orig = STRAT_PATH.read_text()
|
||||
results: dict[str, Any] = {"wfo": {}, "regimes": {}, "cost_stress": {}}
|
||||
|
||||
try:
|
||||
patch_strategy("1h", "4h", "8h")
|
||||
set_regime("all")
|
||||
|
||||
print("===== 1) Walk-Forward (fixed params, no re-opt) =====")
|
||||
for name, tr in WFO:
|
||||
r = run_bt(tr)
|
||||
results["wfo"][name] = {"timerange": tr, **r}
|
||||
print(
|
||||
f" {name:<8} {tr:<22} profit={r['profit_pct']:>7.2f}% "
|
||||
f"n={r['trades']:<3} dd={r['dd_pct']:.1f}% pf={r['pf']:.2f} wr={r['winrate']:.1f}%",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
print("\n===== 2) Regime split (20230101-) =====")
|
||||
for mode in ["all", "bull", "bear", "range"]:
|
||||
r = run_bt("20230101-", regime=mode)
|
||||
results["regimes"][mode] = r
|
||||
print(
|
||||
f" {mode:<6} profit={r['profit_pct']:>7.2f}% n={r['trades']:<3} "
|
||||
f"dd={r['dd_pct']:.1f}% pf={r['pf']:.2f} wr={r['winrate']:.1f}% "
|
||||
f"(loaded={r.get('regime_loaded')})",
|
||||
flush=True,
|
||||
)
|
||||
set_regime("all")
|
||||
|
||||
print("\n===== 3) Cost stress (20230101-) =====")
|
||||
for label, fee, extra in [
|
||||
("fee_5bps", 0.0005, 0.0),
|
||||
("fee_10bps", 0.0010, 0.0),
|
||||
("fee_5bps+slip_5bps", 0.0005, 0.0005),
|
||||
("fee_10bps+slip_10bps", 0.0010, 0.0010),
|
||||
]:
|
||||
r = run_bt("20230101-", fee=fee, extra_cost=extra)
|
||||
results["cost_stress"][label] = r
|
||||
flag = "OK" if r["pf"] >= 1.3 else ("WEAK" if r["pf"] >= 1.0 else "FAIL")
|
||||
print(
|
||||
f" {label:<22} profit={r['profit_pct']:>7.2f}% n={r['trades']:<3} "
|
||||
f"pf={r['pf']:.2f} [{flag}]",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
wfo = results["wfo"]
|
||||
results["verdict"] = {
|
||||
"validate_profit_ok": wfo["validate"]["profit_pct"] > 0,
|
||||
"validate_pf_ge_1": wfo["validate"]["pf"] >= 1.0,
|
||||
"test_pf_ge_1": wfo["test"]["pf"] >= 1.0,
|
||||
"cost_mid_pf_ge_1_3": results["cost_stress"]["fee_5bps+slip_5bps"]["pf"] >= 1.3,
|
||||
"next": [
|
||||
"若 validate/test 稳定 → paper / 小资金",
|
||||
"若仅 train 好 → 参数过拟合,冻结开发",
|
||||
"可并行加 SOS/LPS 趋势跟随以提高频率",
|
||||
],
|
||||
}
|
||||
print("\n===== Verdict =====")
|
||||
print(json.dumps(results["verdict"], ensure_ascii=False, indent=2))
|
||||
finally:
|
||||
STRAT_PATH.write_text(orig)
|
||||
print("\nRestored strategy file", flush=True)
|
||||
|
||||
OUT.write_text(json.dumps(results, indent=2, ensure_ascii=False))
|
||||
print(f"Saved {OUT}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,261 @@
|
||||
#!/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()
|
||||
@@ -0,0 +1,382 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Regime Attribution Study — 策略完全冻结
|
||||
|
||||
问题:为什么 Spring 在 2023+ BTC 有效,全历史 / 多品种不稳健?
|
||||
方法:逐笔交易打市场状态标签,按桶看 net PF(不改任何入场逻辑)
|
||||
|
||||
输出:
|
||||
- scripts/wyckoff_regime_attribution_trades.jsonl 逐笔
|
||||
- scripts/wyckoff_regime_attribution_result.json 汇总
|
||||
- research/VALIDITY_BOUNDARY.md 适用域草案
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import talib.abstract as ta
|
||||
|
||||
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 = "Wyckoff_BTC_V1_BASELINE"
|
||||
CONFIG = ROOT / "user_data/Chan/config/Wyckoff_BTC_V1_BASELINE.json"
|
||||
DATADIR = ROOT / "user_data/data/binance/futures"
|
||||
OUT_JSON = ROOT / "user_data/Chan/scripts/wyckoff_regime_attribution_result.json"
|
||||
OUT_TRADES = ROOT / "user_data/Chan/scripts/wyckoff_regime_attribution_trades.jsonl"
|
||||
OUT_BOUNDARY = ROOT / "user_data/Chan/research/VALIDITY_BOUNDARY.md"
|
||||
STATUS = ROOT / "user_data/Chan/research/SYSTEM_STATUS.md"
|
||||
|
||||
PAIR = "BTC/USDT:USDT"
|
||||
TIMERANGE = "20190901-"
|
||||
FEE = 0.0005
|
||||
SLIP = 0.0005 # 评价用 net
|
||||
|
||||
|
||||
def _pf(profits: list[float]) -> float:
|
||||
wins = [p for p in profits if p > 0]
|
||||
losses = [-p for p in profits if p <= 0]
|
||||
gw, gl = sum(wins), sum(losses)
|
||||
if gl <= 0:
|
||||
return 999.0 if gw > 0 else 0.0
|
||||
return gw / gl
|
||||
|
||||
|
||||
def _bucket_stats(rows: list[dict], key: str) -> dict[str, Any]:
|
||||
groups: dict[str, list[float]] = defaultdict(list)
|
||||
for r in rows:
|
||||
groups[str(r.get(key, "na"))].append(float(r["profit_ratio"]))
|
||||
out = {}
|
||||
for k, ps in sorted(groups.items(), key=lambda x: -len(x[1])):
|
||||
out[k] = {
|
||||
"n": len(ps),
|
||||
"winrate": 100.0 * sum(1 for p in ps if p > 0) / len(ps),
|
||||
"avg_pct": 100.0 * float(np.mean(ps)),
|
||||
"sum_pct": 100.0 * float(np.sum(ps)),
|
||||
"pf": round(_pf(ps), 3),
|
||||
}
|
||||
return out
|
||||
|
||||
|
||||
def build_feature_frames(pair_file: str = "BTC_USDT_USDT") -> tuple[pd.DataFrame, pd.DataFrame]:
|
||||
"""1h ATR percentile + 8h structure features(与策略无关的分析层)。"""
|
||||
h1 = pd.read_feather(DATADIR / f"{pair_file}-1h-futures.feather")
|
||||
h1["date"] = pd.to_datetime(h1["date"], utc=True)
|
||||
h1 = h1.sort_values("date").reset_index(drop=True)
|
||||
h1["atr"] = ta.ATR(h1, timeperiod=14)
|
||||
# 滚动 90 天 ≈ 2160 根 1h 的 ATR 分位
|
||||
win = 2160
|
||||
h1["atr_percentile"] = h1["atr"].rolling(win, min_periods=200).apply(
|
||||
lambda x: pd.Series(x).rank(pct=True).iloc[-1], raw=False
|
||||
)
|
||||
|
||||
h8 = pd.read_feather(DATADIR / f"{pair_file}-8h-futures.feather")
|
||||
h8["date"] = pd.to_datetime(h8["date"], utc=True)
|
||||
h8 = h8.sort_values("date").reset_index(drop=True)
|
||||
h8["ema50"] = ta.EMA(h8, timeperiod=50)
|
||||
h8["ema200"] = ta.EMA(h8, timeperiod=200)
|
||||
h8["adx"] = ta.ADX(h8, timeperiod=14)
|
||||
h8["ema_slope"] = (h8["ema50"] - h8["ema50"].shift(6)) / h8["ema50"].shift(6)
|
||||
h8["dist_ema200"] = (h8["close"] - h8["ema200"]) / h8["ema200"]
|
||||
h8["bull"] = (h8["close"] > h8["ema200"]) & (h8["ema50"] > h8["ema200"])
|
||||
h8["bear"] = (h8["close"] < h8["ema200"]) & (h8["ema50"] < h8["ema200"])
|
||||
|
||||
# Cycle(粗粒度威科夫语境,非策略信号)
|
||||
slope = h8["ema_slope"]
|
||||
cycle = np.full(len(h8), "transition", dtype=object)
|
||||
cycle[(h8["bear"]) & (slope < -0.01)] = "markdown"
|
||||
cycle[(h8["bear"]) & (slope >= -0.01)] = "accumulation_like"
|
||||
cycle[(h8["bull"]) & (slope > 0.005)] = "markup"
|
||||
cycle[(h8["bull"]) & (slope <= 0.005)] = "distribution_like"
|
||||
h8["btc_cycle"] = cycle
|
||||
|
||||
# trend strength
|
||||
ts = np.full(len(h8), "weak", dtype=object)
|
||||
ts[(h8["adx"] >= 25) & (h8["adx"] < 35)] = "moderate"
|
||||
ts[h8["adx"] >= 35] = "strong"
|
||||
h8["trend_strength"] = ts
|
||||
|
||||
regime = np.full(len(h8), "range", dtype=object)
|
||||
regime[h8["bull"].fillna(False)] = "bull"
|
||||
regime[h8["bear"].fillna(False)] = "bear"
|
||||
h8["market_regime"] = regime
|
||||
return h1, h8
|
||||
|
||||
|
||||
def atr_bucket(p: float) -> str:
|
||||
if pd.isna(p):
|
||||
return "atr_unknown"
|
||||
if p < 0.33:
|
||||
return "atr_low"
|
||||
if p < 0.66:
|
||||
return "atr_mid"
|
||||
return "atr_high"
|
||||
|
||||
|
||||
def slope_bucket(s: float) -> str:
|
||||
if pd.isna(s):
|
||||
return "slope_unknown"
|
||||
if s > 0.01:
|
||||
return "slope_up_strong"
|
||||
if s > 0:
|
||||
return "slope_up_mild"
|
||||
if s > -0.01:
|
||||
return "slope_flat_down"
|
||||
return "slope_down_strong"
|
||||
|
||||
|
||||
def run_backtest_trades() -> list[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)])
|
||||
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 + SLIP,
|
||||
"exchange": {
|
||||
**config.get("exchange", {}),
|
||||
"name": "binance",
|
||||
"pair_whitelist": [PAIR],
|
||||
},
|
||||
}
|
||||
)
|
||||
bt = Backtesting(config)
|
||||
bt.start()
|
||||
|
||||
rows = []
|
||||
for t in LocalTrade.bt_trades:
|
||||
rows.append(
|
||||
{
|
||||
"pair": t.pair,
|
||||
"enter_tag": t.enter_tag or "",
|
||||
"is_short": bool(t.is_short),
|
||||
"entry_date": t.open_date_utc.isoformat(),
|
||||
"exit_date": t.close_date_utc.isoformat() if t.close_date_utc else None,
|
||||
"profit_ratio": float(t.close_profit or 0.0),
|
||||
"exit_reason": t.exit_reason or "",
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def attribute(trades: list[dict], h1: pd.DataFrame, h8: pd.DataFrame) -> list[dict]:
|
||||
h1 = h1.set_index("date").sort_index()
|
||||
h8 = h8.set_index("date").sort_index()
|
||||
out = []
|
||||
for t in trades:
|
||||
ed = pd.Timestamp(t["entry_date"])
|
||||
if ed.tzinfo is None:
|
||||
ed = ed.tz_localize("UTC")
|
||||
# asof merge:入场前最后一根已收盘特征
|
||||
i1 = h1.index.get_indexer([ed], method="ffill")[0]
|
||||
i8 = h8.index.get_indexer([ed], method="ffill")[0]
|
||||
if i1 < 0 or i8 < 0:
|
||||
continue
|
||||
r1 = h1.iloc[i1]
|
||||
r8 = h8.iloc[i8]
|
||||
ap = float(r1["atr_percentile"]) if pd.notna(r1["atr_percentile"]) else float("nan")
|
||||
slope = float(r8["ema_slope"]) if pd.notna(r8["ema_slope"]) else float("nan")
|
||||
adx = float(r8["adx"]) if pd.notna(r8["adx"]) else float("nan")
|
||||
era = "2023plus" if ed >= pd.Timestamp("2023-01-01", tz="UTC") else "pre_2023"
|
||||
rec = {
|
||||
**t,
|
||||
"market_regime": str(r8["market_regime"]),
|
||||
"8h_adx": round(adx, 2) if not np.isnan(adx) else None,
|
||||
"8h_ema_slope": round(slope, 5) if not np.isnan(slope) else None,
|
||||
"atr_percentile": round(ap, 3) if not np.isnan(ap) else None,
|
||||
"btc_cycle": str(r8["btc_cycle"]),
|
||||
"trend_strength": str(r8["trend_strength"]),
|
||||
"dist_ema200": round(float(r8["dist_ema200"]), 4) if pd.notna(r8["dist_ema200"]) else None,
|
||||
"atr_bucket": atr_bucket(ap),
|
||||
"slope_bucket": slope_bucket(slope),
|
||||
"era": era,
|
||||
"setup": t["enter_tag"] or ("UTAD_SHORT" if t["is_short"] else "SPRING_LONG"),
|
||||
"result": "win" if t["profit_ratio"] > 0 else "loss",
|
||||
}
|
||||
out.append(rec)
|
||||
return out
|
||||
|
||||
|
||||
def write_boundary(summary: dict[str, Any]) -> None:
|
||||
# 从桶结果提炼适用域草案(描述性,非自动交易规则)
|
||||
atr = summary["by_atr_bucket"]
|
||||
cycle = summary["by_btc_cycle"]
|
||||
era = summary["by_era"]
|
||||
ts = summary["by_trend_strength"]
|
||||
|
||||
def best_worst(d: dict) -> tuple[str, str]:
|
||||
items = [(k, v) for k, v in d.items() if v["n"] >= 5]
|
||||
if not items:
|
||||
return "n/a", "n/a"
|
||||
best = max(items, key=lambda x: x[1]["pf"])
|
||||
worst = min(items, key=lambda x: x[1]["pf"])
|
||||
return f"{best[0]} (PF {best[1]['pf']}, n={best[1]['n']})", f"{worst[0]} (PF {worst[1]['pf']}, n={worst[1]['n']})"
|
||||
|
||||
ab, aw = best_worst(atr)
|
||||
cb, cw = best_worst(cycle)
|
||||
tb, tw = best_worst(ts)
|
||||
|
||||
text = f"""# Validity Boundary — Spring Baseline (draft)
|
||||
|
||||
> 策略规则冻结。本文仅来自 Regime Attribution,**不是**新入场条件。
|
||||
|
||||
## Evidence snapshot
|
||||
|
||||
| Era | n | PF (net) | sum%% |
|
||||
|-----|---|----------|-------|
|
||||
| pre_2023 | {era.get('pre_2023', {}).get('n', 0)} | {era.get('pre_2023', {}).get('pf', 0)} | {era.get('pre_2023', {}).get('sum_pct', 0):.1f} |
|
||||
| 2023plus | {era.get('2023plus', {}).get('n', 0)} | {era.get('2023plus', {}).get('pf', 0)} | {era.get('2023plus', {}).get('sum_pct', 0):.1f} |
|
||||
|
||||
## Observed favorable (descriptive)
|
||||
|
||||
- ATR bucket best: **{ab}**
|
||||
- Cycle best: **{cb}**
|
||||
- Trend strength best: **{tb}**
|
||||
|
||||
## Observed unfavorable (descriptive)
|
||||
|
||||
- ATR bucket worst: **{aw}**
|
||||
- Cycle worst: **{cw}**
|
||||
- Trend strength worst: **{tw}**
|
||||
|
||||
## Draft Validity Boundary
|
||||
|
||||
```
|
||||
Spring Strategy (BTC)
|
||||
适用(研究假设,待 Decision Engine 验证):
|
||||
✓ BTC(非默认跨资产)
|
||||
✓ 2023+ 类「明确资金方向 / Markup 启动」环境
|
||||
✓ 高/中波动(ATR rising / mid-high percentile)若数据支持
|
||||
✓ Accumulation_like → Markup 过渡语境
|
||||
|
||||
不适用(当前证据):
|
||||
✗ 默认全历史无条件交易
|
||||
✗ 横盘 / range regime
|
||||
✗ 跨资产默认开启(ETH/SOL Phase3 未过)
|
||||
✗ 熊市 Markdown 快速崩跌阶段(若桶显示 PF 差)
|
||||
```
|
||||
|
||||
## Next for Decision Engine
|
||||
|
||||
Market State 先判定「是否落在适用域」→ 再允许 SPRING_LONG / UTAD_SHORT 信号。
|
||||
**禁止**把本文件桶标签直接写回 Baseline 参数扫参。
|
||||
"""
|
||||
OUT_BOUNDARY.write_text(text)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
logging.getLogger("freqtrade").setLevel(logging.ERROR)
|
||||
install_offline_markets([PAIR])
|
||||
|
||||
print("===== 1) Frozen baseline backtest (BTC, net cost) =====", flush=True)
|
||||
raw = run_backtest_trades()
|
||||
print(f" trades={len(raw)}", flush=True)
|
||||
|
||||
print("===== 2) Build regime features =====", flush=True)
|
||||
h1, h8 = build_feature_frames()
|
||||
rows = attribute(raw, h1, h8)
|
||||
print(f" attributed={len(rows)}", flush=True)
|
||||
|
||||
with OUT_TRADES.open("w") as f:
|
||||
for r in rows:
|
||||
f.write(json.dumps(r, ensure_ascii=False) + "\n")
|
||||
|
||||
summary: dict[str, Any] = {
|
||||
"pair": PAIR,
|
||||
"timerange": TIMERANGE,
|
||||
"fee_model": f"fee {FEE}+slip {SLIP}",
|
||||
"n": len(rows),
|
||||
"overall_pf": round(_pf([r["profit_ratio"] for r in rows]), 3),
|
||||
"by_era": _bucket_stats(rows, "era"),
|
||||
"by_setup": _bucket_stats(rows, "setup"),
|
||||
"by_market_regime": _bucket_stats(rows, "market_regime"),
|
||||
"by_atr_bucket": _bucket_stats(rows, "atr_bucket"),
|
||||
"by_trend_strength": _bucket_stats(rows, "trend_strength"),
|
||||
"by_slope_bucket": _bucket_stats(rows, "slope_bucket"),
|
||||
"by_btc_cycle": _bucket_stats(rows, "btc_cycle"),
|
||||
"by_era_x_cycle": {},
|
||||
"by_era_x_atr": {},
|
||||
"interpretation": [],
|
||||
}
|
||||
|
||||
# 交叉:era × cycle / atr
|
||||
for era in ("pre_2023", "2023plus"):
|
||||
sub = [r for r in rows if r["era"] == era]
|
||||
summary["by_era_x_cycle"][era] = _bucket_stats(sub, "btc_cycle")
|
||||
summary["by_era_x_atr"][era] = _bucket_stats(sub, "atr_bucket")
|
||||
|
||||
# 自动写几条解释线索(非交易规则)
|
||||
era = summary["by_era"]
|
||||
if era.get("2023plus", {}).get("pf", 0) > era.get("pre_2023", {}).get("pf", 0):
|
||||
summary["interpretation"].append(
|
||||
"2023plus PF 显著高于 pre_2023 → 存在 regime/cycle 依赖,非随机噪声单一窗口。"
|
||||
)
|
||||
cyc = summary["by_btc_cycle"]
|
||||
if cyc:
|
||||
best_c = max(cyc.items(), key=lambda x: (x[1]["n"] >= 5, x[1]["pf"]))
|
||||
summary["interpretation"].append(
|
||||
f"全样本 cycle 最优桶(n≥5 优先): {best_c[0]} PF={best_c[1]['pf']} n={best_c[1]['n']}"
|
||||
)
|
||||
|
||||
print("\n===== 3) Attribution tables =====", flush=True)
|
||||
for name in (
|
||||
"by_era", "by_setup", "by_market_regime", "by_atr_bucket",
|
||||
"by_trend_strength", "by_slope_bucket", "by_btc_cycle",
|
||||
):
|
||||
print(f"\n-- {name} --")
|
||||
for k, v in summary[name].items():
|
||||
print(f" {k:<22} n={v['n']:<3} pf={v['pf']:<6} wr={v['winrate']:.0f}% sum={v['sum_pct']:.1f}%")
|
||||
|
||||
print("\n-- by_era_x_cycle --")
|
||||
print(json.dumps(summary["by_era_x_cycle"], indent=2, ensure_ascii=False))
|
||||
|
||||
write_boundary(summary)
|
||||
OUT_JSON.write_text(json.dumps(summary, indent=2, ensure_ascii=False))
|
||||
|
||||
# 更新 SYSTEM_STATUS
|
||||
if STATUS.exists():
|
||||
st = STATUS.read_text()
|
||||
marker = "## Frozen Baseline"
|
||||
block = (
|
||||
"**Status update (Regime Attribution):**\n"
|
||||
"Evidence: PASS (2023+ BTC) · Robustness: FAILED (multi-cycle) · "
|
||||
"Confidence: LOW-MEDIUM · Next: Decision Engine validity gate "
|
||||
f"(see `VALIDITY_BOUNDARY.md`, trades=`{OUT_TRADES.name}`).\n\n"
|
||||
)
|
||||
if "Status update (Regime Attribution)" not in st:
|
||||
st = st.replace(marker, block + marker)
|
||||
STATUS.write_text(st)
|
||||
|
||||
print(f"\nSaved {OUT_JSON}")
|
||||
print(f"Saved {OUT_TRADES}")
|
||||
print(f"Saved {OUT_BOUNDARY}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,305 @@
|
||||
#!/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()
|
||||
@@ -0,0 +1,226 @@
|
||||
#!/usr/bin/env python3
|
||||
"""离线网格:对比 Wyckoff 多周期组合(不依赖 Binance API)。"""
|
||||
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))
|
||||
|
||||
STRAT_PATH = ROOT / "user_data/Chan/strategies/Wyckoff_BTC.py"
|
||||
CONFIG_PATH = ROOT / "user_data/Chan/config/Wyckoff_BTC.json"
|
||||
|
||||
COMBOS = [
|
||||
("1h_4h_noBias", "1h", "4h", None),
|
||||
("1h_4h_8h", "1h", "4h", "8h"),
|
||||
("1h_8h_noBias", "1h", "8h", None),
|
||||
("30m_4h_8h", "30m", "4h", "8h"),
|
||||
("30m_4h_noBias", "30m", "4h", None),
|
||||
("15m_1h_4h", "15m", "1h", "4h"),
|
||||
("15m_4h_8h", "15m", "4h", "8h"),
|
||||
("4h_8h_noBias", "4h", "8h", None),
|
||||
]
|
||||
|
||||
|
||||
def stub_market(symbol: str = "BTC/USDT:USDT") -> dict[str, Any]:
|
||||
base = symbol.split("/")[0]
|
||||
return {
|
||||
"id": symbol,
|
||||
"symbol": symbol,
|
||||
"base": base,
|
||||
"quote": "USDT",
|
||||
"settle": "USDT",
|
||||
"baseId": base,
|
||||
"quoteId": "USDT",
|
||||
"settleId": "USDT",
|
||||
"type": "swap",
|
||||
"spot": False,
|
||||
"swap": True,
|
||||
"future": False,
|
||||
"option": False,
|
||||
"active": True,
|
||||
"contract": True,
|
||||
"linear": True,
|
||||
"inverse": False,
|
||||
"contractSize": 1.0,
|
||||
"precision": {"amount": 0.001, "price": 0.1},
|
||||
"limits": {
|
||||
"amount": {"min": 0.001, "max": 1000.0},
|
||||
"price": {"min": 0.1, "max": None},
|
||||
"cost": {"min": 5.0, "max": None},
|
||||
"leverage": {"min": 1.0, "max": 125.0},
|
||||
},
|
||||
"percentage": True,
|
||||
"taker": 0.0005,
|
||||
"maker": 0.0002,
|
||||
"info": {},
|
||||
}
|
||||
|
||||
|
||||
def install_offline_markets(pairs: Optional[list[str]] = None) -> None:
|
||||
import ccxt
|
||||
import freqtrade.exchange.exchange as exmod
|
||||
from freqtrade.util import dt_ts
|
||||
|
||||
if pairs is None:
|
||||
pairs = ["BTC/USDT:USDT"]
|
||||
markets = {p: stub_market(p) for p in pairs}
|
||||
tiers = {
|
||||
p: [
|
||||
{
|
||||
"minNotional": 0,
|
||||
"maxNotional": 1e12,
|
||||
"maintenanceMarginRate": 0.005,
|
||||
"maxLeverage": 125,
|
||||
"info": {},
|
||||
}
|
||||
]
|
||||
for p in pairs
|
||||
}
|
||||
|
||||
def fake_reload(self, force: bool = False, *, load_leverage_tiers: bool = True) -> None:
|
||||
self._markets = markets
|
||||
try:
|
||||
self._api.precisionMode = ccxt.TICK_SIZE
|
||||
self._api_async.precisionMode = ccxt.TICK_SIZE
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
self._api.set_markets(markets)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
self._api_async.set_markets(markets)
|
||||
except Exception:
|
||||
pass
|
||||
self._last_markets_refresh = dt_ts()
|
||||
self._leverage_tiers = tiers
|
||||
self._trading_fees = {}
|
||||
|
||||
exmod.Exchange.reload_markets = fake_reload # type: ignore
|
||||
exmod.Exchange.fills_leverage_tiers = lambda self: setattr(self, "_leverage_tiers", tiers) # type: ignore
|
||||
|
||||
|
||||
def patch_strategy(exec_tf: str, structure_tf: str, bias_tf: Optional[str]) -> None:
|
||||
text = STRAT_PATH.read_text()
|
||||
bias_repr = "None" if bias_tf is None else f'"{bias_tf}"'
|
||||
text = re.sub(r'^(\ttimeframe = ).*$', rf'\g<1>"{exec_tf}"', text, count=1, flags=re.M)
|
||||
text = re.sub(
|
||||
r'^(\tstructure_timeframe = ).*$', rf'\g<1>"{structure_tf}"', text, count=1, flags=re.M
|
||||
)
|
||||
text = re.sub(
|
||||
r'^(\tbias_timeframe: Optional\[str\] = ).*$',
|
||||
rf'\g<1>{bias_repr}',
|
||||
text,
|
||||
count=1,
|
||||
flags=re.M,
|
||||
)
|
||||
startup = 220 if exec_tf in ("1h", "4h", "8h") else 400
|
||||
text = re.sub(
|
||||
r'^(\tstartup_candle_count = ).*$', rf'\g<1>{startup}', text, count=1, flags=re.M
|
||||
)
|
||||
STRAT_PATH.write_text(text)
|
||||
|
||||
|
||||
def run_one(exec_tf: str, timerange: str) -> 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
|
||||
bt_output.show_backtest_result = lambda *a, **k: None # type: ignore
|
||||
|
||||
for mod in list(sys.modules):
|
||||
if "Wyckoff_BTC" in mod or mod.endswith("Wyckoff_BTC"):
|
||||
del sys.modules[mod]
|
||||
|
||||
config = Configuration.from_files([str(CONFIG_PATH)])
|
||||
config["strategy"] = "Wyckoff_BTC"
|
||||
config["strategy_path"] = str(ROOT / "user_data/Chan/strategies")
|
||||
config["timerange"] = timerange
|
||||
config["timeframe"] = exec_tf
|
||||
config["export"] = "none"
|
||||
config["runmode"] = RunMode.BACKTEST
|
||||
config["datadir"] = ROOT / "user_data/data/binance"
|
||||
config["user_data_dir"] = ROOT / "user_data"
|
||||
config["enable_protections"] = False
|
||||
|
||||
bt = Backtesting(config)
|
||||
bt.start()
|
||||
stats = bt.results
|
||||
strat_stats = stats["strategy"].get("Wyckoff_BTC") or list(stats["strategy"].values())[0]
|
||||
trades = int(strat_stats.get("total_trades") or 0)
|
||||
profit_pct = strat_stats.get("profit_total_pct")
|
||||
if profit_pct is None:
|
||||
profit_pct = float(strat_stats.get("profit_total") or 0) * 100
|
||||
dd = float(strat_stats.get("max_drawdown_account") or 0) * 100
|
||||
wr = float(strat_stats.get("winrate") or 0) * 100
|
||||
return {
|
||||
"ok": True,
|
||||
"profit_pct": float(profit_pct),
|
||||
"trades": trades,
|
||||
"dd_pct": dd,
|
||||
"pf": float(strat_stats.get("profit_factor") or 0),
|
||||
"winrate": wr,
|
||||
"rejected": int(strat_stats.get("rejected_signals") or 0),
|
||||
"timeframe_used": config.get("timeframe"),
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
logging.getLogger("freqtrade").setLevel(logging.ERROR)
|
||||
timerange = sys.argv[1] if len(sys.argv) > 1 else "20240101-"
|
||||
install_offline_markets()
|
||||
orig = STRAT_PATH.read_text()
|
||||
rows: list[dict[str, Any]] = []
|
||||
try:
|
||||
for label, exec_tf, stf, btf in COMBOS:
|
||||
print(f"=== {label} ===", flush=True)
|
||||
patch_strategy(exec_tf, stf, btf)
|
||||
try:
|
||||
res = run_one(exec_tf, timerange)
|
||||
except Exception as e:
|
||||
res = {"ok": False, "error": f"{type(e).__name__}: {e}"}
|
||||
res["label"] = label
|
||||
res["exec"] = exec_tf
|
||||
res["struct"] = stf
|
||||
res["bias"] = btf or "-"
|
||||
rows.append(res)
|
||||
if res.get("ok"):
|
||||
print(
|
||||
f" profit={res['profit_pct']:.2f}% trades={res['trades']} "
|
||||
f"dd={res['dd_pct']:.2f}% pf={res['pf']:.2f} wr={res['winrate']:.1f}% "
|
||||
f"rej={res['rejected']}",
|
||||
flush=True,
|
||||
)
|
||||
else:
|
||||
print(f" FAILED: {res.get('error')}", flush=True)
|
||||
finally:
|
||||
STRAT_PATH.write_text(orig)
|
||||
|
||||
ok = [r for r in rows if r.get("ok")]
|
||||
ok.sort(key=lambda r: (r["profit_pct"], r["pf"]), reverse=True)
|
||||
print("\n========== RANKING ==========")
|
||||
print(f"{'label':<16} {'E':<5} {'S':<5} {'B':<5} {'profit%':>8} {'trades':>7} {'dd%':>7} {'pf':>6} {'wr%':>6}")
|
||||
for r in ok:
|
||||
print(
|
||||
f"{r['label']:<16} {r['exec']:<5} {r['struct']:<5} {r['bias']:<5} "
|
||||
f"{r['profit_pct']:>8.2f} {r['trades']:>7} {r['dd_pct']:>7.2f} {r['pf']:>6.2f} {r['winrate']:>6.1f}"
|
||||
)
|
||||
out = ROOT / "user_data/Chan/scripts/wyckoff_tf_grid_result.txt"
|
||||
out.write_text(json.dumps({"timerange": timerange, "rows": rows}, indent=2))
|
||||
print(f"\nSaved {out}")
|
||||
if ok:
|
||||
best = ok[0]
|
||||
print(f"BEST: {best['label']} -> 将写入策略默认周期")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user