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
+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()