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:
jackyu66git
2026-08-25 22:57:43 +08:00
co-authored by Cursor
parent 1e60ab3bfa
commit 8ee11317d3
104 changed files with 21452 additions and 4988 deletions
+221
View File
@@ -0,0 +1,221 @@
#!/usr/bin/env python3
"""
Market State Gate OOS — Baseline vs GatedSpring 冻结)
比较:
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()
+240
View File
@@ -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()
+239
View File
@@ -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()
+145
View File
@@ -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()
+206
View File
@@ -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 PFfee 计入)。
"""
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()
+113
View File
@@ -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()
+185
View File
@@ -0,0 +1,185 @@
#!/usr/bin/env python3
"""
Wyckoff Phase 2:鲁棒性验证(固定当前参数,不再扫参)
1) Walk-ForwardTrain 2023-2024 / Validate 2025 / Test 2026
2) 市场状态拆分:bull / bear / range8h 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()
+261
View File
@@ -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()
+382
View File
@@ -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()
+305
View File
@@ -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>=5DD 越低越好;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()
+226
View File
@@ -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()