Files
Chan/scripts/wyckoff_regime_attribution.py
T
jackyu66gitandCursor 8ee11317d3 fix(web): 自动刷新保留 K 线视窗;威科夫与图表增量更新
自动刷新改用 tail update 与 scrollToPosition 恢复视窗,避免 setData 后跳到最右;拆分 chart_tv 模块并扩展 analyze/recent API。同步威科夫分析、pipeline 增量构建及相关策略与配置。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 22:57:43 +08:00

383 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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()