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