Files
trade/engine/example/regime_display.py
T
Rekey 4294ec401d feat: 多周期牛熊判定模块 — 方案一矩阵展示 + 四法投票 + 多TF策略
- engine/indicators/regime.py: RegimeDetector(四法投票) + MultiTimeframeRegime(多周期并行)
  四法: EMA200斜率 / 价格vsEMA200 / ATH回撤 / 窄幅盘整(<3%振幅)
  全部 O(1)/bar 增量计算,适用于回测和实时
- engine/example/regime_display.py: 多周期牛熊矩阵展示脚本
  独立加载各周期数据 → 运行判定 → 日线对齐矩阵 + 详细拆解 + 统计
  输出 engine/backtest/REGIME_MATRIX_BTCUSDT.md
- engine/example/regime_mtf_strategy.py: 多周期共识策略 + 四策略对比回测
  MTF Consensus: 1w定方向 + 1d确认 + 4h EMA入场
  vs Old Regime(单TF基线) vs Long/Short(无过滤)
- engine/indicators/__init__.py: 导出 RegimeDetector, MultiTimeframeRegime
2026-06-17 11:30:19 +08:00

438 lines
16 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.
"""
多周期牛熊矩阵 — 方案一:独立多周期判定矩阵
在统一时间轴(1d bar 边界)上对齐全周期牛熊判定,
输出矩阵表格、统计信息和详细拆解。
用法:
source .venv/bin/activate && python example/regime_display.py
定制:
python example/regime_display.py --symbol ETHUSDT
python example/regime_display.py --matrix-rows 50 # 输出最近 50 个时间点
"""
import asyncio
import sys
from datetime import datetime, timezone
from pathlib import Path
_project_root = Path(__file__).resolve().parent.parent.parent
if str(_project_root) not in sys.path:
sys.path.insert(0, str(_project_root))
from engine.common.config import config
from engine.data.service import DataService, INTERVAL_MS, INTERVAL_TO_TABLE
from engine.indicators.regime import RegimeDetector
# ── 默认配置 ──
SYMBOL = "BTCUSDT"
TIMEFRAMES = ["1h", "4h", "1d", "1w"]
MATRIX_ROWS = 30
OUTPUT_FILE = None # 运行时设置
def parse_args():
"""解析命令行参数"""
args = sys.argv[1:]
kwargs = {"symbol": SYMBOL, "matrix_rows": MATRIX_ROWS}
i = 0
while i < len(args):
if args[i] == "--symbol" and i + 1 < len(args):
kwargs["symbol"] = args[i + 1].upper()
i += 2
elif args[i] == "--matrix-rows" and i + 1 < len(args):
kwargs["matrix_rows"] = int(args[i + 1])
i += 2
else:
i += 1
return kwargs
# ════════════════════════════════════════════════════════
# 1. 加载各周期数据并运行独立判定
# ════════════════════════════════════════════════════════
async def load_and_detect(
ds: DataService, symbol: str, timeframe: str
) -> tuple[list[float], list[dict], datetime, datetime]:
"""加载指定周期全量数据,逐根运行 RegimeDetector,返回 (times, regimes, 数据起始, 数据结束)
regimes[i] = {"time_ms": float, "close": float, "regime": str, "detail": dict}
仅从第 220 根 bar 开始有效判定,之前为 unknown。
"""
start, end = await ds.fetch_symbol_date_range(symbol, timeframe)
all_klines = await ds.fetch_klines(
symbol=symbol,
interval=timeframe,
start_time=start,
end_time=end,
limit=10_000_000,
)
det = RegimeDetector()
regimes = []
times = []
for k in all_klines:
times.append(k.open_time)
det.update(k.close)
if det.ready:
regime = det.detect(k.close, len(det._e200) - 1)
detail = det.detect_detail(k.close, len(det._e200) - 1)
else:
regime = "unknown"
detail = {"final": "unknown"}
regimes.append({
"time_ms": k.open_time,
"time_dt": datetime.fromtimestamp(k.open_time / 1000, tz=timezone.utc),
"close": k.close,
"regime": regime,
"detail": detail,
"ema200": det.ema200,
"ath": det.ath,
})
return times, regimes, start, end
# ════════════════════════════════════════════════════════
# 2. 时间对齐 — 以日线为基准,查找各周期的即时判定
# ════════════════════════════════════════════════════════
def find_regime_at(
regimes: list[dict], target_ms: float, interval_ms: float
) -> dict:
"""在指定周期的 regime 序列中,找到 target_ms 时刻的判定。
规则:取开盘时间最接近且不晚于 target_ms 的那根 bar 的判定。
若 target_ms 在该 bar 的 [open_time, open_time + interval_ms) 范围内,返回该 bar 的判定。
"""
best = None
for r in regimes:
bar_time = r["time_ms"]
if bar_time <= target_ms < bar_time + interval_ms:
return r
if bar_time > target_ms:
break
best = r
return best
def build_matrix(
times_by_tf: dict[str, list[float]],
regimes_by_tf: dict[str, list[dict]],
reference_tf: str = "1d",
num_rows: int = MATRIX_ROWS,
) -> list[dict]:
"""以 reference_tf 的 bar 时间点为基准,构建多周期判定矩阵。
Returns:
[{time_dt, time_str, 1h: regime, 4h: regime, 1d: regime, 1w: regime}, ...]
按时间倒序,只包含各周期均有有效数据的时间点。
"""
ref_regimes = regimes_by_tf[reference_tf]
# 只取有效区间(所有周期都过了 220 根 bar 之后)
ref_valid = [
r for r in ref_regimes
if r["regime"] != "unknown"
]
matrix = []
for ref_r in ref_valid[-num_rows:]:
row = {
"time_dt": ref_r["time_dt"],
"time_str": ref_r["time_dt"].strftime("%Y-%m-%d %H:%M"),
"time_ms": ref_r["time_ms"],
f"{reference_tf}_close": ref_r["close"],
}
for tf in TIMEFRAMES:
if tf == reference_tf:
row[tf] = ref_r["regime"]
row[f"{tf}_detail"] = ref_r["detail"]
row[f"{tf}_ema200"] = ref_r.get("ema200", 0)
row[f"{tf}_ath"] = ref_r.get("ath", 0)
else:
tf_regime = find_regime_at(
regimes_by_tf[tf],
ref_r["time_ms"],
INTERVAL_MS[tf],
)
if tf_regime:
row[tf] = tf_regime["regime"]
row[f"{tf}_detail"] = tf_regime["detail"]
row[f"{tf}_ema200"] = tf_regime.get("ema200", 0)
row[f"{tf}_ath"] = tf_regime.get("ath", 0)
else:
row[tf] = ""
matrix.append(row)
# 倒序(最新在前)
return list(reversed(matrix))
# ════════════════════════════════════════════════════════
# 3. 统计分析
# ════════════════════════════════════════════════════════
def regime_icon(r: str) -> str:
return {"bull": "🐂", "bear": "🐻", "sideways": "", "unknown": "", "": ""}.get(r, r)
def calc_alignment_stats(matrix: list[dict]) -> dict:
"""计算各周期判定对齐统计"""
total = len(matrix)
if total == 0:
return {}
stats = {}
for tf in TIMEFRAMES:
cnt_bull = sum(1 for r in matrix if r.get(tf) == "bull")
cnt_bear = sum(1 for r in matrix if r.get(tf) == "bear")
cnt_side = sum(1 for r in matrix if r.get(tf) == "sideways")
stats[tf] = {
"bull": cnt_bull,
"bull_pct": cnt_bull / total * 100,
"bear": cnt_bear,
"bear_pct": cnt_bear / total * 100,
"sideways": cnt_side,
"sideways_pct": cnt_side / total * 100,
}
# 全周期一致(所有非 "—" 的周期判定相同)
all_agree = 0
for r in matrix:
regimes = [r[tf] for tf in TIMEFRAMES if r.get(tf) not in ("", "unknown")]
if regimes and len(set(regimes)) == 1:
all_agree += 1
stats["all_agree"] = all_agree
stats["all_agree_pct"] = all_agree / total * 100
# 大小周期背离(1h 与 1w 相反)
diverge = 0
for r in matrix:
r1h = r.get("1h")
r1w = r.get("1w")
if r1h and r1w and r1h != "" and r1w != "":
if (r1h == "bull" and r1w == "bear") or (r1h == "bear" and r1w == "bull"):
diverge += 1
stats["diverge_1h_1w"] = diverge
stats["diverge_1h_1w_pct"] = diverge / total * 100
return stats
# ════════════════════════════════════════════════════════
# 4. 输出格式化
# ════════════════════════════════════════════════════════
def render_matrix_table(matrix: list[dict]) -> str:
"""渲染矩阵表格为 Markdown 格式"""
header = "| 时间 |"
sep = "|------|"
for tf in TIMEFRAMES:
header += f" {tf} |"
sep += "----|"
header += " BTC Close |"
sep += "-----------|"
lines = [header, sep]
for row in matrix:
line = f"| {row['time_str']} |"
for tf in TIMEFRAMES:
r = row.get(tf, "")
line += f" {regime_icon(r)} {r} |"
close_str = f"{row.get('1d_close', 0):.2f}"
line += f" {close_str} |"
lines.append(line)
return "\n".join(lines)
def render_detail_table(matrix: list[dict], tf: str, n: int = 5) -> str:
"""渲染单个周期的详细判定表(最近 n 条)"""
lines = [
f"### {tf} 详细判定(最近 {n} 个时间点)",
"",
"| 时间 | 最终 | EMA200斜率 | 价格vsEMA200 | ATH回撤 | 窄幅盘整 | 振幅% | 牛票 | 熊票 | 震票 | EMA200 | ATH |",
"|------|------|-----------|-------------|---------|---------|------|------|------|------|--------|-----|",
]
detail_key = f"{tf}_detail"
for row in matrix[:n]:
d = row.get(detail_key, {})
f_icon = regime_icon(d.get("final", ""))
ema200_val = row.get(f"{tf}_ema200", 0)
ath_val = row.get(f"{tf}_ath", 0)
range_pct = d.get("range_pct", 0) * 100
lines.append(
f"| {row['time_str']} | {f_icon} {d.get('final', '')} | "
f"{d.get('ema200_slope', '')} | {d.get('price_vs_ema200', '')} | "
f"{d.get('ath_drawdown', '')} | {d.get('price_range', '')} | "
f"{range_pct:.1f}% | {d.get('bull_votes', '')} | "
f"{d.get('bear_votes', '')} | {d.get('sideways_votes', '')} | "
f"{ema200_val:.2f} | {ath_val:.2f} |"
)
return "\n".join(lines)
def render_current_status(regimes_by_tf: dict[str, list[dict]]) -> str:
"""渲染当前最新状态"""
lines = [
"## 当前各周期牛熊状态",
"",
"| 周期 | 判定 | EMA200斜率 | 价格vsEMA200 | ATH回撤 | 窄幅盘整 | 振幅% | 牛票 | 熊票 | 震票 | EMA200 | Close |",
"|------|------|-----------|-------------|---------|---------|------|------|------|------|--------|-------|",
]
for tf in TIMEFRAMES:
regs = regimes_by_tf[tf]
if not regs or regs[-1]["regime"] == "unknown":
lines.append(f"| {tf} | ⏳ 数据不足 | — | — | — | — | — | — | — | — | — | — |")
continue
r = regs[-1]
d = r["detail"]
icon = regime_icon(r["regime"])
range_pct = d.get("range_pct", 0) * 100
lines.append(
f"| {tf} | {icon} **{r['regime']}** | "
f"{d.get('ema200_slope', '')} | {d.get('price_vs_ema200', '')} | "
f"{d.get('ath_drawdown', '')} | {d.get('price_range', '')} | "
f"{range_pct:.1f}% | {d.get('bull_votes', '')} | "
f"{d.get('bear_votes', '')} | {d.get('sideways_votes', '')} | "
f"{r.get('ema200', 0):.2f} | {r['close']:.2f} |"
)
return "\n".join(lines)
def render_stats(stats: dict) -> str:
"""渲染统计信息"""
lines = [
"## 周期判定分布统计",
"",
"| 周期 | 🐂 牛市 | 占比 | 🐻 熊市 | 占比 | ⚪ 震荡 | 占比 |",
"|------|--------|------|--------|------|--------|------|",
]
for tf in TIMEFRAMES:
s = stats.get(tf, {})
lines.append(
f"| {tf} | {s.get('bull', 0)} | {s.get('bull_pct', 0):.1f}% | "
f"{s.get('bear', 0)} | {s.get('bear_pct', 0):.1f}% | "
f"{s.get('sideways', 0)} | {s.get('sideways_pct', 0):.1f}% |"
)
lines.append("")
lines.append("## 周期一致性统计")
lines.append("")
lines.append(f"- **全周期一致**(所有周期判定相同):{stats.get('all_agree', 0)} 天 ({stats.get('all_agree_pct', 0):.1f}%)")
lines.append(f"- **1h ↔ 1w 背离**1h 与 1w 方向相反):{stats.get('diverge_1h_1w', 0)} 天 ({stats.get('diverge_1h_1w_pct', 0):.1f}%)")
return "\n".join(lines)
# ════════════════════════════════════════════════════════
# 主流程
# ════════════════════════════════════════════════════════
async def main():
kw = parse_args()
symbol = kw["symbol"]
matrix_rows = kw["matrix_rows"]
global OUTPUT_FILE
OUTPUT_FILE = (
Path(__file__).resolve().parent.parent
/ "backtest"
/ f"REGIME_MATRIX_{symbol}.md"
)
out: list[str] = []
def w(line: str = ""):
out.append(line)
print(line)
w(f"# 多周期牛熊判定矩阵 — {symbol}")
w()
w(f"> 生成时间:{datetime.now().strftime('%Y-%m-%d %H:%M:%S')} UTC")
w(f"> 判定方法:EMA200斜率 + 价格vsEMA200 + ATH回撤 + 窄幅盘整,四选二投票")
w(f"> 时间对齐基准:日线(1d) bar 边界")
w()
# ── 加载数据并运行判定 ──
ds = DataService(config.db)
await ds.connect()
try:
times_by_tf: dict[str, list[float]] = {}
regimes_by_tf: dict[str, list[dict]] = {}
date_ranges: dict[str, tuple[datetime, datetime]] = {}
for tf in TIMEFRAMES:
print(f" 加载 {tf} 数据...", end=" ", flush=True)
times, regimes, data_start, data_end = await load_and_detect(ds, symbol, tf)
times_by_tf[tf] = times
regimes_by_tf[tf] = regimes
date_ranges[tf] = (data_start, data_end)
valid_count = sum(1 for r in regimes if r["regime"] != "unknown")
print(f"{len(regimes)} 根 bar{valid_count} 根有效(>220热身)")
# ── 数据范围 ──
w()
w("## 数据覆盖范围")
w()
w("| 周期 | 数据条数 | 有效条数 | 起始时间 | 结束时间 |")
w("|------|---------|---------|---------|---------|")
for tf in TIMEFRAMES:
regs = regimes_by_tf[tf]
valid = sum(1 for r in regs if r["regime"] != "unknown")
s, e = date_ranges[tf]
w(f"| {tf} | {len(regs)} | {valid} | {s.strftime('%Y-%m-%d')} | {e.strftime('%Y-%m-%d')} |")
w()
# ── 当前状态 ──
w(render_current_status(regimes_by_tf))
w()
# ── 历史矩阵 ──
matrix = build_matrix(times_by_tf, regimes_by_tf, "1d", num_rows=matrix_rows)
w(f"## 历史牛熊矩阵(最近 {len(matrix)} 个日线时间点)")
w()
w("> 以日线 bar 的 open_time 为基准,查找各周期在该时刻的即时判定。")
w()
w(render_matrix_table(matrix))
w()
# ── 各周期详细拆解 ──
w("## 各周期详细拆解")
w()
for tf in TIMEFRAMES:
w(render_detail_table(matrix, tf, n=min(5, len(matrix))))
w()
# ── 统计 ──
stats = calc_alignment_stats(matrix)
w(render_stats(stats))
w()
finally:
await ds.close()
# ── 写出文件 ──
OUTPUT_FILE.parent.mkdir(parents=True, exist_ok=True)
with open(OUTPUT_FILE, "w", encoding="utf-8") as f:
f.write("\n".join(out) + "\n")
print(f"\n✓ 结果已保存到: {OUTPUT_FILE}")
if __name__ == "__main__":
asyncio.run(main())