From 4294ec401d76a6b9fad95be0e8320e1318bbe86d Mon Sep 17 00:00:00 2001 From: Rekey Date: Wed, 17 Jun 2026 11:30:19 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A4=9A=E5=91=A8=E6=9C=9F=E7=89=9B?= =?UTF-8?q?=E7=86=8A=E5=88=A4=E5=AE=9A=E6=A8=A1=E5=9D=97=20=E2=80=94=20?= =?UTF-8?q?=E6=96=B9=E6=A1=88=E4=B8=80=E7=9F=A9=E9=98=B5=E5=B1=95=E7=A4=BA?= =?UTF-8?q?=20+=20=E5=9B=9B=E6=B3=95=E6=8A=95=E7=A5=A8=20+=20=E5=A4=9ATF?= =?UTF-8?q?=E7=AD=96=E7=95=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- engine/backtest/REGIME_MATRIX_BTCUSDT.md | 118 ++++++ engine/example/regime_display.py | 437 +++++++++++++++++++++++ engine/example/regime_mtf_strategy.py | 376 +++++++++++++++++++ engine/indicators/__init__.py | 3 + engine/indicators/regime.py | 268 ++++++++++++++ 5 files changed, 1202 insertions(+) create mode 100644 engine/backtest/REGIME_MATRIX_BTCUSDT.md create mode 100644 engine/example/regime_display.py create mode 100644 engine/example/regime_mtf_strategy.py create mode 100644 engine/indicators/regime.py diff --git a/engine/backtest/REGIME_MATRIX_BTCUSDT.md b/engine/backtest/REGIME_MATRIX_BTCUSDT.md new file mode 100644 index 0000000..67cb981 --- /dev/null +++ b/engine/backtest/REGIME_MATRIX_BTCUSDT.md @@ -0,0 +1,118 @@ +# 多周期牛熊判定矩阵 — BTCUSDT + +> 生成时间:2026-06-17 11:22:34 UTC +> 判定方法:EMA200斜率 + 价格vsEMA200 + ATH回撤,三选二投票 +> 时间对齐基准:日线(1d) bar 边界 + + +## 数据覆盖范围 + +| 周期 | 数据条数 | 有效条数 | 起始时间 | 结束时间 | +|------|---------|---------|---------|---------| +| 1h | 77290 | 77071 | 2017-08-17 | 2026-06-16 | +| 4h | 19338 | 19119 | 2017-08-17 | 2026-06-16 | +| 1d | 3225 | 3006 | 2017-08-17 | 2026-06-16 | +| 1w | 371 | 152 | 2017-08-14 | 2026-06-15 | + +## 当前各周期牛熊状态 + +| 周期 | 判定 | EMA200斜率 | 价格vsEMA200 | ATH回撤 | 窄幅盘整 | 振幅% | 牛票 | 熊票 | 震票 | EMA200 | Close | +|------|------|-----------|-------------|---------|---------|------|------|------|------|--------|-------| +| 1h | 🐂 **bull** | bull | bull | bear | sideways | 1.9% | 2 | 1 | 1 | 64738.90 | 65776.97 | +| 4h | 🐻 **bear** | bear | bear | bear | unknown | 5.2% | 0 | 3 | 0 | 68982.99 | 65675.88 | +| 1d | 🐻 **bear** | bear | bear | bear | unknown | 18.2% | 0 | 3 | 0 | 78707.71 | 66328.74 | +| 1w | 🐂 **bull** | bull | bull | bear | unknown | 32.4% | 2 | 1 | 0 | 62716.28 | 65746.45 | + +## 历史牛熊矩阵(最近 30 个日线时间点) + +> 以日线 bar 的 open_time 为基准,查找各周期在该时刻的即时判定。 + +| 时间 | 1h | 4h | 1d | 1w | BTC Close | +|------|----|----|----|----|-----------| +| 2026-06-15 00:00 | ⚪ sideways | 🐻 bear | 🐻 bear | 🐂 bull | 66328.74 | +| 2026-06-14 00:00 | ⚪ sideways | 🐻 bear | 🐻 bear | 🐂 bull | 65746.45 | +| 2026-06-13 00:00 | 🐻 bear | 🐻 bear | 🐻 bear | 🐂 bull | 64458.01 | +| 2026-06-12 00:00 | 🐻 bear | 🐻 bear | 🐻 bear | 🐂 bull | 63580.01 | +| 2026-06-11 00:00 | 🐻 bear | 🐻 bear | 🐻 bear | 🐂 bull | 63625.99 | +| 2026-06-10 00:00 | 🐻 bear | 🐻 bear | 🐻 bear | 🐂 bull | 61510.99 | +| 2026-06-09 00:00 | 🐻 bear | 🐻 bear | 🐻 bear | 🐂 bull | 61730.00 | +| 2026-06-08 00:00 | 🐻 bear | 🐻 bear | 🐻 bear | 🐂 bull | 63085.99 | +| 2026-06-07 00:00 | 🐻 bear | 🐻 bear | 🐻 bear | 🐂 bull | 63332.01 | +| 2026-06-06 00:00 | 🐻 bear | 🐻 bear | 🐻 bear | 🐂 bull | 60884.62 | +| 2026-06-05 00:00 | 🐻 bear | 🐻 bear | 🐻 bear | 🐂 bull | 61056.47 | +| 2026-06-04 00:00 | 🐻 bear | 🐻 bear | 🐻 bear | 🐂 bull | 63885.99 | +| 2026-06-03 00:00 | 🐻 bear | 🐻 bear | 🐻 bear | 🐂 bull | 64142.75 | +| 2026-06-02 00:00 | 🐻 bear | 🐻 bear | 🐻 bear | 🐂 bull | 66760.83 | +| 2026-06-01 00:00 | 🐻 bear | 🐻 bear | 🐻 bear | 🐂 bull | 71408.90 | +| 2026-05-31 00:00 | 🐻 bear | 🐻 bear | 🐻 bear | 🐂 bull | 73674.39 | +| 2026-05-30 00:00 | 🐻 bear | 🐻 bear | 🐻 bear | 🐂 bull | 73884.38 | +| 2026-05-29 00:00 | 🐻 bear | 🐻 bear | 🐻 bear | 🐂 bull | 73460.78 | +| 2026-05-28 00:00 | 🐻 bear | 🐻 bear | 🐻 bear | 🐂 bull | 73617.51 | +| 2026-05-27 00:00 | ⚪ sideways | ⚪ sideways | 🐻 bear | 🐂 bull | 74449.30 | +| 2026-05-26 00:00 | ⚪ sideways | 🐻 bear | 🐻 bear | 🐂 bull | 75930.01 | +| 2026-05-25 00:00 | ⚪ sideways | 🐻 bear | 🐻 bear | 🐂 bull | 77322.01 | +| 2026-05-24 00:00 | 🐻 bear | 🐻 bear | 🐻 bear | 🐂 bull | 77064.96 | +| 2026-05-23 00:00 | 🐻 bear | ⚪ sideways | 🐻 bear | 🐂 bull | 76752.01 | +| 2026-05-22 00:00 | ⚪ sideways | ⚪ sideways | 🐻 bear | 🐂 bull | 75539.50 | +| 2026-05-21 00:00 | 🐻 bear | ⚪ sideways | 🐻 bear | 🐂 bull | 77615.52 | +| 2026-05-20 00:00 | 🐻 bear | ⚪ sideways | 🐻 bear | 🐂 bull | 77552.23 | +| 2026-05-19 00:00 | 🐻 bear | ⚪ sideways | 🐻 bear | 🐂 bull | 76834.36 | +| 2026-05-18 00:00 | 🐻 bear | ⚪ sideways | 🐻 bear | 🐂 bull | 77001.87 | +| 2026-05-17 00:00 | 🐻 bear | 🐂 bull | 🐻 bear | 🐂 bull | 77457.67 | + +## 各周期详细拆解 + +### 1h 详细判定(最近 5 个时间点) + +| 时间 | 最终 | EMA200斜率 | 价格vsEMA200 | ATH回撤 | 窄幅盘整 | 振幅% | 牛票 | 熊票 | 震票 | EMA200 | ATH | +|------|------|-----------|-------------|---------|---------|------|------|------|------|--------|-----| +| 2026-06-15 00:00 | ⚪ sideways | sideways | bull | bear | unknown | 3.1% | 1 | 1 | 1 | 63927.15 | 126011.18 | +| 2026-06-14 00:00 | ⚪ sideways | sideways | bull | bear | sideways | 1.5% | 1 | 1 | 2 | 63777.80 | 126011.18 | +| 2026-06-13 00:00 | 🐻 bear | sideways | bear | bear | sideways | 1.6% | 0 | 2 | 2 | 63706.52 | 126011.18 | +| 2026-06-12 00:00 | 🐻 bear | bear | bear | bear | sideways | 1.7% | 0 | 3 | 1 | 63745.31 | 126011.18 | +| 2026-06-11 00:00 | 🐻 bear | bear | bear | bear | sideways | 2.6% | 0 | 3 | 1 | 63934.05 | 126011.18 | + +### 4h 详细判定(最近 5 个时间点) + +| 时间 | 最终 | EMA200斜率 | 价格vsEMA200 | ATH回撤 | 窄幅盘整 | 振幅% | 牛票 | 熊票 | 震票 | EMA200 | ATH | +|------|------|-----------|-------------|---------|---------|------|------|------|------|--------|-----| +| 2026-06-15 00:00 | 🐻 bear | bear | bear | bear | unknown | 4.3% | 0 | 3 | 0 | 69268.75 | 125410.81 | +| 2026-06-14 00:00 | 🐻 bear | bear | bear | bear | unknown | 4.7% | 0 | 3 | 0 | 69547.86 | 125410.81 | +| 2026-06-13 00:00 | 🐻 bear | bear | bear | bear | unknown | 4.3% | 0 | 3 | 0 | 69876.10 | 125410.81 | +| 2026-06-12 00:00 | 🐻 bear | bear | bear | bear | unknown | 4.1% | 0 | 3 | 0 | 70268.64 | 125410.81 | +| 2026-06-11 00:00 | 🐻 bear | bear | bear | bear | unknown | 4.3% | 0 | 3 | 0 | 70704.04 | 125410.81 | + +### 1d 详细判定(最近 5 个时间点) + +| 时间 | 最终 | EMA200斜率 | 价格vsEMA200 | ATH回撤 | 窄幅盘整 | 振幅% | 牛票 | 熊票 | 震票 | EMA200 | ATH | +|------|------|-----------|-------------|---------|---------|------|------|------|------|--------|-----| +| 2026-06-15 00:00 | 🐻 bear | bear | bear | bear | unknown | 18.2% | 0 | 3 | 0 | 78707.71 | 124658.54 | +| 2026-06-14 00:00 | 🐻 bear | bear | bear | bear | unknown | 19.8% | 0 | 3 | 0 | 78832.12 | 124658.54 | +| 2026-06-13 00:00 | 🐻 bear | bear | bear | bear | unknown | 21.3% | 0 | 3 | 0 | 78963.63 | 124658.54 | +| 2026-06-12 00:00 | 🐻 bear | bear | bear | bear | unknown | 21.3% | 0 | 3 | 0 | 79109.42 | 124658.54 | +| 2026-06-11 00:00 | 🐻 bear | bear | bear | bear | unknown | 21.3% | 0 | 3 | 0 | 79265.49 | 124658.54 | + +### 1w 详细判定(最近 5 个时间点) + +| 时间 | 最终 | EMA200斜率 | 价格vsEMA200 | ATH回撤 | 窄幅盘整 | 振幅% | 牛票 | 熊票 | 震票 | EMA200 | ATH | +|------|------|-----------|-------------|---------|---------|------|------|------|------|--------|-----| +| 2026-06-15 00:00 | 🐂 bull | bull | bull | bear | unknown | 32.4% | 2 | 1 | 0 | 62716.28 | 119415.55 | +| 2026-06-14 00:00 | 🐂 bull | bull | bull | bear | unknown | 32.4% | 2 | 1 | 0 | 62716.28 | 119415.55 | +| 2026-06-13 00:00 | 🐂 bull | bull | bull | bear | unknown | 32.4% | 2 | 1 | 0 | 62716.28 | 119415.55 | +| 2026-06-12 00:00 | 🐂 bull | bull | bull | bear | unknown | 32.4% | 2 | 1 | 0 | 62716.28 | 119415.55 | +| 2026-06-11 00:00 | 🐂 bull | bull | bull | bear | unknown | 32.4% | 2 | 1 | 0 | 62716.28 | 119415.55 | + +## 周期判定分布统计 + +| 周期 | 🐂 牛市 | 占比 | 🐻 熊市 | 占比 | ⚪ 震荡 | 占比 | +|------|--------|------|--------|------|--------|------| +| 1h | 0 | 0.0% | 24 | 80.0% | 6 | 20.0% | +| 4h | 1 | 3.3% | 22 | 73.3% | 7 | 23.3% | +| 1d | 0 | 0.0% | 30 | 100.0% | 0 | 0.0% | +| 1w | 30 | 100.0% | 0 | 0.0% | 0 | 0.0% | + +## 周期一致性统计 + +- **全周期一致**(所有周期判定相同):0 天 (0.0%) +- **1h ↔ 1w 背离**(1h 与 1w 方向相反):24 天 (80.0%) + diff --git a/engine/example/regime_display.py b/engine/example/regime_display.py new file mode 100644 index 0000000..abda05d --- /dev/null +++ b/engine/example/regime_display.py @@ -0,0 +1,437 @@ +""" +多周期牛熊矩阵 — 方案一:独立多周期判定矩阵 + +在统一时间轴(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()) diff --git a/engine/example/regime_mtf_strategy.py b/engine/example/regime_mtf_strategy.py new file mode 100644 index 0000000..59eab15 --- /dev/null +++ b/engine/example/regime_mtf_strategy.py @@ -0,0 +1,376 @@ +""" +多周期牛熊共识策略 — 四周期协同判定 + +核心思路: + 1w 定宏观方向(只做多 / 只做空)→ 1d 确认中周期 → 4h EMA 金叉/死叉入场 + 配合 ATR 动态止损 + 多周期投票确认出场 + +对比基准:单周期 RegimeEmaStrategy(regime_all.py)、无过滤 LongShortEmaStrategy + +用法: + source .venv/bin/activate && python example/regime_mtf_strategy.py +""" + +import asyncio +import sys +from datetime import datetime, timezone +from pathlib import Path +from typing import Optional + +_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.base import BaseStrategy, Signal, StrategyConfig +from engine.common.models import Kline +from engine.common.config import config +from engine.backtest import BacktestConfig +from engine.indicators.incremental import EmaInc, AtrInc +from engine.indicators.regime import MultiTimeframeRegime +from engine.example.long_short import LongShortEngine +from engine.example.regime_all import RegimeEmaConfig as OldRegimeCfg +from engine.example.regime_all import RegimeEmaStrategy as OldRegimeS +from engine.example.long_short import LongShortEmaConfig as LSCfg +from engine.example.long_short import LongShortEmaStrategy as LSS + + +# ════════════════════════════════════════════════════════ +# 多周期牛熊共识策略 +# ════════════════════════════════════════════════════════ + + +class MTFConfig(StrategyConfig): + fast: int = 10 + slow: int = 50 + atr_stop: float = 2.5 + + +class MTFRegimeStrategy(BaseStrategy): + """四周期协同牛熊策略 + + 判定层(MultiTimeframeRegime,即时法): + 每个 4h bar 的 close 同时更新 1h / 4h / 1d / 1w 四个检测器, + 1w 定方向宏调,1d 确认中周期,1h 预警微观背离。 + + 入场: + 做多 — 1w bull + 1d not bear + 4h EMA 金叉 + 做空 — 1w bear + 1d not bull + 4h EMA 死叉 + + 出场: + EMA 交叉反转、ATR 跟踪止损、高周期方向逆转 + """ + + strategy_type = "mtf_regime" + + def __init__(self, c: MTFConfig): + super().__init__(c) + self.cfg = c + self._mtf = MultiTimeframeRegime(["1h", "4h", "1d", "1w"]) + self._ema_fast = EmaInc(c.fast) + self._ema_slow = EmaInc(c.slow) + self._atr = AtrInc(14) + self._side: str = "" + self._hp: float = 0.0 + self._lp: float = float("inf") + + async def on_kline(self, k: Kline) -> Optional[Signal]: + self._mtf.update(k.close) + self._ema_fast.update(k.close) + self._ema_slow.update(k.close) + self._atr.update(k.high, k.low, k.close) + + n = len(self._mtf._prices) + if n < 220: + return None + + regimes = self._mtf.detect_all() + r1h = regimes.get("1h", "unknown") + r4h = regimes.get("4h", "unknown") + r1d = regimes.get("1d", "unknown") + r1w = regimes.get("1w", "unknown") + + cf, cs = self._ema_fast[-1], self._ema_slow[-1] + ca = self._atr[-1] + pf, ps = self._ema_fast[-2], self._ema_slow[-2] + if cf == 0 or cs == 0 or ca == 0: + return None + + golden = pf <= ps and cf > cs + death = pf >= ps and cf < cs + + # ── 多头持仓 ── + if self._side == "long": + self._hp = max(self._hp, k.high) + stop = self._hp - self.cfg.atr_stop * ca + + exit_reason = None + if death: + exit_reason = "死叉" + elif k.close < stop: + exit_reason = "ATR止损" + elif r1w == "bear": + exit_reason = "1w转熊→平多" + elif r1d == "bear" and r4h == "bear": + exit_reason = "1d+4h双双转熊→平多" + + if exit_reason: + self._side = "" + return Signal(symbol=self.cfg.symbol, side="SELL", + reason=exit_reason, timestamp=k.open_time) + + # ── 空头持仓 ── + elif self._side == "short": + self._lp = min(self._lp, k.low) + stop = self._lp + self.cfg.atr_stop * ca + + exit_reason = None + if golden: + exit_reason = "金叉" + elif k.close > stop: + exit_reason = "ATR止损" + elif r1w == "bull": + exit_reason = "1w转牛→平空" + elif r1d == "bull" and r4h == "bull": + exit_reason = "1d+4h双双转牛→平空" + + if exit_reason: + self._side = "" + return Signal(symbol=self.cfg.symbol, side="BUY", + reason=exit_reason, timestamp=k.open_time) + + # ── 空仓等信号 ── + else: + # 做多条件:1w牛 + 1d不熊 + 4h金叉 + long_ok = r1w == "bull" and r1d != "bear" and golden + # 做空条件:1w熊 + 1d不牛 + 4h死叉 + short_ok = r1w == "bear" and r1d != "bull" and death + + if long_ok: + self._side = "long" + self._hp = k.close + return Signal(symbol=self.cfg.symbol, side="BUY", + reason=f"多周期做多({r1w}/{r1d}/{r4h})", + timestamp=k.open_time) + + if short_ok: + self._side = "short" + self._lp = k.close + return Signal(symbol=self.cfg.symbol, side="SELL", + reason=f"多周期做空({r1w}/{r1d}/{r4h})", + timestamp=k.open_time) + + return None + + +# ════════════════════════════════════════════════════════ +# 对照策略 2:1w 绝对过滤(更强约束) +# ════════════════════════════════════════════════════════ + + +class MTFStrictConfig(StrategyConfig): + fast: int = 10 + slow: int = 50 + atr_stop: float = 2.5 + + +class MTFStrictStrategy(BaseStrategy): + """严格多周期策略 — 必须 1w + 1d 方向一致才入场 + + 入场: + 做多 — 1w bull + 1d bull + 4h 金叉 + 做空 — 1w bear + 1d bear + 4h 死叉 + 出场: + 4h 死叉/金叉 或 ATR 止损 或 1w 方向逆转 + """ + + strategy_type = "mtf_strict" + + def __init__(self, c: MTFStrictConfig): + super().__init__(c) + self.cfg = c + self._mtf = MultiTimeframeRegime(["1h", "4h", "1d", "1w"]) + self._ema_fast = EmaInc(c.fast) + self._ema_slow = EmaInc(c.slow) + self._atr = AtrInc(14) + self._side: str = "" + self._hp: float = 0.0 + self._lp: float = float("inf") + + async def on_kline(self, k: Kline) -> Optional[Signal]: + self._mtf.update(k.close) + self._ema_fast.update(k.close) + self._ema_slow.update(k.close) + self._atr.update(k.high, k.low, k.close) + + n = len(self._mtf._prices) + if n < 220: + return None + + regimes = self._mtf.detect_all() + r4h = regimes.get("4h", "unknown") + r1d = regimes.get("1d", "unknown") + r1w = regimes.get("1w", "unknown") + + cf, cs = self._ema_fast[-1], self._ema_slow[-1] + ca = self._atr[-1] + pf, ps = self._ema_fast[-2], self._ema_slow[-2] + if cf == 0 or cs == 0 or ca == 0: + return None + + golden = pf <= ps and cf > cs + death = pf >= ps and cf < cs + + # 多头持仓 + if self._side == "long": + self._hp = max(self._hp, k.high) + stop = self._hp - self.cfg.atr_stop * ca + if death or k.close < stop or r1w == "bear": + self._side = "" + reason = "死叉" if death else ("ATR止损" if k.close < stop else "1w转熊") + return Signal(symbol=self.cfg.symbol, side="SELL", + reason=reason, timestamp=k.open_time) + + # 空头持仓 + elif self._side == "short": + self._lp = min(self._lp, k.low) + stop = self._lp + self.cfg.atr_stop * ca + if golden or k.close > stop or r1w == "bull": + self._side = "" + reason = "金叉" if golden else ("ATR止损" if k.close > stop else "1w转牛") + return Signal(symbol=self.cfg.symbol, side="BUY", + reason=reason, timestamp=k.open_time) + + # 空仓 + else: + if r1w == "bull" and r1d == "bull" and golden: + self._side = "long" + self._hp = k.close + return Signal(symbol=self.cfg.symbol, side="BUY", + reason=f"严格做多({r1w}+{r1d}+{r4h})", + timestamp=k.open_time) + if r1w == "bear" and r1d == "bear" and death: + self._side = "short" + self._lp = k.close + return Signal(symbol=self.cfg.symbol, side="SELL", + reason=f"严格做空({r1w}+{r1d}+{r4h})", + timestamp=k.open_time) + + return None + + +# ════════════════════════════════════════════════════════ +# 回测入口 +# ════════════════════════════════════════════════════════ + +SYMBOLS = ["BTCUSDT", "ETHUSDT", "BNBUSDT", "SOLUSDT"] + +PARAMS = { + "BTCUSDT": (10, 50), + "ETHUSDT": (10, 75), + "BNBUSDT": (20, 50), + "SOLUSDT": (30, 50), +} + +DATE_START = datetime(2024, 1, 1) +DATE_END = datetime(2026, 1, 1) + + +async def run_one(engine, strategy_cls, config_cls, symbol, desc, params=None): + fast, slow = PARAMS[symbol] if params is None else params + sc = config_cls(symbol=symbol, fast=fast, slow=slow) + bt = BacktestConfig( + symbol=symbol, interval="4h", + start_time=DATE_START, end_time=DATE_END, + initial_capital=10_000.0, warmup_bars=250, + ) + eng = engine(bt, db_config=config.db) + r = await eng.run(strategy_cls, sc) + return r + + +async def main(): + print() + print("═" * 120) + print(" 多周期牛熊共识策略 — MTF Regime × 4h EMA | 2024-2026") + print("═" * 120) + print() + print(" 对比策略:") + print(" • MTF Consensus — 1w定方向 + 1d确认 + 4h金叉/死叉入场(新)") + print(" • MTF Strict — 1w+1d必须同向才入场(新)") + print(" • Old Regime — 单周期(4h)三法投票(regime_all.py 基线)") + print(" • Long/Short — 无牛熊过滤,始终多空(long_short.py 基线)") + print() + + for symbol in SYMBOLS: + print(f" ── {symbol} ──") + print(f" {'策略':<18} {'总收益%':>7} {'年化%':>7} {'夏普':>6} {'回撤%':>7} {'交易':>5} {'胜率%':>6} {'盈亏比':>6} {'多头P&L':>9} {'空头P&L':>9}") + print(" " + "─" * 110) + + results = {} + + # 1. MTF Consensus (new) + try: + r1 = await run_one(LongShortEngine, MTFRegimeStrategy, MTFConfig, symbol, "MTF Consensus") + m = r1.metrics + long1 = [t for t in r1.trades if t.pnl is not None and t.side == "SELL"] + short1 = [t for t in r1.trades if t.pnl is not None and t.side == "BUY"] + lp1 = sum(t.pnl for t in long1) if long1 else 0 + sp1 = sum(t.pnl for t in short1) if short1 else 0 + results["mtf"] = m + print(f" {'MTF Consensus':<18} {m.total_return_pct:>+6.1f}% {m.annual_return_pct:>6.1f}% {m.sharpe_ratio:>6.2f} {m.max_drawdown_pct:>6.1f}% {m.total_trades:>5} {m.win_rate*100:>5.1f}% {m.profit_factor:>6.2f} {lp1:>+9.0f} {sp1:>+9.0f}") + except Exception as e: + print(f" {'MTF Consensus':<18} 错误: {e}") + + # 2. MTF Strict (new) + try: + r2 = await run_one(LongShortEngine, MTFStrictStrategy, MTFStrictConfig, symbol, "MTF Strict") + m = r2.metrics + long2 = [t for t in r2.trades if t.pnl is not None and t.side == "SELL"] + short2 = [t for t in r2.trades if t.pnl is not None and t.side == "BUY"] + lp2 = sum(t.pnl for t in long2) if long2 else 0 + sp2 = sum(t.pnl for t in short2) if short2 else 0 + results["strict"] = m + print(f" {'MTF Strict':<18} {m.total_return_pct:>+6.1f}% {m.annual_return_pct:>6.1f}% {m.sharpe_ratio:>6.2f} {m.max_drawdown_pct:>6.1f}% {m.total_trades:>5} {m.win_rate*100:>5.1f}% {m.profit_factor:>6.2f} {lp2:>+9.0f} {sp2:>+9.0f}") + except Exception as e: + print(f" {'MTF Strict':<18} 错误: {e}") + + # 3. Old Regime (baseline) + try: + r3 = await run_one(LongShortEngine, OldRegimeS, OldRegimeCfg, symbol, "Old Regime") + m = r3.metrics + long3 = [t for t in r3.trades if t.pnl is not None and t.side == "SELL"] + short3 = [t for t in r3.trades if t.pnl is not None and t.side == "BUY"] + lp3 = sum(t.pnl for t in long3) if long3 else 0 + sp3 = sum(t.pnl for t in short3) if short3 else 0 + results["old"] = m + print(f" {'Old Regime':<18} {m.total_return_pct:>+6.1f}% {m.annual_return_pct:>6.1f}% {m.sharpe_ratio:>6.2f} {m.max_drawdown_pct:>6.1f}% {m.total_trades:>5} {m.win_rate*100:>5.1f}% {m.profit_factor:>6.2f} {lp3:>+9.0f} {sp3:>+9.0f}") + except Exception as e: + print(f" {'Old Regime':<18} 错误: {e}") + + # 4. Long/Short (baseline, no filter) + try: + r4 = await run_one(LongShortEngine, LSS, LSCfg, symbol, "Long/Short") + m = r4.metrics + long4 = [t for t in r4.trades if t.pnl is not None and t.side == "SELL"] + short4 = [t for t in r4.trades if t.pnl is not None and t.side == "BUY"] + lp4 = sum(t.pnl for t in long4) if long4 else 0 + sp4 = sum(t.pnl for t in short4) if short4 else 0 + results["ls"] = m + print(f" {'Long/Short':<18} {m.total_return_pct:>+6.1f}% {m.annual_return_pct:>6.1f}% {m.sharpe_ratio:>6.2f} {m.max_drawdown_pct:>6.1f}% {m.total_trades:>5} {m.win_rate*100:>5.1f}% {m.profit_factor:>6.2f} {lp4:>+9.0f} {sp4:>+9.0f}") + except Exception as e: + print(f" {'Long/Short':<18} 错误: {e}") + + # 简要对比 + if len(results) >= 2: + best_sharpe = max(results.items(), key=lambda x: x[1].sharpe_ratio) + worst_dd = min(results.items(), key=lambda x: x[1].max_drawdown_pct) + print(f" └ 最佳夏普: {best_sharpe[0]} ({best_sharpe[1].sharpe_ratio:.2f}) | " + f"最小回撤: {worst_dd[0]} ({worst_dd[1].max_drawdown_pct:.1f}%)") + + print() + + print("═" * 120) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/engine/indicators/__init__.py b/engine/indicators/__init__.py index fe6b00c..3194240 100644 --- a/engine/indicators/__init__.py +++ b/engine/indicators/__init__.py @@ -18,6 +18,7 @@ from .momentum import rsi, stoch, stoch_k, stoch_d from .volatility import bollinger, bollinger_upper, bollinger_mid, bollinger_lower, atr from .volume import obv, vwap from .incremental import EmaInc, AtrInc, RsiInc, BbInc +from .regime import RegimeDetector, MultiTimeframeRegime __all__ = [ # 趋势 @@ -30,4 +31,6 @@ __all__ = [ "obv", "vwap", # 增量 "EmaInc", "AtrInc", "RsiInc", "BbInc", + # 牛熊判定 + "RegimeDetector", "MultiTimeframeRegime", ] diff --git a/engine/indicators/regime.py b/engine/indicators/regime.py new file mode 100644 index 0000000..896ef37 --- /dev/null +++ b/engine/indicators/regime.py @@ -0,0 +1,268 @@ +""" +多周期牛熊判定模块 + +基于 EMA200 斜率、价格 vs EMA200、ATH 回撤三法投票,支持单周期判定 +和多周期并行分析。每根 bar O(1) 增量更新,适用于回测和实时分析。 + +用法: + # 单周期 + det = RegimeDetector() + for price in closes: + det.update(price) + regime = det.detect(closes[-1], len(closes) - 1) + + # 多周期 + mtf = MultiTimeframeRegime(['1h', '4h', '1d', '1w']) + for price in closes: + mtf.update(price) + matrix = mtf.detect_all(closes[-1], len(closes) - 1) +""" + +from .incremental import EmaInc + + +class RegimeDetector: + """单周期牛熊判定器 — 四法投票 + + 四个独立判定维度: + 1. EMA200 斜率:近 20 根 bar 的 EMA200 变化率 → bull/bear/sideways + 2. 价格 vs EMA200:当前价在 EMA200 上方/下方 → bull/bear + 3. ATH 回撤:当前价距历史高点的回撤幅度 → bull/bear/sideways + 4. 窄幅盘整:近 20 根 bar 收盘价振幅 < 3% → sideways(否则 unknown,不投票) + + 最终判定:四选二投票(>=2 票即定论)。 + """ + + def __init__(self, range_period: int = 20, range_threshold: float = 0.03): + self._ath = 0.0 + self._e200 = EmaInc(200) + self._range_period = range_period + self._range_threshold = range_threshold + self._recent_high: list[float] = [] + self._recent_low: list[float] = [] + + def update(self, price: float) -> None: + """每根 bar 调一次:更新 ATH + EMA200 + 价格区间窗口""" + if price > self._ath: + self._ath = price + self._e200.update(price) + # 滚动窗口:维持最近 range_period 根 bar 的最高/最低(只用 close 做代理) + self._recent_high.append(price) + self._recent_low.append(price) + if len(self._recent_high) > self._range_period: + self._recent_high.pop(0) + self._recent_low.pop(0) + + def detect(self, price: float, idx: int) -> str: + """返回当前 bar 的牛熊判定:bull / bear / sideways""" + r1 = self._ema200_slope(idx) + r2 = self._price_vs_ema200(price, idx) + r3 = self._ath_drawdown(price) + r4 = self._price_range() + + b = sum(1 for r in [r1, r2, r3, r4] if r == "bull") + br = sum(1 for r in [r1, r2, r3, r4] if r == "bear") + sx = sum(1 for r in [r1, r2, r3, r4] if r == "sideways") + + if b >= 2: + return "bull" + if br >= 2: + return "bear" + if sx >= 2: + return "sideways" + return "sideways" + + def detect_detail(self, price: float, idx: int) -> dict: + """返回详细判定(含各子法的结果 + 三态票数)""" + r1 = self._ema200_slope(idx) + r2 = self._price_vs_ema200(price, idx) + r3 = self._ath_drawdown(price) + r4 = self._price_range() + b = sum(1 for r in [r1, r2, r3, r4] if r == "bull") + br = sum(1 for r in [r1, r2, r3, r4] if r == "bear") + sx = sum(1 for r in [r1, r2, r3, r4] if r == "sideways") + if b >= 2: + final = "bull" + elif br >= 2: + final = "bear" + elif sx >= 2: + final = "sideways" + else: + final = "sideways" + return { + "final": final, + "ema200_slope": r1, + "price_vs_ema200": r2, + "ath_drawdown": r3, + "price_range": r4, + "bull_votes": b, + "bear_votes": br, + "sideways_votes": sx, + "range_pct": self._range_pct(), + } + + def _ema200_slope(self, idx: int) -> str: + if idx < 220: + return "unknown" + e200 = self._e200 + if e200[idx - 20] == 0: + return "unknown" + slope = (e200[idx] - e200[idx - 20]) / e200[idx - 20] + if slope > 0.002: + return "bull" + if slope < -0.002: + return "bear" + return "sideways" + + def _price_vs_ema200(self, price: float, idx: int) -> str: + if idx < 210: + return "unknown" + e = self._e200[idx] + if e == 0: + return "unknown" + return "bull" if price > e else "bear" + + def _ath_drawdown(self, price: float) -> str: + if self._ath == 0: + return "unknown" + dd = (price - self._ath) / self._ath + if dd > -0.20: + return "bull" + if dd < -0.40: + return "bear" + return "sideways" + + def _price_range(self) -> str: + """窄幅盘整检测:近 range_period 根 bar 振幅 < 阈值 → sideways""" + if len(self._recent_high) < self._range_period: + return "unknown" + highest = max(self._recent_high) + lowest = min(self._recent_low) + if highest == 0: + return "unknown" + range_pct = (highest - lowest) / highest + return "sideways" if range_pct < self._range_threshold else "unknown" + + def _range_pct(self) -> float: + """当前波动区间百分比(用于展示)""" + if len(self._recent_high) < self._range_period: + return 0.0 + highest = max(self._recent_high) + if highest == 0: + return 0.0 + return (highest - min(self._recent_low)) / highest + + @property + def ema200(self) -> float: + """最新 EMA200 值""" + return self._e200.current + + @property + def ath(self) -> float: + """历史最高价""" + return self._ath + + @property + def ready(self) -> bool: + """是否已有足够数据做判定""" + return len(self._e200) >= 220 + + +class MultiTimeframeRegime: + """多周期牛熊并行分析 + + 管理多个 RegimeDetector 实例,每个对应一个周期。 + 所有检测器共用同一价格序列更新,保证 O(1)/bar 的增量计算。 + + Attributes: + timeframes: 周期列表(如 ['1h', '4h', '1d', '1w']) + detectors: 周期 → RegimeDetector 映射 + _prices: 价格序列(所有检测器共享) + """ + + def __init__(self, timeframes: list[str]): + self.timeframes = timeframes + self.detectors: dict[str, RegimeDetector] = { + tf: RegimeDetector() for tf in timeframes + } + self._prices: list[float] = [] + + def update(self, price: float) -> None: + """更新所有周期检测器""" + self._prices.append(price) + for det in self.detectors.values(): + det.update(price) + + def detect_all(self) -> dict[str, str]: + """返回各周期当前判定(需至少 220 根 bar)""" + n = len(self._prices) + if n < 220: + return {tf: "unknown" for tf in self.timeframes} + price = self._prices[-1] + return { + tf: self.detectors[tf].detect(price, n - 1) + for tf in self.timeframes + } + + def detect_all_detail(self) -> dict[str, dict]: + """返回各周期详细判定""" + n = len(self._prices) + if n < 220: + return {tf: {"final": "unknown"} for tf in self.timeframes} + price = self._prices[-1] + return { + tf: self.detectors[tf].detect_detail(price, n - 1) + for tf in self.timeframes + } + + def detect_at(self, idx: int) -> dict[str, str]: + """返回指定 bar 位置各周期的判定""" + if idx < 0 or idx >= len(self._prices): + raise IndexError(f"idx {idx} out of range [0, {len(self._prices)})") + price = self._prices[idx] + result = {} + for tf in self.timeframes: + if idx < 220: + result[tf] = "unknown" + else: + result[tf] = self.detectors[tf].detect(price, idx) + return result + + def score(self) -> float: + """加权综合评分 [-1, 1] + + 各周期赋分:bull=+1, sideways=0, bear=-1 + 权重:周期越长权重越大 + + Returns: + -1.0 (强熊) ~ +1.0 (强牛) + """ + n = len(self._prices) + if n < 220: + return 0.0 + + # 周期权重:1h=1, 4h=2, 1d=3, 1w=4, 1mon=5 + tf_weights = { + "1m": 1, "3m": 1, "5m": 1, "15m": 2, "30m": 2, + "1h": 3, "2h": 3, "4h": 4, "6h": 4, "8h": 4, + "1d": 5, "1w": 6, "1mon": 7, + } + + score_map = {"bull": 1.0, "sideways": 0.0, "bear": -1.0, "unknown": 0.0} + regimes = self.detect_all() + total_weight = 0.0 + weighted_score = 0.0 + + for tf, regime in regimes.items(): + w = tf_weights.get(tf, 1) + weighted_score += score_map[regime] * w + total_weight += w + + if total_weight == 0: + return 0.0 + return weighted_score / total_weight + + @property + def ready(self) -> bool: + """所有检测器是否都已就绪""" + return len(self._prices) >= 220