Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7e16edd2c9 | ||
|
|
5c10e35b76 | ||
|
|
8c165f11cd | ||
|
|
97e77847d0 | ||
|
|
90499533fb | ||
|
|
8ee11317d3 | ||
|
|
1e60ab3bfa | ||
|
|
d3188ca83c | ||
|
|
ac6be80278 | ||
|
|
081a57a90e | ||
|
|
df27b4dde8 | ||
|
|
9f1e7361b6 |
@@ -0,0 +1,33 @@
|
|||||||
|
# chan — Agent Entry
|
||||||
|
|
||||||
|
本仓受 ESS 约束。不要一上来扫全库或加载全部 governance。
|
||||||
|
|
||||||
|
## Boot
|
||||||
|
|
||||||
|
1. `docs/PROJECT_PROFILE.md`
|
||||||
|
2. `docs/PROJECT_RULES.md`
|
||||||
|
3. `docs/STATE/CURRENT.md` + `docs/AGENT_MEMORY.md`
|
||||||
|
4. 有进行中任务再读 `docs/TASKS/` / 对应 ECR / HANDOFF
|
||||||
|
5. 角色文件:ESS 根目录 `agents/{ARCHITECT|ENGINEER|REVIEWER|RELEASE_MANAGER}.md`
|
||||||
|
|
||||||
|
## Roles(选一)
|
||||||
|
|
||||||
|
| 意图 | 角色 |
|
||||||
|
|------|------|
|
||||||
|
| 规格 / 架构 / ECR | ARCHITECT |
|
||||||
|
| 实现 / 修 bug | ENGINEER |
|
||||||
|
| 审阅 | REVIEWER |
|
||||||
|
| 发版 / tag | RELEASE_MANAGER |
|
||||||
|
|
||||||
|
## Never
|
||||||
|
|
||||||
|
- 无 ECR 改 `config/` / `strategies/` 交易逻辑
|
||||||
|
- 无 ADR 改缠论算法语义
|
||||||
|
- 无 ECR 删减 `/api/analyze` 字段
|
||||||
|
- 把聊天记录当成完成;阶段结束须落盘 `docs/`
|
||||||
|
|
||||||
|
## Pointers
|
||||||
|
|
||||||
|
- TRACEABILITY: `docs/TRACEABILITY.md`
|
||||||
|
- CHANGELOG: `docs/CHANGELOG/CHANGELOG.md`
|
||||||
|
- 人类向导:`CLAUDE.md`
|
||||||
@@ -8,9 +8,11 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||||||
|
|
||||||
## Governance
|
## Governance
|
||||||
|
|
||||||
- ESS 文档:`docs/PROJECT_PROFILE.md`、`docs/ECR/`、`docs/ENGINEERING_SPEC/`
|
- Agent 入口:`AGENTS.md`(boot 顺序)· `docs/PROJECT_PROFILE.md` · `docs/AGENT_MEMORY.md` · `docs/STATE/CURRENT.md`
|
||||||
|
- ESS 文档:`docs/ECR/`、`docs/ENGINEERING_SPEC/`、`docs/TRACEABILITY.md`、`docs/CHANGELOG/`
|
||||||
- **正式引擎包**:`chanlun/`;strategies / web 已用 `from chanlun import ...`
|
- **正式引擎包**:`chanlun/`;strategies / web 已用 `from chanlun import ...`
|
||||||
- 根目录 `Chan*.py` / `TF_DF.py` 仍为 **兼容 shim**(旧脚本可用)
|
- 根目录 `Chan*.py` / `TF_DF.py` 仍为 **兼容 shim**(旧脚本可用)
|
||||||
|
- 变更分级:无 ECR 不改 strategies/config;无 ADR 不改缠论算法语义
|
||||||
|
|
||||||
## Core Architecture
|
## Core Architecture
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
"""威科夫分析(启发式):交易区间 / 阶段 / 事件 / Volume Profile / Live。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from .engine import analyze_wyckoff
|
||||||
|
from .live import execution_signal_from_wyckoff
|
||||||
|
|
||||||
|
__all__ = ["analyze_wyckoff", "execution_signal_from_wyckoff"]
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
"""威科夫分析入口:Cycle → Phase → Event → VP + Live(MULTI-CYCLE / LIVE-STRUCTURE)。
|
||||||
|
|
||||||
|
range.py 只产 TradingRange;Confirmed 走 events.py;Live 走 live.py。
|
||||||
|
cycles[0]=ACTIVE;禁止 cycles[-1] 取 active。
|
||||||
|
Execution 只消费 Confirmed(见 live.execution_signal_from_wyckoff)。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
from .events import build_phases, detect_bias_and_events
|
||||||
|
from .live import analyze_live_structure
|
||||||
|
from .range import detect_trading_ranges
|
||||||
|
from .volume_profile import compute_volume_profile
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt_time(v) -> Optional[str]:
|
||||||
|
if v is None:
|
||||||
|
return None
|
||||||
|
if hasattr(v, "isoformat"):
|
||||||
|
try:
|
||||||
|
return v.isoformat()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return str(v)
|
||||||
|
|
||||||
|
|
||||||
|
def _empty(vp_bins: int) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"cycles": [],
|
||||||
|
"trading_range": None,
|
||||||
|
"bias": "unknown",
|
||||||
|
"phases": [],
|
||||||
|
"events": [],
|
||||||
|
"volume_profile": {"bins": [], "poc": None, "vah": None, "val": None, "bin_count": vp_bins},
|
||||||
|
"volume_confirm": {"avg_volume": 0.0, "event_checks": {}},
|
||||||
|
"live": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _confidence_for_confirmed(
|
||||||
|
tr: Dict[str, Any],
|
||||||
|
phases: List[Dict[str, Any]],
|
||||||
|
events: List[Dict[str, Any]],
|
||||||
|
) -> Dict[str, float]:
|
||||||
|
range_c = float(tr.get("range_confidence") or 0.5)
|
||||||
|
labels = {p.get("phase") for p in phases}
|
||||||
|
phase_c = 0.35
|
||||||
|
if "A" in labels and "B" in labels:
|
||||||
|
phase_c += 0.15
|
||||||
|
if "C" in labels:
|
||||||
|
phase_c += 0.2
|
||||||
|
if "D" in labels or "E" in labels:
|
||||||
|
phase_c += 0.15
|
||||||
|
phase_c = min(0.95, phase_c)
|
||||||
|
types = {e.get("type") for e in events}
|
||||||
|
event_c = 0.25
|
||||||
|
for t in ("Spring", "UTAD", "SOS", "SOW", "LPS", "LPSY"):
|
||||||
|
if t in types:
|
||||||
|
event_c += 0.12
|
||||||
|
event_c = min(0.95, event_c)
|
||||||
|
overall = 0.4 * range_c + 0.3 * phase_c + 0.3 * event_c
|
||||||
|
return {
|
||||||
|
"range": round(range_c, 3),
|
||||||
|
"phase": round(phase_c, 3),
|
||||||
|
"event": round(event_c, 3),
|
||||||
|
"overall": round(overall, 3),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _build_cycle(
|
||||||
|
work: pd.DataFrame,
|
||||||
|
tr: Dict[str, Any],
|
||||||
|
cycle_id: int,
|
||||||
|
vp_bins: int,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
bias, events, volume_confirm = detect_bias_and_events(work, tr)
|
||||||
|
phases = build_phases(work, tr, bias, events)
|
||||||
|
vp = compute_volume_profile(
|
||||||
|
work,
|
||||||
|
int(tr["abs_start_idx"]),
|
||||||
|
int(tr["abs_end_idx"]),
|
||||||
|
bin_count=vp_bins,
|
||||||
|
)
|
||||||
|
for ev in events:
|
||||||
|
ev["time"] = _fmt_time(ev.get("time"))
|
||||||
|
for ph in phases:
|
||||||
|
ph["start_time"] = _fmt_time(ph.get("start_time"))
|
||||||
|
ph["end_time"] = _fmt_time(ph.get("end_time"))
|
||||||
|
|
||||||
|
is_active = cycle_id == 0
|
||||||
|
trading_range = {
|
||||||
|
"start_time": _fmt_time(tr.get("start_time")),
|
||||||
|
"end_time": _fmt_time(tr.get("end_time")),
|
||||||
|
"high": float(tr["high"]),
|
||||||
|
"low": float(tr["low"]),
|
||||||
|
"mid": float(tr["mid"]),
|
||||||
|
"active": bool(is_active),
|
||||||
|
"bars": int(tr.get("bars", 0)),
|
||||||
|
}
|
||||||
|
conf = _confidence_for_confirmed(tr, phases, events)
|
||||||
|
|
||||||
|
# Live 层:仅 ACTIVE 周期做推演;历史周期归档为 COMPLETED
|
||||||
|
if is_active:
|
||||||
|
live = analyze_live_structure(
|
||||||
|
work, tr, confirmed_events=events, confirmed_phases=phases, bias=bias,
|
||||||
|
)
|
||||||
|
lifecycle = live.get("lifecycle") or "FORMING"
|
||||||
|
else:
|
||||||
|
live = None
|
||||||
|
lifecycle = "COMPLETED"
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": int(cycle_id),
|
||||||
|
"role": "latest" if is_active else "historical",
|
||||||
|
# MULTI-CYCLE:时间线角色
|
||||||
|
"status": "ACTIVE" if is_active else "HISTORICAL",
|
||||||
|
# LIVE-STRUCTURE:生命周期
|
||||||
|
"lifecycle": lifecycle,
|
||||||
|
"direction": "latest" if is_active else "historical",
|
||||||
|
"period": {
|
||||||
|
"start_time": _fmt_time(tr.get("start_time")),
|
||||||
|
"end_time": _fmt_time(tr.get("end_time")),
|
||||||
|
"bars": int(tr.get("bars", 0)),
|
||||||
|
},
|
||||||
|
"confidence": conf,
|
||||||
|
"trading_range": trading_range,
|
||||||
|
"bias": bias,
|
||||||
|
# 兼容旧读法:顶层 phases/events = confirmed
|
||||||
|
"phases": phases,
|
||||||
|
"events": events,
|
||||||
|
"confirmed": {
|
||||||
|
"phases": phases,
|
||||||
|
"events": events,
|
||||||
|
"volume_confirm": volume_confirm,
|
||||||
|
},
|
||||||
|
"live": live,
|
||||||
|
"volume_profile": vp,
|
||||||
|
"volume_confirm": volume_confirm,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def analyze_wyckoff(
|
||||||
|
df: pd.DataFrame,
|
||||||
|
lookback: int = 120,
|
||||||
|
vp_bins: int = 50,
|
||||||
|
min_bars: int = 24,
|
||||||
|
atr_mult: float = 1.2,
|
||||||
|
range_start_time=None,
|
||||||
|
prefer_start_time=None,
|
||||||
|
max_cycles: int = 8,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
多周期威科夫分析。
|
||||||
|
cycles[0] = ACTIVE;顶层 phases/events 只镜像 Confirmed。
|
||||||
|
顶层 live 镜像 cycles[0].live。
|
||||||
|
"""
|
||||||
|
empty = _empty(vp_bins)
|
||||||
|
if df is None or len(df) < 30:
|
||||||
|
return empty
|
||||||
|
if not all(c in df.columns for c in ("open", "high", "low", "close")):
|
||||||
|
return empty
|
||||||
|
work = df.copy()
|
||||||
|
if "volume" not in work.columns:
|
||||||
|
work["volume"] = 1.0
|
||||||
|
|
||||||
|
trs = detect_trading_ranges(
|
||||||
|
work,
|
||||||
|
lookback=lookback,
|
||||||
|
min_bars=max(8, int(min_bars)),
|
||||||
|
atr_mult=atr_mult,
|
||||||
|
max_cycles=max(1, min(8, int(max_cycles))),
|
||||||
|
prefer_start_time=prefer_start_time,
|
||||||
|
range_start_time=range_start_time,
|
||||||
|
)
|
||||||
|
if not trs:
|
||||||
|
return empty
|
||||||
|
|
||||||
|
cycles: List[Dict[str, Any]] = []
|
||||||
|
for i, tr in enumerate(trs):
|
||||||
|
cycles.append(_build_cycle(work, tr, cycle_id=i, vp_bins=vp_bins))
|
||||||
|
|
||||||
|
active = cycles[0]
|
||||||
|
return {
|
||||||
|
"cycles": cycles,
|
||||||
|
"trading_range": active["trading_range"],
|
||||||
|
"bias": active["bias"],
|
||||||
|
"phases": active["confirmed"]["phases"],
|
||||||
|
"events": active["confirmed"]["events"],
|
||||||
|
"volume_profile": active["volume_profile"],
|
||||||
|
"volume_confirm": active["volume_confirm"],
|
||||||
|
"live": active.get("live"),
|
||||||
|
"lifecycle": active.get("lifecycle"),
|
||||||
|
}
|
||||||
@@ -0,0 +1,369 @@
|
|||||||
|
"""威科夫阶段与事件(启发式)。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
|
||||||
|
def _bar_time(df: pd.DataFrame, i: int):
|
||||||
|
row = df.iloc[i]
|
||||||
|
if "date" in df.columns and pd.notna(row["date"]):
|
||||||
|
return row["date"]
|
||||||
|
if "timestamp" in df.columns:
|
||||||
|
return row["timestamp"]
|
||||||
|
return i
|
||||||
|
|
||||||
|
|
||||||
|
def _avg_vol(df: pd.DataFrame, i: int, win: int = 20) -> float:
|
||||||
|
a = max(0, i - win + 1)
|
||||||
|
v = df["volume"].astype(float).iloc[a : i + 1]
|
||||||
|
m = float(v.mean()) if len(v) else 0.0
|
||||||
|
return m if m > 0 else 1.0
|
||||||
|
|
||||||
|
|
||||||
|
def detect_bias_and_events(
|
||||||
|
df: pd.DataFrame,
|
||||||
|
tr: Dict[str, Any],
|
||||||
|
) -> Tuple[str, List[Dict[str, Any]], Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
返回 bias、events、volume_confirm。
|
||||||
|
|
||||||
|
Spring/UTAD 相对「结构高低」判定:取区间内次低/次高(剔除单根极值),
|
||||||
|
避免箱体把假破低点吃进 lo 后永远刺不破、从而无 C 阶段。
|
||||||
|
"""
|
||||||
|
hi = float(tr["high"])
|
||||||
|
lo = float(tr["low"])
|
||||||
|
mid = float(tr["mid"])
|
||||||
|
tol = float(tr.get("tol") or (hi - lo) * 0.05)
|
||||||
|
s = int(tr["abs_start_idx"])
|
||||||
|
e = int(tr["abs_end_idx"])
|
||||||
|
events: List[Dict[str, Any]] = []
|
||||||
|
|
||||||
|
# 结构边界:用次低/次高作假破参照(至少 8 根才启用)
|
||||||
|
seg = df.iloc[s : e + 1]
|
||||||
|
event_lo, event_hi = lo, hi
|
||||||
|
if len(seg) >= 8:
|
||||||
|
lows = seg["low"].astype(float)
|
||||||
|
highs = seg["high"].astype(float)
|
||||||
|
# nsmallest(2) 的较大者 = 次低;nlargest(2) 的较小者 = 次高
|
||||||
|
event_lo = float(lows.nsmallest(min(2, len(lows))).iloc[-1])
|
||||||
|
event_hi = float(highs.nlargest(min(2, len(highs))).iloc[-1])
|
||||||
|
# 勿比公布箱沿更「松」:结构带应在箱内
|
||||||
|
event_lo = max(event_lo, lo)
|
||||||
|
event_hi = min(event_hi, hi)
|
||||||
|
# 若次低仍等于极值(多根同价),略抬参照便于识别收回
|
||||||
|
if abs(event_lo - lo) < 1e-12:
|
||||||
|
event_lo = lo + max(tol * 0.35, (hi - lo) * 0.02)
|
||||||
|
if abs(event_hi - hi) < 1e-12:
|
||||||
|
event_hi = hi - max(tol * 0.35, (hi - lo) * 0.02)
|
||||||
|
|
||||||
|
# 扫描区间内及之后(含 tail_reserve)
|
||||||
|
scan_end = int(tr.get("abs_scan_end_idx", min(len(df) - 1, e + 15)))
|
||||||
|
scan_end = min(len(df) - 1, max(scan_end, e))
|
||||||
|
spring = None
|
||||||
|
utad = None
|
||||||
|
sos = None
|
||||||
|
sod = None # sign of weakness / distribution breakdown
|
||||||
|
lps = None
|
||||||
|
lpsy = None
|
||||||
|
|
||||||
|
for i in range(s + 2, scan_end + 1):
|
||||||
|
row = df.iloc[i]
|
||||||
|
low = float(row["low"])
|
||||||
|
high = float(row["high"])
|
||||||
|
close = float(row["close"])
|
||||||
|
vol = float(row["volume"]) if "volume" in df.columns else 0.0
|
||||||
|
avg_v = _avg_vol(df, i)
|
||||||
|
ratio = vol / avg_v if avg_v else 0.0
|
||||||
|
|
||||||
|
# Spring: pierce below structural support then close back
|
||||||
|
if spring is None and low < event_lo - tol * 0.35 and close >= event_lo - tol * 0.35:
|
||||||
|
vol_ok = ratio <= 1.35 or (i + 1 <= scan_end and float(df.iloc[min(i + 1, scan_end)]["volume"]) / avg_v < 1.2)
|
||||||
|
spring = {
|
||||||
|
"type": "Spring",
|
||||||
|
"time": _bar_time(df, i),
|
||||||
|
"price": low,
|
||||||
|
"note": "假破下沿后收回",
|
||||||
|
"volume_ratio": round(ratio, 3),
|
||||||
|
"volume_ok": bool(vol_ok),
|
||||||
|
"idx": i,
|
||||||
|
}
|
||||||
|
|
||||||
|
# UTAD: pierce above structural resistance then close back
|
||||||
|
if utad is None and high > event_hi + tol * 0.35 and close <= event_hi + tol * 0.35:
|
||||||
|
vol_ok = ratio >= 0.8
|
||||||
|
utad = {
|
||||||
|
"type": "UTAD",
|
||||||
|
"time": _bar_time(df, i),
|
||||||
|
"price": high,
|
||||||
|
"note": "假破上沿后跌回",
|
||||||
|
"volume_ratio": round(ratio, 3),
|
||||||
|
"volume_ok": bool(vol_ok),
|
||||||
|
"idx": i,
|
||||||
|
}
|
||||||
|
|
||||||
|
# SOS: close above high with volume
|
||||||
|
if sos is None and close > hi + tol * 0.15:
|
||||||
|
vol_ok = ratio >= 1.15
|
||||||
|
sos = {
|
||||||
|
"type": "SOS",
|
||||||
|
"time": _bar_time(df, i),
|
||||||
|
"price": close,
|
||||||
|
"note": "放量上破交易区间",
|
||||||
|
"volume_ratio": round(ratio, 3),
|
||||||
|
"volume_ok": bool(vol_ok),
|
||||||
|
"idx": i,
|
||||||
|
}
|
||||||
|
|
||||||
|
# SOW / breakdown
|
||||||
|
if sod is None and close < lo - tol * 0.15:
|
||||||
|
vol_ok = ratio >= 1.15
|
||||||
|
sod = {
|
||||||
|
"type": "SOW",
|
||||||
|
"time": _bar_time(df, i),
|
||||||
|
"price": close,
|
||||||
|
"note": "放量下破交易区间",
|
||||||
|
"volume_ratio": round(ratio, 3),
|
||||||
|
"volume_ok": bool(vol_ok),
|
||||||
|
"idx": i,
|
||||||
|
}
|
||||||
|
|
||||||
|
# LPS after SOS: pullback that holds above mid/high-band with lighter volume
|
||||||
|
if sos is not None:
|
||||||
|
si = int(sos["idx"])
|
||||||
|
for i in range(si + 1, min(len(df), si + 25)):
|
||||||
|
row = df.iloc[i]
|
||||||
|
low = float(row["low"])
|
||||||
|
close = float(row["close"])
|
||||||
|
vol = float(row["volume"]) if "volume" in df.columns else 0.0
|
||||||
|
avg_v = _avg_vol(df, i)
|
||||||
|
ratio = vol / avg_v if avg_v else 0.0
|
||||||
|
if low >= mid - tol and close >= hi - tol * 2:
|
||||||
|
vol_ok = ratio <= 1.05
|
||||||
|
lps = {
|
||||||
|
"type": "LPS",
|
||||||
|
"time": _bar_time(df, i),
|
||||||
|
"price": low,
|
||||||
|
"note": "突破后缩量回踩不破",
|
||||||
|
"volume_ratio": round(ratio, 3),
|
||||||
|
"volume_ok": bool(vol_ok),
|
||||||
|
"idx": i,
|
||||||
|
}
|
||||||
|
break
|
||||||
|
|
||||||
|
if sod is not None:
|
||||||
|
si = int(sod["idx"])
|
||||||
|
for i in range(si + 1, min(len(df), si + 25)):
|
||||||
|
row = df.iloc[i]
|
||||||
|
high = float(row["high"])
|
||||||
|
close = float(row["close"])
|
||||||
|
vol = float(row["volume"]) if "volume" in df.columns else 0.0
|
||||||
|
avg_v = _avg_vol(df, i)
|
||||||
|
ratio = vol / avg_v if avg_v else 0.0
|
||||||
|
if high <= mid + tol and close <= lo + tol * 2:
|
||||||
|
vol_ok = ratio <= 1.05
|
||||||
|
lpsy = {
|
||||||
|
"type": "LPSY",
|
||||||
|
"time": _bar_time(df, i),
|
||||||
|
"price": high,
|
||||||
|
"note": "下跌突破后缩量反抽不过",
|
||||||
|
"volume_ratio": round(ratio, 3),
|
||||||
|
"volume_ok": bool(vol_ok),
|
||||||
|
"idx": i,
|
||||||
|
}
|
||||||
|
break
|
||||||
|
|
||||||
|
# 冲突清理:已判定吸筹且有 SOS 时,丢弃更早的 UTAD(避免阶段/图面误导)
|
||||||
|
# 派发且有 SOW 时,丢弃更晚才合理的 Spring 假信号同理在偏置后再滤
|
||||||
|
keep = []
|
||||||
|
for ev in (spring, sos, lps, utad, sod, lpsy):
|
||||||
|
if not ev:
|
||||||
|
continue
|
||||||
|
keep.append(ev)
|
||||||
|
|
||||||
|
# bias(先算)
|
||||||
|
last_c = float(df["close"].iloc[-1])
|
||||||
|
bias = "unknown"
|
||||||
|
if sos and (not sod or int(sos.get("idx", 0)) >= int(sod.get("idx", 0))):
|
||||||
|
bias = "accumulation"
|
||||||
|
elif sod and (not sos or int(sod.get("idx", 0)) > int(sos.get("idx", 0))):
|
||||||
|
bias = "distribution"
|
||||||
|
elif spring and not utad:
|
||||||
|
bias = "accumulation"
|
||||||
|
elif utad and not spring:
|
||||||
|
bias = "distribution"
|
||||||
|
elif last_c >= mid:
|
||||||
|
bias = "accumulation"
|
||||||
|
else:
|
||||||
|
bias = "distribution"
|
||||||
|
|
||||||
|
filtered = []
|
||||||
|
for ev in keep:
|
||||||
|
if bias == "accumulation" and ev["type"] == "UTAD" and sos and int(ev["idx"]) <= int(sos["idx"]):
|
||||||
|
continue
|
||||||
|
if bias == "distribution" and ev["type"] == "Spring" and sod and int(ev["idx"]) <= int(sod["idx"]):
|
||||||
|
continue
|
||||||
|
filtered.append(ev)
|
||||||
|
|
||||||
|
events = [{k: v for k, v in ev.items() if k != "idx"} for ev in filtered]
|
||||||
|
|
||||||
|
avg_volume = float(df["volume"].astype(float).iloc[max(0, e - 20) : e + 1].mean()) if "volume" in df.columns else 0.0
|
||||||
|
volume_confirm = {
|
||||||
|
"avg_volume": avg_volume,
|
||||||
|
"event_checks": {ev["type"]: {"volume_ok": ev.get("volume_ok"), "volume_ratio": ev.get("volume_ratio")} for ev in events},
|
||||||
|
}
|
||||||
|
return bias, events, volume_confirm
|
||||||
|
|
||||||
|
|
||||||
|
def build_phases(
|
||||||
|
df: pd.DataFrame,
|
||||||
|
tr: Dict[str, Any],
|
||||||
|
bias: str,
|
||||||
|
events: List[Dict[str, Any]],
|
||||||
|
min_bars: int = 3,
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
按威科夫事件锚点切分 A–E(启发式)。
|
||||||
|
|
||||||
|
吸筹:A停止 → B筑底 → C测试(Spring) → D拉升(SOS…LPS) → E离开
|
||||||
|
派发:A停止 → B筑顶 → C测试(UTAD) → D派发(SOW…LPSY) → E离开
|
||||||
|
|
||||||
|
无 Spring/UTAD 时:若已有 SOS/SOW,用突破前末次沿带测试补 C;仍无则省略 C。
|
||||||
|
"""
|
||||||
|
s = int(tr["abs_start_idx"])
|
||||||
|
e = int(tr["abs_end_idx"])
|
||||||
|
hi = float(tr["high"])
|
||||||
|
lo = float(tr["low"])
|
||||||
|
n_last = len(df) - 1
|
||||||
|
min_span = max(2, min_bars - 1)
|
||||||
|
range_len = max(1, e - s)
|
||||||
|
|
||||||
|
def _match_idx(t) -> Optional[int]:
|
||||||
|
if t is None:
|
||||||
|
return None
|
||||||
|
lo = max(0, s - 2)
|
||||||
|
hi = min(len(df), e + 40)
|
||||||
|
for i in range(lo, hi):
|
||||||
|
if _bar_time(df, i) == t:
|
||||||
|
return i
|
||||||
|
try:
|
||||||
|
tt = pd.Timestamp(t)
|
||||||
|
sample = None
|
||||||
|
if "date" in df.columns and len(df):
|
||||||
|
sample = df["date"].iloc[min(s, n_last)]
|
||||||
|
if sample is not None and getattr(sample, "tzinfo", None) is not None and tt.tzinfo is None:
|
||||||
|
tt = tt.tz_localize(sample.tzinfo)
|
||||||
|
for i in range(lo, hi):
|
||||||
|
bt = _bar_time(df, i)
|
||||||
|
try:
|
||||||
|
if abs((pd.Timestamp(bt) - tt).total_seconds()) <= 1:
|
||||||
|
return i
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
event_idx: Dict[str, int] = {}
|
||||||
|
for ev in events:
|
||||||
|
idx = _match_idx(ev.get("time"))
|
||||||
|
if idx is not None:
|
||||||
|
event_idx[str(ev.get("type"))] = idx
|
||||||
|
|
||||||
|
accum = bias != "distribution"
|
||||||
|
if accum:
|
||||||
|
c_ev = event_idx.get("Spring")
|
||||||
|
d_ev = event_idx.get("SOS")
|
||||||
|
d_tail = event_idx.get("LPS") or d_ev
|
||||||
|
else:
|
||||||
|
c_ev = event_idx.get("UTAD")
|
||||||
|
d_ev = event_idx.get("SOW")
|
||||||
|
d_tail = event_idx.get("LPSY") or d_ev
|
||||||
|
|
||||||
|
# 有 D 无明确测试事件时:用突破前最后一次触及下/上沿作为 C(次级测试)
|
||||||
|
if c_ev is None and d_ev is not None:
|
||||||
|
band = lo + (hi - lo) * 0.28 if accum else hi - (hi - lo) * 0.28
|
||||||
|
for i in range(int(d_ev) - 1, s + 1, -1):
|
||||||
|
row = df.iloc[i]
|
||||||
|
if accum and float(row["low"]) <= band:
|
||||||
|
c_ev = i
|
||||||
|
break
|
||||||
|
if not accum and float(row["high"]) >= band:
|
||||||
|
c_ev = i
|
||||||
|
break
|
||||||
|
|
||||||
|
def _lab(phase: str) -> str:
|
||||||
|
if accum:
|
||||||
|
m = {"A": "A停止下跌", "B": "B筑底", "C": "C测试", "D": "D拉升", "E": "E离开"}
|
||||||
|
else:
|
||||||
|
m = {"A": "A停止上涨", "B": "B筑顶", "C": "C测试", "D": "D派发", "E": "E离开"}
|
||||||
|
return m.get(phase, phase)
|
||||||
|
|
||||||
|
a_end = s + max(min_bars, range_len // 5)
|
||||||
|
|
||||||
|
c_start = c_end = None
|
||||||
|
if c_ev is not None:
|
||||||
|
c_start = max(s, int(c_ev) - 1)
|
||||||
|
c_end = min(n_last, int(c_ev) + 1)
|
||||||
|
|
||||||
|
if d_ev is not None:
|
||||||
|
d_start = int(d_ev)
|
||||||
|
d_end = min(n_last, max(int(d_tail or d_ev), d_start) + max(min_bars, range_len // 8))
|
||||||
|
if d_tail is not None:
|
||||||
|
d_end = max(d_end, min(n_last, int(d_tail) + 1))
|
||||||
|
else:
|
||||||
|
d_start = d_end = None
|
||||||
|
|
||||||
|
if c_start is not None:
|
||||||
|
b_end = max(a_end + 1, c_start)
|
||||||
|
elif d_start is not None:
|
||||||
|
b_end = max(a_end + 1, d_start)
|
||||||
|
else:
|
||||||
|
b_end = max(a_end + 1, e)
|
||||||
|
|
||||||
|
if d_end is not None:
|
||||||
|
e_start = min(n_last, d_end)
|
||||||
|
e_end = n_last
|
||||||
|
else:
|
||||||
|
e_start = e_end = None
|
||||||
|
|
||||||
|
raw = [("A", s, a_end), ("B", a_end, b_end)]
|
||||||
|
if c_start is not None and c_end is not None:
|
||||||
|
raw.append(("C", c_start, c_end))
|
||||||
|
if d_start is not None and d_end is not None:
|
||||||
|
raw.append(("D", d_start, d_end))
|
||||||
|
if e_start is not None and e_end is not None and e_end > e_start:
|
||||||
|
raw.append(("E", e_start, e_end))
|
||||||
|
|
||||||
|
phases: List[Dict[str, Any]] = []
|
||||||
|
cursor = s
|
||||||
|
for phase, _a, _b in raw:
|
||||||
|
if cursor >= n_last:
|
||||||
|
break
|
||||||
|
a = max(int(_a), cursor)
|
||||||
|
b = int(max(int(_b), a))
|
||||||
|
need = 1 if phase == "C" else min_span
|
||||||
|
if b < a + need:
|
||||||
|
b = min(n_last, a + need)
|
||||||
|
b = int(np.clip(b, a, n_last))
|
||||||
|
if b < a:
|
||||||
|
continue
|
||||||
|
if phases and phases[-1].get("_a") == a and phases[-1].get("_b") == b:
|
||||||
|
continue
|
||||||
|
phases.append(
|
||||||
|
{
|
||||||
|
"phase": phase,
|
||||||
|
"label": _lab(phase),
|
||||||
|
"start_time": _bar_time(df, a),
|
||||||
|
"end_time": _bar_time(df, b),
|
||||||
|
"_a": a,
|
||||||
|
"_b": b,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
cursor = b
|
||||||
|
for p in phases:
|
||||||
|
p.pop("_a", None)
|
||||||
|
p.pop("_b", None)
|
||||||
|
return phases
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
"""威科夫 Live / Developing 层(WYCKOFF-LIVE-STRUCTURE-001)。
|
||||||
|
|
||||||
|
独立于 Confirmed Engine:不修改 events 确认条件,不写入 confirmed.events。
|
||||||
|
Execution 不得消费本模块输出。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Dict, List, Optional, Set
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
|
||||||
|
def _avg_vol(df: pd.DataFrame, i: int, win: int = 20) -> float:
|
||||||
|
a = max(0, i - win + 1)
|
||||||
|
v = df["volume"].astype(float).iloc[a : i + 1]
|
||||||
|
m = float(v.mean()) if len(v) else 0.0
|
||||||
|
return m if m > 0 else 1.0
|
||||||
|
|
||||||
|
|
||||||
|
def _empty_live() -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"lifecycle": "UNKNOWN",
|
||||||
|
"range_formation": None,
|
||||||
|
"phase_candidate": None,
|
||||||
|
"event_candidates": [],
|
||||||
|
"next_expected": None,
|
||||||
|
"confidence": {
|
||||||
|
"cycle": 0.0,
|
||||||
|
"phase": 0.0,
|
||||||
|
"event": 0.0,
|
||||||
|
"structure": 0.0,
|
||||||
|
"volume": 0.0,
|
||||||
|
"overall": 0.0,
|
||||||
|
},
|
||||||
|
"note": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def analyze_live_structure(
|
||||||
|
df: pd.DataFrame,
|
||||||
|
tr: Optional[Dict[str, Any]],
|
||||||
|
confirmed_events: Optional[List[Dict[str, Any]]] = None,
|
||||||
|
confirmed_phases: Optional[List[Dict[str, Any]]] = None,
|
||||||
|
bias: str = "unknown",
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
基于当前 TradingRange 与已确认事件,推演 Live candidates。
|
||||||
|
confirmed_* 只读,用于避免重复提示已确认事件,不修改之。
|
||||||
|
"""
|
||||||
|
out = _empty_live()
|
||||||
|
if df is None or len(df) < 20 or tr is None:
|
||||||
|
out["note"] = "insufficient structure"
|
||||||
|
return out
|
||||||
|
|
||||||
|
confirmed_events = confirmed_events or []
|
||||||
|
confirmed_phases = confirmed_phases or []
|
||||||
|
confirmed_types: Set[str] = {str(e.get("type")) for e in confirmed_events if e.get("type")}
|
||||||
|
|
||||||
|
s = int(tr["abs_start_idx"])
|
||||||
|
e = int(tr["abs_end_idx"])
|
||||||
|
scan_end = int(tr.get("abs_scan_end_idx", len(df) - 1))
|
||||||
|
scan_end = min(len(df) - 1, max(scan_end, e))
|
||||||
|
hi = float(tr["high"])
|
||||||
|
lo = float(tr["low"])
|
||||||
|
mid = float(tr["mid"])
|
||||||
|
tol = float(tr.get("tol") or (hi - lo) * 0.05)
|
||||||
|
atr = float(tr.get("atr") or max((hi - lo) * 0.2, 1e-9))
|
||||||
|
|
||||||
|
seg = df.iloc[s : e + 1]
|
||||||
|
if len(seg) < 8:
|
||||||
|
out["note"] = "range too short"
|
||||||
|
return out
|
||||||
|
|
||||||
|
# —— Range Formation(横盘 / 波动收敛)——
|
||||||
|
closes = seg["close"].astype(float)
|
||||||
|
highs = seg["high"].astype(float)
|
||||||
|
lows = seg["low"].astype(float)
|
||||||
|
vols = seg["volume"].astype(float) if "volume" in seg.columns else pd.Series([1.0] * len(seg))
|
||||||
|
half = max(4, len(seg) // 2)
|
||||||
|
vol_early = float(np.std(closes.iloc[:half])) if half > 1 else 0.0
|
||||||
|
vol_late = float(np.std(closes.iloc[-half:])) if half > 1 else 0.0
|
||||||
|
width = hi - lo
|
||||||
|
width_atr = width / atr if atr > 0 else 99.0
|
||||||
|
converging = vol_early > 1e-12 and vol_late < vol_early * 0.85
|
||||||
|
range_ok = 1.2 <= width_atr <= 10.0 and len(seg) >= 16
|
||||||
|
structure_score = 0.35
|
||||||
|
if range_ok:
|
||||||
|
structure_score += 0.25
|
||||||
|
if converging:
|
||||||
|
structure_score += 0.2
|
||||||
|
if width_atr <= 6.0:
|
||||||
|
structure_score += 0.1
|
||||||
|
structure_score = float(min(0.95, structure_score))
|
||||||
|
|
||||||
|
out["range_formation"] = {
|
||||||
|
"potential_trading_range": bool(range_ok),
|
||||||
|
"converging": bool(converging),
|
||||||
|
"width_atr": round(width_atr, 3),
|
||||||
|
"bars": int(len(seg)),
|
||||||
|
}
|
||||||
|
|
||||||
|
# —— 最近 K 形态(Phase C / Event candidates)——
|
||||||
|
i = scan_end
|
||||||
|
row = df.iloc[i]
|
||||||
|
o = float(row["open"])
|
||||||
|
h = float(row["high"])
|
||||||
|
l = float(row["low"])
|
||||||
|
c = float(row["close"])
|
||||||
|
rng = max(h - l, 1e-9)
|
||||||
|
lower_wick = min(o, c) - l
|
||||||
|
upper_wick = h - max(o, c)
|
||||||
|
avg_v = _avg_vol(df, i)
|
||||||
|
vol = float(row["volume"]) if "volume" in df.columns else avg_v
|
||||||
|
vol_ratio = vol / avg_v if avg_v else 1.0
|
||||||
|
volume_score = float(np.clip(1.1 - abs(vol_ratio - 1.0) * 0.35, 0.2, 0.95))
|
||||||
|
|
||||||
|
phase_candidate = None
|
||||||
|
phase_conf = 0.0
|
||||||
|
# Phase C:测低 + 下影 + 缩量(吸筹语境)
|
||||||
|
near_lo = l <= lo + tol * 1.2
|
||||||
|
test_low = l < mid and lower_wick >= rng * 0.35
|
||||||
|
vol_contract = vol_ratio <= 1.05
|
||||||
|
if bias != "distribution" and near_lo and test_low and vol_contract:
|
||||||
|
phase_candidate = "C"
|
||||||
|
phase_conf = 0.55 + (0.1 if lower_wick >= rng * 0.5 else 0) + (0.08 if vol_ratio < 0.9 else 0)
|
||||||
|
# Phase D 候选:价格在箱上半、有上破意图但未确认 SOS
|
||||||
|
elif c >= mid and (h >= hi - tol or c > hi - tol * 0.5):
|
||||||
|
phase_candidate = "D"
|
||||||
|
phase_conf = 0.5 + (0.1 if c > mid else 0)
|
||||||
|
elif c < mid and (l <= lo + tol):
|
||||||
|
phase_candidate = "B"
|
||||||
|
phase_conf = 0.45
|
||||||
|
|
||||||
|
# 已有 confirmed phase 时,candidate 取「下一阶段」提示,不覆盖事实
|
||||||
|
confirmed_phase_set = {str(p.get("phase")) for p in confirmed_phases}
|
||||||
|
if "E" in confirmed_phase_set:
|
||||||
|
phase_candidate = phase_candidate or "E"
|
||||||
|
phase_conf = max(phase_conf, 0.7)
|
||||||
|
elif "D" in confirmed_phase_set and phase_candidate is None:
|
||||||
|
phase_candidate = "D"
|
||||||
|
phase_conf = max(phase_conf, 0.65)
|
||||||
|
|
||||||
|
out["phase_candidate"] = phase_candidate
|
||||||
|
phase_conf = float(min(0.92, phase_conf))
|
||||||
|
|
||||||
|
# —— Event candidates(仅 Spring / SOS / LPS / UTAD)——
|
||||||
|
candidates: List[Dict[str, Any]] = []
|
||||||
|
|
||||||
|
def _add(typ: str, conf: float, note: str) -> None:
|
||||||
|
if typ in confirmed_types:
|
||||||
|
return # 已确认则不再作为 candidate
|
||||||
|
candidates.append(
|
||||||
|
{
|
||||||
|
"type": typ,
|
||||||
|
"confidence": round(float(min(0.9, conf)), 3),
|
||||||
|
"confirmed": False,
|
||||||
|
"note": note,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Spring candidate:刺破或贴近下沿,收盘收回,但未达 Confirmed 规则(或不在 confirmed)
|
||||||
|
pierce_lo = l < lo - tol * 0.15
|
||||||
|
close_back = c >= lo - tol * 0.5
|
||||||
|
if pierce_lo and close_back:
|
||||||
|
_add("Spring", 0.5 + (0.12 if vol_ratio <= 1.2 else 0) + (0.08 if close_back else 0), "假破下沿收回(未确认)")
|
||||||
|
elif l <= lo + tol * 0.35 and close_back and lower_wick >= rng * 0.4:
|
||||||
|
_add("Spring", 0.45 + (0.1 if vol_contract else 0), "测下沿长下影(未确认)")
|
||||||
|
|
||||||
|
# UTAD candidate
|
||||||
|
pierce_hi = h > hi + tol * 0.15
|
||||||
|
close_back_dn = c <= hi + tol * 0.5
|
||||||
|
if pierce_hi and close_back_dn:
|
||||||
|
_add("UTAD", 0.5 + (0.1 if vol_ratio >= 0.9 else 0), "假破上沿跌回(未确认)")
|
||||||
|
|
||||||
|
# SOS candidate:接近/轻破上沿,量能一般,未确认
|
||||||
|
if c > hi - tol * 0.4 or h >= hi:
|
||||||
|
sos_conf = 0.48 + (0.12 if c > hi else 0) + (0.1 if vol_ratio >= 1.05 else 0)
|
||||||
|
_add("SOS", sos_conf, "上破/逼近箱顶(未确认)")
|
||||||
|
|
||||||
|
# LPS candidate:站上 mid/上沿带后回踩
|
||||||
|
if c >= mid and l >= mid - tol * 1.5 and l > lo + (hi - lo) * 0.25:
|
||||||
|
_add("LPS", 0.46 + (0.1 if vol_ratio <= 1.0 else 0), "箱内上沿带回踩(未确认)")
|
||||||
|
|
||||||
|
candidates.sort(key=lambda x: x["confidence"], reverse=True)
|
||||||
|
out["event_candidates"] = candidates[:4]
|
||||||
|
|
||||||
|
event_score = float(candidates[0]["confidence"]) if candidates else 0.25
|
||||||
|
|
||||||
|
# next_expected(简规则)
|
||||||
|
next_exp = None
|
||||||
|
if "Spring" in confirmed_types and "SOS" not in confirmed_types:
|
||||||
|
next_exp = "SOS"
|
||||||
|
elif "SOS" in confirmed_types and "LPS" not in confirmed_types:
|
||||||
|
next_exp = "LPS"
|
||||||
|
elif "UTAD" in confirmed_types and "SOW" not in confirmed_types:
|
||||||
|
next_exp = "SOW"
|
||||||
|
elif any(c["type"] == "Spring" for c in candidates):
|
||||||
|
next_exp = "Test"
|
||||||
|
elif any(c["type"] == "SOS" for c in candidates):
|
||||||
|
next_exp = "LPS"
|
||||||
|
out["next_expected"] = next_exp
|
||||||
|
|
||||||
|
# —— lifecycle ——
|
||||||
|
key_confirmed = confirmed_types & {"Spring", "SOS", "UTAD", "SOW", "LPS", "LPSY"}
|
||||||
|
if key_confirmed:
|
||||||
|
lifecycle = "CONFIRMED"
|
||||||
|
elif range_ok or phase_candidate or candidates:
|
||||||
|
lifecycle = "FORMING"
|
||||||
|
else:
|
||||||
|
lifecycle = "UNKNOWN"
|
||||||
|
out["lifecycle"] = lifecycle
|
||||||
|
|
||||||
|
cycle_c = structure_score
|
||||||
|
overall = 0.35 * cycle_c + 0.25 * phase_conf + 0.25 * event_score + 0.15 * volume_score
|
||||||
|
out["confidence"] = {
|
||||||
|
"cycle": round(cycle_c, 3),
|
||||||
|
"phase": round(phase_conf, 3),
|
||||||
|
"event": round(event_score, 3),
|
||||||
|
"structure": round(structure_score, 3),
|
||||||
|
"volume": round(volume_score, 3),
|
||||||
|
"overall": round(float(overall), 3),
|
||||||
|
}
|
||||||
|
parts = []
|
||||||
|
if out["range_formation"]["potential_trading_range"]:
|
||||||
|
parts.append("Potential TR")
|
||||||
|
if phase_candidate:
|
||||||
|
parts.append(f"Phase {phase_candidate} candidate")
|
||||||
|
if candidates:
|
||||||
|
parts.append(f"{candidates[0]['type']} candidate")
|
||||||
|
out["note"] = "; ".join(parts) if parts else "observing"
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def execution_signal_from_wyckoff(payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Execution 边界:只允许 Confirmed。
|
||||||
|
返回 source='confirmed' 的信号描述;Live-only 时返回 None。
|
||||||
|
"""
|
||||||
|
if not payload:
|
||||||
|
return None
|
||||||
|
cycles = payload.get("cycles") or []
|
||||||
|
active = cycles[0] if cycles else None
|
||||||
|
events = []
|
||||||
|
if active and isinstance(active.get("confirmed"), dict):
|
||||||
|
events = list(active["confirmed"].get("events") or [])
|
||||||
|
if not events:
|
||||||
|
# 兼容旧顶层 events(均为 confirmed 镜像)
|
||||||
|
events = list(payload.get("events") or [])
|
||||||
|
if not events:
|
||||||
|
return None
|
||||||
|
last = events[-1]
|
||||||
|
return {
|
||||||
|
"source": "confirmed",
|
||||||
|
"type": last.get("type"),
|
||||||
|
"time": last.get("time"),
|
||||||
|
"lifecycle": (active or {}).get("lifecycle") or "CONFIRMED",
|
||||||
|
}
|
||||||
@@ -0,0 +1,442 @@
|
|||||||
|
"""交易区间检测:仅负责 TradingRange(起止/高低/结构分)。
|
||||||
|
|
||||||
|
WYCKOFF-MULTI-CYCLE-001:Phase/Event/VP 不得进入本模块。
|
||||||
|
过滤顺序固定:detect → quality → trend → overlap(<0.2) → accept → mask。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
MAX_CYCLES = 8
|
||||||
|
OVERLAP_RATIO_MAX = 0.2
|
||||||
|
|
||||||
|
|
||||||
|
def _atr(df: pd.DataFrame, period: int = 14) -> pd.Series:
|
||||||
|
high = df["high"].astype(float)
|
||||||
|
low = df["low"].astype(float)
|
||||||
|
close = df["close"].astype(float)
|
||||||
|
prev_close = close.shift(1)
|
||||||
|
tr = pd.concat(
|
||||||
|
[
|
||||||
|
(high - low).abs(),
|
||||||
|
(high - prev_close).abs(),
|
||||||
|
(low - prev_close).abs(),
|
||||||
|
],
|
||||||
|
axis=1,
|
||||||
|
).max(axis=1)
|
||||||
|
return tr.rolling(period, min_periods=max(3, period // 2)).mean()
|
||||||
|
|
||||||
|
|
||||||
|
def _robust_width(seg: pd.DataFrame) -> float:
|
||||||
|
"""用 90/10 分位估宽,避免单根影线把长窗卡死。"""
|
||||||
|
h = seg["high"].astype(float)
|
||||||
|
l = seg["low"].astype(float)
|
||||||
|
if len(seg) < 6:
|
||||||
|
return float(h.max() - l.min())
|
||||||
|
return float(np.nanpercentile(h, 90) - np.nanpercentile(l, 10))
|
||||||
|
|
||||||
|
|
||||||
|
def _score_segment(
|
||||||
|
length: int,
|
||||||
|
near_hi: int,
|
||||||
|
near_lo: int,
|
||||||
|
inside: float,
|
||||||
|
width: float,
|
||||||
|
atr: float,
|
||||||
|
) -> float:
|
||||||
|
"""结构质量分(非 Phase/Event)。"""
|
||||||
|
touch = min(near_hi, 6) + min(near_lo, 6)
|
||||||
|
width_pen = (width / atr) if atr > 0 else width
|
||||||
|
return float(touch) * 4.0 + float(inside) * 25.0 - width_pen * 3.0 + min(length / 40.0, 2.0)
|
||||||
|
|
||||||
|
|
||||||
|
def _time_col(df: pd.DataFrame) -> Optional[str]:
|
||||||
|
if "date" in df.columns:
|
||||||
|
return "date"
|
||||||
|
if "timestamp" in df.columns:
|
||||||
|
return "timestamp"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _bar_index_at_or_after(work: pd.DataFrame, ts: Any) -> Optional[int]:
|
||||||
|
col = _time_col(work)
|
||||||
|
if col is None or ts is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
target = pd.Timestamp(ts)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
series = pd.to_datetime(work[col], utc=True, errors="coerce")
|
||||||
|
if target.tzinfo is None:
|
||||||
|
target = target.tz_localize("UTC")
|
||||||
|
else:
|
||||||
|
target = target.tz_convert("UTC")
|
||||||
|
if series.isna().all():
|
||||||
|
return None
|
||||||
|
ge = series >= target
|
||||||
|
if ge.any():
|
||||||
|
return int(np.flatnonzero(ge.to_numpy())[0])
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _pack_range(
|
||||||
|
work: pd.DataFrame,
|
||||||
|
df: pd.DataFrame,
|
||||||
|
start_i: int,
|
||||||
|
end_i: int,
|
||||||
|
hi: float,
|
||||||
|
lo: float,
|
||||||
|
tol: float,
|
||||||
|
last_atr: float,
|
||||||
|
score: float,
|
||||||
|
n: int,
|
||||||
|
window_offset: int = 0,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""组装 TradingRange(仅结构字段)。"""
|
||||||
|
mid = (hi + lo) / 2.0
|
||||||
|
last_c = float(work["close"].iloc[min(end_i, len(work) - 1)])
|
||||||
|
price_in_box = (lo - tol * 1.5) <= last_c <= (hi + tol * 1.5)
|
||||||
|
bars = int(end_i - start_i + 1)
|
||||||
|
# 结构置信:归一化 score(启发式)
|
||||||
|
range_conf = float(np.clip(score / 55.0, 0.05, 0.99))
|
||||||
|
best = {
|
||||||
|
"start_idx": int(start_i),
|
||||||
|
"end_idx": int(end_i),
|
||||||
|
"high": float(hi),
|
||||||
|
"low": float(lo),
|
||||||
|
"mid": float(mid),
|
||||||
|
"active": bool(price_in_box),
|
||||||
|
"atr": float(last_atr),
|
||||||
|
"tol": float(tol),
|
||||||
|
"bars": bars,
|
||||||
|
"score": float(score),
|
||||||
|
"quality": float(score),
|
||||||
|
"range_confidence": range_conf,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _ts(row) -> Any:
|
||||||
|
col = _time_col(work)
|
||||||
|
if col and pd.notna(row[col]):
|
||||||
|
return row[col]
|
||||||
|
return None
|
||||||
|
|
||||||
|
best["start_time"] = _ts(work.iloc[best["start_idx"]])
|
||||||
|
best["end_time"] = _ts(work.iloc[best["end_idx"]])
|
||||||
|
# window_offset:slice 相对父 DataFrame 的起点;勿用 len(df)-len(work)
|
||||||
|
offset = int(window_offset)
|
||||||
|
best["abs_start_idx"] = offset + best["start_idx"]
|
||||||
|
best["abs_end_idx"] = offset + best["end_idx"]
|
||||||
|
best["abs_scan_end_idx"] = offset + n - 1
|
||||||
|
return best
|
||||||
|
|
||||||
|
|
||||||
|
def _overlap_ratio(a0: int, a1: int, b0: int, b1: int) -> float:
|
||||||
|
"""两闭区间重叠长度 / 较短区间长度。"""
|
||||||
|
lo = max(a0, b0)
|
||||||
|
hi = min(a1, b1)
|
||||||
|
if hi < lo:
|
||||||
|
return 0.0
|
||||||
|
overlap = hi - lo + 1
|
||||||
|
shorter = min(a1 - a0 + 1, b1 - b0 + 1)
|
||||||
|
if shorter <= 0:
|
||||||
|
return 0.0
|
||||||
|
return float(overlap) / float(shorter)
|
||||||
|
|
||||||
|
|
||||||
|
def _passes_quality(tr: Dict[str, Any], min_bars: int) -> bool:
|
||||||
|
if tr is None:
|
||||||
|
return False
|
||||||
|
if int(tr.get("bars") or 0) < max(8, min_bars // 2):
|
||||||
|
return False
|
||||||
|
if float(tr.get("score") or 0) < 12.0:
|
||||||
|
return False
|
||||||
|
hi = float(tr["high"])
|
||||||
|
lo = float(tr["low"])
|
||||||
|
atr = float(tr.get("atr") or 0) or 1.0
|
||||||
|
if (hi - lo) / atr > 12.0:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _passes_trend_filter(work: pd.DataFrame, tr: Dict[str, Any]) -> bool:
|
||||||
|
"""趋势污染:定向位移过大则非震荡箱。"""
|
||||||
|
s = int(tr["start_idx"])
|
||||||
|
e = int(tr["end_idx"])
|
||||||
|
seg = work.iloc[s : e + 1]
|
||||||
|
if len(seg) < 8:
|
||||||
|
return False
|
||||||
|
c0 = float(seg["close"].iloc[0])
|
||||||
|
c1 = float(seg["close"].iloc[-1])
|
||||||
|
atr = float(tr.get("atr") or 0) or 1.0
|
||||||
|
drift = abs(c1 - c0) / atr
|
||||||
|
# 相对箱宽:漂移占箱宽过大 → 趋势
|
||||||
|
width = max(float(tr["high"]) - float(tr["low"]), atr)
|
||||||
|
drift_frac = abs(c1 - c0) / width
|
||||||
|
if drift > 6.0 and drift_frac > 0.55:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _detect_in_window(
|
||||||
|
df: pd.DataFrame,
|
||||||
|
win_start: int,
|
||||||
|
win_end: int,
|
||||||
|
min_bars: int = 24,
|
||||||
|
atr_mult: float = 1.2,
|
||||||
|
tail_reserve: int = 12,
|
||||||
|
prefer_start_time: Any = None,
|
||||||
|
range_start_time: Any = None,
|
||||||
|
) -> Optional[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
在 df[win_start:win_end+1] 内检测单个 TradingRange。
|
||||||
|
只返回箱体结构,不含 Phase/Event/VP。
|
||||||
|
"""
|
||||||
|
if df is None or win_end < win_start:
|
||||||
|
return None
|
||||||
|
slice_df = df.iloc[win_start : win_end + 1].reset_index(drop=True)
|
||||||
|
lookback = len(slice_df)
|
||||||
|
if lookback < min_bars + 5:
|
||||||
|
return None
|
||||||
|
|
||||||
|
work = slice_df
|
||||||
|
n = len(work)
|
||||||
|
reserve = min(tail_reserve, max(0, n - min_bars - 2))
|
||||||
|
core_end = n - reserve if reserve > 0 else n
|
||||||
|
core = work.iloc[:core_end]
|
||||||
|
if len(core) < min_bars:
|
||||||
|
core = work
|
||||||
|
core_end = n
|
||||||
|
reserve = 0
|
||||||
|
|
||||||
|
atr = _atr(work)
|
||||||
|
last_atr = float(atr.iloc[core_end - 1]) if atr.notna().iloc[:core_end].any() else float(
|
||||||
|
(core["high"] - core["low"]).mean()
|
||||||
|
)
|
||||||
|
if not np.isfinite(last_atr) or last_atr <= 0:
|
||||||
|
last_atr = float(core["close"].iloc[-1]) * 0.01
|
||||||
|
|
||||||
|
eff_atr_mult = float(atr_mult)
|
||||||
|
if lookback >= 280:
|
||||||
|
eff_atr_mult = atr_mult * 1.7
|
||||||
|
elif lookback >= 160:
|
||||||
|
eff_atr_mult = atr_mult * 1.3
|
||||||
|
width_factor = 3.8 + min(2.2, max(0.0, (lookback - 80) / 100.0))
|
||||||
|
max_width = last_atr * eff_atr_mult * width_factor
|
||||||
|
tol = last_atr * eff_atr_mult * 0.35
|
||||||
|
|
||||||
|
prefer_i = None
|
||||||
|
if prefer_start_time is not None:
|
||||||
|
prefer_i = _bar_index_at_or_after(work, prefer_start_time)
|
||||||
|
|
||||||
|
if range_start_time is not None:
|
||||||
|
start_i = _bar_index_at_or_after(work, range_start_time)
|
||||||
|
if start_i is not None and start_i <= core_end - 8:
|
||||||
|
seg = work.iloc[start_i:core_end]
|
||||||
|
hi = float(seg["high"].max())
|
||||||
|
lo = float(seg["low"].min())
|
||||||
|
rw = _robust_width(seg)
|
||||||
|
if 0 < rw <= max_width * 1.15:
|
||||||
|
near_hi = int((seg["high"] >= hi - tol).sum())
|
||||||
|
near_lo = int((seg["low"] <= lo + tol).sum())
|
||||||
|
inside = float(((seg["close"] >= lo - tol) & (seg["close"] <= hi + tol)).mean())
|
||||||
|
if near_hi >= 2 and near_lo >= 2 and inside >= 0.70:
|
||||||
|
score = _score_segment(len(seg), near_hi, near_lo, inside, rw, last_atr)
|
||||||
|
return _pack_range(
|
||||||
|
work, df, start_i, core_end - 1, hi, lo, tol, last_atr, score, n,
|
||||||
|
window_offset=win_start,
|
||||||
|
)
|
||||||
|
|
||||||
|
eff_min_bars = max(8, int(min_bars))
|
||||||
|
cn = len(core)
|
||||||
|
max_bars = min(cn, max(eff_min_bars * 2, min(96, max(eff_min_bars + 8, int(cn * 0.5)))))
|
||||||
|
cands: List[Tuple[float, int, int, int, float, float, float]] = []
|
||||||
|
|
||||||
|
def _try_seg(start_i: int, end_i: int, prefer_boost: float = 0.0) -> None:
|
||||||
|
if end_i - start_i + 1 < eff_min_bars:
|
||||||
|
return
|
||||||
|
if start_i < 0 or end_i >= cn or start_i > end_i:
|
||||||
|
return
|
||||||
|
seg = work.iloc[start_i : end_i + 1]
|
||||||
|
hi = float(seg["high"].max())
|
||||||
|
lo = float(seg["low"].min())
|
||||||
|
rw = _robust_width(seg)
|
||||||
|
if rw <= 0 or rw > max_width:
|
||||||
|
return
|
||||||
|
raw_w = hi - lo
|
||||||
|
if raw_w > max_width * 1.35:
|
||||||
|
return
|
||||||
|
near_hi = int((seg["high"] >= hi - tol).sum())
|
||||||
|
near_lo = int((seg["low"] <= lo + tol).sum())
|
||||||
|
if near_hi < 2 or near_lo < 2:
|
||||||
|
return
|
||||||
|
inside = float(((seg["close"] >= lo - tol) & (seg["close"] <= hi + tol)).mean())
|
||||||
|
if inside < 0.72:
|
||||||
|
return
|
||||||
|
length = end_i - start_i + 1
|
||||||
|
score = _score_segment(length, near_hi, near_lo, inside, rw, last_atr) + prefer_boost
|
||||||
|
cands.append((score, length, start_i, end_i, hi, lo, rw))
|
||||||
|
|
||||||
|
for length in range(min(cn, max_bars), eff_min_bars - 1, -4):
|
||||||
|
start_i = cn - length
|
||||||
|
boost = 0.0
|
||||||
|
if prefer_i is not None:
|
||||||
|
dist = abs(start_i - int(prefer_i))
|
||||||
|
if dist <= 6:
|
||||||
|
boost = 10.0
|
||||||
|
elif dist <= 14:
|
||||||
|
boost = 4.0
|
||||||
|
elif start_i > int(prefer_i) + 16:
|
||||||
|
boost = -10.0
|
||||||
|
_try_seg(start_i, cn - 1, boost)
|
||||||
|
|
||||||
|
if prefer_i is not None:
|
||||||
|
pi = int(prefer_i)
|
||||||
|
if 0 <= pi < cn:
|
||||||
|
align_max = min(cn, max(max_bars, int(cn * 0.65)))
|
||||||
|
alen = cn - pi
|
||||||
|
if eff_min_bars <= alen <= align_max:
|
||||||
|
_try_seg(pi, cn - 1, prefer_boost=18.0)
|
||||||
|
elif alen > align_max:
|
||||||
|
start_i = max(0, cn - align_max)
|
||||||
|
if start_i > pi:
|
||||||
|
start_i = pi
|
||||||
|
end_i = min(cn - 1, pi + align_max - 1)
|
||||||
|
else:
|
||||||
|
end_i = cn - 1
|
||||||
|
_try_seg(start_i, end_i, prefer_boost=12.0)
|
||||||
|
|
||||||
|
if not cands:
|
||||||
|
return None
|
||||||
|
|
||||||
|
cands.sort(key=lambda x: x[0], reverse=True)
|
||||||
|
best_score = cands[0][0]
|
||||||
|
band = max(4.0, abs(best_score) * 0.10)
|
||||||
|
near = [c for c in cands if c[0] >= best_score - band]
|
||||||
|
chosen = max(near, key=lambda x: (x[1], x[0]))
|
||||||
|
score, _length, start_i, end_i, hi, lo, _rw = chosen
|
||||||
|
return _pack_range(work, df, start_i, end_i, hi, lo, tol, last_atr, score, n, window_offset=win_start)
|
||||||
|
|
||||||
|
|
||||||
|
def detect_trading_ranges(
|
||||||
|
df: pd.DataFrame,
|
||||||
|
lookback: Optional[int] = None,
|
||||||
|
min_bars: int = 24,
|
||||||
|
atr_mult: float = 1.2,
|
||||||
|
tail_reserve: int = 12,
|
||||||
|
max_cycles: int = MAX_CYCLES,
|
||||||
|
prefer_start_time: Any = None,
|
||||||
|
range_start_time: Any = None,
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
倒序切多段 TradingRange(近→远)。
|
||||||
|
过滤顺序:detect → quality → trend → overlap → accept → mask。
|
||||||
|
返回列表已按时间倒序,调用方将 [0] 标为 ACTIVE。
|
||||||
|
"""
|
||||||
|
if df is None or len(df) < min_bars + 5:
|
||||||
|
return []
|
||||||
|
lb = int(lookback) if lookback is not None else len(df)
|
||||||
|
work = df.tail(lb).reset_index(drop=True)
|
||||||
|
n = len(work)
|
||||||
|
occupied: List[Dict[str, Any]] = []
|
||||||
|
accepted: List[Dict[str, Any]] = []
|
||||||
|
|
||||||
|
# 搜索右端从 n-1 往左收缩;每接受一段后右端移到该段 start 之前
|
||||||
|
search_end = n - 1
|
||||||
|
prefer = prefer_start_time
|
||||||
|
hard_start = range_start_time
|
||||||
|
|
||||||
|
while len(accepted) < max(1, int(max_cycles)) and search_end >= min_bars + 4:
|
||||||
|
# 在剩余历史内从右往左试多个右边界,避免历史箱必须贴住 search_end
|
||||||
|
# (否则中间趋势会挡住更早的真实箱)
|
||||||
|
cand = None
|
||||||
|
step = max(4, min(12, (search_end - min_bars) // 10 or 4))
|
||||||
|
for end_try in range(search_end, min_bars + 4, -step):
|
||||||
|
trial = _detect_in_window(
|
||||||
|
work,
|
||||||
|
0,
|
||||||
|
end_try,
|
||||||
|
min_bars=min_bars,
|
||||||
|
atr_mult=atr_mult,
|
||||||
|
tail_reserve=tail_reserve,
|
||||||
|
prefer_start_time=prefer if len(accepted) == 0 and end_try == search_end else None,
|
||||||
|
range_start_time=hard_start if len(accepted) == 0 and end_try == search_end else None,
|
||||||
|
)
|
||||||
|
# 1) detect
|
||||||
|
if trial is None:
|
||||||
|
continue
|
||||||
|
# 2) quality
|
||||||
|
if not _passes_quality(trial, min_bars):
|
||||||
|
continue
|
||||||
|
# 3) trend contamination
|
||||||
|
if not _passes_trend_filter(work, trial):
|
||||||
|
continue
|
||||||
|
# 4) overlap with accepted
|
||||||
|
a0, a1 = int(trial["abs_start_idx"]), int(trial["abs_end_idx"])
|
||||||
|
overlap_bad = False
|
||||||
|
for occ in occupied:
|
||||||
|
ratio = _overlap_ratio(a0, a1, int(occ["start"]), int(occ["end"]))
|
||||||
|
if ratio >= OVERLAP_RATIO_MAX:
|
||||||
|
overlap_bad = True
|
||||||
|
break
|
||||||
|
if overlap_bad:
|
||||||
|
continue
|
||||||
|
# 取最靠右的合格箱(倒序第一段)
|
||||||
|
cand = trial
|
||||||
|
break
|
||||||
|
|
||||||
|
if cand is None:
|
||||||
|
break
|
||||||
|
|
||||||
|
# 5) accept
|
||||||
|
accepted.append(cand)
|
||||||
|
a0, a1 = int(cand["abs_start_idx"]), int(cand["abs_end_idx"])
|
||||||
|
# 6) mask
|
||||||
|
occupied.append(
|
||||||
|
{
|
||||||
|
"start": a0,
|
||||||
|
"end": max(a1, int(cand.get("abs_scan_end_idx", a1))),
|
||||||
|
"quality": float(cand.get("quality") or 0),
|
||||||
|
"high": float(cand["high"]),
|
||||||
|
"low": float(cand["low"]),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
# 下一轮只在更早窗口搜
|
||||||
|
search_end = int(cand["abs_start_idx"]) - 1
|
||||||
|
hard_start = None
|
||||||
|
prefer = None
|
||||||
|
|
||||||
|
# abs_* 目前相对 work;若 df 比 work 长需加 offset
|
||||||
|
offset = len(df) - len(work)
|
||||||
|
if offset:
|
||||||
|
for tr in accepted:
|
||||||
|
tr["abs_start_idx"] = int(tr["abs_start_idx"]) + offset
|
||||||
|
tr["abs_end_idx"] = int(tr["abs_end_idx"]) + offset
|
||||||
|
tr["abs_scan_end_idx"] = int(tr["abs_scan_end_idx"]) + offset
|
||||||
|
|
||||||
|
return accepted
|
||||||
|
|
||||||
|
|
||||||
|
def detect_trading_range(
|
||||||
|
df: pd.DataFrame,
|
||||||
|
lookback: int = 120,
|
||||||
|
min_bars: int = 24,
|
||||||
|
atr_mult: float = 1.2,
|
||||||
|
tail_reserve: int = 12,
|
||||||
|
range_start_time: Any = None,
|
||||||
|
prefer_start_time: Any = None,
|
||||||
|
) -> Optional[Dict[str, Any]]:
|
||||||
|
"""兼容旧接口:返回倒序列表中的第一段(ACTIVE 候选)。"""
|
||||||
|
ranges = detect_trading_ranges(
|
||||||
|
df,
|
||||||
|
lookback=lookback,
|
||||||
|
min_bars=min_bars,
|
||||||
|
atr_mult=atr_mult,
|
||||||
|
tail_reserve=tail_reserve,
|
||||||
|
max_cycles=1,
|
||||||
|
prefer_start_time=prefer_start_time,
|
||||||
|
range_start_time=range_start_time,
|
||||||
|
)
|
||||||
|
return ranges[0] if ranges else None
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
"""区间内 Volume Profile。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Dict, List
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
|
||||||
|
def compute_volume_profile(
|
||||||
|
df: pd.DataFrame,
|
||||||
|
start_idx: int,
|
||||||
|
end_idx: int,
|
||||||
|
bin_count: int = 50,
|
||||||
|
value_area_pct: float = 0.70,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
seg = df.iloc[start_idx : end_idx + 1]
|
||||||
|
if seg.empty:
|
||||||
|
return {"bins": [], "poc": None, "vah": None, "val": None, "bin_count": bin_count}
|
||||||
|
|
||||||
|
typical = (seg["high"].astype(float) + seg["low"].astype(float) + seg["close"].astype(float)) / 3.0
|
||||||
|
vol = seg["volume"].astype(float).fillna(0.0)
|
||||||
|
lo = float(seg["low"].min())
|
||||||
|
hi = float(seg["high"].max())
|
||||||
|
if not np.isfinite(lo) or not np.isfinite(hi) or hi <= lo:
|
||||||
|
mid = float(seg["close"].iloc[-1])
|
||||||
|
return {
|
||||||
|
"bins": [{"price": mid, "volume": float(vol.sum())}],
|
||||||
|
"poc": mid,
|
||||||
|
"vah": mid,
|
||||||
|
"val": mid,
|
||||||
|
"bin_count": 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
edges = np.linspace(lo, hi, bin_count + 1)
|
||||||
|
# 右开最后一桶闭合
|
||||||
|
idx = np.clip(np.digitize(typical.values, edges) - 1, 0, bin_count - 1)
|
||||||
|
vols = np.zeros(bin_count, dtype=float)
|
||||||
|
for i, v in zip(idx, vol.values):
|
||||||
|
vols[i] += float(v)
|
||||||
|
|
||||||
|
centers = (edges[:-1] + edges[1:]) / 2.0
|
||||||
|
poc_i = int(np.argmax(vols)) if vols.sum() > 0 else bin_count // 2
|
||||||
|
poc = float(centers[poc_i])
|
||||||
|
|
||||||
|
# Value Area:从 POC 向两侧扩展直到累计 >= value_area_pct
|
||||||
|
total = float(vols.sum()) or 1.0
|
||||||
|
target = total * value_area_pct
|
||||||
|
left = right = poc_i
|
||||||
|
acc = float(vols[poc_i])
|
||||||
|
while acc < target and (left > 0 or right < bin_count - 1):
|
||||||
|
left_v = vols[left - 1] if left > 0 else -1.0
|
||||||
|
right_v = vols[right + 1] if right < bin_count - 1 else -1.0
|
||||||
|
if right_v >= left_v and right < bin_count - 1:
|
||||||
|
right += 1
|
||||||
|
acc += float(vols[right])
|
||||||
|
elif left > 0:
|
||||||
|
left -= 1
|
||||||
|
acc += float(vols[left])
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
|
||||||
|
bins: List[Dict[str, float]] = [
|
||||||
|
{"price": float(centers[i]), "volume": float(vols[i])} for i in range(bin_count)
|
||||||
|
]
|
||||||
|
return {
|
||||||
|
"bins": bins,
|
||||||
|
"poc": poc,
|
||||||
|
"vah": float(centers[right]),
|
||||||
|
"val": float(centers[left]),
|
||||||
|
"bin_count": bin_count,
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
"""增量更新:新K只追加 KLU/KLC,笔与笔中枢在当前列表上重算。
|
||||||
|
|
||||||
|
不改 init_TF_DF 的整段语义。笔必须整表重扫:最后一笔 is_sure 允许收回
|
||||||
|
(OWN_CHAN_ZS_001 上 60 天出现 7 次)。笔中枢用 cal_bi_zs_list_pure。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
from pandas import DataFrame
|
||||||
|
from technical.util import resample_to_interval
|
||||||
|
|
||||||
|
from chanlun.core.ChanEnum import Chan_FX_TYPE, Chan_KLC_FX, Chan_KLC_STATE
|
||||||
|
from chanlun.core.ChanKLU import ChanKLU
|
||||||
|
|
||||||
|
|
||||||
|
class IncrementalBuilderMixin:
|
||||||
|
def init_stream(self, df, interval=1, timeframe=None):
|
||||||
|
"""用历史K线初始化流式状态,之后用 append_bar / replace_last_bar。"""
|
||||||
|
if df is None or df.empty:
|
||||||
|
raise ValueError("DataFrame for stream is empty.")
|
||||||
|
if "date" not in df.columns:
|
||||||
|
raise ValueError(f"DataFrame missing 'date' column. Columns: {df.columns.tolist()}")
|
||||||
|
self.timeframe = timeframe
|
||||||
|
self.interval = interval
|
||||||
|
if interval == 1:
|
||||||
|
self.dataframe = df.copy()
|
||||||
|
else:
|
||||||
|
self.dataframe = resample_to_interval(df, interval)
|
||||||
|
self.dataframe = self.add_indicators(self.dataframe)
|
||||||
|
self.klu_list = []
|
||||||
|
self.klc_list = []
|
||||||
|
self.bi_list = []
|
||||||
|
self.bi_zs_list = []
|
||||||
|
self.seg_list = []
|
||||||
|
self.zs_list = []
|
||||||
|
self.bsp_list = []
|
||||||
|
self.klc_fx_list = []
|
||||||
|
self.big_zs_list = []
|
||||||
|
self._klc_feed_last_klu = None
|
||||||
|
for i in range(len(self.dataframe)):
|
||||||
|
self._append_row_at(i, rebuild=False)
|
||||||
|
self.rebuild_bi_zs()
|
||||||
|
return self
|
||||||
|
|
||||||
|
def append_bar(self, row):
|
||||||
|
"""追加一根已收盘K线。同一时间戳则改为替换最后一根。"""
|
||||||
|
self._ensure_stream_state()
|
||||||
|
item = self._normalize_row(row)
|
||||||
|
if self.klu_list and self.klu_list[-1].time == self._row_time_str(item):
|
||||||
|
return self.replace_last_bar(item)
|
||||||
|
self._append_item_to_dataframe(item)
|
||||||
|
self.dataframe = self.add_indicators(self.dataframe)
|
||||||
|
self._append_row_at(len(self.dataframe) - 1, rebuild=True)
|
||||||
|
return self
|
||||||
|
|
||||||
|
def replace_last_bar(self, row):
|
||||||
|
"""更新最后一根K(未完成K线走新OHLC)。包含关系从 KLU 列表重放。"""
|
||||||
|
self._ensure_stream_state()
|
||||||
|
if not self.klu_list:
|
||||||
|
return self.append_bar(row)
|
||||||
|
item = self._normalize_row(row)
|
||||||
|
idx = self.dataframe.index[-1]
|
||||||
|
for key, val in item.items():
|
||||||
|
self.dataframe.at[idx, key] = val
|
||||||
|
self.dataframe = self.add_indicators(self.dataframe)
|
||||||
|
self._apply_item_to_klu(self.klu_list[-1], self.dataframe.iloc[-1])
|
||||||
|
self._rebuild_klc_from_klu()
|
||||||
|
self.rebuild_bi_zs()
|
||||||
|
return self
|
||||||
|
|
||||||
|
def rebuild_bi_zs(self):
|
||||||
|
"""在当前 KLC 上重算笔 + cal_bi_zs_list_pure。会先清分型标记。"""
|
||||||
|
self._reset_klc_bi_marks(self.klc_list)
|
||||||
|
self.bi_list = self.cal_bi_list(self.klc_list) if self.klc_list else []
|
||||||
|
self.bi_zs_list = self.cal_bi_zs_list_pure(self.bi_list) if self.bi_list else []
|
||||||
|
return self.bi_zs_list
|
||||||
|
|
||||||
|
def _ensure_stream_state(self):
|
||||||
|
if not hasattr(self, "klu_list") or self.klu_list is None:
|
||||||
|
self.klu_list = []
|
||||||
|
if not hasattr(self, "klc_list") or self.klc_list is None:
|
||||||
|
self.klc_list = []
|
||||||
|
if not hasattr(self, "dataframe") or self.dataframe is None:
|
||||||
|
self.dataframe = DataFrame(
|
||||||
|
columns=["date", "open", "high", "low", "close", "volume"]
|
||||||
|
)
|
||||||
|
if not hasattr(self, "_klc_feed_last_klu"):
|
||||||
|
self._klc_feed_last_klu = self.klu_list[-1] if self.klu_list else None
|
||||||
|
if not hasattr(self, "bi_zs_list"):
|
||||||
|
self.bi_zs_list = []
|
||||||
|
|
||||||
|
def _rebuild_klc_from_klu(self):
|
||||||
|
self.klc_list = []
|
||||||
|
last_klu = None
|
||||||
|
for klu in self.klu_list:
|
||||||
|
self._push_klu_into_klc_list(self.klc_list, klu, last_klu)
|
||||||
|
last_klu = klu
|
||||||
|
self._klc_feed_last_klu = last_klu
|
||||||
|
|
||||||
|
def _append_row_at(self, idx, rebuild=True):
|
||||||
|
item = self.dataframe.iloc[idx]
|
||||||
|
klu = self._klu_from_item(item, idx)
|
||||||
|
if self.klu_list:
|
||||||
|
self.klu_list[-1].set_next(klu)
|
||||||
|
klu.set_pre(self.klu_list[-1])
|
||||||
|
self._push_klu_into_klc_list(self.klc_list, klu, self._klc_feed_last_klu)
|
||||||
|
self._klc_feed_last_klu = klu
|
||||||
|
self.klu_list.append(klu)
|
||||||
|
if rebuild:
|
||||||
|
self.rebuild_bi_zs()
|
||||||
|
|
||||||
|
def _klu_from_item(self, item, idx):
|
||||||
|
klu = ChanKLU(
|
||||||
|
self._item_time_str(item),
|
||||||
|
item["open"],
|
||||||
|
item["high"],
|
||||||
|
item["low"],
|
||||||
|
item["close"],
|
||||||
|
item["volume"],
|
||||||
|
)
|
||||||
|
klu.set_idx(idx)
|
||||||
|
if not hasattr(klu, "ema13"):
|
||||||
|
klu.ema13 = 0
|
||||||
|
if "macd" in item:
|
||||||
|
klu.set_indicators(item)
|
||||||
|
return klu
|
||||||
|
|
||||||
|
def _apply_item_to_klu(self, klu, item):
|
||||||
|
klu.time = self._item_time_str(item)
|
||||||
|
klu.open = item["open"]
|
||||||
|
klu.high = item["high"]
|
||||||
|
klu.low = item["low"]
|
||||||
|
klu.close = item["close"]
|
||||||
|
klu.volume = item["volume"]
|
||||||
|
klu.range = klu.high - klu.low
|
||||||
|
klu.body = abs(klu.close - klu.open)
|
||||||
|
if "macd" in item:
|
||||||
|
klu.set_indicators(item)
|
||||||
|
|
||||||
|
def _reset_klc_bi_marks(self, klc_list):
|
||||||
|
for klc in klc_list:
|
||||||
|
klc.fx = Chan_FX_TYPE.UNKNOWN
|
||||||
|
klc.klc_fx_type = Chan_KLC_FX.UNKNOWN
|
||||||
|
klc.klc_state = Chan_KLC_STATE.UNKNOWN
|
||||||
|
klc.bi = None
|
||||||
|
klc.fx_confirmed = False
|
||||||
|
|
||||||
|
def _item_time_str(self, item):
|
||||||
|
date = item["date"]
|
||||||
|
if hasattr(date, "to_pydatetime"):
|
||||||
|
date = date.to_pydatetime()
|
||||||
|
if isinstance(date, datetime):
|
||||||
|
return date.strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
return str(date)
|
||||||
|
|
||||||
|
def _row_time_str(self, item):
|
||||||
|
return self._item_time_str(item)
|
||||||
|
|
||||||
|
def _normalize_row(self, row):
|
||||||
|
if isinstance(row, pd.Series):
|
||||||
|
return row
|
||||||
|
return pd.Series(row)
|
||||||
|
|
||||||
|
def _append_item_to_dataframe(self, item):
|
||||||
|
row_df = DataFrame([item])
|
||||||
|
if self.dataframe is None or self.dataframe.empty:
|
||||||
|
self.dataframe = row_df
|
||||||
|
else:
|
||||||
|
self.dataframe = pd.concat([self.dataframe, row_df], ignore_index=True)
|
||||||
@@ -55,8 +55,8 @@ class IndicatorsBuilderMixin:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
def add_indicators(self, df):
|
def add_indicators(self, df):
|
||||||
fast = 26
|
fast = 12
|
||||||
slow = 52
|
slow = 26
|
||||||
period = 9
|
period = 9
|
||||||
macd = ta.MACD(df, fastperiod=fast, slowperiod=slow, signalperiod=period)
|
macd = ta.MACD(df, fastperiod=fast, slowperiod=slow, signalperiod=period)
|
||||||
bb365 = ta.BBANDS(df, timeperiod=365, nbdevup=3.0, nbdevdn=3.0, matype=0)
|
bb365 = ta.BBANDS(df, timeperiod=365, nbdevup=3.0, nbdevdn=3.0, matype=0)
|
||||||
|
|||||||
@@ -86,7 +86,8 @@ class KlineBuilderMixin:
|
|||||||
return Chan_FX_TYPE.UNKNOWN
|
return Chan_FX_TYPE.UNKNOWN
|
||||||
|
|
||||||
def check_fx(self, klc):
|
def check_fx(self, klc):
|
||||||
if klc.pre and klc.next:
|
# 右K未完成(仍在包含合并)时不分型:否则确认笔会随 next 扩区间被 check_*_fx 收回
|
||||||
|
if klc.pre and klc.next and klc.next.end_klu is not None:
|
||||||
if klc.high > klc.pre.high and klc.high > klc.next.high and klc.low > klc.pre.low and klc.low > klc.next.low:
|
if klc.high > klc.pre.high and klc.high > klc.next.high and klc.low > klc.pre.low and klc.low > klc.next.low:
|
||||||
#if (klc.close > klc.ema52 or klc.next.close > klc.next.ema52) and klc.macd > 0:
|
#if (klc.close > klc.ema52 or klc.next.close > klc.next.ema52) and klc.macd > 0:
|
||||||
klc.set_fx(Chan_FX_TYPE.TOP)
|
klc.set_fx(Chan_FX_TYPE.TOP)
|
||||||
@@ -171,31 +172,8 @@ class KlineBuilderMixin:
|
|||||||
def get_kl_data(self, dataframe:DataFrame):
|
def get_kl_data(self, dataframe:DataFrame):
|
||||||
return self.cal_kl_data(dataframe)
|
return self.cal_kl_data(dataframe)
|
||||||
|
|
||||||
def get_klc_list(self, klu_list):
|
def _push_klu_into_klc_list(self, klc_list, klu, last_klu):
|
||||||
klc_list = []
|
"""把一根 KLU 并入包含K线列表。与 get_klc_list 的几何规则相同。"""
|
||||||
last_klu = None
|
|
||||||
macd = ChanMACD(klu_list)
|
|
||||||
klu_list = macd.cal_macd_state()
|
|
||||||
ema_up_list = []
|
|
||||||
ema_down_list = []
|
|
||||||
ema_up_count = 0
|
|
||||||
ema_down_count = 0
|
|
||||||
last_klu = None
|
|
||||||
for klu in klu_list:
|
|
||||||
ema = klu.ema52
|
|
||||||
last_ema = last_klu.ema52 if last_klu else 0
|
|
||||||
if klu.close >= ema:
|
|
||||||
ema_up_count += 1
|
|
||||||
elif klu.close < ema:
|
|
||||||
ema_down_count += 1
|
|
||||||
if last_klu and last_klu.close >= last_ema and klu.close < ema:
|
|
||||||
ema_up_list.append(ema_up_count)
|
|
||||||
#print(last_klu.time, ema_up_count, "UP END")
|
|
||||||
ema_up_count = 0
|
|
||||||
elif last_klu and last_klu.close < last_ema and klu.close >= ema:
|
|
||||||
ema_down_list.append(ema_down_count)
|
|
||||||
#print(last_klu.time, ema_down_count, "DOWN END")
|
|
||||||
ema_down_count = 0
|
|
||||||
if len(klc_list) > 0:
|
if len(klc_list) > 0:
|
||||||
last_klc = klc_list[-1]
|
last_klc = klc_list[-1]
|
||||||
if klu.exception:
|
if klu.exception:
|
||||||
@@ -210,7 +188,6 @@ class KlineBuilderMixin:
|
|||||||
klc.set_pre(last_klc)
|
klc.set_pre(last_klc)
|
||||||
last_klc.set_end_klu(last_klu)
|
last_klc.set_end_klu(last_klu)
|
||||||
klc.set_pre_fx()
|
klc.set_pre_fx()
|
||||||
#print(klu.time, klu.high, klu.low, klu.close, klu.open, klu.exception)
|
|
||||||
else:
|
else:
|
||||||
included = last_klc.check_klu_included(klu)
|
included = last_klc.check_klu_included(klu)
|
||||||
if not included:
|
if not included:
|
||||||
@@ -231,6 +208,35 @@ class KlineBuilderMixin:
|
|||||||
ddir = Chan_KLINE_DIR.DOWN
|
ddir = Chan_KLINE_DIR.DOWN
|
||||||
klc = ChanKLC(klu, 0, ddir)
|
klc = ChanKLC(klu, 0, ddir)
|
||||||
klc_list.append(klc)
|
klc_list.append(klc)
|
||||||
|
|
||||||
|
def get_klc_list(self, klu_list):
|
||||||
|
klc_list = []
|
||||||
|
last_klu = None
|
||||||
|
# ChanMACD.__init__ 已调用 cal_macd_state,切勿再调一次(会重复堆积 seg/unittf)
|
||||||
|
macd = ChanMACD(klu_list)
|
||||||
|
klu_list = macd.klu_list
|
||||||
|
self._last_chan_macd = macd
|
||||||
|
ema_up_list = []
|
||||||
|
ema_down_list = []
|
||||||
|
ema_up_count = 0
|
||||||
|
ema_down_count = 0
|
||||||
|
last_klu = None
|
||||||
|
for klu in klu_list:
|
||||||
|
ema = klu.ema52
|
||||||
|
last_ema = last_klu.ema52 if last_klu else 0
|
||||||
|
if klu.close >= ema:
|
||||||
|
ema_up_count += 1
|
||||||
|
elif klu.close < ema:
|
||||||
|
ema_down_count += 1
|
||||||
|
if last_klu and last_klu.close >= last_ema and klu.close < ema:
|
||||||
|
ema_up_list.append(ema_up_count)
|
||||||
|
#print(last_klu.time, ema_up_count, "UP END")
|
||||||
|
ema_up_count = 0
|
||||||
|
elif last_klu and last_klu.close < last_ema and klu.close >= ema:
|
||||||
|
ema_down_list.append(ema_down_count)
|
||||||
|
#print(last_klu.time, ema_down_count, "DOWN END")
|
||||||
|
ema_down_count = 0
|
||||||
|
self._push_klu_into_klc_list(klc_list, klu, last_klu)
|
||||||
last_klu = klu
|
last_klu = klu
|
||||||
klc_list = self.cal_trend(klc_list)
|
klc_list = self.cal_trend(klc_list)
|
||||||
#print(ema52_up_list, ema52_down_list)
|
#print(ema52_up_list, ema52_down_list)
|
||||||
|
|||||||
@@ -355,9 +355,12 @@ class ZsBuilderMixin:
|
|||||||
return bi_zs_list
|
return bi_zs_list
|
||||||
|
|
||||||
def get_zs_range(bis):
|
def get_zs_range(bis):
|
||||||
zg = min(bi.high for bi in bis)
|
bis_list = bis[0:3]
|
||||||
zd = max(bi.low for bi in bis)
|
zg = min(bi.high for bi in bis_list)
|
||||||
return zg, zd
|
zd = max(bi.low for bi in bis_list)
|
||||||
|
dd = min(bi.low for bi in bis_list)
|
||||||
|
gg = max(bi.high for bi in bis_list)
|
||||||
|
return zg, zd, dd, gg
|
||||||
|
|
||||||
def is_bi_overlap_range(bi, zg, zd):
|
def is_bi_overlap_range(bi, zg, zd):
|
||||||
return bi.high >= zd and bi.low <= zg
|
return bi.high >= zd and bi.low <= zg
|
||||||
@@ -375,8 +378,8 @@ class ZsBuilderMixin:
|
|||||||
zs.bi_list = list(bis)
|
zs.bi_list = list(bis)
|
||||||
for bi in zs.bi_list:
|
for bi in zs.bi_list:
|
||||||
bi.set_bi_zs(zs)
|
bi.set_bi_zs(zs)
|
||||||
zs.set_gg(max(bi.high for bi in zs.bi_list))
|
#zs.set_gg(max(bi.high for bi in zs.bi_list))
|
||||||
zs.set_dd(min(bi.low for bi in zs.bi_list))
|
#zs.set_dd(min(bi.low for bi in zs.bi_list))
|
||||||
zs.classify_zs()
|
zs.classify_zs()
|
||||||
|
|
||||||
last_zs = None
|
last_zs = None
|
||||||
@@ -394,7 +397,7 @@ class ZsBuilderMixin:
|
|||||||
start_idx += 1
|
start_idx += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
zg, zd = get_zs_range([bi1, bi2, bi3])
|
zg, zd, dd, gg = get_zs_range([bi1, bi2, bi3])
|
||||||
if zg <= zd:
|
if zg <= zd:
|
||||||
start_idx += 1
|
start_idx += 1
|
||||||
continue
|
continue
|
||||||
@@ -420,6 +423,8 @@ class ZsBuilderMixin:
|
|||||||
zs = ChanBIZS(bi1, len(bi_zs_list), zs_dir)
|
zs = ChanBIZS(bi1, len(bi_zs_list), zs_dir)
|
||||||
zs.set_zg(zg)
|
zs.set_zg(zg)
|
||||||
zs.set_zd(zd)
|
zs.set_zd(zd)
|
||||||
|
zs.set_dd(dd)
|
||||||
|
zs.set_gg(gg)
|
||||||
|
|
||||||
set_zs_bi_list(zs, bis_for_zs)
|
set_zs_bi_list(zs, bis_for_zs)
|
||||||
zs.set_end_bi(bis_for_zs[-1], bis_for_zs[-1].sure_time)
|
zs.set_end_bi(bis_for_zs[-1], bis_for_zs[-1].sure_time)
|
||||||
|
|||||||
@@ -178,6 +178,15 @@ class ChanLun():
|
|||||||
def cal_bi_zs_list(self, bi_list):
|
def cal_bi_zs_list(self, bi_list):
|
||||||
#return self.tf_df.cal_bi_zs(bi_list)
|
#return self.tf_df.cal_bi_zs(bi_list)
|
||||||
return self.tf_df.cal_bi_zs_list(bi_list)
|
return self.tf_df.cal_bi_zs_list(bi_list)
|
||||||
|
def cal_bi_zs_list_pure(self, bi_list):
|
||||||
|
return self.tf_df.cal_bi_zs_list_pure(bi_list)
|
||||||
|
def init_stream(self, dataframe, interval=1, timeframe=None):
|
||||||
|
self.tf_df.init_stream(dataframe, interval, timeframe)
|
||||||
|
return self.tf_df
|
||||||
|
def append_bar(self, row):
|
||||||
|
return self.tf_df.append_bar(row)
|
||||||
|
def replace_last_bar(self, row):
|
||||||
|
return self.tf_df.replace_last_bar(row)
|
||||||
def get_bi_zs_list(self, bi_list):
|
def get_bi_zs_list(self, bi_list):
|
||||||
return self.tf_df.get_bi_zs_list(bi_list)
|
return self.tf_df.get_bi_zs_list(bi_list)
|
||||||
def get_decimal(self, value):
|
def get_decimal(self, value):
|
||||||
|
|||||||
@@ -31,12 +31,13 @@ from chanlun.core.ChanZS import ChanZS, ChanZS_Big
|
|||||||
from chanlun.indicators.ChanMACD import ChanMACD
|
from chanlun.indicators.ChanMACD import ChanMACD
|
||||||
from chanlun.pipeline.builders.bi import BiBuilderMixin
|
from chanlun.pipeline.builders.bi import BiBuilderMixin
|
||||||
from chanlun.pipeline.builders.bsp import BspBuilderMixin
|
from chanlun.pipeline.builders.bsp import BspBuilderMixin
|
||||||
|
from chanlun.pipeline.builders.incremental import IncrementalBuilderMixin
|
||||||
from chanlun.pipeline.builders.indicators import IndicatorsBuilderMixin
|
from chanlun.pipeline.builders.indicators import IndicatorsBuilderMixin
|
||||||
from chanlun.pipeline.builders.kline import KlineBuilderMixin
|
from chanlun.pipeline.builders.kline import KlineBuilderMixin
|
||||||
from chanlun.pipeline.builders.seg import SegBuilderMixin
|
from chanlun.pipeline.builders.seg import SegBuilderMixin
|
||||||
from chanlun.pipeline.builders.zs import ZsBuilderMixin
|
from chanlun.pipeline.builders.zs import ZsBuilderMixin
|
||||||
|
|
||||||
class TF_DF(IndicatorsBuilderMixin, KlineBuilderMixin, BiBuilderMixin, SegBuilderMixin, ZsBuilderMixin, BspBuilderMixin):
|
class TF_DF(IndicatorsBuilderMixin, KlineBuilderMixin, BiBuilderMixin, SegBuilderMixin, ZsBuilderMixin, BspBuilderMixin, IncrementalBuilderMixin):
|
||||||
def __init__(self, df=None, interval=0, timeframe=None):
|
def __init__(self, df=None, interval=0, timeframe=None):
|
||||||
if df is not None:
|
if df is not None:
|
||||||
self.init_TF_DF(df, interval, timeframe)
|
self.init_TF_DF(df, interval, timeframe)
|
||||||
@@ -59,17 +60,22 @@ class TF_DF(IndicatorsBuilderMixin, KlineBuilderMixin, BiBuilderMixin, SegBuilde
|
|||||||
self.klc_list = []
|
self.klc_list = []
|
||||||
self.bi_list = []
|
self.bi_list = []
|
||||||
self.zs_list = []
|
self.zs_list = []
|
||||||
|
self.bi_zs_list = []
|
||||||
self.bsp_list = []
|
self.bsp_list = []
|
||||||
self.seg_list = []
|
self.seg_list = []
|
||||||
self.klc_fx_list = []
|
self.klc_fx_list = []
|
||||||
self.klu_list = self.cal_kl_data(self.dataframe)
|
self.klu_list = self.cal_kl_data(self.dataframe)
|
||||||
self.klc_list = self.get_klc_list(self.klu_list)
|
self.klc_list = self.get_klc_list(self.klu_list)
|
||||||
self.bi_list = self.cal_bi_list(self.klc_list)
|
self.bi_list = self.cal_bi_list(self.klc_list)
|
||||||
|
self.bi_zs_list = self.cal_bi_zs_list_pure(self.bi_list)
|
||||||
self.seg_list = self.get_seg_list(self.bi_list)
|
self.seg_list = self.get_seg_list(self.bi_list)
|
||||||
self.zs_list = self.get_zs_list(self.bi_list, self.seg_list)
|
self.zs_list = self.get_zs_list(self.bi_list, self.seg_list)
|
||||||
self.big_zs_list = self.get_big_zs_list(self.zs_list)
|
self.big_zs_list = self.get_big_zs_list(self.zs_list)
|
||||||
|
# get_klc_list 内已算过 ChanMACD,直接复用
|
||||||
|
self.chanmacd = getattr(self, '_last_chan_macd', None)
|
||||||
|
if self.chanmacd is None:
|
||||||
self.chanmacd = ChanMACD(self.klu_list)
|
self.chanmacd = ChanMACD(self.klu_list)
|
||||||
self.klu_list = self.chanmacd.cal_macd_state()
|
self.klu_list = self.chanmacd.klu_list
|
||||||
|
|
||||||
|
|
||||||
def get_current_klc(self):
|
def get_current_klc(self):
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
from __future__ import annotations
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
_CHAN = Path(__file__).resolve().parents[2]
|
||||||
|
if str(_CHAN) not in sys.path:
|
||||||
|
sys.path.insert(0, str(_CHAN))
|
||||||
|
|
||||||
|
from chanlun.pipeline.timeframe import TF_DF # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def _zigzag_df(n=160, step=8):
|
||||||
|
dates = pd.date_range("2024-01-01", periods=n, freq="5min")
|
||||||
|
rows = []
|
||||||
|
price = 100.0
|
||||||
|
for i, date in enumerate(dates):
|
||||||
|
up = (i // step) % 2 == 0
|
||||||
|
if up:
|
||||||
|
o = price
|
||||||
|
c = price + 1.5
|
||||||
|
h = c + 0.3
|
||||||
|
l = o - 0.2
|
||||||
|
else:
|
||||||
|
o = price
|
||||||
|
c = price - 1.5
|
||||||
|
h = o + 0.2
|
||||||
|
l = c - 0.3
|
||||||
|
price = c
|
||||||
|
rows.append(
|
||||||
|
{
|
||||||
|
"date": date,
|
||||||
|
"open": o,
|
||||||
|
"high": h,
|
||||||
|
"low": l,
|
||||||
|
"close": c,
|
||||||
|
"volume": 1.0,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return pd.DataFrame(rows)
|
||||||
|
|
||||||
|
|
||||||
|
def _sure_bi_key(bi):
|
||||||
|
return (str(bi.start_time), bi.dir.name, round(float(bi.high), 6), round(float(bi.low), 6))
|
||||||
|
|
||||||
|
|
||||||
|
def _zs_key(zs):
|
||||||
|
return (
|
||||||
|
str(zs.start_time),
|
||||||
|
round(float(zs.zg), 6),
|
||||||
|
round(float(zs.zd), 6),
|
||||||
|
len(zs.bi_list),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestIncremental(unittest.TestCase):
|
||||||
|
def test_init_stream_matches_batch_push(self):
|
||||||
|
df = _zigzag_df()
|
||||||
|
stream = TF_DF()
|
||||||
|
stream.init_stream(df, 1, "5m")
|
||||||
|
|
||||||
|
batch = TF_DF()
|
||||||
|
indexed = batch.add_indicators(df.copy())
|
||||||
|
klu = batch.cal_kl_data(indexed)
|
||||||
|
klc = []
|
||||||
|
last = None
|
||||||
|
for k in klu:
|
||||||
|
batch._push_klu_into_klc_list(klc, k, last)
|
||||||
|
last = k
|
||||||
|
batch.klc_list = klc
|
||||||
|
batch.rebuild_bi_zs()
|
||||||
|
|
||||||
|
self.assertEqual(len(stream.klu_list), len(klu))
|
||||||
|
self.assertEqual(len(stream.klc_list), len(klc))
|
||||||
|
self.assertEqual(
|
||||||
|
[_sure_bi_key(b) for b in stream.bi_list if b.is_sure],
|
||||||
|
[_sure_bi_key(b) for b in batch.bi_list if b.is_sure],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
[_zs_key(z) for z in stream.bi_zs_list],
|
||||||
|
[_zs_key(z) for z in batch.bi_zs_list],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_append_bar_matches_init_stream(self):
|
||||||
|
df = _zigzag_df()
|
||||||
|
stream = TF_DF()
|
||||||
|
stream.init_stream(df, 1, "5m")
|
||||||
|
|
||||||
|
inc = TF_DF()
|
||||||
|
for _, row in df.iterrows():
|
||||||
|
inc.append_bar(row)
|
||||||
|
|
||||||
|
self.assertEqual(len(inc.klu_list), len(stream.klu_list))
|
||||||
|
self.assertEqual(len(inc.klc_list), len(stream.klc_list))
|
||||||
|
self.assertEqual(
|
||||||
|
[_sure_bi_key(b) for b in inc.bi_list if b.is_sure],
|
||||||
|
[_sure_bi_key(b) for b in stream.bi_list if b.is_sure],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
[_zs_key(z) for z in inc.bi_zs_list],
|
||||||
|
[_zs_key(z) for z in stream.bi_zs_list],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_replace_last_bar_keeps_count(self):
|
||||||
|
df = _zigzag_df(n=80)
|
||||||
|
tf = TF_DF()
|
||||||
|
tf.init_stream(df, 1, "5m")
|
||||||
|
n_klu = len(tf.klu_list)
|
||||||
|
last = df.iloc[-1].copy()
|
||||||
|
last["close"] = float(last["close"]) + 0.01
|
||||||
|
last["high"] = max(float(last["high"]), float(last["close"]))
|
||||||
|
tf.replace_last_bar(last)
|
||||||
|
self.assertEqual(len(tf.klu_list), n_klu)
|
||||||
|
self.assertGreater(len(tf.klc_list), 0)
|
||||||
|
|
||||||
|
def test_check_fx_skips_forming_right_wing(self):
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
from chanlun.core.ChanEnum import Chan_FX_TYPE
|
||||||
|
|
||||||
|
tf = TF_DF()
|
||||||
|
pre = SimpleNamespace(high=10, low=8)
|
||||||
|
nxt_open = SimpleNamespace(high=11, low=7, end_klu=None)
|
||||||
|
nxt_done = SimpleNamespace(high=11, low=7, end_klu=object())
|
||||||
|
center = SimpleNamespace(
|
||||||
|
pre=pre,
|
||||||
|
next=nxt_open,
|
||||||
|
high=12,
|
||||||
|
low=9,
|
||||||
|
set_fx=lambda *_a, **_k: None,
|
||||||
|
)
|
||||||
|
self.assertEqual(tf.check_fx(center), Chan_FX_TYPE.UNKNOWN)
|
||||||
|
center.next = nxt_done
|
||||||
|
self.assertEqual(tf.check_fx(center), Chan_FX_TYPE.TOP)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://schema.freqtrade.io/schema.json",
|
||||||
|
"strategy": "BTC_Maker_Micro_Scalper",
|
||||||
|
"max_open_trades": 1,
|
||||||
|
"stake_currency": "USDT",
|
||||||
|
"stake_amount": "unlimited",
|
||||||
|
"tradable_balance_ratio": 0.99,
|
||||||
|
"fiat_display_currency": "USD",
|
||||||
|
"dry_run": true,
|
||||||
|
"db_url": "sqlite:///tradesv3.btc_maker_micro_scalper.sqlite",
|
||||||
|
"dry_run_wallet": 10000,
|
||||||
|
"cancel_open_orders_on_exit": true,
|
||||||
|
"trading_mode": "futures",
|
||||||
|
"margin_mode": "isolated",
|
||||||
|
"can_short": true,
|
||||||
|
"timeframe": "1m",
|
||||||
|
"process_only_new_candles": true,
|
||||||
|
"fee": 0.00016,
|
||||||
|
"unfilledtimeout": {
|
||||||
|
"entry": 1,
|
||||||
|
"exit": 1,
|
||||||
|
"exit_timeout_count": 3,
|
||||||
|
"unit": "minutes"
|
||||||
|
},
|
||||||
|
"order_types": {
|
||||||
|
"entry": "limit",
|
||||||
|
"exit": "limit",
|
||||||
|
"stoploss": "limit",
|
||||||
|
"stoploss_on_exchange": false
|
||||||
|
},
|
||||||
|
"order_time_in_force": {
|
||||||
|
"entry": "GTC",
|
||||||
|
"exit": "GTC"
|
||||||
|
},
|
||||||
|
"entry_pricing": {
|
||||||
|
"price_side": "same",
|
||||||
|
"use_order_book": true,
|
||||||
|
"order_book_top": 1,
|
||||||
|
"price_last_balance": 0.0,
|
||||||
|
"check_depth_of_market": {
|
||||||
|
"enabled": false,
|
||||||
|
"bids_to_ask_delta": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"exit_pricing": {
|
||||||
|
"price_side": "same",
|
||||||
|
"use_order_book": true,
|
||||||
|
"order_book_top": 1
|
||||||
|
},
|
||||||
|
"exchange": {
|
||||||
|
"name": "binance",
|
||||||
|
"key": "YOUR_BINANCE_API_KEY",
|
||||||
|
"secret": "YOUR_BINANCE_API_SECRET",
|
||||||
|
"ccxt_config": {
|
||||||
|
"proxies": {
|
||||||
|
"http": "http://127.0.0.1:7897",
|
||||||
|
"https": "http://127.0.0.1:7897"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ccxt_async_config": {
|
||||||
|
"aiohttp_proxy": "http://127.0.0.1:7897"
|
||||||
|
},
|
||||||
|
"pair_whitelist": [
|
||||||
|
"BTC/USDT:USDT"
|
||||||
|
],
|
||||||
|
"pair_blacklist": [
|
||||||
|
"BNB/.*"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"pairlists": [
|
||||||
|
{
|
||||||
|
"method": "StaticPairList"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"telegram": {
|
||||||
|
"enabled": false,
|
||||||
|
"token": "",
|
||||||
|
"chat_id": ""
|
||||||
|
},
|
||||||
|
"api_server": {
|
||||||
|
"enabled": false,
|
||||||
|
"listen_ip_address": "127.0.0.1",
|
||||||
|
"listen_port": 8821,
|
||||||
|
"verbosity": "error",
|
||||||
|
"enable_openapi": false,
|
||||||
|
"jwt_secret_key": "change_me_mms_v1",
|
||||||
|
"ws_token": "change_me_mms_ws",
|
||||||
|
"CORS_origins": [],
|
||||||
|
"username": "freqtrader",
|
||||||
|
"password": "FreqTrade007"
|
||||||
|
},
|
||||||
|
"bot_name": "BTC_Maker_Micro_Scalper",
|
||||||
|
"initial_state": "running",
|
||||||
|
"force_entry_enable": false,
|
||||||
|
"internals": {
|
||||||
|
"process_throttle_secs": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://schema.freqtrade.io/schema.json",
|
||||||
|
"strategy": "BTC_Maker_Micro_Scalper_v11",
|
||||||
|
"max_open_trades": 1,
|
||||||
|
"stake_currency": "USDT",
|
||||||
|
"stake_amount": "unlimited",
|
||||||
|
"tradable_balance_ratio": 0.99,
|
||||||
|
"fiat_display_currency": "USD",
|
||||||
|
"dry_run": true,
|
||||||
|
"db_url": "sqlite:///tradesv3.btc_maker_micro_scalper_v11.sqlite",
|
||||||
|
"dry_run_wallet": 10000,
|
||||||
|
"cancel_open_orders_on_exit": true,
|
||||||
|
"trading_mode": "futures",
|
||||||
|
"margin_mode": "isolated",
|
||||||
|
"can_short": true,
|
||||||
|
"timeframe": "1m",
|
||||||
|
"process_only_new_candles": true,
|
||||||
|
"fee": 0.00016,
|
||||||
|
"unfilledtimeout": {
|
||||||
|
"entry": 3,
|
||||||
|
"exit": 2,
|
||||||
|
"exit_timeout_count": 3,
|
||||||
|
"unit": "minutes"
|
||||||
|
},
|
||||||
|
"order_types": {
|
||||||
|
"entry": "limit",
|
||||||
|
"exit": "limit",
|
||||||
|
"stoploss": "limit",
|
||||||
|
"stoploss_on_exchange": false
|
||||||
|
},
|
||||||
|
"order_time_in_force": {
|
||||||
|
"entry": "GTC",
|
||||||
|
"exit": "GTC"
|
||||||
|
},
|
||||||
|
"entry_pricing": {
|
||||||
|
"price_side": "same",
|
||||||
|
"use_order_book": true,
|
||||||
|
"order_book_top": 1,
|
||||||
|
"price_last_balance": 0.0,
|
||||||
|
"check_depth_of_market": {
|
||||||
|
"enabled": false,
|
||||||
|
"bids_to_ask_delta": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"exit_pricing": {
|
||||||
|
"price_side": "same",
|
||||||
|
"use_order_book": true,
|
||||||
|
"order_book_top": 1
|
||||||
|
},
|
||||||
|
"exchange": {
|
||||||
|
"name": "binance",
|
||||||
|
"key": "YOUR_BINANCE_API_KEY",
|
||||||
|
"secret": "YOUR_BINANCE_API_SECRET",
|
||||||
|
"ccxt_config": {
|
||||||
|
"proxies": {
|
||||||
|
"http": "http://127.0.0.1:7897",
|
||||||
|
"https": "http://127.0.0.1:7897"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ccxt_async_config": {
|
||||||
|
"aiohttp_proxy": "http://127.0.0.1:7897"
|
||||||
|
},
|
||||||
|
"pair_whitelist": [
|
||||||
|
"BTC/USDT:USDT"
|
||||||
|
],
|
||||||
|
"pair_blacklist": [
|
||||||
|
"BNB/.*"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"pairlists": [
|
||||||
|
{
|
||||||
|
"method": "StaticPairList"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"telegram": {
|
||||||
|
"enabled": false,
|
||||||
|
"token": "",
|
||||||
|
"chat_id": ""
|
||||||
|
},
|
||||||
|
"api_server": {
|
||||||
|
"enabled": false,
|
||||||
|
"listen_ip_address": "127.0.0.1",
|
||||||
|
"listen_port": 8822,
|
||||||
|
"verbosity": "error",
|
||||||
|
"enable_openapi": false,
|
||||||
|
"jwt_secret_key": "change_me_mms_v11",
|
||||||
|
"ws_token": "change_me_mms_v11_ws",
|
||||||
|
"CORS_origins": [],
|
||||||
|
"username": "freqtrader",
|
||||||
|
"password": "FreqTrade007"
|
||||||
|
},
|
||||||
|
"bot_name": "BTC_Maker_Micro_Scalper_v11",
|
||||||
|
"initial_state": "running",
|
||||||
|
"force_entry_enable": false,
|
||||||
|
"internals": {
|
||||||
|
"process_throttle_secs": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -39,8 +39,15 @@
|
|||||||
"name": "binance",
|
"name": "binance",
|
||||||
"key": "hvoXanRExQvcN4tyGFvEnsSF4gqxXp6ZJnBu5lnhvlVuHaDbj2PhLBQGCLkkyeI8",
|
"key": "hvoXanRExQvcN4tyGFvEnsSF4gqxXp6ZJnBu5lnhvlVuHaDbj2PhLBQGCLkkyeI8",
|
||||||
"secret": "3UKA2oyDj7OoXrausmnaLwLlNfXmlNf2imBdmQqqKHArcJfk6X9xjaUF19wzu82l",
|
"secret": "3UKA2oyDj7OoXrausmnaLwLlNfXmlNf2imBdmQqqKHArcJfk6X9xjaUF19wzu82l",
|
||||||
"ccxt_config": {},
|
"ccxt_config": {
|
||||||
"ccxt_async_config": {},
|
"proxies": {
|
||||||
|
"http": "http://127.0.0.1:7897",
|
||||||
|
"https": "http://127.0.0.1:7897"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ccxt_async_config": {
|
||||||
|
"aiohttp_proxy": "http://127.0.0.1:7897"
|
||||||
|
},
|
||||||
"pair_whitelist": [
|
"pair_whitelist": [
|
||||||
"BTC/USDT:USDT"
|
"BTC/USDT:USDT"
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://schema.freqtrade.io/schema.json",
|
||||||
|
"strategy": "MakerEdgeProbe",
|
||||||
|
"max_open_trades": 1,
|
||||||
|
"stake_currency": "USDT",
|
||||||
|
"stake_amount": "unlimited",
|
||||||
|
"tradable_balance_ratio": 0.99,
|
||||||
|
"fiat_display_currency": "USD",
|
||||||
|
"dry_run": true,
|
||||||
|
"db_url": "sqlite:///tradesv3.maker_edge_probe.sqlite",
|
||||||
|
"dry_run_wallet": 10000,
|
||||||
|
"cancel_open_orders_on_exit": true,
|
||||||
|
"trading_mode": "futures",
|
||||||
|
"margin_mode": "isolated",
|
||||||
|
"can_short": true,
|
||||||
|
"timeframe": "1m",
|
||||||
|
"process_only_new_candles": false,
|
||||||
|
"fee": 0.00016,
|
||||||
|
"unfilledtimeout": {
|
||||||
|
"entry": 3,
|
||||||
|
"exit": 2,
|
||||||
|
"exit_timeout_count": 3,
|
||||||
|
"unit": "minutes"
|
||||||
|
},
|
||||||
|
"order_types": {
|
||||||
|
"entry": "limit",
|
||||||
|
"exit": "limit",
|
||||||
|
"stoploss": "market",
|
||||||
|
"stoploss_on_exchange": false
|
||||||
|
},
|
||||||
|
"order_time_in_force": {
|
||||||
|
"entry": "GTC",
|
||||||
|
"exit": "GTC"
|
||||||
|
},
|
||||||
|
"entry_pricing": {
|
||||||
|
"price_side": "same",
|
||||||
|
"use_order_book": true,
|
||||||
|
"order_book_top": 1
|
||||||
|
},
|
||||||
|
"exit_pricing": {
|
||||||
|
"price_side": "same",
|
||||||
|
"use_order_book": true,
|
||||||
|
"order_book_top": 1
|
||||||
|
},
|
||||||
|
"exchange": {
|
||||||
|
"name": "binance",
|
||||||
|
"key": "YOUR_BINANCE_API_KEY",
|
||||||
|
"secret": "YOUR_BINANCE_API_SECRET",
|
||||||
|
"ccxt_config": {
|
||||||
|
"proxies": {
|
||||||
|
"http": "http://127.0.0.1:7897",
|
||||||
|
"https": "http://127.0.0.1:7897"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ccxt_async_config": {
|
||||||
|
"aiohttp_proxy": "http://127.0.0.1:7897"
|
||||||
|
},
|
||||||
|
"pair_whitelist": [
|
||||||
|
"BTC/USDT:USDT"
|
||||||
|
],
|
||||||
|
"pair_blacklist": [
|
||||||
|
"BNB/.*"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"pairlists": [
|
||||||
|
{
|
||||||
|
"method": "StaticPairList"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"telegram": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"api_server": {
|
||||||
|
"enabled": true,
|
||||||
|
"listen_ip_address": "127.0.0.1",
|
||||||
|
"listen_port": 8823,
|
||||||
|
"verbosity": "error",
|
||||||
|
"enable_openapi": false,
|
||||||
|
"jwt_secret_key": "maker_edge_probe_change_me",
|
||||||
|
"ws_token": "maker_edge_probe_ws",
|
||||||
|
"CORS_origins": [],
|
||||||
|
"username": "freqtrader",
|
||||||
|
"password": "FreqTrade007"
|
||||||
|
},
|
||||||
|
"bot_name": "MakerEdgeProbe",
|
||||||
|
"initial_state": "running",
|
||||||
|
"force_entry_enable": false,
|
||||||
|
"internals": {
|
||||||
|
"process_throttle_secs": 2
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://schema.freqtrade.io/schema.json",
|
||||||
|
"max_open_trades": 1,
|
||||||
|
"stake_currency": "USDT",
|
||||||
|
"stake_amount": "unlimited",
|
||||||
|
"tradable_balance_ratio": 0.99,
|
||||||
|
"fiat_display_currency": "USD",
|
||||||
|
"dry_run": true,
|
||||||
|
"db_url": "sqlite:///tradesv3.turtle_btc.sqlite",
|
||||||
|
"dry_run_wallet": 10000,
|
||||||
|
"cancel_open_orders_on_exit": true,
|
||||||
|
"trading_mode": "futures",
|
||||||
|
"margin_mode": "isolated",
|
||||||
|
"can_short": true,
|
||||||
|
"timeframe": "15m",
|
||||||
|
"process_only_new_candles": true,
|
||||||
|
"unfilledtimeout": {
|
||||||
|
"entry": 15,
|
||||||
|
"exit": 15,
|
||||||
|
"exit_timeout_count": 5,
|
||||||
|
"unit": "minutes"
|
||||||
|
},
|
||||||
|
"entry_pricing": {
|
||||||
|
"price_side": "same",
|
||||||
|
"use_order_book": true,
|
||||||
|
"order_book_top": 1,
|
||||||
|
"price_last_balance": 0.0,
|
||||||
|
"check_depth_of_market": {
|
||||||
|
"enabled": false,
|
||||||
|
"bids_to_ask_delta": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"exit_pricing": {
|
||||||
|
"price_side": "same",
|
||||||
|
"use_order_book": true,
|
||||||
|
"order_book_top": 1
|
||||||
|
},
|
||||||
|
"exchange": {
|
||||||
|
"name": "binance",
|
||||||
|
"key": "",
|
||||||
|
"secret": "",
|
||||||
|
"ccxt_config": {
|
||||||
|
"proxies": {
|
||||||
|
"http": "http://127.0.0.1:7897",
|
||||||
|
"https": "http://127.0.0.1:7897"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ccxt_async_config": {
|
||||||
|
"aiohttp_proxy": "http://127.0.0.1:7897"
|
||||||
|
},
|
||||||
|
"pair_whitelist": [
|
||||||
|
"BTC/USDT:USDT"
|
||||||
|
],
|
||||||
|
"pair_blacklist": [
|
||||||
|
"BNB/.*"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"pairlists": [
|
||||||
|
{
|
||||||
|
"method": "StaticPairList"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"telegram": {
|
||||||
|
"enabled": false,
|
||||||
|
"token": "",
|
||||||
|
"chat_id": ""
|
||||||
|
},
|
||||||
|
"api_server": {
|
||||||
|
"enabled": false,
|
||||||
|
"listen_ip_address": "127.0.0.1",
|
||||||
|
"listen_port": 8822,
|
||||||
|
"verbosity": "error",
|
||||||
|
"enable_openapi": false,
|
||||||
|
"jwt_secret_key": "turtle-btc-change-me",
|
||||||
|
"ws_token": "turtle-btc-ws-change-me",
|
||||||
|
"CORS_origins": [],
|
||||||
|
"username": "freqtrader",
|
||||||
|
"password": "FreqTrade007"
|
||||||
|
},
|
||||||
|
"bot_name": "turtle_btc",
|
||||||
|
"initial_state": "running",
|
||||||
|
"force_entry_enable": false,
|
||||||
|
"internals": {
|
||||||
|
"process_throttle_secs": 5
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://schema.freqtrade.io/schema.json",
|
||||||
|
"max_open_trades": 1,
|
||||||
|
"stake_currency": "USDT",
|
||||||
|
"stake_amount": "unlimited",
|
||||||
|
"tradable_balance_ratio": 0.99,
|
||||||
|
"fiat_display_currency": "USD",
|
||||||
|
"dry_run": true,
|
||||||
|
"db_url": "sqlite:///tradesv3.wyckoff_btc.sqlite",
|
||||||
|
"dry_run_wallet": 10000,
|
||||||
|
"cancel_open_orders_on_exit": true,
|
||||||
|
"trading_mode": "futures",
|
||||||
|
"margin_mode": "isolated",
|
||||||
|
"can_short": true,
|
||||||
|
"timeframe": "1h",
|
||||||
|
"process_only_new_candles": true,
|
||||||
|
"unfilledtimeout": {
|
||||||
|
"entry": 60,
|
||||||
|
"exit": 60,
|
||||||
|
"exit_timeout_count": 5,
|
||||||
|
"unit": "minutes"
|
||||||
|
},
|
||||||
|
"entry_pricing": {
|
||||||
|
"price_side": "same",
|
||||||
|
"use_order_book": true,
|
||||||
|
"order_book_top": 1,
|
||||||
|
"price_last_balance": 0.0,
|
||||||
|
"check_depth_of_market": {
|
||||||
|
"enabled": false,
|
||||||
|
"bids_to_ask_delta": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"exit_pricing": {
|
||||||
|
"price_side": "same",
|
||||||
|
"use_order_book": true,
|
||||||
|
"order_book_top": 1
|
||||||
|
},
|
||||||
|
"exchange": {
|
||||||
|
"name": "binance",
|
||||||
|
"key": "",
|
||||||
|
"secret": "",
|
||||||
|
"ccxt_config": {
|
||||||
|
"proxies": {
|
||||||
|
"http": "http://127.0.0.1:7897",
|
||||||
|
"https": "http://127.0.0.1:7897"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ccxt_async_config": {
|
||||||
|
"aiohttp_proxy": "http://127.0.0.1:7897"
|
||||||
|
},
|
||||||
|
"pair_whitelist": [
|
||||||
|
"BTC/USDT:USDT"
|
||||||
|
],
|
||||||
|
"pair_blacklist": [
|
||||||
|
"BNB/.*"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"pairlists": [
|
||||||
|
{
|
||||||
|
"method": "StaticPairList"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"telegram": {
|
||||||
|
"enabled": false,
|
||||||
|
"token": "",
|
||||||
|
"chat_id": ""
|
||||||
|
},
|
||||||
|
"api_server": {
|
||||||
|
"enabled": false,
|
||||||
|
"listen_ip_address": "127.0.0.1",
|
||||||
|
"listen_port": 8823,
|
||||||
|
"verbosity": "error",
|
||||||
|
"enable_openapi": false,
|
||||||
|
"jwt_secret_key": "wyckoff-btc-change-me",
|
||||||
|
"ws_token": "wyckoff-btc-ws-change-me",
|
||||||
|
"CORS_origins": [],
|
||||||
|
"username": "freqtrader",
|
||||||
|
"password": "FreqTrade007"
|
||||||
|
},
|
||||||
|
"bot_name": "wyckoff_btc",
|
||||||
|
"initial_state": "running",
|
||||||
|
"force_entry_enable": false,
|
||||||
|
"internals": {
|
||||||
|
"process_throttle_secs": 5
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://schema.freqtrade.io/schema.json",
|
||||||
|
"max_open_trades": 1,
|
||||||
|
"stake_currency": "USDT",
|
||||||
|
"stake_amount": "unlimited",
|
||||||
|
"tradable_balance_ratio": 0.99,
|
||||||
|
"fiat_display_currency": "USD",
|
||||||
|
"dry_run": true,
|
||||||
|
"db_url": "sqlite:///tradesv3.wyckoff_btc_gated.sqlite",
|
||||||
|
"dry_run_wallet": 10000,
|
||||||
|
"cancel_open_orders_on_exit": true,
|
||||||
|
"trading_mode": "futures",
|
||||||
|
"margin_mode": "isolated",
|
||||||
|
"can_short": true,
|
||||||
|
"timeframe": "1h",
|
||||||
|
"process_only_new_candles": true,
|
||||||
|
"unfilledtimeout": {
|
||||||
|
"entry": 60,
|
||||||
|
"exit": 60,
|
||||||
|
"exit_timeout_count": 5,
|
||||||
|
"unit": "minutes"
|
||||||
|
},
|
||||||
|
"entry_pricing": {
|
||||||
|
"price_side": "same",
|
||||||
|
"use_order_book": true,
|
||||||
|
"order_book_top": 1,
|
||||||
|
"price_last_balance": 0.0,
|
||||||
|
"check_depth_of_market": {
|
||||||
|
"enabled": false,
|
||||||
|
"bids_to_ask_delta": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"exit_pricing": {
|
||||||
|
"price_side": "same",
|
||||||
|
"use_order_book": true,
|
||||||
|
"order_book_top": 1
|
||||||
|
},
|
||||||
|
"exchange": {
|
||||||
|
"name": "binance",
|
||||||
|
"key": "",
|
||||||
|
"secret": "",
|
||||||
|
"ccxt_config": {
|
||||||
|
"proxies": {
|
||||||
|
"http": "http://127.0.0.1:7897",
|
||||||
|
"https": "http://127.0.0.1:7897"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ccxt_async_config": {
|
||||||
|
"aiohttp_proxy": "http://127.0.0.1:7897"
|
||||||
|
},
|
||||||
|
"pair_whitelist": [
|
||||||
|
"BTC/USDT:USDT"
|
||||||
|
],
|
||||||
|
"pair_blacklist": [
|
||||||
|
"BNB/.*"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"pairlists": [
|
||||||
|
{
|
||||||
|
"method": "StaticPairList"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"telegram": {
|
||||||
|
"enabled": false,
|
||||||
|
"token": "",
|
||||||
|
"chat_id": ""
|
||||||
|
},
|
||||||
|
"api_server": {
|
||||||
|
"enabled": false,
|
||||||
|
"listen_ip_address": "127.0.0.1",
|
||||||
|
"listen_port": 8825,
|
||||||
|
"verbosity": "error",
|
||||||
|
"enable_openapi": false,
|
||||||
|
"jwt_secret_key": "wyckoff-gated-change-me",
|
||||||
|
"ws_token": "wyckoff-gated-ws-change-me",
|
||||||
|
"CORS_origins": [],
|
||||||
|
"username": "freqtrader",
|
||||||
|
"password": "FreqTrade007"
|
||||||
|
},
|
||||||
|
"bot_name": "wyckoff_btc_gated",
|
||||||
|
"initial_state": "running",
|
||||||
|
"force_entry_enable": false,
|
||||||
|
"internals": {
|
||||||
|
"process_throttle_secs": 5
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://schema.freqtrade.io/schema.json",
|
||||||
|
"max_open_trades": 1,
|
||||||
|
"stake_currency": "USDT",
|
||||||
|
"stake_amount": "unlimited",
|
||||||
|
"tradable_balance_ratio": 0.99,
|
||||||
|
"fiat_display_currency": "USD",
|
||||||
|
"dry_run": true,
|
||||||
|
"db_url": "sqlite:///tradesv3.wyckoff_btc_lps.sqlite",
|
||||||
|
"dry_run_wallet": 10000,
|
||||||
|
"cancel_open_orders_on_exit": true,
|
||||||
|
"trading_mode": "futures",
|
||||||
|
"margin_mode": "isolated",
|
||||||
|
"can_short": true,
|
||||||
|
"timeframe": "1h",
|
||||||
|
"process_only_new_candles": true,
|
||||||
|
"unfilledtimeout": {
|
||||||
|
"entry": 60,
|
||||||
|
"exit": 60,
|
||||||
|
"exit_timeout_count": 5,
|
||||||
|
"unit": "minutes"
|
||||||
|
},
|
||||||
|
"entry_pricing": {
|
||||||
|
"price_side": "same",
|
||||||
|
"use_order_book": true,
|
||||||
|
"order_book_top": 1,
|
||||||
|
"price_last_balance": 0.0,
|
||||||
|
"check_depth_of_market": {
|
||||||
|
"enabled": false,
|
||||||
|
"bids_to_ask_delta": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"exit_pricing": {
|
||||||
|
"price_side": "same",
|
||||||
|
"use_order_book": true,
|
||||||
|
"order_book_top": 1
|
||||||
|
},
|
||||||
|
"exchange": {
|
||||||
|
"name": "binance",
|
||||||
|
"key": "",
|
||||||
|
"secret": "",
|
||||||
|
"ccxt_config": {
|
||||||
|
"proxies": {
|
||||||
|
"http": "http://127.0.0.1:7897",
|
||||||
|
"https": "http://127.0.0.1:7897"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ccxt_async_config": {
|
||||||
|
"aiohttp_proxy": "http://127.0.0.1:7897"
|
||||||
|
},
|
||||||
|
"pair_whitelist": [
|
||||||
|
"BTC/USDT:USDT"
|
||||||
|
],
|
||||||
|
"pair_blacklist": [
|
||||||
|
"BNB/.*"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"pairlists": [
|
||||||
|
{
|
||||||
|
"method": "StaticPairList"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"telegram": {
|
||||||
|
"enabled": false,
|
||||||
|
"token": "",
|
||||||
|
"chat_id": ""
|
||||||
|
},
|
||||||
|
"api_server": {
|
||||||
|
"enabled": false,
|
||||||
|
"listen_ip_address": "127.0.0.1",
|
||||||
|
"listen_port": 8824,
|
||||||
|
"verbosity": "error",
|
||||||
|
"enable_openapi": false,
|
||||||
|
"jwt_secret_key": "wyckoff-lps-change-me",
|
||||||
|
"ws_token": "wyckoff-lps-ws-change-me",
|
||||||
|
"CORS_origins": [],
|
||||||
|
"username": "freqtrader",
|
||||||
|
"password": "FreqTrade007"
|
||||||
|
},
|
||||||
|
"bot_name": "wyckoff_btc_lps",
|
||||||
|
"initial_state": "running",
|
||||||
|
"force_entry_enable": false,
|
||||||
|
"internals": {
|
||||||
|
"process_throttle_secs": 5
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://schema.freqtrade.io/schema.json",
|
||||||
|
"max_open_trades": 1,
|
||||||
|
"stake_currency": "USDT",
|
||||||
|
"stake_amount": "unlimited",
|
||||||
|
"tradable_balance_ratio": 0.99,
|
||||||
|
"fiat_display_currency": "USD",
|
||||||
|
"dry_run": true,
|
||||||
|
"db_url": "sqlite:///tradesv3.wyckoff_btc_v1_baseline.sqlite",
|
||||||
|
"dry_run_wallet": 10000,
|
||||||
|
"cancel_open_orders_on_exit": true,
|
||||||
|
"trading_mode": "futures",
|
||||||
|
"margin_mode": "isolated",
|
||||||
|
"can_short": true,
|
||||||
|
"timeframe": "1h",
|
||||||
|
"process_only_new_candles": true,
|
||||||
|
"unfilledtimeout": {
|
||||||
|
"entry": 60,
|
||||||
|
"exit": 60,
|
||||||
|
"exit_timeout_count": 5,
|
||||||
|
"unit": "minutes"
|
||||||
|
},
|
||||||
|
"entry_pricing": {
|
||||||
|
"price_side": "same",
|
||||||
|
"use_order_book": true,
|
||||||
|
"order_book_top": 1,
|
||||||
|
"price_last_balance": 0.0,
|
||||||
|
"check_depth_of_market": {
|
||||||
|
"enabled": false,
|
||||||
|
"bids_to_ask_delta": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"exit_pricing": {
|
||||||
|
"price_side": "same",
|
||||||
|
"use_order_book": true,
|
||||||
|
"order_book_top": 1
|
||||||
|
},
|
||||||
|
"exchange": {
|
||||||
|
"name": "binance",
|
||||||
|
"key": "",
|
||||||
|
"secret": "",
|
||||||
|
"ccxt_config": {
|
||||||
|
"proxies": {
|
||||||
|
"http": "http://127.0.0.1:7897",
|
||||||
|
"https": "http://127.0.0.1:7897"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ccxt_async_config": {
|
||||||
|
"aiohttp_proxy": "http://127.0.0.1:7897"
|
||||||
|
},
|
||||||
|
"pair_whitelist": [
|
||||||
|
"BTC/USDT:USDT"
|
||||||
|
],
|
||||||
|
"pair_blacklist": [
|
||||||
|
"BNB/.*"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"pairlists": [
|
||||||
|
{
|
||||||
|
"method": "StaticPairList"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"telegram": {
|
||||||
|
"enabled": false,
|
||||||
|
"token": "",
|
||||||
|
"chat_id": ""
|
||||||
|
},
|
||||||
|
"api_server": {
|
||||||
|
"enabled": false,
|
||||||
|
"listen_ip_address": "127.0.0.1",
|
||||||
|
"listen_port": 8823,
|
||||||
|
"verbosity": "error",
|
||||||
|
"enable_openapi": false,
|
||||||
|
"jwt_secret_key": "wyckoff-v1-baseline-change-me",
|
||||||
|
"ws_token": "wyckoff-v1-baseline-ws-change-me",
|
||||||
|
"CORS_origins": [],
|
||||||
|
"username": "freqtrader",
|
||||||
|
"password": "FreqTrade007"
|
||||||
|
},
|
||||||
|
"bot_name": "wyckoff_btc_v1_baseline",
|
||||||
|
"initial_state": "running",
|
||||||
|
"force_entry_enable": false,
|
||||||
|
"internals": {
|
||||||
|
"process_throttle_secs": 5
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
"""crypto_wyckoff — multi-TF screener for crypto (ported from A_Share_DP Architecture v1.0)."""
|
||||||
|
|
||||||
|
from crypto_wyckoff.version import ARCHITECTURE_VERSION, WYCKOFF_ENGINE_VERSION
|
||||||
|
|
||||||
|
__all__ = ["WYCKOFF_ENGINE_VERSION", "ARCHITECTURE_VERSION"]
|
||||||
@@ -0,0 +1,342 @@
|
|||||||
|
"""Walk-forward Wyckoff phase/event annotations for chart overlay."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from crypto_wyckoff.domain_models import OHLCVFrame, WyckoffCycle, WyckoffEvent, WyckoffPhase
|
||||||
|
from crypto_wyckoff.cycle import CycleEngine
|
||||||
|
from crypto_wyckoff.event import EventEngine
|
||||||
|
from crypto_wyckoff.features import FeatureEngine
|
||||||
|
from crypto_wyckoff.phase import PhaseEngine
|
||||||
|
|
||||||
|
_MIN_BARS = {"1d": 40, "1w": 26, "1M": 18}
|
||||||
|
|
||||||
|
_NOTABLE_EVENTS = {
|
||||||
|
WyckoffEvent.PS.value,
|
||||||
|
WyckoffEvent.SC.value,
|
||||||
|
WyckoffEvent.AR.value,
|
||||||
|
WyckoffEvent.ST.value,
|
||||||
|
WyckoffEvent.SPRING.value,
|
||||||
|
WyckoffEvent.TEST.value,
|
||||||
|
WyckoffEvent.SOS.value,
|
||||||
|
WyckoffEvent.LPS.value,
|
||||||
|
WyckoffEvent.JUMP.value,
|
||||||
|
WyckoffEvent.BACKUP.value,
|
||||||
|
WyckoffEvent.BC.value,
|
||||||
|
WyckoffEvent.UTAD.value,
|
||||||
|
WyckoffEvent.SOW.value,
|
||||||
|
WyckoffEvent.LPSY.value,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _slice_frame(frame: OHLCVFrame, end_idx: int) -> OHLCVFrame:
|
||||||
|
n = end_idx + 1
|
||||||
|
return OHLCVFrame(
|
||||||
|
ts_code=frame.ts_code,
|
||||||
|
timeframe=frame.timeframe,
|
||||||
|
trade_dates=frame.trade_dates[:n],
|
||||||
|
open=frame.open[:n],
|
||||||
|
high=frame.high[:n],
|
||||||
|
low=frame.low[:n],
|
||||||
|
close=frame.close[:n],
|
||||||
|
volume=frame.volume[:n],
|
||||||
|
amount=frame.amount[:n] if frame.amount else [],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _compress_phases(points: list[tuple[str, str]]) -> list[dict]:
|
||||||
|
"""points: [(date_iso, phase), ...] → segments."""
|
||||||
|
if not points:
|
||||||
|
return []
|
||||||
|
segs: list[dict] = []
|
||||||
|
start, phase = points[0]
|
||||||
|
prev = start
|
||||||
|
for d, p in points[1:]:
|
||||||
|
if p != phase:
|
||||||
|
segs.append({"start": start, "end": prev, "phase": phase})
|
||||||
|
start, phase = d, p
|
||||||
|
prev = d
|
||||||
|
segs.append({"start": start, "end": prev, "phase": phase})
|
||||||
|
return segs
|
||||||
|
|
||||||
|
|
||||||
|
def annotate_frame(
|
||||||
|
frame: OHLCVFrame,
|
||||||
|
step: int | None = None,
|
||||||
|
*,
|
||||||
|
role: str | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""Pure annotation: phase bands + event markers + latest levels.
|
||||||
|
|
||||||
|
``role`` is the D/W/M rule alias (1d/1w/1M). Defaults to frame.timeframe.
|
||||||
|
``step`` defaults by role to keep interactive charts snappy.
|
||||||
|
"""
|
||||||
|
tf = role or frame.timeframe
|
||||||
|
min_bars = _MIN_BARS.get(tf, 30)
|
||||||
|
if step is None:
|
||||||
|
step = {"1d": 2, "1w": 1, "1M": 1}.get(tf, 2)
|
||||||
|
|
||||||
|
empty = {
|
||||||
|
"phases": [],
|
||||||
|
"events": [],
|
||||||
|
"levels": {},
|
||||||
|
"bars": len(frame),
|
||||||
|
"timeframe": tf,
|
||||||
|
}
|
||||||
|
if frame.empty or len(frame) < min_bars:
|
||||||
|
return empty
|
||||||
|
|
||||||
|
feat_eng = FeatureEngine()
|
||||||
|
cycle_eng = CycleEngine()
|
||||||
|
phase_eng = PhaseEngine()
|
||||||
|
event_eng = EventEngine()
|
||||||
|
|
||||||
|
phase_points: list[tuple[str, str]] = []
|
||||||
|
events: list[dict] = []
|
||||||
|
last_event: str | None = None
|
||||||
|
levels: dict = {}
|
||||||
|
|
||||||
|
# Ensure last bar is always evaluated
|
||||||
|
indices = list(range(min_bars - 1, len(frame), step))
|
||||||
|
if indices[-1] != len(frame) - 1:
|
||||||
|
indices.append(len(frame) - 1)
|
||||||
|
|
||||||
|
for i in indices:
|
||||||
|
sub = _slice_frame(frame, i)
|
||||||
|
f = feat_eng.run(sub, tf)
|
||||||
|
c = cycle_eng.run(f, tf)
|
||||||
|
p = phase_eng.run(c, f, tf)
|
||||||
|
e = event_eng.run(c, p, f, tf)
|
||||||
|
|
||||||
|
d = str(frame.trade_dates[i])[:10]
|
||||||
|
phase = p.payload.get("phase") or WyckoffPhase.NONE.value
|
||||||
|
phase_points.append((d, phase))
|
||||||
|
|
||||||
|
cur = e.payload.get("current_event") or WyckoffEvent.NONE.value
|
||||||
|
if cur in _NOTABLE_EVENTS and cur != last_event:
|
||||||
|
events.append({
|
||||||
|
"date": d,
|
||||||
|
"event": cur,
|
||||||
|
"price": float(frame.close[i]),
|
||||||
|
"low": float(frame.low[i]),
|
||||||
|
"high": float(frame.high[i]),
|
||||||
|
})
|
||||||
|
last_event = cur
|
||||||
|
elif cur == WyckoffEvent.NONE.value:
|
||||||
|
last_event = None
|
||||||
|
|
||||||
|
if i == len(frame) - 1 and not f.payload.get("insufficient"):
|
||||||
|
levels = {
|
||||||
|
k: f.payload.get(k)
|
||||||
|
for k in (
|
||||||
|
"range_high", "range_low", "ma20", "ma60",
|
||||||
|
"swing_high", "swing_low", "close",
|
||||||
|
)
|
||||||
|
if f.payload.get(k) is not None
|
||||||
|
}
|
||||||
|
levels["phase"] = phase
|
||||||
|
levels["cycle"] = c.payload.get("cycle")
|
||||||
|
levels["current_event"] = cur
|
||||||
|
|
||||||
|
return {
|
||||||
|
"phases": _compress_phases(phase_points),
|
||||||
|
"events": events,
|
||||||
|
"levels": levels,
|
||||||
|
"bars": len(frame),
|
||||||
|
"timeframe": tf,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
_RANGE_CYCLES = {
|
||||||
|
WyckoffCycle.ACCUMULATION.value,
|
||||||
|
WyckoffCycle.RE_ACCUMULATION.value,
|
||||||
|
WyckoffCycle.DISTRIBUTION.value,
|
||||||
|
WyckoffCycle.RE_DISTRIBUTION.value,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _build_range_zones(
|
||||||
|
price_frame: OHLCVFrame,
|
||||||
|
cycle_segs: list[dict],
|
||||||
|
levels: dict | None = None,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Build price boxes (high/low × date span) for accum/distrib ranges."""
|
||||||
|
if price_frame.empty:
|
||||||
|
return []
|
||||||
|
dates = [str(d)[:10] for d in price_frame.trade_dates]
|
||||||
|
highs = price_frame.high
|
||||||
|
lows = price_frame.low
|
||||||
|
zones: list[dict] = []
|
||||||
|
|
||||||
|
for seg in cycle_segs or []:
|
||||||
|
cy = seg.get("cycle")
|
||||||
|
if cy not in _RANGE_CYCLES:
|
||||||
|
continue
|
||||||
|
start, end = seg["start"], seg["end"]
|
||||||
|
idxs = [i for i, d in enumerate(dates) if start <= d <= end]
|
||||||
|
if not idxs:
|
||||||
|
# weekly bar date may sit between daily bars — take nearest window
|
||||||
|
i0 = next((i for i, d in enumerate(dates) if d >= start), None)
|
||||||
|
if i0 is None:
|
||||||
|
continue
|
||||||
|
i1 = next((i for i, d in enumerate(dates) if d > end), len(dates)) - 1
|
||||||
|
idxs = list(range(i0, max(i0, i1) + 1))
|
||||||
|
if not idxs:
|
||||||
|
continue
|
||||||
|
# pad short weekly hits to at least ~1 week of dailies for visibility
|
||||||
|
if len(idxs) < 5 and idxs[-1] + 1 < len(dates):
|
||||||
|
extra = min(5 - len(idxs), len(dates) - 1 - idxs[-1])
|
||||||
|
idxs = list(range(idxs[0], idxs[-1] + 1 + max(0, extra)))
|
||||||
|
hi = max(highs[i] for i in idxs)
|
||||||
|
lo = min(lows[i] for i in idxs)
|
||||||
|
if hi <= lo:
|
||||||
|
continue
|
||||||
|
zones.append({
|
||||||
|
"kind": cy,
|
||||||
|
"start": dates[idxs[0]],
|
||||||
|
"end": dates[idxs[-1]],
|
||||||
|
"high": float(hi),
|
||||||
|
"low": float(lo),
|
||||||
|
"current": False,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Always expose the latest trading-range box from feature snapshot
|
||||||
|
levels = levels or {}
|
||||||
|
rh, rl = levels.get("range_high"), levels.get("range_low")
|
||||||
|
if rh is not None and rl is not None and float(rh) > float(rl):
|
||||||
|
look = min(60, len(dates))
|
||||||
|
cy = levels.get("cycle") or "Unknown"
|
||||||
|
if cy not in _RANGE_CYCLES:
|
||||||
|
# Phase B/C in a range → treat as accumulation-style TR for display
|
||||||
|
ph = levels.get("phase") or ""
|
||||||
|
if ph in ("A", "B", "C"):
|
||||||
|
cy = WyckoffCycle.ACCUMULATION.value
|
||||||
|
elif ph in ("D", "E") and float(levels.get("close") or 0) < float(rh):
|
||||||
|
cy = WyckoffCycle.ACCUMULATION.value
|
||||||
|
else:
|
||||||
|
cy = "Range"
|
||||||
|
zones.append({
|
||||||
|
"kind": cy,
|
||||||
|
"start": dates[-look],
|
||||||
|
"end": dates[-1],
|
||||||
|
"high": float(rh),
|
||||||
|
"low": float(rl),
|
||||||
|
"current": True,
|
||||||
|
})
|
||||||
|
|
||||||
|
return zones
|
||||||
|
|
||||||
|
|
||||||
|
def annotate_symbol(
|
||||||
|
ts_code: str,
|
||||||
|
freq: str,
|
||||||
|
end_date: date | None = None,
|
||||||
|
lookback: int = 180,
|
||||||
|
*,
|
||||||
|
combo_id: str | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""IO + annotate for one symbol (used by API).
|
||||||
|
|
||||||
|
For the combo *low* chart, phase bands come from **mid** structure,
|
||||||
|
while event markers / levels come from the low TF.
|
||||||
|
"""
|
||||||
|
from crypto_wyckoff.combos import ROLE_HIGH, ROLE_LOW, ROLE_MID, get_combo
|
||||||
|
from crypto_wyckoff.io import load_frame
|
||||||
|
|
||||||
|
combo = get_combo(combo_id)
|
||||||
|
allowed = {combo["low"], combo["mid"], combo["high"]}
|
||||||
|
if freq not in allowed:
|
||||||
|
raise ValueError(f"freq {freq} not in combo {combo['id']} ({combo['label']})")
|
||||||
|
empty = {
|
||||||
|
"ts_code": ts_code,
|
||||||
|
"freq": freq,
|
||||||
|
"phases": [],
|
||||||
|
"events": [],
|
||||||
|
"levels": {},
|
||||||
|
"zones": [],
|
||||||
|
"bars": 0,
|
||||||
|
"phase_source": freq,
|
||||||
|
"cycles": [],
|
||||||
|
"combo_id": combo["id"],
|
||||||
|
}
|
||||||
|
_ = end_date
|
||||||
|
|
||||||
|
if freq == combo["low"]:
|
||||||
|
low = load_frame(ts_code, combo["low"], lookback)
|
||||||
|
mid = load_frame(ts_code, combo["mid"], max(60, lookback // 3))
|
||||||
|
if low is None:
|
||||||
|
return empty
|
||||||
|
d_ann = annotate_frame(low, role=ROLE_LOW)
|
||||||
|
w_ann = annotate_frame(mid, role=ROLE_MID) if mid is not None else {"phases": []}
|
||||||
|
cycles = _cycle_segments(mid, role=ROLE_MID) if mid is not None else []
|
||||||
|
levels = d_ann.get("levels") or {}
|
||||||
|
if cycles:
|
||||||
|
levels = {**levels, "cycle": cycles[-1].get("cycle") or levels.get("cycle")}
|
||||||
|
for p in reversed(w_ann.get("phases") or []):
|
||||||
|
if p.get("phase") not in (None, "None"):
|
||||||
|
levels = {**levels, "phase": p["phase"]}
|
||||||
|
break
|
||||||
|
return {
|
||||||
|
"ts_code": ts_code,
|
||||||
|
"freq": freq,
|
||||||
|
"end_date": low.trade_dates[-1].isoformat() if low.trade_dates else None,
|
||||||
|
"phases": w_ann.get("phases") or [],
|
||||||
|
"events": d_ann.get("events") or [],
|
||||||
|
"levels": d_ann.get("levels") or {},
|
||||||
|
"zones": _build_range_zones(low, cycles, levels),
|
||||||
|
"bars": d_ann.get("bars", 0),
|
||||||
|
"phase_source": combo["mid"],
|
||||||
|
"cycles": cycles,
|
||||||
|
"combo_id": combo["id"],
|
||||||
|
}
|
||||||
|
|
||||||
|
role = ROLE_MID if freq == combo["mid"] else ROLE_HIGH
|
||||||
|
frame = load_frame(ts_code, freq, lookback)
|
||||||
|
if frame is None:
|
||||||
|
return empty
|
||||||
|
out = annotate_frame(frame, role=role)
|
||||||
|
out["ts_code"] = ts_code
|
||||||
|
out["freq"] = freq
|
||||||
|
out["end_date"] = frame.trade_dates[-1].isoformat() if frame.trade_dates else None
|
||||||
|
out["phase_source"] = freq
|
||||||
|
out["cycles"] = _cycle_segments(frame, role=ROLE_HIGH if role == ROLE_HIGH else ROLE_MID)
|
||||||
|
out["zones"] = _build_range_zones(frame, out["cycles"], out.get("levels") or {})
|
||||||
|
out["combo_id"] = combo["id"]
|
||||||
|
if role == ROLE_HIGH:
|
||||||
|
if not any(p.get("phase") not in (None, "None") for p in out["phases"]):
|
||||||
|
out["phases"] = [
|
||||||
|
{"start": c["start"], "end": c["end"], "phase": c["cycle"]}
|
||||||
|
for c in out["cycles"]
|
||||||
|
if c.get("cycle") and c["cycle"] != "Unknown"
|
||||||
|
]
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _cycle_segments(
|
||||||
|
frame: OHLCVFrame,
|
||||||
|
step: int | None = None,
|
||||||
|
*,
|
||||||
|
role: str | None = None,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Walk-forward cycle labels compressed to segments."""
|
||||||
|
tf = role or frame.timeframe
|
||||||
|
min_bars = _MIN_BARS.get(tf, 30)
|
||||||
|
if step is None:
|
||||||
|
step = {"1d": 3, "1w": 1, "1M": 1}.get(tf, 2)
|
||||||
|
if frame.empty or len(frame) < min_bars:
|
||||||
|
return []
|
||||||
|
|
||||||
|
feat_eng = FeatureEngine()
|
||||||
|
cycle_eng = CycleEngine()
|
||||||
|
points: list[tuple[str, str]] = []
|
||||||
|
indices = list(range(min_bars - 1, len(frame), step))
|
||||||
|
if indices[-1] != len(frame) - 1:
|
||||||
|
indices.append(len(frame) - 1)
|
||||||
|
for i in indices:
|
||||||
|
sub = _slice_frame(frame, i)
|
||||||
|
f = feat_eng.run(sub, tf)
|
||||||
|
c = cycle_eng.run(f, tf)
|
||||||
|
points.append((str(frame.trade_dates[i])[:10], c.payload.get("cycle") or "Unknown"))
|
||||||
|
segs = _compress_phases(points)
|
||||||
|
return [{"start": s["start"], "end": s["end"], "cycle": s["phase"]} for s in segs]
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
"""Multi-timeframe combo presets for Crypto Wyckoff Screener.
|
||||||
|
|
||||||
|
Roles (engine rule aliases stay D/W/M):
|
||||||
|
high → Cycle (rules as 1M)
|
||||||
|
mid → Phase (rules as 1w)
|
||||||
|
low → Event (rules as 1d)
|
||||||
|
|
||||||
|
Actual bar TFs come from the combo (e.g. 8h/4h/1h).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import threading
|
||||||
|
from copy import deepcopy
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from crypto_wyckoff.io import DATA_DIR, ensure_dirs
|
||||||
|
|
||||||
|
ROLE_LOW = "1d"
|
||||||
|
ROLE_MID = "1w"
|
||||||
|
ROLE_HIGH = "1M"
|
||||||
|
|
||||||
|
# Minutes for ordering / validation (provider labels)
|
||||||
|
_TF_MINUTES: dict[str, int] = {
|
||||||
|
"1m": 1, "2m": 2, "3m": 3, "4m": 4, "5m": 5,
|
||||||
|
"10m": 10, "15m": 15, "20m": 20, "25m": 25, "30m": 30, "45m": 45,
|
||||||
|
"1h": 60, "2h": 120, "3h": 180, "4h": 240, "5h": 300,
|
||||||
|
"6h": 360, "7h": 420, "8h": 480, "9h": 540, "10h": 600,
|
||||||
|
"11h": 660, "12h": 720, "16h": 960, "20h": 1200,
|
||||||
|
"1d": 1440, "2d": 2880, "3d": 4320, "4d": 5760, "5d": 7200, "6d": 8640,
|
||||||
|
"1w": 10080, "2w": 20160, "3w": 30240,
|
||||||
|
"1M": 43200,
|
||||||
|
}
|
||||||
|
|
||||||
|
# TFs we allow in custom combos (provider-backed + local 1M)
|
||||||
|
ALLOWED_TFS: tuple[str, ...] = (
|
||||||
|
"1h", "2h", "3h", "4h", "6h", "8h", "12h",
|
||||||
|
"1d", "2d", "3d", "1w", "1M",
|
||||||
|
)
|
||||||
|
|
||||||
|
BUILTIN: list[dict[str, Any]] = [
|
||||||
|
{
|
||||||
|
"id": "h8_4_1",
|
||||||
|
"label": "8h / 4h / 1h",
|
||||||
|
"high": "8h",
|
||||||
|
"mid": "4h",
|
||||||
|
"low": "1h",
|
||||||
|
"builtin": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "d_w_m",
|
||||||
|
"label": "1d / 1w / 1M",
|
||||||
|
"high": "1M",
|
||||||
|
"mid": "1w",
|
||||||
|
"low": "1d",
|
||||||
|
"builtin": True,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
_COMBOS_FILE = DATA_DIR / "combos.json"
|
||||||
|
_lock = threading.Lock()
|
||||||
|
_cache: list[dict[str, Any]] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def tf_minutes(tf: str) -> int | None:
|
||||||
|
if tf in _TF_MINUTES:
|
||||||
|
return _TF_MINUTES[tf]
|
||||||
|
# tolerate provider typo "10" → skip
|
||||||
|
m = re.fullmatch(r"(\d+)([mhdwM])", tf)
|
||||||
|
if not m:
|
||||||
|
return None
|
||||||
|
n, u = int(m.group(1)), m.group(2)
|
||||||
|
mult = {"m": 1, "h": 60, "d": 1440, "w": 10080, "M": 43200}[u]
|
||||||
|
return n * mult
|
||||||
|
|
||||||
|
|
||||||
|
def combo_id_for(high: str, mid: str, low: str) -> str:
|
||||||
|
def _tok(t: str) -> str:
|
||||||
|
return t.replace("/", "_")
|
||||||
|
|
||||||
|
return f"{_tok(high)}_{_tok(mid)}_{_tok(low)}"
|
||||||
|
|
||||||
|
|
||||||
|
def validate_combo(high: str, mid: str, low: str) -> str | None:
|
||||||
|
"""Return error message or None if ok."""
|
||||||
|
for tf in (high, mid, low):
|
||||||
|
if tf not in ALLOWED_TFS:
|
||||||
|
return f"不支持的周期: {tf}"
|
||||||
|
if len({high, mid, low}) < 3:
|
||||||
|
return "高/中/低周期必须互不相同"
|
||||||
|
hm, mm, lm = tf_minutes(high), tf_minutes(mid), tf_minutes(low)
|
||||||
|
if hm is None or mm is None or lm is None:
|
||||||
|
return "无法解析周期长度"
|
||||||
|
if not (hm > mm > lm):
|
||||||
|
return "须满足 高 > 中 > 低(例如 8h > 4h > 1h)"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize(row: dict[str, Any]) -> dict[str, Any] | None:
|
||||||
|
high, mid, low = row.get("high"), row.get("mid"), row.get("low")
|
||||||
|
if not high or not mid or not low:
|
||||||
|
return None
|
||||||
|
err = validate_combo(str(high), str(mid), str(low))
|
||||||
|
if err:
|
||||||
|
return None
|
||||||
|
cid = str(row.get("id") or combo_id_for(high, mid, low))
|
||||||
|
label = str(row.get("label") or f"{high} / {mid} / {low}")
|
||||||
|
return {
|
||||||
|
"id": cid,
|
||||||
|
"label": label,
|
||||||
|
"high": str(high),
|
||||||
|
"mid": str(mid),
|
||||||
|
"low": str(low),
|
||||||
|
"builtin": bool(row.get("builtin", False)),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _load_raw() -> list[dict[str, Any]]:
|
||||||
|
ensure_dirs()
|
||||||
|
if not _COMBOS_FILE.exists():
|
||||||
|
return deepcopy(BUILTIN)
|
||||||
|
try:
|
||||||
|
data = json.loads(_COMBOS_FILE.read_text(encoding="utf-8"))
|
||||||
|
items = data.get("combos") if isinstance(data, dict) else data
|
||||||
|
if not isinstance(items, list):
|
||||||
|
return deepcopy(BUILTIN)
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
return deepcopy(BUILTIN)
|
||||||
|
|
||||||
|
out: list[dict[str, Any]] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for b in BUILTIN:
|
||||||
|
out.append(deepcopy(b))
|
||||||
|
seen.add(b["id"])
|
||||||
|
for row in items:
|
||||||
|
if not isinstance(row, dict):
|
||||||
|
continue
|
||||||
|
norm = _normalize(row)
|
||||||
|
if not norm or norm["id"] in seen:
|
||||||
|
continue
|
||||||
|
if norm["id"] in {b["id"] for b in BUILTIN}:
|
||||||
|
continue
|
||||||
|
norm["builtin"] = False
|
||||||
|
out.append(norm)
|
||||||
|
seen.add(norm["id"])
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _save(combos: list[dict[str, Any]]) -> None:
|
||||||
|
ensure_dirs()
|
||||||
|
custom = [c for c in combos if not c.get("builtin")]
|
||||||
|
payload = {"combos": custom}
|
||||||
|
tmp = _COMBOS_FILE.with_suffix(".tmp")
|
||||||
|
tmp.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||||
|
tmp.replace(_COMBOS_FILE)
|
||||||
|
|
||||||
|
|
||||||
|
def list_combos() -> list[dict[str, Any]]:
|
||||||
|
global _cache
|
||||||
|
with _lock:
|
||||||
|
if _cache is None:
|
||||||
|
_cache = _load_raw()
|
||||||
|
return deepcopy(_cache)
|
||||||
|
|
||||||
|
|
||||||
|
def get_combo(combo_id: str | None) -> dict[str, Any]:
|
||||||
|
combos = list_combos()
|
||||||
|
if combo_id:
|
||||||
|
for c in combos:
|
||||||
|
if c["id"] == combo_id:
|
||||||
|
return deepcopy(c)
|
||||||
|
return deepcopy(combos[0])
|
||||||
|
|
||||||
|
|
||||||
|
def add_combo(high: str, mid: str, low: str, label: str | None = None) -> dict[str, Any]:
|
||||||
|
err = validate_combo(high, mid, low)
|
||||||
|
if err:
|
||||||
|
raise ValueError(err)
|
||||||
|
cid = combo_id_for(high, mid, low)
|
||||||
|
row = {
|
||||||
|
"id": cid,
|
||||||
|
"label": label or f"{high} / {mid} / {low}",
|
||||||
|
"high": high,
|
||||||
|
"mid": mid,
|
||||||
|
"low": low,
|
||||||
|
"builtin": False,
|
||||||
|
}
|
||||||
|
with _lock:
|
||||||
|
combos = _load_raw()
|
||||||
|
for c in combos:
|
||||||
|
if c["id"] == cid or (c["high"], c["mid"], c["low"]) == (high, mid, low):
|
||||||
|
_cache = combos
|
||||||
|
return deepcopy(c)
|
||||||
|
combos.append(row)
|
||||||
|
_save(combos)
|
||||||
|
_cache = combos
|
||||||
|
return deepcopy(row)
|
||||||
|
|
||||||
|
|
||||||
|
def delete_combo(combo_id: str) -> bool:
|
||||||
|
with _lock:
|
||||||
|
combos = _load_raw()
|
||||||
|
kept: list[dict[str, Any]] = []
|
||||||
|
removed = False
|
||||||
|
for c in combos:
|
||||||
|
if c["id"] == combo_id:
|
||||||
|
if c.get("builtin"):
|
||||||
|
raise ValueError("内置组合不可删除")
|
||||||
|
removed = True
|
||||||
|
continue
|
||||||
|
kept.append(c)
|
||||||
|
if removed:
|
||||||
|
_save(kept)
|
||||||
|
_cache = kept
|
||||||
|
return removed
|
||||||
|
|
||||||
|
|
||||||
|
def all_tfs_for_combos(combos: list[dict[str, Any]] | None = None) -> list[str]:
|
||||||
|
"""Unique TFs needed by active combos (stable order)."""
|
||||||
|
rows = combos if combos is not None else list_combos()
|
||||||
|
seen: list[str] = []
|
||||||
|
for c in rows:
|
||||||
|
for k in ("low", "mid", "high"):
|
||||||
|
tf = c[k]
|
||||||
|
if tf not in seen:
|
||||||
|
seen.append(tf)
|
||||||
|
return seen
|
||||||
|
|
||||||
|
|
||||||
|
def lookback_for(tf: str) -> int:
|
||||||
|
defaults = {
|
||||||
|
"1h": 500,
|
||||||
|
"2h": 400,
|
||||||
|
"3h": 350,
|
||||||
|
"4h": 300,
|
||||||
|
"6h": 280,
|
||||||
|
"8h": 250,
|
||||||
|
"12h": 220,
|
||||||
|
"1d": 250,
|
||||||
|
"2d": 200,
|
||||||
|
"3d": 180,
|
||||||
|
"1w": 104,
|
||||||
|
"1M": 60,
|
||||||
|
}
|
||||||
|
return defaults.get(tf, 200)
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
"""Cycle Engine — monthly/weekly macro cycle via Rule Registry."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from crypto_wyckoff.domain_models import EngineResult, WyckoffCycle
|
||||||
|
from crypto_wyckoff.rules.base import RuleHit
|
||||||
|
from crypto_wyckoff.rules.registry import rule_registry
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_range_conflict(hits: list[RuleHit], features: dict) -> list[RuleHit]:
|
||||||
|
"""Accumulation vs Distribution overlap → mutually exclusive by MA120 position."""
|
||||||
|
accum = [h for h in hits if h.cycle == WyckoffCycle.ACCUMULATION.value]
|
||||||
|
dist = [h for h in hits if h.cycle == WyckoffCycle.DISTRIBUTION.value]
|
||||||
|
if not (accum and dist):
|
||||||
|
return hits
|
||||||
|
|
||||||
|
close = float(features.get("close") or 0)
|
||||||
|
ma120 = float(features.get("ma120") or close) or close
|
||||||
|
others = [
|
||||||
|
h for h in hits
|
||||||
|
if h.cycle not in (WyckoffCycle.ACCUMULATION.value, WyckoffCycle.DISTRIBUTION.value)
|
||||||
|
]
|
||||||
|
# Below MA120 → accumulation; above → distribution; equal band uses relative position
|
||||||
|
if close < ma120 * 0.995:
|
||||||
|
return others + accum
|
||||||
|
if close > ma120 * 1.005:
|
||||||
|
return others + dist
|
||||||
|
# Tight band: keep higher confidence only
|
||||||
|
best_a = max(accum, key=lambda h: h.confidence)
|
||||||
|
best_d = max(dist, key=lambda h: h.confidence)
|
||||||
|
return others + ([best_a] if best_a.confidence >= best_d.confidence else [best_d])
|
||||||
|
|
||||||
|
|
||||||
|
class CycleEngine:
|
||||||
|
name = "Cycle"
|
||||||
|
version = "1.0.0"
|
||||||
|
|
||||||
|
def run(self, feature: EngineResult, timeframe: str) -> EngineResult:
|
||||||
|
features = feature.payload
|
||||||
|
if features.get("insufficient"):
|
||||||
|
return EngineResult(
|
||||||
|
name=self.name,
|
||||||
|
version=self.version,
|
||||||
|
confidence=15.0,
|
||||||
|
score=40.0,
|
||||||
|
reasons=[f"{timeframe} 数据不足,Cycle=Unknown"],
|
||||||
|
warnings=["insufficient_features"],
|
||||||
|
payload={
|
||||||
|
"cycle": WyckoffCycle.UNKNOWN.value,
|
||||||
|
"timeframe": timeframe,
|
||||||
|
"trend_score": 40.0,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
context = {"features": features, "timeframe": timeframe}
|
||||||
|
hits: list[RuleHit] = []
|
||||||
|
for rule in rule_registry.by_category("cycle", timeframe):
|
||||||
|
hit = rule.evaluate(context)
|
||||||
|
if hit and hit.cycle:
|
||||||
|
hits.append(hit)
|
||||||
|
|
||||||
|
hits = _resolve_range_conflict(hits, features)
|
||||||
|
|
||||||
|
if not hits:
|
||||||
|
return EngineResult(
|
||||||
|
name=self.name,
|
||||||
|
version=self.version,
|
||||||
|
confidence=30.0,
|
||||||
|
score=40.0,
|
||||||
|
reasons=["无匹配周期规则,标记 Unknown"],
|
||||||
|
payload={
|
||||||
|
"cycle": WyckoffCycle.UNKNOWN.value,
|
||||||
|
"timeframe": timeframe,
|
||||||
|
"trend_score": 40.0,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
best = max(hits, key=lambda h: h.confidence)
|
||||||
|
trend_score = best.score
|
||||||
|
if best.cycle == WyckoffCycle.MARKUP.value:
|
||||||
|
trend_score = max(trend_score, 75.0)
|
||||||
|
elif best.cycle == WyckoffCycle.ACCUMULATION.value:
|
||||||
|
trend_score = max(60.0, trend_score * 0.9)
|
||||||
|
elif best.cycle == WyckoffCycle.DISTRIBUTION.value:
|
||||||
|
trend_score = min(45.0, 100 - trend_score * 0.5)
|
||||||
|
elif best.cycle == WyckoffCycle.MARKDOWN.value:
|
||||||
|
trend_score = min(30.0, 100 - trend_score)
|
||||||
|
|
||||||
|
return EngineResult(
|
||||||
|
name=self.name,
|
||||||
|
version=self.version,
|
||||||
|
confidence=best.confidence,
|
||||||
|
score=trend_score,
|
||||||
|
reasons=best.reasons,
|
||||||
|
metrics=best.metrics,
|
||||||
|
payload={
|
||||||
|
"cycle": best.cycle,
|
||||||
|
"timeframe": timeframe,
|
||||||
|
"rule_id": best.rule_id,
|
||||||
|
"trend_score": trend_score,
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
"""Decision Engine — multi-timeframe fusion and tradability (Architecture v1.0)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from crypto_wyckoff.domain_models import (
|
||||||
|
DecisionSignal,
|
||||||
|
EngineResult,
|
||||||
|
RiskLevel,
|
||||||
|
WyckoffCycle,
|
||||||
|
WyckoffEvent,
|
||||||
|
WyckoffPhase,
|
||||||
|
)
|
||||||
|
|
||||||
|
BULL_CYCLES = {
|
||||||
|
WyckoffCycle.ACCUMULATION.value,
|
||||||
|
WyckoffCycle.RE_ACCUMULATION.value,
|
||||||
|
WyckoffCycle.MARKUP.value,
|
||||||
|
}
|
||||||
|
BEAR_CYCLES = {
|
||||||
|
WyckoffCycle.DISTRIBUTION.value,
|
||||||
|
WyckoffCycle.RE_DISTRIBUTION.value,
|
||||||
|
WyckoffCycle.MARKDOWN.value,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class DecisionEngine:
|
||||||
|
name = "Decision"
|
||||||
|
version = "1.0.0"
|
||||||
|
|
||||||
|
def run(
|
||||||
|
self,
|
||||||
|
monthly_cycle: EngineResult,
|
||||||
|
weekly_cycle: EngineResult,
|
||||||
|
weekly_phase: EngineResult,
|
||||||
|
weekly_event: EngineResult,
|
||||||
|
daily_event: EngineResult,
|
||||||
|
daily_signal: EngineResult,
|
||||||
|
) -> EngineResult:
|
||||||
|
m_cycle = monthly_cycle.payload.get("cycle", WyckoffCycle.UNKNOWN.value)
|
||||||
|
w_cycle = weekly_cycle.payload.get("cycle", WyckoffCycle.UNKNOWN.value)
|
||||||
|
w_phase = weekly_phase.payload.get("phase", WyckoffPhase.NONE.value)
|
||||||
|
w_event = weekly_event.payload.get("current_event", WyckoffEvent.NONE.value)
|
||||||
|
d_event = daily_event.payload.get("current_event", WyckoffEvent.NONE.value)
|
||||||
|
|
||||||
|
trend_score = float(monthly_cycle.payload.get("trend_score", monthly_cycle.score))
|
||||||
|
structure_score = float(weekly_phase.payload.get("structure_score", weekly_phase.score))
|
||||||
|
entry_score = float(daily_event.payload.get("entry_score", daily_event.score))
|
||||||
|
|
||||||
|
overall_score = 0.30 * trend_score + 0.30 * structure_score + 0.40 * entry_score
|
||||||
|
|
||||||
|
reasons: list[str] = []
|
||||||
|
warnings: list[str] = []
|
||||||
|
alignment = 50.0
|
||||||
|
|
||||||
|
m_bull = m_cycle in BULL_CYCLES
|
||||||
|
m_bear = m_cycle in BEAR_CYCLES
|
||||||
|
w_bull = w_cycle in BULL_CYCLES
|
||||||
|
d_bullish_event = d_event in {
|
||||||
|
WyckoffEvent.SPRING.value,
|
||||||
|
WyckoffEvent.TEST.value,
|
||||||
|
WyckoffEvent.SOS.value,
|
||||||
|
WyckoffEvent.LPS.value,
|
||||||
|
WyckoffEvent.JUMP.value,
|
||||||
|
WyckoffEvent.BACKUP.value,
|
||||||
|
}
|
||||||
|
d_bearish_event = d_event in {
|
||||||
|
WyckoffEvent.UTAD.value,
|
||||||
|
WyckoffEvent.SOW.value,
|
||||||
|
WyckoffEvent.LPSY.value,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Alignment scoring
|
||||||
|
if m_bull and w_bull and d_bullish_event:
|
||||||
|
alignment = 92.0
|
||||||
|
reasons.append("✓ 月/周多头结构与日线多头事件一致")
|
||||||
|
elif m_bull and d_bullish_event:
|
||||||
|
alignment = 78.0
|
||||||
|
reasons.append("✓ 月线支持,日线有入场事件")
|
||||||
|
if not w_bull:
|
||||||
|
warnings.append("周线结构未完全确认")
|
||||||
|
alignment -= 8
|
||||||
|
elif m_bear and d_bullish_event:
|
||||||
|
alignment = 35.0
|
||||||
|
reasons.append("✗ 月线派发/下跌,日线弹簧可能只是反弹")
|
||||||
|
elif m_bear and d_bearish_event:
|
||||||
|
alignment = 85.0
|
||||||
|
reasons.append("✓ 空头多周期一致")
|
||||||
|
else:
|
||||||
|
alignment = 55.0
|
||||||
|
reasons.append("○ 多周期部分一致,需观察")
|
||||||
|
|
||||||
|
if w_phase in (WyckoffPhase.D.value, WyckoffPhase.E.value) and m_bull:
|
||||||
|
alignment = min(98.0, alignment + 6)
|
||||||
|
reasons.append(f"✓ 周线阶段 {w_phase} 结构成熟({w_event})")
|
||||||
|
active = daily_event.payload.get("active_events") or daily_event.payload.get("recent_events") or []
|
||||||
|
if d_event == WyckoffEvent.SPRING.value and len(active) >= 3:
|
||||||
|
alignment = min(98.0, alignment + 4)
|
||||||
|
reasons.append("✓ 日线多重事件同时确认")
|
||||||
|
|
||||||
|
# Decision signal — hard gate on monthly bear + daily spring
|
||||||
|
decision = DecisionSignal.WATCH.value
|
||||||
|
risk = RiskLevel.MEDIUM.value
|
||||||
|
|
||||||
|
if m_bear and d_event == WyckoffEvent.SPRING.value:
|
||||||
|
decision = DecisionSignal.WATCH.value
|
||||||
|
risk = RiskLevel.HIGH.value
|
||||||
|
overall_score = min(overall_score, 55.0)
|
||||||
|
reasons.append("→ 决策:观察(月线不支持,禁止追日线弹簧)")
|
||||||
|
elif m_bear and d_bullish_event:
|
||||||
|
decision = DecisionSignal.AVOID.value
|
||||||
|
risk = RiskLevel.HIGH.value
|
||||||
|
overall_score = min(overall_score, 48.0)
|
||||||
|
reasons.append("→ 决策:回避(逆大周期多头事件)")
|
||||||
|
elif (
|
||||||
|
m_bull
|
||||||
|
and w_phase in (WyckoffPhase.D.value, WyckoffPhase.E.value, WyckoffPhase.C.value)
|
||||||
|
and d_event in (WyckoffEvent.SPRING.value, WyckoffEvent.LPS.value, WyckoffEvent.SOS.value)
|
||||||
|
and alignment >= 85
|
||||||
|
and overall_score >= 80
|
||||||
|
):
|
||||||
|
decision = DecisionSignal.STRONG_BUY.value
|
||||||
|
risk = RiskLevel.LOW.value
|
||||||
|
reasons.append("→ 决策:强烈买入(三级共振)")
|
||||||
|
elif m_bull and d_bullish_event and overall_score >= 68 and alignment >= 70:
|
||||||
|
decision = DecisionSignal.BUY.value
|
||||||
|
risk = RiskLevel.LOW.value if alignment >= 80 else RiskLevel.MEDIUM.value
|
||||||
|
reasons.append("→ 决策:买入")
|
||||||
|
elif m_bear and d_bearish_event and overall_score >= 65:
|
||||||
|
decision = DecisionSignal.SELL.value
|
||||||
|
risk = RiskLevel.MEDIUM.value
|
||||||
|
reasons.append("→ 决策:卖出")
|
||||||
|
else:
|
||||||
|
decision = DecisionSignal.WATCH.value
|
||||||
|
reasons.append("→ 决策:观察")
|
||||||
|
|
||||||
|
# Stars from score + alignment
|
||||||
|
combo = 0.6 * overall_score + 0.4 * alignment
|
||||||
|
if combo >= 90:
|
||||||
|
stars = 5
|
||||||
|
elif combo >= 80:
|
||||||
|
stars = 4
|
||||||
|
elif combo >= 65:
|
||||||
|
stars = 3
|
||||||
|
elif combo >= 50:
|
||||||
|
stars = 2
|
||||||
|
else:
|
||||||
|
stars = 1
|
||||||
|
|
||||||
|
overall_confidence = (
|
||||||
|
0.25 * monthly_cycle.confidence
|
||||||
|
+ 0.25 * weekly_phase.confidence
|
||||||
|
+ 0.25 * daily_event.confidence
|
||||||
|
+ 0.25 * daily_signal.confidence
|
||||||
|
)
|
||||||
|
# Weak event pulls overall down
|
||||||
|
if daily_event.confidence < 60:
|
||||||
|
overall_confidence = min(overall_confidence, daily_event.confidence + 15)
|
||||||
|
|
||||||
|
return EngineResult(
|
||||||
|
name=self.name,
|
||||||
|
version=self.version,
|
||||||
|
confidence=overall_confidence,
|
||||||
|
score=overall_score,
|
||||||
|
reasons=reasons,
|
||||||
|
warnings=warnings,
|
||||||
|
metrics={
|
||||||
|
"trend_score": trend_score,
|
||||||
|
"structure_score": structure_score,
|
||||||
|
"entry_score": entry_score,
|
||||||
|
"alignment": alignment,
|
||||||
|
"stars": stars,
|
||||||
|
},
|
||||||
|
payload={
|
||||||
|
"decision_signal": decision,
|
||||||
|
"alignment": alignment,
|
||||||
|
"stars": stars,
|
||||||
|
"risk": risk,
|
||||||
|
"overall_score": overall_score,
|
||||||
|
"overall_confidence": overall_confidence,
|
||||||
|
"trend_score": trend_score,
|
||||||
|
"structure_score": structure_score,
|
||||||
|
"entry_score": entry_score,
|
||||||
|
"m_cycle": m_cycle,
|
||||||
|
"w_cycle": w_cycle,
|
||||||
|
"w_phase": w_phase,
|
||||||
|
"w_event": w_event,
|
||||||
|
"d_event": d_event,
|
||||||
|
# Facts preserved — never overwritten
|
||||||
|
"facts": {
|
||||||
|
"monthly": {"cycle": m_cycle},
|
||||||
|
"weekly": {"cycle": w_cycle, "phase": w_phase, "event": w_event},
|
||||||
|
"daily": {"event": d_event},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
"""Wyckoff Screener domain models — Architecture v1.0 frozen contracts."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import date, datetime
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
|
||||||
|
class WyckoffCycle(str, Enum):
|
||||||
|
ACCUMULATION = "Accumulation"
|
||||||
|
RE_ACCUMULATION = "ReAccumulation"
|
||||||
|
MARKUP = "Markup"
|
||||||
|
DISTRIBUTION = "Distribution"
|
||||||
|
RE_DISTRIBUTION = "ReDistribution"
|
||||||
|
MARKDOWN = "Markdown"
|
||||||
|
UNKNOWN = "Unknown"
|
||||||
|
|
||||||
|
|
||||||
|
class WyckoffPhase(str, Enum):
|
||||||
|
A = "A"
|
||||||
|
B = "B"
|
||||||
|
C = "C"
|
||||||
|
D = "D"
|
||||||
|
E = "E"
|
||||||
|
NONE = "None"
|
||||||
|
|
||||||
|
|
||||||
|
class WyckoffEvent(str, Enum):
|
||||||
|
PS = "PS"
|
||||||
|
SC = "SC"
|
||||||
|
AR = "AR"
|
||||||
|
ST = "ST"
|
||||||
|
SPRING = "Spring"
|
||||||
|
TEST = "Test"
|
||||||
|
SOS = "SOS"
|
||||||
|
LPS = "LPS"
|
||||||
|
JUMP = "Jump"
|
||||||
|
BACKUP = "Backup"
|
||||||
|
BC = "BC"
|
||||||
|
UTAD = "UTAD"
|
||||||
|
SOW = "SOW"
|
||||||
|
LPSY = "LPSY"
|
||||||
|
NONE = "None"
|
||||||
|
|
||||||
|
|
||||||
|
class DecisionSignal(str, Enum):
|
||||||
|
STRONG_BUY = "StrongBuy"
|
||||||
|
BUY = "Buy"
|
||||||
|
WATCH = "Watch"
|
||||||
|
AVOID = "Avoid"
|
||||||
|
SELL = "Sell"
|
||||||
|
|
||||||
|
|
||||||
|
class RiskLevel(str, Enum):
|
||||||
|
LOW = "Low"
|
||||||
|
MEDIUM = "Medium"
|
||||||
|
HIGH = "High"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class EngineResult:
|
||||||
|
"""Unified result envelope for every Wyckoff engine (v1.0 contract)."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
version: str = "1.0.0"
|
||||||
|
confidence: float = 0.0
|
||||||
|
score: float = 0.0
|
||||||
|
reasons: list[str] = field(default_factory=list)
|
||||||
|
warnings: list[str] = field(default_factory=list)
|
||||||
|
metrics: dict[str, Any] = field(default_factory=dict)
|
||||||
|
payload: dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"name": self.name,
|
||||||
|
"version": self.version,
|
||||||
|
"confidence": self.confidence,
|
||||||
|
"score": self.score,
|
||||||
|
"reasons": self.reasons,
|
||||||
|
"warnings": self.warnings,
|
||||||
|
"metrics": self.metrics,
|
||||||
|
"payload": self.payload,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class OHLCVFrame:
|
||||||
|
"""In-memory OHLCV for one symbol one timeframe. Engines never touch DB."""
|
||||||
|
|
||||||
|
ts_code: str
|
||||||
|
timeframe: str # "1d" | "1w" | "1M"
|
||||||
|
trade_dates: list[date]
|
||||||
|
open: list[float]
|
||||||
|
high: list[float]
|
||||||
|
low: list[float]
|
||||||
|
close: list[float]
|
||||||
|
volume: list[float]
|
||||||
|
amount: list[float] = field(default_factory=list)
|
||||||
|
|
||||||
|
def __len__(self) -> int:
|
||||||
|
return len(self.close)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def empty(self) -> bool:
|
||||||
|
return len(self.close) == 0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class WyckoffScanRow:
|
||||||
|
"""Persisted scan row for wyckoff_scan table."""
|
||||||
|
|
||||||
|
trade_date: date
|
||||||
|
ts_code: str
|
||||||
|
name: str = ""
|
||||||
|
industry: str = ""
|
||||||
|
engine_version: str = "v1.0.0"
|
||||||
|
combo_id: str = "d_w_m"
|
||||||
|
|
||||||
|
m_cycle: str = WyckoffCycle.UNKNOWN.value
|
||||||
|
cycle_confidence: float = 0.0
|
||||||
|
trend_score: float = 0.0
|
||||||
|
|
||||||
|
w_cycle: str = WyckoffCycle.UNKNOWN.value
|
||||||
|
w_phase: str = WyckoffPhase.NONE.value
|
||||||
|
w_current_event: str = WyckoffEvent.NONE.value
|
||||||
|
w_recent_events_json: str = "[]"
|
||||||
|
phase_confidence: float = 0.0
|
||||||
|
structure_score: float = 0.0
|
||||||
|
|
||||||
|
d_current_event: str = WyckoffEvent.NONE.value
|
||||||
|
d_recent_events_json: str = "[]"
|
||||||
|
event_confidence: float = 0.0
|
||||||
|
entry_score: float = 0.0
|
||||||
|
|
||||||
|
entry: Optional[float] = None
|
||||||
|
stop: Optional[float] = None
|
||||||
|
target1: Optional[float] = None
|
||||||
|
target2: Optional[float] = None
|
||||||
|
rr: Optional[float] = None
|
||||||
|
|
||||||
|
alignment: float = 0.0
|
||||||
|
stars: int = 1
|
||||||
|
decision_signal: str = DecisionSignal.WATCH.value
|
||||||
|
signal_confidence: float = 0.0
|
||||||
|
overall_confidence: float = 0.0
|
||||||
|
overall_score: float = 0.0
|
||||||
|
risk: str = RiskLevel.MEDIUM.value
|
||||||
|
reasons_json: str = "[]"
|
||||||
|
|
||||||
|
feature_snapshot_json: str = "{}"
|
||||||
|
markers_json: str = "[]"
|
||||||
|
scanned_at: datetime = field(default_factory=datetime.now)
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
"""Event Engine — active concurrent events via Rule Registry.
|
||||||
|
|
||||||
|
Note: `active_events` are rules that fire on the latest bar snapshot,
|
||||||
|
NOT a historical SC→AR→ST timeline. Do not present as chronological chain.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from crypto_wyckoff.domain_models import EngineResult, WyckoffEvent
|
||||||
|
from crypto_wyckoff.rules.registry import rule_registry
|
||||||
|
|
||||||
|
# Display order only (not temporal history)
|
||||||
|
_DISPLAY_ORDER = [
|
||||||
|
WyckoffEvent.PS.value,
|
||||||
|
WyckoffEvent.SC.value,
|
||||||
|
WyckoffEvent.AR.value,
|
||||||
|
WyckoffEvent.ST.value,
|
||||||
|
WyckoffEvent.SPRING.value,
|
||||||
|
WyckoffEvent.TEST.value,
|
||||||
|
WyckoffEvent.SOS.value,
|
||||||
|
WyckoffEvent.LPS.value,
|
||||||
|
WyckoffEvent.JUMP.value,
|
||||||
|
WyckoffEvent.BACKUP.value,
|
||||||
|
WyckoffEvent.BC.value,
|
||||||
|
WyckoffEvent.UTAD.value,
|
||||||
|
WyckoffEvent.SOW.value,
|
||||||
|
WyckoffEvent.LPSY.value,
|
||||||
|
]
|
||||||
|
|
||||||
|
# Dominant event: highest confidence wins; ties broken by this priority
|
||||||
|
_DOMINANCE_PRIORITY = [
|
||||||
|
WyckoffEvent.SOS.value,
|
||||||
|
WyckoffEvent.LPS.value,
|
||||||
|
WyckoffEvent.UTAD.value,
|
||||||
|
WyckoffEvent.SPRING.value,
|
||||||
|
WyckoffEvent.JUMP.value,
|
||||||
|
WyckoffEvent.BACKUP.value,
|
||||||
|
WyckoffEvent.TEST.value,
|
||||||
|
WyckoffEvent.SC.value,
|
||||||
|
WyckoffEvent.SOW.value,
|
||||||
|
WyckoffEvent.AR.value,
|
||||||
|
WyckoffEvent.ST.value,
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class EventEngine:
|
||||||
|
name = "Event"
|
||||||
|
version = "1.0.0"
|
||||||
|
|
||||||
|
def run(
|
||||||
|
self,
|
||||||
|
cycle: EngineResult,
|
||||||
|
phase: EngineResult,
|
||||||
|
feature: EngineResult,
|
||||||
|
timeframe: str,
|
||||||
|
) -> EngineResult:
|
||||||
|
if feature.payload.get("insufficient"):
|
||||||
|
return EngineResult(
|
||||||
|
name=self.name,
|
||||||
|
version=self.version,
|
||||||
|
confidence=20.0,
|
||||||
|
score=30.0,
|
||||||
|
reasons=["特征不足,跳过事件识别"],
|
||||||
|
warnings=["insufficient_features"],
|
||||||
|
payload={
|
||||||
|
"current_event": WyckoffEvent.NONE.value,
|
||||||
|
"active_events": [],
|
||||||
|
"recent_events": [], # alias for DB/API compat; same as active_events
|
||||||
|
"timeframe": timeframe,
|
||||||
|
"entry_score": 30.0,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
context = {
|
||||||
|
"features": feature.payload,
|
||||||
|
"cycle": cycle.payload,
|
||||||
|
"phase": phase.payload,
|
||||||
|
"timeframe": timeframe,
|
||||||
|
}
|
||||||
|
hits = []
|
||||||
|
for rule in rule_registry.by_category("event", timeframe):
|
||||||
|
hit = rule.evaluate(context)
|
||||||
|
if hit and hit.event:
|
||||||
|
hits.append(hit)
|
||||||
|
|
||||||
|
if not hits:
|
||||||
|
return EngineResult(
|
||||||
|
name=self.name,
|
||||||
|
version=self.version,
|
||||||
|
confidence=35.0,
|
||||||
|
score=40.0,
|
||||||
|
reasons=["无显著事件"],
|
||||||
|
payload={
|
||||||
|
"current_event": WyckoffEvent.NONE.value,
|
||||||
|
"active_events": [],
|
||||||
|
"recent_events": [],
|
||||||
|
"timeframe": timeframe,
|
||||||
|
"entry_score": 40.0,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
by_event: dict[str, float] = {}
|
||||||
|
reasons: list[str] = []
|
||||||
|
metrics: dict = {}
|
||||||
|
for h in hits:
|
||||||
|
prev = by_event.get(h.event, -1.0)
|
||||||
|
if h.confidence >= prev:
|
||||||
|
by_event[h.event] = h.confidence
|
||||||
|
reasons.extend(h.reasons)
|
||||||
|
metrics.update(h.metrics)
|
||||||
|
|
||||||
|
active = [e for e in _DISPLAY_ORDER if e in by_event]
|
||||||
|
for e in by_event:
|
||||||
|
if e not in active:
|
||||||
|
active.append(e)
|
||||||
|
|
||||||
|
# Dominant = max confidence; tie-break by dominance priority index
|
||||||
|
def _dom_key(ev: str) -> tuple:
|
||||||
|
conf = by_event[ev]
|
||||||
|
try:
|
||||||
|
prio = _DOMINANCE_PRIORITY.index(ev)
|
||||||
|
except ValueError:
|
||||||
|
prio = 99
|
||||||
|
return (conf, -prio)
|
||||||
|
|
||||||
|
current = max(by_event.keys(), key=_dom_key)
|
||||||
|
event_conf = by_event[current]
|
||||||
|
co_bonus = min(12.0, max(0, len(active) - 1) * 3)
|
||||||
|
entry_score = min(98.0, event_conf + co_bonus)
|
||||||
|
if current == WyckoffEvent.SPRING.value and WyckoffEvent.TEST.value in by_event:
|
||||||
|
entry_score = min(98.0, entry_score + 5)
|
||||||
|
|
||||||
|
return EngineResult(
|
||||||
|
name=self.name,
|
||||||
|
version=self.version,
|
||||||
|
confidence=event_conf,
|
||||||
|
score=entry_score,
|
||||||
|
reasons=list(dict.fromkeys(reasons))[:8],
|
||||||
|
warnings=["active_events_are_concurrent_not_timeline"],
|
||||||
|
metrics=metrics,
|
||||||
|
payload={
|
||||||
|
"current_event": current,
|
||||||
|
"active_events": active,
|
||||||
|
"recent_events": active, # persisted column name; semantic = active
|
||||||
|
"event_scores": by_event,
|
||||||
|
"timeframe": timeframe,
|
||||||
|
"entry_score": entry_score,
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
"""Feature Engine — pure function over OHLCVFrame → EngineResult(FeatureSnapshot)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from crypto_wyckoff.domain_models import EngineResult, OHLCVFrame
|
||||||
|
|
||||||
|
|
||||||
|
def _sma(arr: np.ndarray, n: int) -> float:
|
||||||
|
if len(arr) < n:
|
||||||
|
return float(arr[-1]) if len(arr) else 0.0
|
||||||
|
return float(np.mean(arr[-n:]))
|
||||||
|
|
||||||
|
|
||||||
|
def _atr(high: np.ndarray, low: np.ndarray, close: np.ndarray, n: int = 14) -> float:
|
||||||
|
if len(close) < 2:
|
||||||
|
return 0.0
|
||||||
|
prev_close = close[:-1]
|
||||||
|
tr = np.maximum(high[1:] - low[1:], np.maximum(np.abs(high[1:] - prev_close), np.abs(low[1:] - prev_close)))
|
||||||
|
if len(tr) < n:
|
||||||
|
return float(np.mean(tr)) if len(tr) else 0.0
|
||||||
|
return float(np.mean(tr[-n:]))
|
||||||
|
|
||||||
|
|
||||||
|
def _adx(high: np.ndarray, low: np.ndarray, close: np.ndarray, n: int = 14) -> float:
|
||||||
|
"""Simplified ADX approximation."""
|
||||||
|
if len(close) < n + 2:
|
||||||
|
return 15.0
|
||||||
|
up = high[1:] - high[:-1]
|
||||||
|
down = low[:-1] - low[1:]
|
||||||
|
plus_dm = np.where((up > down) & (up > 0), up, 0.0)
|
||||||
|
minus_dm = np.where((down > up) & (down > 0), down, 0.0)
|
||||||
|
tr = np.maximum(high[1:] - low[1:], np.maximum(np.abs(high[1:] - close[:-1]), np.abs(low[1:] - close[:-1])))
|
||||||
|
atr = np.mean(tr[-n:]) or 1e-9
|
||||||
|
plus_di = 100 * np.mean(plus_dm[-n:]) / atr
|
||||||
|
minus_di = 100 * np.mean(minus_dm[-n:]) / atr
|
||||||
|
denom = plus_di + minus_di
|
||||||
|
if denom < 1e-9:
|
||||||
|
return 10.0
|
||||||
|
dx = 100 * abs(plus_di - minus_di) / denom
|
||||||
|
return float(min(60.0, dx))
|
||||||
|
|
||||||
|
|
||||||
|
def compute_feature_snapshot(frame: OHLCVFrame) -> dict[str, Any]:
|
||||||
|
"""Compute technical snapshot dict from OHLCV (no I/O)."""
|
||||||
|
if frame.empty or len(frame) < 5:
|
||||||
|
return {"ts_code": frame.ts_code, "timeframe": frame.timeframe, "bars": len(frame)}
|
||||||
|
|
||||||
|
close = np.asarray(frame.close, dtype=float)
|
||||||
|
high = np.asarray(frame.high, dtype=float)
|
||||||
|
low = np.asarray(frame.low, dtype=float)
|
||||||
|
volume = np.asarray(frame.volume, dtype=float)
|
||||||
|
open_ = np.asarray(frame.open, dtype=float)
|
||||||
|
|
||||||
|
ma20 = _sma(close, 20)
|
||||||
|
ma60 = _sma(close, 60)
|
||||||
|
ma120 = _sma(close, min(120, len(close)))
|
||||||
|
atr = _atr(high, low, close, 14)
|
||||||
|
vol_ma20 = _sma(volume, 20) or 1e-9
|
||||||
|
volume_ratio = float(volume[-1] / vol_ma20)
|
||||||
|
|
||||||
|
look = min(60, len(close))
|
||||||
|
window_h = high[-look:]
|
||||||
|
window_l = low[-look:]
|
||||||
|
range_high = float(np.max(window_h))
|
||||||
|
range_low = float(np.min(window_l))
|
||||||
|
rng = max(range_high - range_low, 1e-9)
|
||||||
|
range_pct_60 = float(rng / close[-1]) if close[-1] else 0.0
|
||||||
|
range_position = float((close[-1] - range_low) / rng)
|
||||||
|
|
||||||
|
# Spring / UTAD hints
|
||||||
|
pierce_below = max(0.0, (range_low - low[-1]) / close[-1]) if close[-1] else 0.0
|
||||||
|
# if previous bars broke below and last close back in range
|
||||||
|
prior_low = float(np.min(low[-6:-1])) if len(low) >= 6 else float(low[-2])
|
||||||
|
pierce_below = max(pierce_below, max(0.0, (range_low - prior_low) / close[-1]))
|
||||||
|
close_back_in_range = 1.0 if close[-1] >= range_low else 0.0
|
||||||
|
reclaim_speed = 0.0
|
||||||
|
if pierce_below > 0 and close[-1] >= range_low:
|
||||||
|
reclaim_speed = min(1.0, (close[-1] - low[-1]) / max(atr, 1e-9) / 2)
|
||||||
|
|
||||||
|
pierce_above = max(0.0, (high[-1] - range_high) / close[-1])
|
||||||
|
fail_back = 1.0 if pierce_above > 0 and close[-1] <= range_high else 0.0
|
||||||
|
breakout_above = 1.0 if close[-1] > range_high and volume_ratio >= 1.0 else -1.0
|
||||||
|
|
||||||
|
# pullback hold: close near ma20 from above after being higher
|
||||||
|
pullback_hold = 0.0
|
||||||
|
if len(close) >= 5 and close[-1] > ma20 and close[-3] > close[-1] and (close[-1] - ma20) / max(atr, 1e-9) < 1.5:
|
||||||
|
pullback_hold = 0.8
|
||||||
|
|
||||||
|
ma60_prev = _sma(close[:-5], 60) if len(close) > 65 else ma60
|
||||||
|
ma60_slope = (ma60 - ma60_prev) / max(abs(ma60_prev), 1e-9)
|
||||||
|
|
||||||
|
# volume trend: recent 10 vs prior 10
|
||||||
|
if len(volume) >= 20:
|
||||||
|
volume_trend = float(np.mean(volume[-10:]) / (np.mean(volume[-20:-10]) + 1e-9) - 1.0)
|
||||||
|
else:
|
||||||
|
volume_trend = 0.0
|
||||||
|
|
||||||
|
bar_range_atr = float((high[-1] - low[-1]) / max(atr, 1e-9))
|
||||||
|
bounce_from_low = float((close[-1] - float(np.min(low[-10:]))) / close[-1]) if close[-1] else 0.0
|
||||||
|
gap_up_pct = float((open_[-1] - close[-2]) / close[-2]) if len(close) >= 2 and close[-2] else 0.0
|
||||||
|
after_strength = 0.0
|
||||||
|
if len(close) >= 4 and close[-3] > close[-4]:
|
||||||
|
after_strength = 0.7
|
||||||
|
|
||||||
|
spring_score_hint = 0.0
|
||||||
|
if pierce_below >= 0.002 and close_back_in_range:
|
||||||
|
spring_score_hint = min(90.0, 50 + pierce_below * 1500 + reclaim_speed * 20)
|
||||||
|
utad_score_hint = min(90.0, 50 + pierce_above * 1500) if pierce_above >= 0.002 and fail_back else 0.0
|
||||||
|
|
||||||
|
# swing
|
||||||
|
swing_high = float(np.max(high[-20:])) if len(high) >= 5 else float(high[-1])
|
||||||
|
swing_low = float(np.min(low[-20:])) if len(low) >= 5 else float(low[-1])
|
||||||
|
|
||||||
|
return {
|
||||||
|
"ts_code": frame.ts_code,
|
||||||
|
"timeframe": frame.timeframe,
|
||||||
|
"bars": len(frame),
|
||||||
|
"close": float(close[-1]),
|
||||||
|
"open": float(open_[-1]),
|
||||||
|
"high": float(high[-1]),
|
||||||
|
"low": float(low[-1]),
|
||||||
|
"volume": float(volume[-1]),
|
||||||
|
"ma20": ma20,
|
||||||
|
"ma60": ma60,
|
||||||
|
"ma120": ma120,
|
||||||
|
"ma60_slope": float(ma60_slope),
|
||||||
|
"atr": atr,
|
||||||
|
"adx": _adx(high, low, close),
|
||||||
|
"volume_ma20": float(vol_ma20),
|
||||||
|
"volume_ratio": volume_ratio,
|
||||||
|
"volume_trend": volume_trend,
|
||||||
|
"range_high": range_high,
|
||||||
|
"range_low": range_low,
|
||||||
|
"range_pct_60": range_pct_60,
|
||||||
|
"range_position": range_position,
|
||||||
|
"pierce_below_range": pierce_below,
|
||||||
|
"pierce_above_range": pierce_above,
|
||||||
|
"close_back_in_range": close_back_in_range,
|
||||||
|
"reclaim_speed": reclaim_speed,
|
||||||
|
"fail_back_into_range": fail_back,
|
||||||
|
"breakout_above_range": breakout_above,
|
||||||
|
"pullback_hold": pullback_hold,
|
||||||
|
"bar_range_atr": bar_range_atr,
|
||||||
|
"bounce_from_low": bounce_from_low,
|
||||||
|
"gap_up_pct": gap_up_pct,
|
||||||
|
"after_strength": after_strength,
|
||||||
|
"spring_score_hint": spring_score_hint,
|
||||||
|
"utad_score_hint": utad_score_hint,
|
||||||
|
"swing_high": swing_high,
|
||||||
|
"swing_low": swing_low,
|
||||||
|
"trade_date": str(frame.trade_dates[-1]) if frame.trade_dates else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Minimum bars before a timeframe is considered usable (no cross-TF borrow)
|
||||||
|
_MIN_BARS = {"1d": 40, "1w": 26, "1M": 18}
|
||||||
|
|
||||||
|
|
||||||
|
class FeatureEngine:
|
||||||
|
"""Pure Feature Engine — no database access."""
|
||||||
|
|
||||||
|
name = "Feature"
|
||||||
|
version = "1.0.0"
|
||||||
|
|
||||||
|
def run(self, frame: OHLCVFrame | None, timeframe: str | None = None) -> EngineResult:
|
||||||
|
tf = timeframe or (frame.timeframe if frame else "1d")
|
||||||
|
min_bars = _MIN_BARS.get(tf, 30)
|
||||||
|
|
||||||
|
if frame is None or frame.empty or len(frame) < min_bars:
|
||||||
|
bars = 0 if frame is None or frame.empty else len(frame)
|
||||||
|
return EngineResult(
|
||||||
|
name=self.name,
|
||||||
|
version=self.version,
|
||||||
|
confidence=10.0,
|
||||||
|
score=10.0,
|
||||||
|
reasons=[f"{tf} bars={bars} < min={min_bars},标记 insufficient"],
|
||||||
|
warnings=["insufficient_features"],
|
||||||
|
metrics={"bars": bars, "min_bars": min_bars},
|
||||||
|
payload={
|
||||||
|
"ts_code": getattr(frame, "ts_code", ""),
|
||||||
|
"timeframe": tf,
|
||||||
|
"bars": bars,
|
||||||
|
"insufficient": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
snap = compute_feature_snapshot(frame)
|
||||||
|
snap["insufficient"] = False
|
||||||
|
conf = 90.0 if snap.get("bars", 0) >= 60 else 50.0 + min(40.0, snap.get("bars", 0) * 0.5)
|
||||||
|
warnings = []
|
||||||
|
if snap.get("bars", 0) < 60:
|
||||||
|
warnings.append("bars偏少,特征可靠性中等")
|
||||||
|
return EngineResult(
|
||||||
|
name=self.name,
|
||||||
|
version=self.version,
|
||||||
|
confidence=conf,
|
||||||
|
score=conf,
|
||||||
|
reasons=[f"computed {snap.get('bars', 0)} bars {tf}"],
|
||||||
|
warnings=warnings,
|
||||||
|
metrics={"bars": snap.get("bars", 0)},
|
||||||
|
payload=snap,
|
||||||
|
)
|
||||||
@@ -0,0 +1,363 @@
|
|||||||
|
"""Paths + OHLCV cache + DATA_SERVICE fetch (crypto continuous calendar)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
import time
|
||||||
|
from datetime import date, datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Iterable
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from crypto_wyckoff.domain_models import OHLCVFrame
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
DATA_DIR = Path(os.environ.get("CRYPTO_WYCKOFF_DATA", str(_REPO_ROOT / "data" / "crypto_wyckoff")))
|
||||||
|
BARS_DB = DATA_DIR / "bars.sqlite"
|
||||||
|
SCAN_DB = DATA_DIR / "scan.sqlite"
|
||||||
|
|
||||||
|
DATA_SERVICE_URL = os.environ.get(
|
||||||
|
"DATA_SERVICE_URL",
|
||||||
|
os.environ.get("DATASVC_URL", "https://provider.jackyu66.com"),
|
||||||
|
).rstrip("/")
|
||||||
|
|
||||||
|
# Continuous crypto: bar counts (not A-share weekend-padded calendar multipliers)
|
||||||
|
# Provider has many TFs; 1M is resampled locally from daily UTC months.
|
||||||
|
LOOKBACK = {
|
||||||
|
"1h": 500,
|
||||||
|
"2h": 400,
|
||||||
|
"4h": 300,
|
||||||
|
"6h": 280,
|
||||||
|
"8h": 250,
|
||||||
|
"12h": 220,
|
||||||
|
"1d": 250,
|
||||||
|
"1w": 104,
|
||||||
|
"1M": 60,
|
||||||
|
}
|
||||||
|
# Default D/W/M stack (kept for compat); combos may request more TFs from provider.
|
||||||
|
TF_PROVIDER = ("1h", "4h", "8h", "1d", "1w")
|
||||||
|
TF_LIST = ("1d", "1w", "1M")
|
||||||
|
LOCAL_ONLY_TFS = frozenset({"1M"})
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_dirs() -> None:
|
||||||
|
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _symbol_key(symbol: str) -> str:
|
||||||
|
return symbol.replace("/", "_").replace(":", "_")
|
||||||
|
|
||||||
|
|
||||||
|
def _bars_conn() -> sqlite3.Connection:
|
||||||
|
ensure_dirs()
|
||||||
|
conn = sqlite3.connect(str(BARS_DB), timeout=60)
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS bars (
|
||||||
|
symbol TEXT NOT NULL,
|
||||||
|
tf TEXT NOT NULL,
|
||||||
|
ts INTEGER NOT NULL,
|
||||||
|
open REAL, high REAL, low REAL, close REAL, volume REAL,
|
||||||
|
PRIMARY KEY (symbol, tf, ts)
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_bars_sym_tf ON bars(symbol, tf)")
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_candles(
|
||||||
|
symbol: str,
|
||||||
|
tf: str,
|
||||||
|
*,
|
||||||
|
limit: int | None = None,
|
||||||
|
start_ms: int | None = None,
|
||||||
|
end_ms: int | None = None,
|
||||||
|
timeout: float = 15.0,
|
||||||
|
) -> list[dict]:
|
||||||
|
params: dict = {"symbol": symbol, "tf": tf}
|
||||||
|
if limit is not None:
|
||||||
|
params["limit"] = int(limit)
|
||||||
|
if start_ms is not None:
|
||||||
|
params["start"] = int(start_ms)
|
||||||
|
if end_ms is not None:
|
||||||
|
params["end"] = int(end_ms)
|
||||||
|
resp = requests.get(f"{DATA_SERVICE_URL}/api/candles", params=params, timeout=timeout)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
if not isinstance(data, list):
|
||||||
|
return []
|
||||||
|
out = []
|
||||||
|
for row in data:
|
||||||
|
try:
|
||||||
|
ts = int(float(row["timestamp"]))
|
||||||
|
out.append(
|
||||||
|
{
|
||||||
|
"ts": ts,
|
||||||
|
"open": float(row["open"]),
|
||||||
|
"high": float(row["high"]),
|
||||||
|
"low": float(row["low"]),
|
||||||
|
"close": float(row["close"]),
|
||||||
|
"volume": float(row.get("volume") or 0),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except (KeyError, TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
out.sort(key=lambda r: r["ts"])
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def upsert_bars(symbol: str, tf: str, rows: list[dict]) -> int:
|
||||||
|
if not rows:
|
||||||
|
return 0
|
||||||
|
conn = _bars_conn()
|
||||||
|
try:
|
||||||
|
conn.executemany(
|
||||||
|
"""
|
||||||
|
INSERT INTO bars(symbol, tf, ts, open, high, low, close, volume)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(symbol, tf, ts) DO UPDATE SET
|
||||||
|
open=excluded.open, high=excluded.high, low=excluded.low,
|
||||||
|
close=excluded.close, volume=excluded.volume
|
||||||
|
""",
|
||||||
|
[
|
||||||
|
(symbol, tf, r["ts"], r["open"], r["high"], r["low"], r["close"], r["volume"])
|
||||||
|
for r in rows
|
||||||
|
],
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return len(rows)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def is_intraday_tf(tf: str) -> bool:
|
||||||
|
"""True for minute/hour TFs that need clock time on charts."""
|
||||||
|
t = (tf or "").strip()
|
||||||
|
return t.endswith("m") or t.endswith("h")
|
||||||
|
|
||||||
|
|
||||||
|
def load_bars_with_ts(
|
||||||
|
symbol: str, tf: str, lookback: int | None = None
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Return OHLCV rows with UTC ms ts (for chart labels).
|
||||||
|
|
||||||
|
``datetime`` is wall-clock in Asia/Shanghai (UTC+8) for display.
|
||||||
|
"""
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
tz_cn = ZoneInfo("Asia/Shanghai")
|
||||||
|
if lookback is None:
|
||||||
|
try:
|
||||||
|
from crypto_wyckoff.combos import lookback_for
|
||||||
|
|
||||||
|
lookback = lookback_for(tf)
|
||||||
|
except Exception:
|
||||||
|
lookback = LOOKBACK.get(tf, 100)
|
||||||
|
lookback = lookback or LOOKBACK.get(tf, 100)
|
||||||
|
conn = _bars_conn()
|
||||||
|
try:
|
||||||
|
cur = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT ts, open, high, low, close, volume FROM bars
|
||||||
|
WHERE symbol=? AND tf=?
|
||||||
|
ORDER BY ts DESC LIMIT ?
|
||||||
|
""",
|
||||||
|
(symbol, tf, lookback),
|
||||||
|
)
|
||||||
|
rows = list(reversed(cur.fetchall()))
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
out = []
|
||||||
|
for ts, o, h, l, c, v in rows:
|
||||||
|
dt_utc = datetime.fromtimestamp(ts / 1000.0, tz=timezone.utc)
|
||||||
|
dt_cn = dt_utc.astimezone(tz_cn)
|
||||||
|
out.append(
|
||||||
|
{
|
||||||
|
"ts": int(ts),
|
||||||
|
"datetime": dt_cn.strftime("%Y-%m-%dT%H:%M:%S+08:00"),
|
||||||
|
"date": dt_cn.strftime("%Y-%m-%d"),
|
||||||
|
"open": o,
|
||||||
|
"high": h,
|
||||||
|
"low": l,
|
||||||
|
"close": c,
|
||||||
|
"volume": v,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def load_frame(symbol: str, tf: str, lookback: int | None = None) -> OHLCVFrame | None:
|
||||||
|
rows = load_bars_with_ts(symbol, tf, lookback)
|
||||||
|
if not rows:
|
||||||
|
return None
|
||||||
|
return OHLCVFrame(
|
||||||
|
ts_code=symbol,
|
||||||
|
timeframe=tf,
|
||||||
|
trade_dates=[
|
||||||
|
datetime.fromtimestamp(r["ts"] / 1000.0, tz=timezone.utc).date() for r in rows
|
||||||
|
],
|
||||||
|
open=[r["open"] for r in rows],
|
||||||
|
high=[r["high"] for r in rows],
|
||||||
|
low=[r["low"] for r in rows],
|
||||||
|
close=[r["close"] for r in rows],
|
||||||
|
volume=[r["volume"] for r in rows],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def bar_count(symbol: str, tf: str) -> int:
|
||||||
|
conn = _bars_conn()
|
||||||
|
try:
|
||||||
|
cur = conn.execute(
|
||||||
|
"SELECT COUNT(*) FROM bars WHERE symbol=? AND tf=?", (symbol, tf)
|
||||||
|
)
|
||||||
|
return int(cur.fetchone()[0])
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def rebuild_monthly_from_daily(symbol: str) -> int:
|
||||||
|
"""Aggregate UTC calendar-month OHLCV from local daily bars (provider has no 1M)."""
|
||||||
|
conn = _bars_conn()
|
||||||
|
try:
|
||||||
|
cur = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT ts, open, high, low, close, volume FROM bars
|
||||||
|
WHERE symbol=? AND tf='1d' ORDER BY ts ASC
|
||||||
|
""",
|
||||||
|
(symbol,),
|
||||||
|
)
|
||||||
|
daily = cur.fetchall()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
if not daily:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
months: dict[tuple[int, int], dict] = {}
|
||||||
|
for ts, o, h, l, c, v in daily:
|
||||||
|
dt = datetime.fromtimestamp(ts / 1000.0, tz=timezone.utc)
|
||||||
|
key = (dt.year, dt.month)
|
||||||
|
# month bar open timestamp = first day 00:00 UTC
|
||||||
|
month_ts = int(datetime(dt.year, dt.month, 1, tzinfo=timezone.utc).timestamp() * 1000)
|
||||||
|
if key not in months:
|
||||||
|
months[key] = {
|
||||||
|
"ts": month_ts,
|
||||||
|
"open": o,
|
||||||
|
"high": h,
|
||||||
|
"low": l,
|
||||||
|
"close": c,
|
||||||
|
"volume": v or 0.0,
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
m = months[key]
|
||||||
|
m["high"] = max(m["high"], h)
|
||||||
|
m["low"] = min(m["low"], l)
|
||||||
|
m["close"] = c
|
||||||
|
m["volume"] = (m["volume"] or 0) + (v or 0)
|
||||||
|
|
||||||
|
rows = sorted(months.values(), key=lambda r: r["ts"])
|
||||||
|
# drop stale months then upsert
|
||||||
|
conn = _bars_conn()
|
||||||
|
try:
|
||||||
|
conn.execute("DELETE FROM bars WHERE symbol=? AND tf='1M'", (symbol,))
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
return upsert_bars(symbol, "1M", rows)
|
||||||
|
|
||||||
|
|
||||||
|
def backfill_symbol(symbol: str, tfs: Iterable[str] = TF_LIST) -> dict:
|
||||||
|
"""Pull history for requested TFs; monthly derived from daily when needed."""
|
||||||
|
wanted = list(dict.fromkeys(tfs))
|
||||||
|
stats: dict = {}
|
||||||
|
need_monthly = "1M" in wanted
|
||||||
|
if need_monthly and "1d" not in wanted:
|
||||||
|
wanted = ["1d", *wanted]
|
||||||
|
|
||||||
|
for tf in wanted:
|
||||||
|
if tf in LOCAL_ONLY_TFS:
|
||||||
|
continue
|
||||||
|
need = LOOKBACK.get(tf, 100)
|
||||||
|
if tf == "1d" and need_monthly:
|
||||||
|
need = max(need, LOOKBACK["1M"] * 31)
|
||||||
|
try:
|
||||||
|
rows = fetch_candles(symbol, tf, limit=need)
|
||||||
|
n = upsert_bars(symbol, tf, rows)
|
||||||
|
stats[tf] = n
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("backfill %s %s failed: %s", symbol, tf, e)
|
||||||
|
stats[tf] = 0
|
||||||
|
time.sleep(0.05)
|
||||||
|
|
||||||
|
if need_monthly:
|
||||||
|
try:
|
||||||
|
stats["1M"] = rebuild_monthly_from_daily(symbol)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("monthly rebuild %s failed: %s", symbol, e)
|
||||||
|
stats["1M"] = 0
|
||||||
|
return stats
|
||||||
|
|
||||||
|
|
||||||
|
def tip_update_symbol(symbol: str, tfs: Iterable[str] = TF_LIST) -> bool:
|
||||||
|
"""Update forming tip bars (limit=3). Returns True if any bar changed."""
|
||||||
|
wanted = list(dict.fromkeys(tfs))
|
||||||
|
changed = False
|
||||||
|
for tf in wanted:
|
||||||
|
if tf in LOCAL_ONLY_TFS:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
rows = fetch_candles(symbol, tf, limit=3)
|
||||||
|
if not rows:
|
||||||
|
continue
|
||||||
|
before = _tip_fingerprint(symbol, tf)
|
||||||
|
upsert_bars(symbol, tf, rows)
|
||||||
|
after = _tip_fingerprint(symbol, tf)
|
||||||
|
if before != after:
|
||||||
|
changed = True
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug("tip %s %s: %s", symbol, tf, e)
|
||||||
|
time.sleep(0.02)
|
||||||
|
if "1M" in wanted:
|
||||||
|
before_m = _tip_fingerprint(symbol, "1M")
|
||||||
|
try:
|
||||||
|
rebuild_monthly_from_daily(symbol)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug("monthly tip %s: %s", symbol, e)
|
||||||
|
after_m = _tip_fingerprint(symbol, "1M")
|
||||||
|
if before_m != after_m:
|
||||||
|
changed = True
|
||||||
|
return changed
|
||||||
|
|
||||||
|
|
||||||
|
def _tip_fingerprint(symbol: str, tf: str) -> tuple | None:
|
||||||
|
conn = _bars_conn()
|
||||||
|
try:
|
||||||
|
cur = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT ts, open, high, low, close, volume FROM bars
|
||||||
|
WHERE symbol=? AND tf=? ORDER BY ts DESC LIMIT 1
|
||||||
|
""",
|
||||||
|
(symbol, tf),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
return tuple(row) if row else None
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_symbols_from_provider() -> list[str]:
|
||||||
|
try:
|
||||||
|
resp = requests.get(f"{DATA_SERVICE_URL}/health", timeout=8)
|
||||||
|
resp.raise_for_status()
|
||||||
|
payload = resp.json()
|
||||||
|
symbols = payload.get("symbols") or payload.get("symbol_list") or []
|
||||||
|
return [s for s in symbols if isinstance(s, str)]
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("health symbols failed: %s", e)
|
||||||
|
return []
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
"""Phase Engine — Phase A–E via Rule Registry."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from crypto_wyckoff.domain_models import EngineResult, WyckoffPhase
|
||||||
|
from crypto_wyckoff.rules.registry import rule_registry
|
||||||
|
|
||||||
|
|
||||||
|
class PhaseEngine:
|
||||||
|
name = "Phase"
|
||||||
|
version = "1.0.0"
|
||||||
|
|
||||||
|
def run(self, cycle: EngineResult, feature: EngineResult, timeframe: str) -> EngineResult:
|
||||||
|
if feature.payload.get("insufficient") or cycle.payload.get("cycle") == "Unknown":
|
||||||
|
return EngineResult(
|
||||||
|
name=self.name,
|
||||||
|
version=self.version,
|
||||||
|
confidence=20.0,
|
||||||
|
score=30.0,
|
||||||
|
reasons=["数据/周期不足,Phase=None"],
|
||||||
|
warnings=["insufficient_features"],
|
||||||
|
payload={
|
||||||
|
"phase": WyckoffPhase.NONE.value,
|
||||||
|
"timeframe": timeframe,
|
||||||
|
"cycle": cycle.payload.get("cycle"),
|
||||||
|
"structure_score": 30.0,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
context = {
|
||||||
|
"features": feature.payload,
|
||||||
|
"cycle": cycle.payload,
|
||||||
|
"timeframe": timeframe,
|
||||||
|
}
|
||||||
|
hits = []
|
||||||
|
for rule in rule_registry.by_category("phase", timeframe):
|
||||||
|
hit = rule.evaluate(context)
|
||||||
|
if hit and hit.phase:
|
||||||
|
hits.append(hit)
|
||||||
|
|
||||||
|
if not hits:
|
||||||
|
return EngineResult(
|
||||||
|
name=self.name,
|
||||||
|
version=self.version,
|
||||||
|
confidence=40.0,
|
||||||
|
score=cycle.score * 0.5,
|
||||||
|
reasons=["未识别明确 Phase"],
|
||||||
|
payload={
|
||||||
|
"phase": WyckoffPhase.NONE.value,
|
||||||
|
"timeframe": timeframe,
|
||||||
|
"cycle": cycle.payload.get("cycle"),
|
||||||
|
"structure_score": cycle.score * 0.5,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
best = max(hits, key=lambda h: h.confidence)
|
||||||
|
structure_score = best.score
|
||||||
|
# Phase D/E stronger structure
|
||||||
|
if best.phase in (WyckoffPhase.D.value, WyckoffPhase.E.value):
|
||||||
|
structure_score = max(structure_score, 80.0)
|
||||||
|
elif best.phase == WyckoffPhase.C.value:
|
||||||
|
structure_score = max(structure_score, 72.0)
|
||||||
|
|
||||||
|
return EngineResult(
|
||||||
|
name=self.name,
|
||||||
|
version=self.version,
|
||||||
|
confidence=best.confidence,
|
||||||
|
score=structure_score,
|
||||||
|
reasons=best.reasons,
|
||||||
|
metrics=best.metrics,
|
||||||
|
payload={
|
||||||
|
"phase": best.phase,
|
||||||
|
"timeframe": timeframe,
|
||||||
|
"cycle": cycle.payload.get("cycle"),
|
||||||
|
"rule_id": best.rule_id,
|
||||||
|
"structure_score": structure_score,
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
"""Scan pipeline: load local frames → engines → store (per TF combo)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from datetime import date, datetime, timezone
|
||||||
|
|
||||||
|
from crypto_wyckoff.combos import ROLE_HIGH, ROLE_LOW, ROLE_MID, get_combo, lookback_for
|
||||||
|
from crypto_wyckoff.cycle import CycleEngine
|
||||||
|
from crypto_wyckoff.decision import DecisionEngine
|
||||||
|
from crypto_wyckoff.domain_models import WyckoffScanRow
|
||||||
|
from crypto_wyckoff.event import EventEngine
|
||||||
|
from crypto_wyckoff.features import FeatureEngine
|
||||||
|
from crypto_wyckoff.io import load_frame
|
||||||
|
from crypto_wyckoff.phase import PhaseEngine
|
||||||
|
from crypto_wyckoff.plan import PlanEngine
|
||||||
|
from crypto_wyckoff.signal import SignalEngine
|
||||||
|
from crypto_wyckoff.store import upsert_row
|
||||||
|
from crypto_wyckoff.symbols_cn import display_name_cn
|
||||||
|
from crypto_wyckoff.version import WYCKOFF_ENGINE_VERSION
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def analyze_symbol(
|
||||||
|
low_frame,
|
||||||
|
mid_frame,
|
||||||
|
high_frame,
|
||||||
|
*,
|
||||||
|
feature_eng: FeatureEngine,
|
||||||
|
cycle_eng: CycleEngine,
|
||||||
|
phase_eng: PhaseEngine,
|
||||||
|
event_eng: EventEngine,
|
||||||
|
signal_eng: SignalEngine,
|
||||||
|
decision_eng: DecisionEngine,
|
||||||
|
plan_eng: PlanEngine,
|
||||||
|
) -> dict:
|
||||||
|
"""Run engines with D/W/M *role* aliases so existing rules match.
|
||||||
|
|
||||||
|
Frames may be any TF combo (e.g. 1h/4h/8h); rules still see 1d/1w/1M roles.
|
||||||
|
"""
|
||||||
|
f_d = feature_eng.run(low_frame, ROLE_LOW)
|
||||||
|
f_w = feature_eng.run(mid_frame, ROLE_MID)
|
||||||
|
f_m = feature_eng.run(high_frame, ROLE_HIGH)
|
||||||
|
|
||||||
|
c_m = cycle_eng.run(f_m, ROLE_HIGH)
|
||||||
|
c_w = cycle_eng.run(f_w, ROLE_MID)
|
||||||
|
|
||||||
|
p_w = phase_eng.run(c_w, f_w, ROLE_MID)
|
||||||
|
p_d = phase_eng.run(c_w, f_d, ROLE_LOW)
|
||||||
|
|
||||||
|
e_w = event_eng.run(c_w, p_w, f_w, ROLE_MID)
|
||||||
|
e_d = event_eng.run(c_w, p_d, f_d, ROLE_LOW)
|
||||||
|
|
||||||
|
s_d = signal_eng.run(e_d, p_d)
|
||||||
|
decision = decision_eng.run(c_m, c_w, p_w, e_w, e_d, s_d)
|
||||||
|
plan = plan_eng.run(f_d, decision)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"f_d": f_d, "f_w": f_w, "f_m": f_m,
|
||||||
|
"c_m": c_m, "c_w": c_w, "p_w": p_w,
|
||||||
|
"e_w": e_w, "e_d": e_d, "s_d": s_d,
|
||||||
|
"decision": decision, "plan": plan,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _to_row(
|
||||||
|
trade_date: date,
|
||||||
|
symbol: str,
|
||||||
|
result: dict,
|
||||||
|
*,
|
||||||
|
combo_id: str,
|
||||||
|
combo_label: str,
|
||||||
|
) -> WyckoffScanRow:
|
||||||
|
d = result["decision"]
|
||||||
|
p = result["plan"]
|
||||||
|
c_m, c_w, p_w = result["c_m"], result["c_w"], result["p_w"]
|
||||||
|
e_w, e_d, s_d = result["e_w"], result["e_d"], result["s_d"]
|
||||||
|
f_d, f_w, f_m = result["f_d"], result["f_w"], result["f_m"]
|
||||||
|
|
||||||
|
snapshot = {
|
||||||
|
"combo_id": combo_id,
|
||||||
|
"combo_label": combo_label,
|
||||||
|
"daily": {k: f_d.payload.get(k) for k in (
|
||||||
|
"ma20", "ma60", "ma120", "atr", "adx", "volume_ratio",
|
||||||
|
"range_high", "range_low", "swing_high", "swing_low", "close",
|
||||||
|
)},
|
||||||
|
"weekly": {k: f_w.payload.get(k) for k in ("ma20", "ma60", "adx", "close")},
|
||||||
|
"monthly": {k: f_m.payload.get(k) for k in ("ma20", "ma60", "adx", "close")},
|
||||||
|
}
|
||||||
|
markers = []
|
||||||
|
for key, typ in (("entry", "entry"), ("stop", "stop"), ("target1", "target1"), ("target2", "target2")):
|
||||||
|
if p.payload.get(key) is not None:
|
||||||
|
markers.append({"type": typ, "price": p.payload[key]})
|
||||||
|
|
||||||
|
return WyckoffScanRow(
|
||||||
|
trade_date=trade_date,
|
||||||
|
ts_code=symbol,
|
||||||
|
name=display_name_cn(symbol),
|
||||||
|
industry="crypto",
|
||||||
|
engine_version=WYCKOFF_ENGINE_VERSION,
|
||||||
|
m_cycle=c_m.payload.get("cycle", "Unknown"),
|
||||||
|
cycle_confidence=c_m.confidence,
|
||||||
|
trend_score=float(d.payload.get("trend_score", c_m.score)),
|
||||||
|
w_cycle=c_w.payload.get("cycle", "Unknown"),
|
||||||
|
w_phase=p_w.payload.get("phase", "None"),
|
||||||
|
w_current_event=e_w.payload.get("current_event", "None"),
|
||||||
|
w_recent_events_json=json.dumps(
|
||||||
|
e_w.payload.get("active_events") or e_w.payload.get("recent_events") or [],
|
||||||
|
ensure_ascii=False,
|
||||||
|
),
|
||||||
|
phase_confidence=p_w.confidence,
|
||||||
|
structure_score=float(d.payload.get("structure_score", p_w.score)),
|
||||||
|
d_current_event=e_d.payload.get("current_event", "None"),
|
||||||
|
d_recent_events_json=json.dumps(
|
||||||
|
e_d.payload.get("active_events") or e_d.payload.get("recent_events") or [],
|
||||||
|
ensure_ascii=False,
|
||||||
|
),
|
||||||
|
event_confidence=e_d.confidence,
|
||||||
|
entry_score=float(d.payload.get("entry_score", e_d.score)),
|
||||||
|
entry=p.payload.get("entry"),
|
||||||
|
stop=p.payload.get("stop"),
|
||||||
|
target1=p.payload.get("target1"),
|
||||||
|
target2=p.payload.get("target2"),
|
||||||
|
rr=p.payload.get("rr"),
|
||||||
|
alignment=float(d.payload.get("alignment", 0)),
|
||||||
|
stars=int(d.payload.get("stars", 1)),
|
||||||
|
decision_signal=d.payload.get("decision_signal", "Watch"),
|
||||||
|
signal_confidence=s_d.confidence,
|
||||||
|
overall_confidence=float(d.payload.get("overall_confidence", d.confidence)),
|
||||||
|
overall_score=float(d.payload.get("overall_score", d.score)),
|
||||||
|
risk=d.payload.get("risk", "Medium"),
|
||||||
|
reasons_json=json.dumps(d.reasons + d.warnings, ensure_ascii=False),
|
||||||
|
feature_snapshot_json=json.dumps(snapshot, ensure_ascii=False),
|
||||||
|
markers_json=json.dumps(markers, ensure_ascii=False),
|
||||||
|
scanned_at=datetime.now(timezone.utc),
|
||||||
|
combo_id=combo_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_ENGINES = None
|
||||||
|
|
||||||
|
|
||||||
|
def _engines():
|
||||||
|
global _ENGINES
|
||||||
|
if _ENGINES is None:
|
||||||
|
_ENGINES = {
|
||||||
|
"feature_eng": FeatureEngine(),
|
||||||
|
"cycle_eng": CycleEngine(),
|
||||||
|
"phase_eng": PhaseEngine(),
|
||||||
|
"event_eng": EventEngine(),
|
||||||
|
"signal_eng": SignalEngine(),
|
||||||
|
"decision_eng": DecisionEngine(),
|
||||||
|
"plan_eng": PlanEngine(),
|
||||||
|
}
|
||||||
|
return _ENGINES
|
||||||
|
|
||||||
|
|
||||||
|
def analyze_and_store(
|
||||||
|
symbol: str,
|
||||||
|
trade_date: date | None = None,
|
||||||
|
*,
|
||||||
|
combo_id: str | None = None,
|
||||||
|
) -> WyckoffScanRow | None:
|
||||||
|
eng = _engines()
|
||||||
|
combo = get_combo(combo_id)
|
||||||
|
low_tf, mid_tf, high_tf = combo["low"], combo["mid"], combo["high"]
|
||||||
|
|
||||||
|
low = load_frame(symbol, low_tf, lookback_for(low_tf))
|
||||||
|
mid = load_frame(symbol, mid_tf, lookback_for(mid_tf))
|
||||||
|
high = load_frame(symbol, high_tf, lookback_for(high_tf))
|
||||||
|
if low is None or len(low) < 40:
|
||||||
|
return None
|
||||||
|
result = analyze_symbol(low, mid, high, **eng)
|
||||||
|
td = trade_date or (
|
||||||
|
low.trade_dates[-1] if low.trade_dates else datetime.now(timezone.utc).date()
|
||||||
|
)
|
||||||
|
row = _to_row(td, symbol, result, combo_id=combo["id"], combo_label=combo["label"])
|
||||||
|
upsert_row(row)
|
||||||
|
return row
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
"""Plan Engine — Entry / Stop / Target / RR only when Decision is tradable."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from crypto_wyckoff.domain_models import DecisionSignal, EngineResult
|
||||||
|
|
||||||
|
|
||||||
|
_TRADABLE = {
|
||||||
|
DecisionSignal.STRONG_BUY.value,
|
||||||
|
DecisionSignal.BUY.value,
|
||||||
|
DecisionSignal.SELL.value,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class PlanEngine:
|
||||||
|
name = "Plan"
|
||||||
|
version = "1.0.0"
|
||||||
|
|
||||||
|
def run(self, daily_feature: EngineResult, decision: EngineResult) -> EngineResult:
|
||||||
|
f = daily_feature.payload
|
||||||
|
close = float(f.get("close") or 0)
|
||||||
|
atr = float(f.get("atr") or 0) or close * 0.02
|
||||||
|
swing_low = float(f.get("swing_low") or close - 2 * atr)
|
||||||
|
swing_high = float(f.get("swing_high") or close + 2 * atr)
|
||||||
|
range_high = float(f.get("range_high") or swing_high)
|
||||||
|
signal = decision.payload.get("decision_signal", DecisionSignal.WATCH.value)
|
||||||
|
|
||||||
|
entry = stop = t1 = t2 = rr = None
|
||||||
|
reasons: list[str] = []
|
||||||
|
|
||||||
|
if signal not in _TRADABLE or close <= 0:
|
||||||
|
reasons.append(f"无交易计划(信号={signal})")
|
||||||
|
return EngineResult(
|
||||||
|
name=self.name,
|
||||||
|
version=self.version,
|
||||||
|
confidence=decision.confidence,
|
||||||
|
score=decision.score,
|
||||||
|
reasons=reasons,
|
||||||
|
payload={
|
||||||
|
"entry": None,
|
||||||
|
"stop": None,
|
||||||
|
"target1": None,
|
||||||
|
"target2": None,
|
||||||
|
"rr": None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if signal in (DecisionSignal.STRONG_BUY.value, DecisionSignal.BUY.value):
|
||||||
|
entry = round(close, 4)
|
||||||
|
stop = round(min(swing_low, close - 1.5 * atr), 4)
|
||||||
|
risk = max(entry - stop, 1e-6)
|
||||||
|
t1 = round(entry + 2.0 * risk, 4)
|
||||||
|
t2 = round(max(range_high, entry + 3.0 * risk), 4)
|
||||||
|
rr = round((t1 - entry) / risk, 2)
|
||||||
|
reasons.append(f"入场={entry} 止损={stop} 目标一={t1} 盈亏比={rr}")
|
||||||
|
else: # Sell
|
||||||
|
entry = round(close, 4)
|
||||||
|
stop = round(max(swing_high, close + 1.5 * atr), 4)
|
||||||
|
risk = max(stop - entry, 1e-6)
|
||||||
|
t1 = round(entry - 2.0 * risk, 4)
|
||||||
|
t2 = round(entry - 3.0 * risk, 4)
|
||||||
|
rr = round((entry - t1) / risk, 2)
|
||||||
|
reasons.append(f"做空计划 入场={entry} 止损={stop} 目标一={t1}")
|
||||||
|
|
||||||
|
return EngineResult(
|
||||||
|
name=self.name,
|
||||||
|
version=self.version,
|
||||||
|
confidence=decision.confidence,
|
||||||
|
score=decision.score,
|
||||||
|
reasons=reasons,
|
||||||
|
payload={
|
||||||
|
"entry": entry,
|
||||||
|
"stop": stop,
|
||||||
|
"target1": t1,
|
||||||
|
"target2": t2,
|
||||||
|
"rr": rr,
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from crypto_wyckoff.rules.registry import rule_registry
|
||||||
|
|
||||||
|
__all__ = ["rule_registry"]
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
"""Rule protocol for Wyckoff Rule Registry."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RuleHit:
|
||||||
|
"""A single rule match."""
|
||||||
|
|
||||||
|
rule_id: str
|
||||||
|
event: str | None = None
|
||||||
|
phase: str | None = None
|
||||||
|
cycle: str | None = None
|
||||||
|
confidence: float = 0.0
|
||||||
|
score: float = 0.0
|
||||||
|
reasons: list[str] = field(default_factory=list)
|
||||||
|
metrics: dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class WyckoffRule(ABC):
|
||||||
|
"""Pluggable rule. Engines iterate registry; never hardcode rule lists."""
|
||||||
|
|
||||||
|
rule_id: str
|
||||||
|
category: str # cycle | phase | event
|
||||||
|
timeframes: tuple[str, ...] = ("1d", "1w", "1M")
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def evaluate(self, context: dict[str, Any]) -> RuleHit | None:
|
||||||
|
"""Return RuleHit if matched, else None. Pure — no I/O."""
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
"""Cycle classification rules (monthly / weekly)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from crypto_wyckoff.domain_models import WyckoffCycle
|
||||||
|
from crypto_wyckoff.rules.base import RuleHit, WyckoffRule
|
||||||
|
|
||||||
|
|
||||||
|
def _f(ctx: dict[str, Any], key: str, default: float = 0.0) -> float:
|
||||||
|
v = ctx.get("features", {}).get(key, default)
|
||||||
|
try:
|
||||||
|
return float(v) if v is not None else default
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
class MarkupCycleRule(WyckoffRule):
|
||||||
|
rule_id = "cycle_markup"
|
||||||
|
category = "cycle"
|
||||||
|
timeframes = ("1M", "1w")
|
||||||
|
|
||||||
|
def evaluate(self, context: dict[str, Any]) -> RuleHit | None:
|
||||||
|
close = _f(context, "close")
|
||||||
|
ma20 = _f(context, "ma20")
|
||||||
|
ma60 = _f(context, "ma60")
|
||||||
|
ma120 = _f(context, "ma120")
|
||||||
|
adx = _f(context, "adx")
|
||||||
|
slope = _f(context, "ma60_slope")
|
||||||
|
if close > ma20 > ma60 and (ma60 >= ma120 or slope > 0) and adx >= 18:
|
||||||
|
conf = min(95.0, 55 + adx + (10 if close > ma120 else 0))
|
||||||
|
return RuleHit(
|
||||||
|
rule_id=self.rule_id,
|
||||||
|
cycle=WyckoffCycle.MARKUP.value,
|
||||||
|
confidence=conf,
|
||||||
|
score=conf,
|
||||||
|
reasons=["价格位于均线多头排列", f"ADX={adx:.1f}"],
|
||||||
|
metrics={"adx": adx, "slope": slope},
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class MarkdownCycleRule(WyckoffRule):
|
||||||
|
rule_id = "cycle_markdown"
|
||||||
|
category = "cycle"
|
||||||
|
timeframes = ("1M", "1w")
|
||||||
|
|
||||||
|
def evaluate(self, context: dict[str, Any]) -> RuleHit | None:
|
||||||
|
close = _f(context, "close")
|
||||||
|
ma20 = _f(context, "ma20")
|
||||||
|
ma60 = _f(context, "ma60")
|
||||||
|
ma120 = _f(context, "ma120")
|
||||||
|
adx = _f(context, "adx")
|
||||||
|
slope = _f(context, "ma60_slope")
|
||||||
|
if close < ma20 < ma60 and (ma60 <= ma120 or slope < 0) and adx >= 18:
|
||||||
|
conf = min(95.0, 55 + adx + (10 if close < ma120 else 0))
|
||||||
|
return RuleHit(
|
||||||
|
rule_id=self.rule_id,
|
||||||
|
cycle=WyckoffCycle.MARKDOWN.value,
|
||||||
|
confidence=conf,
|
||||||
|
score=conf,
|
||||||
|
reasons=["价格位于均线空头排列", f"ADX={adx:.1f}"],
|
||||||
|
metrics={"adx": adx},
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class AccumulationCycleRule(WyckoffRule):
|
||||||
|
rule_id = "cycle_accumulation"
|
||||||
|
category = "cycle"
|
||||||
|
timeframes = ("1M", "1w")
|
||||||
|
|
||||||
|
def evaluate(self, context: dict[str, Any]) -> RuleHit | None:
|
||||||
|
adx = _f(context, "adx")
|
||||||
|
range_pct = _f(context, "range_pct_60")
|
||||||
|
close = _f(context, "close")
|
||||||
|
ma120 = _f(context, "ma120")
|
||||||
|
vol_trend = _f(context, "volume_trend")
|
||||||
|
# Range-bound after decline: strictly at/below MA120 (mutually exclusive vs Distribution)
|
||||||
|
if adx < 22 and range_pct < 0.28 and close <= ma120:
|
||||||
|
conf = 60 + (10 if vol_trend > 0 else 0) + (10 if close < ma120 else 0)
|
||||||
|
return RuleHit(
|
||||||
|
rule_id=self.rule_id,
|
||||||
|
cycle=WyckoffCycle.ACCUMULATION.value,
|
||||||
|
confidence=min(90.0, conf),
|
||||||
|
score=min(90.0, conf),
|
||||||
|
reasons=["低趋势强度区间震荡", "疑似吸筹区间"],
|
||||||
|
metrics={"adx": adx, "range_pct_60": range_pct},
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class DistributionCycleRule(WyckoffRule):
|
||||||
|
rule_id = "cycle_distribution"
|
||||||
|
category = "cycle"
|
||||||
|
timeframes = ("1M", "1w")
|
||||||
|
|
||||||
|
def evaluate(self, context: dict[str, Any]) -> RuleHit | None:
|
||||||
|
adx = _f(context, "adx")
|
||||||
|
range_pct = _f(context, "range_pct_60")
|
||||||
|
close = _f(context, "close")
|
||||||
|
ma120 = _f(context, "ma120")
|
||||||
|
vol_trend = _f(context, "volume_trend")
|
||||||
|
# Range-bound near highs: strictly above MA120 (mutually exclusive vs Accumulation)
|
||||||
|
if adx < 22 and range_pct < 0.28 and close > ma120:
|
||||||
|
conf = 60 + (10 if vol_trend < 0 else 0) + (10 if close > ma120 else 0)
|
||||||
|
return RuleHit(
|
||||||
|
rule_id=self.rule_id,
|
||||||
|
cycle=WyckoffCycle.DISTRIBUTION.value,
|
||||||
|
confidence=min(90.0, conf),
|
||||||
|
score=min(90.0, conf),
|
||||||
|
reasons=["高位低趋势震荡", "疑似派发区间"],
|
||||||
|
metrics={"adx": adx, "range_pct_60": range_pct},
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def build_rules() -> list[WyckoffRule]:
|
||||||
|
# Order: trend cycles first (more decisive), then range cycles
|
||||||
|
return [
|
||||||
|
MarkupCycleRule(),
|
||||||
|
MarkdownCycleRule(),
|
||||||
|
AccumulationCycleRule(),
|
||||||
|
DistributionCycleRule(),
|
||||||
|
]
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
"""Event rules: Spring/SOS/LPS/UTAD/SC/AR/ST/..."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from crypto_wyckoff.domain_models import WyckoffCycle, WyckoffEvent, WyckoffPhase
|
||||||
|
from crypto_wyckoff.rules.base import RuleHit, WyckoffRule
|
||||||
|
|
||||||
|
|
||||||
|
def _f(ctx: dict[str, Any], key: str, default: float = 0.0) -> float:
|
||||||
|
v = ctx.get("features", {}).get(key, default)
|
||||||
|
try:
|
||||||
|
return float(v) if v is not None else default
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def _cycle(ctx: dict[str, Any]) -> str:
|
||||||
|
return (ctx.get("cycle") or {}).get("cycle") or ""
|
||||||
|
|
||||||
|
|
||||||
|
def _phase(ctx: dict[str, Any]) -> str:
|
||||||
|
return (ctx.get("phase") or {}).get("phase") or ""
|
||||||
|
|
||||||
|
|
||||||
|
class SpringRule(WyckoffRule):
|
||||||
|
rule_id = "event_spring"
|
||||||
|
category = "event"
|
||||||
|
timeframes = ("1d",)
|
||||||
|
|
||||||
|
def evaluate(self, context: dict[str, Any]) -> RuleHit | None:
|
||||||
|
cycle = _cycle(context)
|
||||||
|
if cycle not in (WyckoffCycle.ACCUMULATION.value, WyckoffCycle.RE_ACCUMULATION.value,
|
||||||
|
WyckoffCycle.MARKUP.value):
|
||||||
|
# Allow spring only in accumulative contexts; Decision will filter MTF
|
||||||
|
if cycle == WyckoffCycle.DISTRIBUTION.value:
|
||||||
|
pass # still detect for facts but lower confidence
|
||||||
|
pierce = _f(context, "pierce_below_range")
|
||||||
|
reclaim = _f(context, "reclaim_speed")
|
||||||
|
vol_ratio = _f(context, "volume_ratio")
|
||||||
|
close_in_range = _f(context, "close_back_in_range")
|
||||||
|
if pierce >= 0.002 and close_in_range >= 0.5 and reclaim >= 0.3:
|
||||||
|
strength = min(98.0, 50 + pierce * 2000 + reclaim * 20 + (15 if vol_ratio < 1.2 else 5))
|
||||||
|
return RuleHit(
|
||||||
|
rule_id=self.rule_id,
|
||||||
|
event=WyckoffEvent.SPRING.value,
|
||||||
|
confidence=strength,
|
||||||
|
score=strength,
|
||||||
|
reasons=[
|
||||||
|
f"跌破区间后收回 (pierce={pierce:.3%})",
|
||||||
|
f"回收速度={reclaim:.2f}",
|
||||||
|
f"量比={vol_ratio:.2f}",
|
||||||
|
],
|
||||||
|
metrics={"pierce": pierce, "reclaim": reclaim, "volume_ratio": vol_ratio},
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class TestRule(WyckoffRule):
|
||||||
|
rule_id = "event_test"
|
||||||
|
category = "event"
|
||||||
|
timeframes = ("1d", "1w")
|
||||||
|
|
||||||
|
def evaluate(self, context: dict[str, Any]) -> RuleHit | None:
|
||||||
|
pos = _f(context, "range_position")
|
||||||
|
vol_ratio = _f(context, "volume_ratio")
|
||||||
|
near_low = pos < 0.2
|
||||||
|
if near_low and vol_ratio < 0.85:
|
||||||
|
return RuleHit(
|
||||||
|
rule_id=self.rule_id,
|
||||||
|
event=WyckoffEvent.TEST.value,
|
||||||
|
confidence=68.0,
|
||||||
|
score=65.0,
|
||||||
|
reasons=["低位缩量回测"],
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class SOSRule(WyckoffRule):
|
||||||
|
rule_id = "event_sos"
|
||||||
|
category = "event"
|
||||||
|
timeframes = ("1d", "1w")
|
||||||
|
|
||||||
|
def evaluate(self, context: dict[str, Any]) -> RuleHit | None:
|
||||||
|
breakout = _f(context, "breakout_above_range")
|
||||||
|
vol_ratio = _f(context, "volume_ratio")
|
||||||
|
close = _f(context, "close")
|
||||||
|
ma20 = _f(context, "ma20")
|
||||||
|
if breakout >= 0.0 and vol_ratio >= 1.2 and close > ma20:
|
||||||
|
conf = min(95.0, 70 + vol_ratio * 8)
|
||||||
|
return RuleHit(
|
||||||
|
rule_id=self.rule_id,
|
||||||
|
event=WyckoffEvent.SOS.value,
|
||||||
|
confidence=conf,
|
||||||
|
score=conf,
|
||||||
|
reasons=["放量突破区间上沿 (SOS)"],
|
||||||
|
metrics={"vol_ratio": vol_ratio},
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class LPSRule(WyckoffRule):
|
||||||
|
rule_id = "event_lps"
|
||||||
|
category = "event"
|
||||||
|
timeframes = ("1d", "1w")
|
||||||
|
|
||||||
|
def evaluate(self, context: dict[str, Any]) -> RuleHit | None:
|
||||||
|
# Pullback hold above broken range / MA20 after prior strength
|
||||||
|
pullback = _f(context, "pullback_hold")
|
||||||
|
vol_ratio = _f(context, "volume_ratio")
|
||||||
|
above_ma = _f(context, "close") > _f(context, "ma20")
|
||||||
|
if pullback >= 0.5 and above_ma and vol_ratio <= 1.1:
|
||||||
|
return RuleHit(
|
||||||
|
rule_id=self.rule_id,
|
||||||
|
event=WyckoffEvent.LPS.value,
|
||||||
|
confidence=74.0,
|
||||||
|
score=76.0,
|
||||||
|
reasons=["突破后缩量回踩支撑 (LPS)"],
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class SCRule(WyckoffRule):
|
||||||
|
rule_id = "event_sc"
|
||||||
|
category = "event"
|
||||||
|
timeframes = ("1w", "1d")
|
||||||
|
|
||||||
|
def evaluate(self, context: dict[str, Any]) -> RuleHit | None:
|
||||||
|
vol_ratio = _f(context, "volume_ratio")
|
||||||
|
bar_range = _f(context, "bar_range_atr")
|
||||||
|
pos = _f(context, "range_position")
|
||||||
|
if vol_ratio >= 1.8 and bar_range >= 1.5 and pos < 0.35:
|
||||||
|
return RuleHit(
|
||||||
|
rule_id=self.rule_id,
|
||||||
|
event=WyckoffEvent.SC.value,
|
||||||
|
confidence=72.0,
|
||||||
|
score=70.0,
|
||||||
|
reasons=["低位放量宽幅,疑似 Selling Climax"],
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class ARRule(WyckoffRule):
|
||||||
|
rule_id = "event_ar"
|
||||||
|
category = "event"
|
||||||
|
timeframes = ("1w", "1d")
|
||||||
|
|
||||||
|
def evaluate(self, context: dict[str, Any]) -> RuleHit | None:
|
||||||
|
# Automatic rally: bounce from lows
|
||||||
|
bounce = _f(context, "bounce_from_low")
|
||||||
|
if bounce >= 0.04:
|
||||||
|
return RuleHit(
|
||||||
|
rule_id=self.rule_id,
|
||||||
|
event=WyckoffEvent.AR.value,
|
||||||
|
confidence=65.0,
|
||||||
|
score=62.0,
|
||||||
|
reasons=["低点后自动反弹 (AR)"],
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class STRule(WyckoffRule):
|
||||||
|
rule_id = "event_st"
|
||||||
|
category = "event"
|
||||||
|
timeframes = ("1w", "1d")
|
||||||
|
|
||||||
|
def evaluate(self, context: dict[str, Any]) -> RuleHit | None:
|
||||||
|
pos = _f(context, "range_position")
|
||||||
|
vol_ratio = _f(context, "volume_ratio")
|
||||||
|
if 0.15 < pos < 0.45 and vol_ratio < 1.0:
|
||||||
|
return RuleHit(
|
||||||
|
rule_id=self.rule_id,
|
||||||
|
event=WyckoffEvent.ST.value,
|
||||||
|
confidence=60.0,
|
||||||
|
score=58.0,
|
||||||
|
reasons=["次级测试 (ST)"],
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class UTADRule(WyckoffRule):
|
||||||
|
rule_id = "event_utad"
|
||||||
|
category = "event"
|
||||||
|
timeframes = ("1w", "1d")
|
||||||
|
|
||||||
|
def evaluate(self, context: dict[str, Any]) -> RuleHit | None:
|
||||||
|
cycle = _cycle(context)
|
||||||
|
pierce_up = _f(context, "pierce_above_range")
|
||||||
|
fail = _f(context, "fail_back_into_range")
|
||||||
|
if cycle in (WyckoffCycle.DISTRIBUTION.value, WyckoffCycle.RE_DISTRIBUTION.value,
|
||||||
|
WyckoffCycle.MARKUP.value):
|
||||||
|
if pierce_up >= 0.002 and fail >= 0.5:
|
||||||
|
return RuleHit(
|
||||||
|
rule_id=self.rule_id,
|
||||||
|
event=WyckoffEvent.UTAD.value,
|
||||||
|
confidence=76.0,
|
||||||
|
score=74.0,
|
||||||
|
reasons=["冲高失败回到区间 (UTAD)"],
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class JumpRule(WyckoffRule):
|
||||||
|
rule_id = "event_jump"
|
||||||
|
category = "event"
|
||||||
|
timeframes = ("1d",)
|
||||||
|
|
||||||
|
def evaluate(self, context: dict[str, Any]) -> RuleHit | None:
|
||||||
|
gap = _f(context, "gap_up_pct")
|
||||||
|
vol_ratio = _f(context, "volume_ratio")
|
||||||
|
if gap >= 0.03 and vol_ratio >= 1.3:
|
||||||
|
return RuleHit(
|
||||||
|
rule_id=self.rule_id,
|
||||||
|
event=WyckoffEvent.JUMP.value,
|
||||||
|
confidence=70.0,
|
||||||
|
score=72.0,
|
||||||
|
reasons=["放量向上跳跃 (Jump)"],
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class BackupRule(WyckoffRule):
|
||||||
|
rule_id = "event_backup"
|
||||||
|
category = "event"
|
||||||
|
timeframes = ("1d",)
|
||||||
|
|
||||||
|
def evaluate(self, context: dict[str, Any]) -> RuleHit | None:
|
||||||
|
pullback = _f(context, "pullback_hold")
|
||||||
|
after_jump = _f(context, "after_strength")
|
||||||
|
if after_jump >= 0.5 and pullback >= 0.5:
|
||||||
|
return RuleHit(
|
||||||
|
rule_id=self.rule_id,
|
||||||
|
event=WyckoffEvent.BACKUP.value,
|
||||||
|
confidence=68.0,
|
||||||
|
score=70.0,
|
||||||
|
reasons=["跳跃后回踩 (Backup)"],
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def build_rules() -> list[WyckoffRule]:
|
||||||
|
return [
|
||||||
|
SpringRule(),
|
||||||
|
UTADRule(),
|
||||||
|
SOSRule(),
|
||||||
|
LPSRule(),
|
||||||
|
SCRule(),
|
||||||
|
JumpRule(),
|
||||||
|
BackupRule(),
|
||||||
|
TestRule(),
|
||||||
|
ARRule(),
|
||||||
|
STRule(),
|
||||||
|
]
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
"""Phase A–E rules (primarily weekly)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from crypto_wyckoff.domain_models import WyckoffCycle, WyckoffPhase
|
||||||
|
from crypto_wyckoff.rules.base import RuleHit, WyckoffRule
|
||||||
|
|
||||||
|
|
||||||
|
def _f(ctx: dict[str, Any], key: str, default: float = 0.0) -> float:
|
||||||
|
v = ctx.get("features", {}).get(key, default)
|
||||||
|
try:
|
||||||
|
return float(v) if v is not None else default
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def _cycle(ctx: dict[str, Any]) -> str:
|
||||||
|
return (ctx.get("cycle") or {}).get("cycle") or WyckoffCycle.UNKNOWN.value
|
||||||
|
|
||||||
|
|
||||||
|
class PhaseARule(WyckoffRule):
|
||||||
|
rule_id = "phase_a"
|
||||||
|
category = "phase"
|
||||||
|
timeframes = ("1w", "1d")
|
||||||
|
|
||||||
|
def evaluate(self, context: dict[str, Any]) -> RuleHit | None:
|
||||||
|
cycle = _cycle(context)
|
||||||
|
if cycle not in (WyckoffCycle.ACCUMULATION.value, WyckoffCycle.DISTRIBUTION.value,
|
||||||
|
WyckoffCycle.RE_ACCUMULATION.value, WyckoffCycle.RE_DISTRIBUTION.value):
|
||||||
|
return None
|
||||||
|
# Stopping action: high vol + large range recently, still range-bound
|
||||||
|
vol_ratio = _f(context, "volume_ratio")
|
||||||
|
range_last = _f(context, "bar_range_atr")
|
||||||
|
if vol_ratio >= 1.4 and range_last >= 1.2:
|
||||||
|
return RuleHit(
|
||||||
|
rule_id=self.rule_id,
|
||||||
|
phase=WyckoffPhase.A.value,
|
||||||
|
confidence=70.0,
|
||||||
|
score=65.0,
|
||||||
|
reasons=["放量宽幅波动,疑似 Phase A 停止行为"],
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class PhaseBRule(WyckoffRule):
|
||||||
|
rule_id = "phase_b"
|
||||||
|
category = "phase"
|
||||||
|
timeframes = ("1w", "1d")
|
||||||
|
|
||||||
|
def evaluate(self, context: dict[str, Any]) -> RuleHit | None:
|
||||||
|
cycle = _cycle(context)
|
||||||
|
if cycle not in (WyckoffCycle.ACCUMULATION.value, WyckoffCycle.DISTRIBUTION.value):
|
||||||
|
return None
|
||||||
|
adx = _f(context, "adx")
|
||||||
|
range_pct = _f(context, "range_pct_60")
|
||||||
|
pos = _f(context, "range_position") # 0=low 1=high of range
|
||||||
|
if adx < 20 and 0.25 < pos < 0.75 and range_pct < 0.30:
|
||||||
|
return RuleHit(
|
||||||
|
rule_id=self.rule_id,
|
||||||
|
phase=WyckoffPhase.B.value,
|
||||||
|
confidence=72.0,
|
||||||
|
score=68.0,
|
||||||
|
reasons=["区间中部震荡,疑似 Phase B 建仓/派发"],
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class PhaseCRule(WyckoffRule):
|
||||||
|
rule_id = "phase_c"
|
||||||
|
category = "phase"
|
||||||
|
timeframes = ("1w", "1d")
|
||||||
|
|
||||||
|
def evaluate(self, context: dict[str, Any]) -> RuleHit | None:
|
||||||
|
cycle = _cycle(context)
|
||||||
|
pos = _f(context, "range_position")
|
||||||
|
spring_like = _f(context, "spring_score_hint")
|
||||||
|
utad_like = _f(context, "utad_score_hint")
|
||||||
|
if cycle in (WyckoffCycle.ACCUMULATION.value, WyckoffCycle.RE_ACCUMULATION.value):
|
||||||
|
if pos < 0.25 or spring_like >= 50:
|
||||||
|
return RuleHit(
|
||||||
|
rule_id=self.rule_id,
|
||||||
|
phase=WyckoffPhase.C.value,
|
||||||
|
confidence=75.0 + min(15.0, spring_like * 0.15),
|
||||||
|
score=78.0,
|
||||||
|
reasons=["区间低位测试,疑似 Phase C (Spring/Test)"],
|
||||||
|
)
|
||||||
|
if cycle in (WyckoffCycle.DISTRIBUTION.value, WyckoffCycle.RE_DISTRIBUTION.value):
|
||||||
|
if pos > 0.75 or utad_like >= 50:
|
||||||
|
return RuleHit(
|
||||||
|
rule_id=self.rule_id,
|
||||||
|
phase=WyckoffPhase.C.value,
|
||||||
|
confidence=75.0,
|
||||||
|
score=78.0,
|
||||||
|
reasons=["区间高位测试,疑似 Phase C (UTAD)"],
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class PhaseDRule(WyckoffRule):
|
||||||
|
rule_id = "phase_d"
|
||||||
|
category = "phase"
|
||||||
|
timeframes = ("1w", "1d")
|
||||||
|
|
||||||
|
def evaluate(self, context: dict[str, Any]) -> RuleHit | None:
|
||||||
|
cycle = _cycle(context)
|
||||||
|
close = _f(context, "close")
|
||||||
|
ma20 = _f(context, "ma20")
|
||||||
|
range_high = _f(context, "range_high")
|
||||||
|
range_low = _f(context, "range_low")
|
||||||
|
vol_ratio = _f(context, "volume_ratio")
|
||||||
|
if cycle in (WyckoffCycle.ACCUMULATION.value, WyckoffCycle.RE_ACCUMULATION.value):
|
||||||
|
if close > ma20 and range_high > 0 and close >= range_high * 0.98 and vol_ratio >= 1.1:
|
||||||
|
return RuleHit(
|
||||||
|
rule_id=self.rule_id,
|
||||||
|
phase=WyckoffPhase.D.value,
|
||||||
|
confidence=80.0,
|
||||||
|
score=82.0,
|
||||||
|
reasons=["突破区间上沿放量,疑似 Phase D SOS"],
|
||||||
|
)
|
||||||
|
if cycle in (WyckoffCycle.DISTRIBUTION.value, WyckoffCycle.RE_DISTRIBUTION.value):
|
||||||
|
if close < ma20 and range_low > 0 and close <= range_low * 1.02:
|
||||||
|
return RuleHit(
|
||||||
|
rule_id=self.rule_id,
|
||||||
|
phase=WyckoffPhase.D.value,
|
||||||
|
confidence=80.0,
|
||||||
|
score=82.0,
|
||||||
|
reasons=["跌破区间下沿,疑似 Phase D SOW"],
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class PhaseERule(WyckoffRule):
|
||||||
|
rule_id = "phase_e"
|
||||||
|
category = "phase"
|
||||||
|
timeframes = ("1w", "1d")
|
||||||
|
|
||||||
|
def evaluate(self, context: dict[str, Any]) -> RuleHit | None:
|
||||||
|
cycle = _cycle(context)
|
||||||
|
# Markup/Markdown already imply trend continuation (Phase E of prior structure)
|
||||||
|
if cycle == WyckoffCycle.MARKUP.value:
|
||||||
|
return RuleHit(
|
||||||
|
rule_id=self.rule_id,
|
||||||
|
phase=WyckoffPhase.E.value,
|
||||||
|
confidence=78.0,
|
||||||
|
score=80.0,
|
||||||
|
reasons=["趋势上行,对应 Phase E Markup"],
|
||||||
|
)
|
||||||
|
if cycle == WyckoffCycle.MARKDOWN.value:
|
||||||
|
return RuleHit(
|
||||||
|
rule_id=self.rule_id,
|
||||||
|
phase=WyckoffPhase.E.value,
|
||||||
|
confidence=78.0,
|
||||||
|
score=80.0,
|
||||||
|
reasons=["趋势下行,对应 Phase E Markdown"],
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def build_rules() -> list[WyckoffRule]:
|
||||||
|
# More specific phases first
|
||||||
|
return [PhaseDRule(), PhaseCRule(), PhaseARule(), PhaseBRule(), PhaseERule()]
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
"""Rule Registry — register Wyckoff rules without modifying engines."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from crypto_wyckoff.rules.base import WyckoffRule
|
||||||
|
|
||||||
|
|
||||||
|
class RuleRegistry:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._rules: dict[str, WyckoffRule] = {}
|
||||||
|
|
||||||
|
def register(self, rule: WyckoffRule) -> None:
|
||||||
|
self._rules[rule.rule_id] = rule
|
||||||
|
|
||||||
|
def get(self, rule_id: str) -> WyckoffRule | None:
|
||||||
|
return self._rules.get(rule_id)
|
||||||
|
|
||||||
|
def by_category(self, category: str, timeframe: str | None = None) -> list[WyckoffRule]:
|
||||||
|
out = [r for r in self._rules.values() if r.category == category]
|
||||||
|
if timeframe:
|
||||||
|
out = [r for r in out if timeframe in r.timeframes]
|
||||||
|
return out
|
||||||
|
|
||||||
|
def all(self) -> list[WyckoffRule]:
|
||||||
|
return list(self._rules.values())
|
||||||
|
|
||||||
|
|
||||||
|
rule_registry = RuleRegistry()
|
||||||
|
|
||||||
|
|
||||||
|
def _register_defaults() -> None:
|
||||||
|
from crypto_wyckoff.rules import cycle_rules, event_rules, phase_rules
|
||||||
|
|
||||||
|
for mod in (cycle_rules, phase_rules, event_rules):
|
||||||
|
for rule in mod.build_rules():
|
||||||
|
rule_registry.register(rule)
|
||||||
|
|
||||||
|
|
||||||
|
_register_defaults()
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
"""Background tip + scan scheduler for crypto wyckoff (all enabled combos)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from crypto_wyckoff.combos import all_tfs_for_combos, list_combos
|
||||||
|
from crypto_wyckoff.io import (
|
||||||
|
backfill_symbol,
|
||||||
|
bar_count,
|
||||||
|
fetch_symbols_from_provider,
|
||||||
|
tip_update_symbol,
|
||||||
|
)
|
||||||
|
from crypto_wyckoff.pipeline import analyze_and_store
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_thread: threading.Thread | None = None
|
||||||
|
_stop = threading.Event()
|
||||||
|
_status: dict = {
|
||||||
|
"running": False,
|
||||||
|
"last_tick_at": None,
|
||||||
|
"last_error": None,
|
||||||
|
"symbols_total": 0,
|
||||||
|
"symbols_scanned": 0,
|
||||||
|
"tick_interval_sec": 60,
|
||||||
|
"backfill_done": False,
|
||||||
|
}
|
||||||
|
_status_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def _set(**kwargs):
|
||||||
|
with _status_lock:
|
||||||
|
_status.update(kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
def get_status() -> dict:
|
||||||
|
with _status_lock:
|
||||||
|
return dict(_status)
|
||||||
|
|
||||||
|
|
||||||
|
def run_tick(max_symbols: int | None = None, force_rescan: bool = False) -> dict:
|
||||||
|
"""One cycle: refresh symbols, tip-update, analyze each combo."""
|
||||||
|
symbols = fetch_symbols_from_provider()
|
||||||
|
if max_symbols:
|
||||||
|
symbols = symbols[:max_symbols]
|
||||||
|
combos = list_combos()
|
||||||
|
tfs = all_tfs_for_combos(combos)
|
||||||
|
_set(symbols_total=len(symbols), running=True, last_error=None)
|
||||||
|
scanned = 0
|
||||||
|
errors = 0
|
||||||
|
changed_n = 0
|
||||||
|
|
||||||
|
for i, sym in enumerate(symbols):
|
||||||
|
try:
|
||||||
|
# Prefer low-TF of first combo for "enough history" gate
|
||||||
|
low0 = combos[0]["low"] if combos else "1d"
|
||||||
|
if bar_count(sym, low0) < 40:
|
||||||
|
backfill_symbol(sym, tfs)
|
||||||
|
tip_changed = tip_update_symbol(sym, tfs)
|
||||||
|
if tip_changed:
|
||||||
|
changed_n += 1
|
||||||
|
if force_rescan or tip_changed:
|
||||||
|
for combo in combos:
|
||||||
|
row = analyze_and_store(sym, combo_id=combo["id"])
|
||||||
|
if row:
|
||||||
|
scanned += 1
|
||||||
|
except Exception as e:
|
||||||
|
errors += 1
|
||||||
|
if errors <= 5:
|
||||||
|
logger.warning("tick %s: %s", sym, e)
|
||||||
|
_set(last_error=str(e))
|
||||||
|
if (i + 1) % 25 == 0:
|
||||||
|
_set(symbols_scanned=scanned)
|
||||||
|
logger.info("wyckoff tick progress %s/%s scanned=%s", i + 1, len(symbols), scanned)
|
||||||
|
|
||||||
|
_set(
|
||||||
|
running=False,
|
||||||
|
symbols_scanned=scanned,
|
||||||
|
last_tick_at=datetime.now(timezone.utc).isoformat(),
|
||||||
|
backfill_done=True,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"symbols": len(symbols),
|
||||||
|
"scanned": scanned,
|
||||||
|
"changed_tips": changed_n,
|
||||||
|
"errors": errors,
|
||||||
|
"combos": [c["id"] for c in combos],
|
||||||
|
"tfs": tfs,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _loop(interval: int, max_symbols: int | None):
|
||||||
|
try:
|
||||||
|
run_tick(max_symbols=max_symbols, force_rescan=True)
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("initial tick failed: %s", e)
|
||||||
|
_set(last_error=str(e), running=False)
|
||||||
|
while not _stop.wait(interval):
|
||||||
|
try:
|
||||||
|
# Tip-driven: only force full rescan when tips change is handled inside
|
||||||
|
run_tick(max_symbols=max_symbols, force_rescan=False)
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("tick failed: %s", e)
|
||||||
|
_set(last_error=str(e), running=False)
|
||||||
|
|
||||||
|
|
||||||
|
def start_scheduler(interval_sec: int = 60, max_symbols: int | None = None) -> None:
|
||||||
|
global _thread
|
||||||
|
if _thread and _thread.is_alive():
|
||||||
|
return
|
||||||
|
_stop.clear()
|
||||||
|
_set(tick_interval_sec=interval_sec)
|
||||||
|
_thread = threading.Thread(
|
||||||
|
target=_loop,
|
||||||
|
args=(interval_sec, max_symbols),
|
||||||
|
name="crypto-wyckoff-scheduler",
|
||||||
|
daemon=True,
|
||||||
|
)
|
||||||
|
_thread.start()
|
||||||
|
logger.info("crypto wyckoff scheduler started interval=%ss", interval_sec)
|
||||||
|
|
||||||
|
|
||||||
|
def stop_scheduler() -> None:
|
||||||
|
_stop.set()
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
"""Signal Engine — timeframe-local status labels only (not tradability)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from crypto_wyckoff.domain_models import EngineResult, WyckoffEvent
|
||||||
|
|
||||||
|
|
||||||
|
class SignalEngine:
|
||||||
|
"""Maps local Event/Phase into a status label. Decision decides tradability."""
|
||||||
|
|
||||||
|
name = "Signal"
|
||||||
|
version = "1.0.0"
|
||||||
|
|
||||||
|
def run(self, event: EngineResult, phase: EngineResult | None = None) -> EngineResult:
|
||||||
|
current = event.payload.get("current_event", WyckoffEvent.NONE.value)
|
||||||
|
conf = event.confidence
|
||||||
|
label = current # status label mirrors event for V1
|
||||||
|
reasons = [f"本地事件标签: {label}"]
|
||||||
|
if phase and phase.payload.get("phase"):
|
||||||
|
reasons.append(f"本地阶段: {phase.payload.get('phase')}")
|
||||||
|
|
||||||
|
return EngineResult(
|
||||||
|
name=self.name,
|
||||||
|
version=self.version,
|
||||||
|
confidence=conf,
|
||||||
|
score=event.score,
|
||||||
|
reasons=reasons,
|
||||||
|
payload={
|
||||||
|
"signal_label": label,
|
||||||
|
"current_event": current,
|
||||||
|
"phase": (phase.payload.get("phase") if phase else None),
|
||||||
|
"active_events": event.payload.get("active_events")
|
||||||
|
or event.payload.get("recent_events", []),
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
"""SQLite persistence for crypto wyckoff scan rows (per combo)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from crypto_wyckoff.domain_models import WyckoffScanRow
|
||||||
|
from crypto_wyckoff.io import SCAN_DB, ensure_dirs
|
||||||
|
|
||||||
|
_COLS = [
|
||||||
|
"trade_date", "combo_id", "ts_code", "name", "industry", "engine_version",
|
||||||
|
"m_cycle", "cycle_confidence", "trend_score",
|
||||||
|
"w_cycle", "w_phase", "w_current_event", "w_recent_events_json",
|
||||||
|
"phase_confidence", "structure_score",
|
||||||
|
"d_current_event", "d_recent_events_json", "event_confidence", "entry_score",
|
||||||
|
"entry", "stop", "target1", "target2", "rr",
|
||||||
|
"alignment", "stars", "decision_signal", "signal_confidence",
|
||||||
|
"overall_confidence", "overall_score", "risk", "reasons_json",
|
||||||
|
"feature_snapshot_json", "markers_json", "scanned_at",
|
||||||
|
]
|
||||||
|
|
||||||
|
_CREATE_SQL = """
|
||||||
|
CREATE TABLE IF NOT EXISTS wyckoff_scan (
|
||||||
|
trade_date TEXT NOT NULL,
|
||||||
|
combo_id TEXT NOT NULL DEFAULT 'd_w_m',
|
||||||
|
ts_code TEXT NOT NULL,
|
||||||
|
name TEXT DEFAULT '',
|
||||||
|
industry TEXT DEFAULT '',
|
||||||
|
engine_version TEXT,
|
||||||
|
m_cycle TEXT, cycle_confidence REAL, trend_score REAL,
|
||||||
|
w_cycle TEXT, w_phase TEXT, w_current_event TEXT, w_recent_events_json TEXT,
|
||||||
|
phase_confidence REAL, structure_score REAL,
|
||||||
|
d_current_event TEXT, d_recent_events_json TEXT, event_confidence REAL, entry_score REAL,
|
||||||
|
entry REAL, stop REAL, target1 REAL, target2 REAL, rr REAL,
|
||||||
|
alignment REAL, stars INTEGER, decision_signal TEXT, signal_confidence REAL,
|
||||||
|
overall_confidence REAL, overall_score REAL, risk TEXT, reasons_json TEXT,
|
||||||
|
feature_snapshot_json TEXT, markers_json TEXT, scanned_at TEXT,
|
||||||
|
PRIMARY KEY (trade_date, combo_id, ts_code)
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _migrate(c: sqlite3.Connection) -> None:
|
||||||
|
cur = c.execute(
|
||||||
|
"SELECT name FROM sqlite_master WHERE type='table' AND name='wyckoff_scan'"
|
||||||
|
)
|
||||||
|
if not cur.fetchone():
|
||||||
|
c.execute(_CREATE_SQL)
|
||||||
|
c.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_cw_score "
|
||||||
|
"ON wyckoff_scan(trade_date, combo_id, overall_score DESC)"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
cols = {r[1] for r in c.execute("PRAGMA table_info(wyckoff_scan)")}
|
||||||
|
if "combo_id" in cols:
|
||||||
|
c.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_cw_score "
|
||||||
|
"ON wyckoff_scan(trade_date, combo_id, overall_score DESC)"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Legacy PK (trade_date, ts_code) → add combo_id via table rebuild
|
||||||
|
c.execute("ALTER TABLE wyckoff_scan RENAME TO wyckoff_scan_old")
|
||||||
|
c.execute(_CREATE_SQL)
|
||||||
|
old_cols = [r[1] for r in c.execute("PRAGMA table_info(wyckoff_scan_old)")]
|
||||||
|
shared = [col for col in _COLS if col != "combo_id" and col in old_cols]
|
||||||
|
col_sql = ",".join(shared)
|
||||||
|
c.execute(
|
||||||
|
f"""
|
||||||
|
INSERT INTO wyckoff_scan (combo_id, {col_sql})
|
||||||
|
SELECT 'd_w_m', {col_sql} FROM wyckoff_scan_old
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
c.execute("DROP TABLE wyckoff_scan_old")
|
||||||
|
c.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_cw_score "
|
||||||
|
"ON wyckoff_scan(trade_date, combo_id, overall_score DESC)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _conn() -> sqlite3.Connection:
|
||||||
|
ensure_dirs()
|
||||||
|
c = sqlite3.connect(str(SCAN_DB), timeout=60)
|
||||||
|
c.row_factory = sqlite3.Row
|
||||||
|
_migrate(c)
|
||||||
|
c.commit()
|
||||||
|
return c
|
||||||
|
|
||||||
|
|
||||||
|
def upsert_row(row: WyckoffScanRow) -> None:
|
||||||
|
combo_id = getattr(row, "combo_id", None) or "d_w_m"
|
||||||
|
vals = (
|
||||||
|
row.trade_date.isoformat() if hasattr(row.trade_date, "isoformat") else str(row.trade_date),
|
||||||
|
combo_id,
|
||||||
|
row.ts_code, row.name, row.industry, row.engine_version,
|
||||||
|
row.m_cycle, row.cycle_confidence, row.trend_score,
|
||||||
|
row.w_cycle, row.w_phase, row.w_current_event, row.w_recent_events_json,
|
||||||
|
row.phase_confidence, row.structure_score,
|
||||||
|
row.d_current_event, row.d_recent_events_json, row.event_confidence, row.entry_score,
|
||||||
|
row.entry, row.stop, row.target1, row.target2, row.rr,
|
||||||
|
row.alignment, row.stars, row.decision_signal, row.signal_confidence,
|
||||||
|
row.overall_confidence, row.overall_score, row.risk, row.reasons_json,
|
||||||
|
row.feature_snapshot_json, row.markers_json,
|
||||||
|
row.scanned_at.isoformat() if isinstance(row.scanned_at, datetime) else str(row.scanned_at),
|
||||||
|
)
|
||||||
|
c = _conn()
|
||||||
|
try:
|
||||||
|
placeholders = ",".join("?" * len(_COLS))
|
||||||
|
col_sql = ",".join(_COLS)
|
||||||
|
updates = ",".join(
|
||||||
|
f"{col}=excluded.{col}"
|
||||||
|
for col in _COLS
|
||||||
|
if col not in ("trade_date", "combo_id", "ts_code")
|
||||||
|
)
|
||||||
|
c.execute(
|
||||||
|
f"""
|
||||||
|
INSERT INTO wyckoff_scan ({col_sql}) VALUES ({placeholders})
|
||||||
|
ON CONFLICT(trade_date, combo_id, ts_code) DO UPDATE SET {updates}
|
||||||
|
""",
|
||||||
|
vals,
|
||||||
|
)
|
||||||
|
c.commit()
|
||||||
|
finally:
|
||||||
|
c.close()
|
||||||
|
|
||||||
|
|
||||||
|
def latest_trade_date(combo_id: str | None = None) -> str | None:
|
||||||
|
c = _conn()
|
||||||
|
try:
|
||||||
|
if combo_id:
|
||||||
|
cur = c.execute(
|
||||||
|
"SELECT MAX(trade_date) FROM wyckoff_scan WHERE combo_id=?",
|
||||||
|
(combo_id,),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
cur = c.execute("SELECT MAX(trade_date) FROM wyckoff_scan")
|
||||||
|
row = cur.fetchone()
|
||||||
|
return row[0] if row and row[0] else None
|
||||||
|
finally:
|
||||||
|
c.close()
|
||||||
|
|
||||||
|
|
||||||
|
def count_for_date(trade_date: str | None = None, combo_id: str | None = None) -> int:
|
||||||
|
td = trade_date or latest_trade_date(combo_id)
|
||||||
|
if not td:
|
||||||
|
return 0
|
||||||
|
c = _conn()
|
||||||
|
try:
|
||||||
|
if combo_id:
|
||||||
|
cur = c.execute(
|
||||||
|
"SELECT COUNT(*) FROM wyckoff_scan WHERE trade_date=? AND combo_id=?",
|
||||||
|
(td, combo_id),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
cur = c.execute("SELECT COUNT(*) FROM wyckoff_scan WHERE trade_date=?", (td,))
|
||||||
|
return int(cur.fetchone()[0])
|
||||||
|
finally:
|
||||||
|
c.close()
|
||||||
|
|
||||||
|
|
||||||
|
def query_scan(
|
||||||
|
*,
|
||||||
|
trade_date: str | None = None,
|
||||||
|
combo_id: str | None = None,
|
||||||
|
m_cycle: str | None = None,
|
||||||
|
w_phase: str | None = None,
|
||||||
|
d_event: str | None = None,
|
||||||
|
decision_signal: str | None = None,
|
||||||
|
min_overall_score: float | None = None,
|
||||||
|
min_alignment: float | None = None,
|
||||||
|
sort: str = "overall_score",
|
||||||
|
limit: int = 100,
|
||||||
|
offset: int = 0,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
cid = combo_id or "d_w_m"
|
||||||
|
td = trade_date or latest_trade_date(cid)
|
||||||
|
if not td:
|
||||||
|
return []
|
||||||
|
sort_col = sort if sort in {
|
||||||
|
"overall_score", "alignment", "entry_score", "trend_score", "structure_score", "stars"
|
||||||
|
} else "overall_score"
|
||||||
|
clauses = ["trade_date=?", "combo_id=?"]
|
||||||
|
args: list[Any] = [td, cid]
|
||||||
|
if m_cycle:
|
||||||
|
clauses.append("m_cycle=?")
|
||||||
|
args.append(m_cycle)
|
||||||
|
if w_phase:
|
||||||
|
clauses.append("w_phase=?")
|
||||||
|
args.append(w_phase)
|
||||||
|
if d_event:
|
||||||
|
clauses.append("d_current_event=?")
|
||||||
|
args.append(d_event)
|
||||||
|
if decision_signal:
|
||||||
|
clauses.append("decision_signal=?")
|
||||||
|
args.append(decision_signal)
|
||||||
|
if min_overall_score is not None:
|
||||||
|
clauses.append("overall_score>=?")
|
||||||
|
args.append(min_overall_score)
|
||||||
|
if min_alignment is not None:
|
||||||
|
clauses.append("alignment>=?")
|
||||||
|
args.append(min_alignment)
|
||||||
|
where = " AND ".join(clauses)
|
||||||
|
args.extend([limit, offset])
|
||||||
|
c = _conn()
|
||||||
|
try:
|
||||||
|
cur = c.execute(
|
||||||
|
f"SELECT * FROM wyckoff_scan WHERE {where} ORDER BY {sort_col} DESC LIMIT ? OFFSET ?",
|
||||||
|
args,
|
||||||
|
)
|
||||||
|
return [dict(r) for r in cur.fetchall()]
|
||||||
|
finally:
|
||||||
|
c.close()
|
||||||
|
|
||||||
|
|
||||||
|
def get_symbol(
|
||||||
|
ts_code: str,
|
||||||
|
trade_date: str | None = None,
|
||||||
|
combo_id: str | None = None,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
cid = combo_id or "d_w_m"
|
||||||
|
td = trade_date or latest_trade_date(cid)
|
||||||
|
if not td:
|
||||||
|
return None
|
||||||
|
c = _conn()
|
||||||
|
try:
|
||||||
|
cur = c.execute(
|
||||||
|
"SELECT * FROM wyckoff_scan WHERE trade_date=? AND combo_id=? AND ts_code=?",
|
||||||
|
(td, cid, ts_code),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
return dict(row) if row else None
|
||||||
|
finally:
|
||||||
|
c.close()
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
"""Crypto symbol → Chinese display name for screener UI."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
# Base asset → 中文名(覆盖 provider 当前币对;未知则回退 base)
|
||||||
|
_BASE_CN: dict[str, str] = {
|
||||||
|
"BTC": "比特币",
|
||||||
|
"ETH": "以太坊",
|
||||||
|
"SOL": "索拉纳",
|
||||||
|
"XAU": "黄金",
|
||||||
|
"XAG": "白银",
|
||||||
|
"SAGA": "Saga",
|
||||||
|
"CL": "原油",
|
||||||
|
"ZEC": "大零币",
|
||||||
|
"XRP": "瑞波币",
|
||||||
|
"DOGE": "狗狗币",
|
||||||
|
"BNB": "币安币",
|
||||||
|
"SUI": "Sui",
|
||||||
|
"BILL": "Bill",
|
||||||
|
"BZ": "BZ",
|
||||||
|
"LAB": "Lab",
|
||||||
|
"TON": "通联币",
|
||||||
|
"CRCL": "Circle",
|
||||||
|
"SNDK": "SNDK",
|
||||||
|
"1000PEPE": "千倍佩佩",
|
||||||
|
"PEPE": "佩佩",
|
||||||
|
"CHIP": "CHIP",
|
||||||
|
"WIF": "狗帽子",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def base_asset(symbol: str) -> str:
|
||||||
|
"""BTC/USDT:USDT → BTC;1000PEPE/USDT:USDT → 1000PEPE."""
|
||||||
|
s = (symbol or "").strip()
|
||||||
|
if not s:
|
||||||
|
return ""
|
||||||
|
head = s.split(":")[0]
|
||||||
|
return head.split("/")[0].upper() if "/" in head else head.upper()
|
||||||
|
|
||||||
|
|
||||||
|
def display_name_cn(symbol: str) -> str:
|
||||||
|
base = base_asset(symbol)
|
||||||
|
if not base:
|
||||||
|
return symbol or ""
|
||||||
|
return _BASE_CN.get(base, base)
|
||||||
|
|
||||||
|
|
||||||
|
def symbol_name_map(symbols: list[str] | None = None) -> dict[str, str]:
|
||||||
|
if not symbols:
|
||||||
|
return {f"{k}/USDT:USDT": v for k, v in _BASE_CN.items()}
|
||||||
|
return {s: display_name_cn(s) for s in symbols}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
"""Wyckoff Screener engine version — bump when rules change."""
|
||||||
|
|
||||||
|
WYCKOFF_ENGINE_VERSION = "v1.0.0"
|
||||||
|
ARCHITECTURE_VERSION = "1.0"
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# AGENT_MEMORY — chan
|
||||||
|
|
||||||
|
> Agent 短记忆。先读 `PROJECT_PROFILE.md`,再读本文件。不要把猜测写进这里。
|
||||||
|
|
||||||
|
## 双前端
|
||||||
|
|
||||||
|
| 入口 | 引擎 | 实时 |
|
||||||
|
|------|------|------|
|
||||||
|
| `/` | Lightweight Charts | HTTP 定时自动刷新(增量 + 每 6 次全量) |
|
||||||
|
| `/chan_tv` | Charting Library 全版 | datafeed `subscribeBars` → WS |
|
||||||
|
|
||||||
|
勿把主站 `live_feed` 方案与 chan_tv datafeed 混为一谈;主站 WS 实时已回退。
|
||||||
|
|
||||||
|
## 版本
|
||||||
|
|
||||||
|
- `system_version`:`v1.0.0`(ECR-001)
|
||||||
|
- `strategy_version`:与 system 解耦;默认不改 `config/` / `strategies/`
|
||||||
|
|
||||||
|
## 近期变更
|
||||||
|
|
||||||
|
- IDEA-002 / `9f1e736`:主站内存泄漏 dispose、首屏单次 analyze、ChanMACD 复用、chan_tv 体验
|
||||||
|
- ECR-002 Reviewed:拆 `web/services/runtime/`、加深 analyze 契约
|
||||||
|
- ECR-003 Reviewed:主站威科夫叠层(`chanlun/analysis/wyckoff/` + `include_wyckoff`)→ `081a57a`
|
||||||
|
- ECR-004 Reviewed:TR 评分硬化 + VP 少系列 + 阶段/门闩/单测(无币种参数)
|
||||||
|
|
||||||
|
## 硬约束提醒
|
||||||
|
|
||||||
|
- `/api/analyze` 字段可增不可删
|
||||||
|
- 无 ADR 不改笔/段/中枢/买卖点语义
|
||||||
|
- 威科夫为独立叠层(ECR-003);勿借机改缠论算法
|
||||||
|
- 交易 L2+ → RISK_REVIEW + EXP;Live 须 Human
|
||||||
|
|
||||||
|
## 已知债务
|
||||||
|
|
||||||
|
- `chart_tv.js` 单体巨大 → 后续可选 ECR
|
||||||
|
- analyze 契约已加深(mock HTTP + wyckoff opt-in);可再加固定 JSON 快照文件
|
||||||
|
- 内存泄漏尚无自动化 heap/监听断言
|
||||||
|
- `macd_config` POST 写本地 global 的历史 quirks(未改)
|
||||||
|
- 威科夫启发式参数未做 UI 调参
|
||||||
@@ -1,5 +1,47 @@
|
|||||||
# CHANGELOG
|
# CHANGELOG
|
||||||
|
|
||||||
|
## Unreleased — 2026-08-06
|
||||||
|
|
||||||
|
### ECR-004(L2,Reviewed)
|
||||||
|
|
||||||
|
- 威科夫 TR 评分选段(防吞前置趋势);阶段非重叠最小跨度
|
||||||
|
- 主站 VP Top-8 + bins≤24;填充线减负
|
||||||
|
- `elements_only` 时不跑威科夫;收紧单测(无币种独立参数)
|
||||||
|
|
||||||
|
### ECR-003(L2,Reviewed)
|
||||||
|
|
||||||
|
- 新增 `chanlun/analysis/wyckoff/`:交易区间、阶段 A–E、Spring/SOS/LPS/UTAD 等事件、区间 VP(POC/VAH/VAL)、量能确认
|
||||||
|
- `/api/analyze` 按需 `include_wyckoff=1` 返回顶层 `wyckoff`
|
||||||
|
- 主站「威科夫」开关与 Lightweight 叠层(区间/阶段/事件/VP)
|
||||||
|
- 单测与 analyze 契约 opt-in 断言
|
||||||
|
|
||||||
|
### ECR-002(L3,Reviewed)
|
||||||
|
|
||||||
|
- 拆分 `web/services/runtime.py` 为包 `web/services/runtime/`(state / timeframes / market_data / indicators / analyze / serialize)
|
||||||
|
- 加深 analyze 契约测试(mock HTTP + analyze_chan 键集 + serialize JSON)
|
||||||
|
- 新增 TF_DF 全量 init 冒烟与 runtime 门面测试
|
||||||
|
|
||||||
|
### IDEA-002(L1 补档)
|
||||||
|
|
||||||
|
对应 commit `9f1e736`。无新 system tag(仍为 `v1.0.0`)。
|
||||||
|
|
||||||
|
#### Fixed
|
||||||
|
|
||||||
|
- 主站自动刷新内存泄漏:`disposeTradingViewCharts`、去掉重复 sync 监听、默认增量刷新(每 6 次全量重建笔/段/中枢)
|
||||||
|
- 加密货币首屏重复调用 `/api/analyze`
|
||||||
|
- ChanMACD 同周期重复全量分析(复用 `get_klc_list` 结果)
|
||||||
|
|
||||||
|
#### Changed
|
||||||
|
|
||||||
|
- `/chan_tv`:WS/REST 可分离配置、指标布局 localStorage、未完成中枢与 datafeed 实时 tick 行为完善
|
||||||
|
- `PROJECT_PROFILE` Realtime 条目与 chan_tv WS 对齐(文档)
|
||||||
|
|
||||||
|
#### Docs
|
||||||
|
|
||||||
|
- ESS:IDEA-002、AGENT_MEMORY、AGENTS;ECR-002 实现与报告
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## v1.0.0 — 2026-08-05(首个正式 Release)
|
## v1.0.0 — 2026-08-05(首个正式 Release)
|
||||||
|
|
||||||
对应 ECR-001 / tag `v1.0.0`。详见 `docs/RELEASE/ECR-001-v1.0.0.md`。
|
对应 ECR-001 / tag `v1.0.0`。详见 `docs/RELEASE/ECR-001-v1.0.0.md`。
|
||||||
|
|||||||
@@ -45,11 +45,11 @@ pytest tests/test_golden_pipeline.py web/tests/test_analyze_contract.py → 6 pa
|
|||||||
|
|
||||||
### Non-blocking(记入债务,需新 ECR 再动)
|
### Non-blocking(记入债务,需新 ECR 再动)
|
||||||
|
|
||||||
1. **`web/services/runtime.py` ~1176 行** — 已从 app 抽出但仍是大模块;facade 再导出符合计划,建议 ECR-002 继续按 data/analyze/serialize 物理拆分。
|
1. **`web/services/runtime.py` ~1176 行** — 已从 app 抽出但仍是大模块;facade 再导出符合计划 → **已起草 `docs/ECR/ECR-002-runtime-split.md`(Draft)**。
|
||||||
2. **`web/static/js/app/chart_tv.js` ~4664 行** — `initTradingView` 单体;行为冻结下可接受。
|
2. **`web/static/js/app/chart_tv.js` ~4664 行** — `initTradingView` 单体;行为冻结下可接受;ECR-002 可选范围。
|
||||||
3. **`/api/analyze` 契约测试偏浅** — 仅关键字段清单 + 路由存在;无固定 fixture 的端到端 JSON 快照(需 mock 行情)。
|
3. **`/api/analyze` 契约测试偏浅** — 仅关键字段清单 + 路由存在;无固定 fixture 的端到端 JSON 快照(需 mock 行情)→ ECR-002。
|
||||||
4. **TEST_REPORT 写「5 passed」** — 现为 6(含 shim 兼容测);Release 前可改正文(L0 docs)。
|
4. **TEST_REPORT 写「5 passed」** — 现为 6(含 shim 兼容测);Release 前可改正文(L0 docs)。
|
||||||
5. **L1:`TF_DF.get_zs_list` 恢复** — 合理兼容修复;golden 走 analyze 路径未覆盖 `TF_DF(df,...)` 全量 `__init__`,建议后续加一条 init 冒烟(非阻断)。
|
5. **L1:`TF_DF.get_zs_list` 恢复** — 合理兼容修复;golden 走 analyze 路径未覆盖 `TF_DF(df,...)` 全量 `__init__` → ECR-002 Acceptance。
|
||||||
|
|
||||||
### No blockers
|
### No blockers
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
# CODE_REVIEW — ECR-002
|
||||||
|
|
||||||
|
**Role:** REVIEWER
|
||||||
|
**Date:** 2026-08-06
|
||||||
|
**Scope:** 工作区未提交实现(相对 `HEAD`/`9f1e736`);包 `web/services/runtime/` + 测试 + ESS 文档
|
||||||
|
**Decision:** Approve
|
||||||
|
|
||||||
|
## Evidence loaded
|
||||||
|
|
||||||
|
- `docs/ECR/ECR-002-runtime-split.md`
|
||||||
|
- `docs/ENGINEERING_SPEC/ECR-002-runtime-split.md`
|
||||||
|
- `docs/IMPLEMENTATION_REPORT/ECR-002.md`
|
||||||
|
- `docs/TEST_REPORT/ECR-002.md`
|
||||||
|
- `docs/HANDOFF/ECR-002-engineer-to-reviewer.md`
|
||||||
|
- 包源码:`web/services/runtime/{__init__,state,timeframes,market_data,indicators,analyze,serialize}.py`
|
||||||
|
- Diff:删除 `web/services/runtime.py`;新增包与测试
|
||||||
|
|
||||||
|
## Acceptance ↔ Evidence
|
||||||
|
|
||||||
|
| Acceptance | Verdict | Evidence |
|
||||||
|
|------------|---------|----------|
|
||||||
|
| runtime 门面公开符号兼容(含历史 `import *` 漏出) | PASS | 手工核对 api 所需符号;`timezone`/`OrderedDict`/`np`/`StructureZone*`/`ThreadPoolExecutor` 等在门面;`test_runtime_facade` |
|
||||||
|
| Golden 通过 | PASS | 复跑 `tests/test_golden_pipeline.py` |
|
||||||
|
| Analyze 契约加深 | PASS | `test_analyze_contract`:键清单 + analyze_chan 键集 + serialize JSON + mock HTTP |
|
||||||
|
| TF_DF 全量 init 冒烟 | PASS | `tests/test_tf_df_init.py`(`interval=1`) |
|
||||||
|
| config/strategies 无交易逻辑 diff | PASS | 工作区无 `config/`/`strategies/` 变更 |
|
||||||
|
| IMPL / TEST / CHANGELOG / TRACEABILITY | PASS | docs 已落盘 |
|
||||||
|
| CODE_REVIEW Approve | PASS | 本文件 |
|
||||||
|
|
||||||
|
## 复跑结果(Reviewer)
|
||||||
|
|
||||||
|
```text
|
||||||
|
PYTHONPATH=.:web python -m pytest \
|
||||||
|
tests/test_golden_pipeline.py \
|
||||||
|
tests/test_tf_df_init.py \
|
||||||
|
web/tests/test_runtime_facade.py \
|
||||||
|
web/tests/test_analyze_contract.py -q
|
||||||
|
→ 13 passed
|
||||||
|
```
|
||||||
|
|
||||||
|
算法冻结抽查:`analyze.py` 仍为 `cal_bi_zs(seg_list)` + `_last_chan_macd` 复用;未改笔段中枢语义。
|
||||||
|
|
||||||
|
## Findings
|
||||||
|
|
||||||
|
### Non-blocking(不挡 Approve)
|
||||||
|
|
||||||
|
1. **门面标量同步只做一次** — `__init__` 在首次 `refresh` 后把 `DATA_SERVICE_AVAILABLE` / `macd_*` 写入模块 dict;之后 `refresh_data_service_metadata` 只改 `state.*`。通过 `R.DATA_SERVICE_AVAILABLE` 读取可能与 state 短期不一致;`from services.runtime import *` 的 bool 拷贝问题在 monolith 时代已存在。建议后续 L1:在 `refresh` 末尾同步写回门面模块,或让标量只经 `state`/`__getattr__` 暴露。
|
||||||
|
2. **`__getattr__` 对已绑定名无效** — 与上条相关;属清理项。
|
||||||
|
3. **`chart_tv.js` 拆分未做** — ECR 明确可选;继续记入 backlog。
|
||||||
|
4. **契约测试仍无「固定 JSON 快照文件」** — 已有 mock HTTP + 键集,比 ECR-001 深;完整响应快照可另开 L1/ECR。
|
||||||
|
5. **`web/tests/test_cn_stock_data_fetch.py` 仍因旧 `user_data.Chan...` 路径无法收集** — 既有问题,非本 ECR 引入。
|
||||||
|
|
||||||
|
### No blockers
|
||||||
|
|
||||||
|
未发现违反「算法语义冻结 / API 可增不可删 / 无 Vite-React / 未动 strategies·config / 未引主站 WS」的证据。
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
**Approve**
|
||||||
|
|
||||||
|
- ECR-002 可标 Done(Reviewed);不强制新 system tag(仍为 `v1.0.0` Unreleased 文档变更)。
|
||||||
|
- 非阻断项进 backlog;不阻塞合并本实现。
|
||||||
|
|
||||||
|
## Next owner
|
||||||
|
|
||||||
|
`engineer` / Human — 提交合并;若要发版再交 `release_manager`(本 ECR 未要求 bump tag)。
|
||||||
|
|
||||||
|
## Traceability
|
||||||
|
|
||||||
|
| Item | Updated |
|
||||||
|
|------|---------|
|
||||||
|
| Acceptance mapping | 本文件 |
|
||||||
|
| STATE.owner | → idle / merge |
|
||||||
|
| ECR Status | → Done (Reviewed) |
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
# CODE_REVIEW — ECR-003
|
||||||
|
|
||||||
|
**Role:** REVIEWER
|
||||||
|
**Date:** 2026-08-06
|
||||||
|
**Scope:** 工作区未提交 ECR-003(相对 `origin/dev` @ `df27b4d`)
|
||||||
|
**Decision:** Approve(带非阻断 Findings;建议合并前勿提交 `.DS_Store`)
|
||||||
|
|
||||||
|
## Evidence loaded
|
||||||
|
|
||||||
|
- `chanlun/analysis/wyckoff/{engine,range,events,volume_profile}.py`
|
||||||
|
- `web/api/analyze.py`(`include_wyckoff`)
|
||||||
|
- `web/templates/index.html`、`chart_view.js`、`macd_ui.js`、`chart_tv.js` 威科夫块
|
||||||
|
- `tests/test_wyckoff.py`、`web/tests/test_analyze_contract.py`
|
||||||
|
- ESS:ECR/PRODUCT/ENG/IMPL/TEST/HANDOFF
|
||||||
|
|
||||||
|
## Acceptance ↔ Evidence
|
||||||
|
|
||||||
|
| Acceptance | Verdict | Evidence |
|
||||||
|
|------------|---------|----------|
|
||||||
|
| `include_wyckoff=1` 返回约定键;默认不强制 | PASS | 契约测试;默认无 `wyckoff` 键 |
|
||||||
|
| 合成 TR + 事件;VP POC | PASS | `test_wyckoff.py`(12 相关套件全绿) |
|
||||||
|
| 主站可开关绘制 | PASS | 主开关按需拉取;子项本地重绘 |
|
||||||
|
| golden 不变 | PASS | `test_golden_pipeline` |
|
||||||
|
| 未改缠论算法 / strategies / chan_tv | PASS | diff 范围核对 |
|
||||||
|
| ESS 闭环 | PASS | IMPL/TEST/TRACE/CHANGELOG/本文件 |
|
||||||
|
|
||||||
|
## 复跑
|
||||||
|
|
||||||
|
```text
|
||||||
|
PYTHONPATH=.:web python -m pytest \
|
||||||
|
tests/test_wyckoff.py tests/test_golden_pipeline.py \
|
||||||
|
web/tests/test_analyze_contract.py -q
|
||||||
|
→ 12 passed
|
||||||
|
```
|
||||||
|
|
||||||
|
## Findings
|
||||||
|
|
||||||
|
### Important(不挡 Approve,建议跟进)
|
||||||
|
|
||||||
|
1. **交易区间易吞并前置趋势**
|
||||||
|
`detect_trading_range` 从最长窗口向下搜,合成夹具下 `abs_start_idx=0`,箱体前下跌段被算进 TR。单测只断言「有区间 + 有事件」,未锁定高低/起点。
|
||||||
|
*建议:* 用「宽度/触边密度」评分取最优段,或要求近端触边;测试断言 `high≈60/low≈40` 与起点靠近箱体。
|
||||||
|
|
||||||
|
2. **VP 叠层系列数偏多,可能加压自动刷新内存**
|
||||||
|
开启 VP 时约每个 bin 一条 `addLineSeries`(默认 ~50),再加区间填充/阶段。与 IDEA-002 内存修复同路径全量重建时放大。
|
||||||
|
*建议:* 只画非零 bin 或合并为少量 series / histogram;或限制 `vp_bins` 上限到 24。
|
||||||
|
|
||||||
|
### Medium
|
||||||
|
|
||||||
|
3. **阶段 C–E 在事件扎堆时常退化重叠**
|
||||||
|
夹具输出中 D/E 起止几乎相同;状态机按事件锚点硬切,缺少最小阶段长度。展示可用,语义偏弱。
|
||||||
|
|
||||||
|
4. **`elements_only=true` 仍可能跑威科夫**
|
||||||
|
威科夫挂在路由末尾,不依赖 `not elements_only`。主站当前不这么发,但契约上奇怪;建议与主周期分析同门闩。
|
||||||
|
|
||||||
|
5. **单测断言偏松**
|
||||||
|
`Spring in types or SOS`、`abs(poc-50)<2` 对回归保护不足。
|
||||||
|
|
||||||
|
### Low
|
||||||
|
|
||||||
|
6. 失败时 `wyckoff.error` 回传异常字符串(与结构区 print 风格一致,信息暴露轻微)。
|
||||||
|
7. 事件 marker 一律 `arrowUp`(跌破类也可 `arrowDown`)。
|
||||||
|
8. 工作区 `.DS_Store` 脏文件——**勿纳入 commit**。
|
||||||
|
|
||||||
|
### No blockers
|
||||||
|
|
||||||
|
未发现:契约删键、缠论语义改动、策略/config 改动、未鉴权危险写操作、主站误引 WS。
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
**Approve**
|
||||||
|
|
||||||
|
可合并提交(排除 `.DS_Store`)。Important #1/#2 可开后续 L1/L2,不阻塞本 ECR 着陆。
|
||||||
|
|
||||||
|
## Next owner
|
||||||
|
|
||||||
|
`engineer` / Human — commit(勿含 `.DS_Store`);可选跟进 TR 评分与 VP 绘图优化。
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# CODE_REVIEW — ECR-004
|
||||||
|
|
||||||
|
**Role:** REVIEWER
|
||||||
|
**Date:** 2026-08-06
|
||||||
|
**Scope:** `d3188ca`(相对 ECR-003)威科夫硬化
|
||||||
|
**Decision:** Approve
|
||||||
|
|
||||||
|
## Evidence loaded
|
||||||
|
|
||||||
|
- Diff `d3188ca`:`range.py` / `events.py` / `analyze.py` / `chart_tv.js` / tests / ESS
|
||||||
|
- 复跑:`tests/test_wyckoff.py` + golden + analyze contract → **14 passed**
|
||||||
|
- 合成夹具抽查:`abs_start_idx=20`,low/high≈40.1/59.9(相对 003 的 bar0 已修好)
|
||||||
|
|
||||||
|
## Acceptance ↔ Evidence
|
||||||
|
|
||||||
|
| Acceptance | Verdict | Evidence |
|
||||||
|
|------------|---------|----------|
|
||||||
|
| TR 不吞明显前置趋势;边界近箱体 | PASS | 评分选段;单测 low/high 带 + `abs_start≥12` + start 时间容差 |
|
||||||
|
| VP series 减负 | PASS | Top-8 + 填充 3 + POC/VAH/VAL;API bins≤24 |
|
||||||
|
| 阶段最小跨度 / 不重合 | PASS | 链式 cursor;unique (start,end) 断言 |
|
||||||
|
| elements_only 门闩 | PASS | `include_wyckoff and not elements_only` + 契约测试 |
|
||||||
|
| golden 不变 / 无策略改动 / 无币种表 | PASS | golden 绿;diff 无 config/strategies |
|
||||||
|
|
||||||
|
## Findings
|
||||||
|
|
||||||
|
### Medium(不挡 Approve)
|
||||||
|
|
||||||
|
1. **同分 tie-break 偏向更长窗口**
|
||||||
|
循环从长到短,`score <= best_score` 时保留已有(更长)。多数情况分数拉开;若实盘出现「长窗与短窗同分」,仍可能略偏长。可选:同分取更短,或加 `1/length` 微项。
|
||||||
|
|
||||||
|
2. **阶段常截断为 A–C**
|
||||||
|
Spring/SOS 落在尾部时 D/E 因 `min_span` 被吃掉——与 ENG「空间不足截断」一致,但 UI 勾选「阶段」时用户可能期望总见 D/E。属产品预期,非缺陷;可在 UI/文档标明「尾部不足则省略」。
|
||||||
|
|
||||||
|
### Low
|
||||||
|
|
||||||
|
3. **`abs_start_idx >= 12` 弱于「箱体起点」** —— 主测已用时间容差;该断言可再收紧到 `>= 16` 一类。
|
||||||
|
4. **VP Top-N 无自动化 series 计数** —— 靠代码审查 + ENG 约定。
|
||||||
|
5. 事件 marker 仍一律 `arrowUp`(003 遗留)。
|
||||||
|
6. 失败路径仍回传 `wyckoff.error` 字符串。
|
||||||
|
|
||||||
|
### No blockers
|
||||||
|
|
||||||
|
未发现契约删键、缠论语义改动、策略改动、或回归红灯。
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
**Approve**
|
||||||
|
|
||||||
|
ECR-004 可维持 Done (Reviewed)。Medium 项进 backlog,不必立刻新 ECR,除非实盘 TR 仍偏长。
|
||||||
|
|
||||||
|
## Next owner
|
||||||
|
|
||||||
|
Human — 主站 BTC 勾选威科夫目测;无发版要求则保持 `v1.0.0` Unreleased 累计。
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
# ECR-002
|
||||||
|
|
||||||
|
**Title:** 拆分 `web/services/runtime.py` + 加深 `/api/analyze` 契约测试
|
||||||
|
**Status:** Done (Reviewed)
|
||||||
|
**Date:** 2026-08-06
|
||||||
|
**Change Level:** L3(行为冻结;若 golden 漂移则升 L2)
|
||||||
|
|
||||||
|
## Change
|
||||||
|
|
||||||
|
将仍偏大的 `web/services/runtime.py` 按职责拆为可维护子模块;加深 analyze API 契约/快照测试;可选拆分主站巨型 `chart_tv.js`(本轮未做)。
|
||||||
|
|
||||||
|
## Motivation
|
||||||
|
|
||||||
|
ECR-001 CODE_REVIEW 非阻断债务:runtime 过大、契约测试偏浅、chart_tv 单体。不处理会继续抬高 Web 改动风险。
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
### Allowed
|
||||||
|
|
||||||
|
- 物理拆分 `web/services/runtime.py` → 包 `web/services/runtime/`(state / timeframes / market_data / indicators / analyze / serialize + 门面)
|
||||||
|
- 加深 `web/tests/`:固定 fixture / mock 行情下的关键字段快照与契约
|
||||||
|
- 补 `TF_DF(..., interval=1)` 全量 `__init__` 冒烟
|
||||||
|
- 更新 TECH_STACK / TRACEABILITY / CHANGELOG
|
||||||
|
|
||||||
|
### Forbidden
|
||||||
|
|
||||||
|
- 修改笔 / 线段 / 中枢 / 买卖点算法语义
|
||||||
|
- 破坏 `/api/analyze` JSON 字段(可增不可删)
|
||||||
|
- 修改 `config/`、`strategies/` 交易逻辑或参数
|
||||||
|
- 引入 Vite/React/TS 构建
|
||||||
|
- 为主站重新引入 WebSocket 实时(须另 ECR)
|
||||||
|
- 无 Approve 即大规模改前端视觉
|
||||||
|
|
||||||
|
## Risk
|
||||||
|
|
||||||
|
| Risk | Mitigation |
|
||||||
|
|------|------------|
|
||||||
|
| 拆文件隐式改行为 | 仅搬移;golden + analyze 契约/快照 |
|
||||||
|
| 门面漏导出 | 保留 `runtime` re-export + 历史 import * 兼容符号 |
|
||||||
|
| 测试依赖真实行情 | mock / fixture;不绑生产 WS |
|
||||||
|
| chart_tv 拆分漏事件 | 本轮不做 |
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [x] `runtime` 门面公开符号与拆分前兼容(含 `timezone`/`OrderedDict`/`np`/StructureZone 等历史漏出)
|
||||||
|
- [x] Golden:`pytest tests/test_golden_pipeline.py` 通过
|
||||||
|
- [x] Analyze 契约/快照测试通过且覆盖关键字段清单以上
|
||||||
|
- [x] TF_DF 全量 init 冒烟通过
|
||||||
|
- [x] `config/` / `strategies/` 无交易逻辑 diff
|
||||||
|
- [x] IMPLEMENTATION_REPORT / TEST_REPORT / CHANGELOG / TRACEABILITY 更新
|
||||||
|
- [x] CODE_REVIEW Approve
|
||||||
|
|
||||||
|
## Rollback
|
||||||
|
|
||||||
|
`git revert` 本 ECR 提交;门面保留期可整包回滚。
|
||||||
|
|
||||||
|
## Risk Review
|
||||||
|
|
||||||
|
- Path: `docs/RISK_REVIEW/ECR-002.md` — N/A(不改交易决策语义)
|
||||||
|
|
||||||
|
## Linked
|
||||||
|
|
||||||
|
- IDEA: `docs/IDEA/IDEA-003-runtime-split.md`
|
||||||
|
- PRODUCT_SPEC: `docs/PRODUCT_SPEC/ECR-002-runtime-split.md`
|
||||||
|
- ENGINEERING_SPEC: `docs/ENGINEERING_SPEC/ECR-002-runtime-split.md`
|
||||||
|
- ADR: 引用 ADR-001(包内再拆,无新顶层布局 ADR)
|
||||||
|
- EXPERIMENT: N/A
|
||||||
|
- TRACEABILITY: Yes
|
||||||
|
- IMPLEMENTATION_REPORT: `docs/IMPLEMENTATION_REPORT/ECR-002.md`
|
||||||
|
- TEST_REPORT: `docs/TEST_REPORT/ECR-002.md`
|
||||||
|
|
||||||
|
## Origin
|
||||||
|
|
||||||
|
- `docs/CODE_REVIEW/ECR-001.md` Findings 1–3、5
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
# ECR-003
|
||||||
|
|
||||||
|
**Title:** 主站威科夫分析与图表展示
|
||||||
|
**Status:** Done (Reviewed)
|
||||||
|
**Date:** 2026-08-06
|
||||||
|
**Change Level:** L2
|
||||||
|
|
||||||
|
## Change
|
||||||
|
|
||||||
|
在主站 `/` 增加威科夫交易区间、阶段(A–E)、关键事件(Spring/SOS/LPS/UTAD 等)、区间内简易 VP(POC/VAH/VAL)与量能确认;按需接入 `/api/analyze`。
|
||||||
|
|
||||||
|
## Motivation
|
||||||
|
|
||||||
|
用户需要在缠论图上叠加威科夫结构解读;与现有结构区语义分离。
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
### Allowed
|
||||||
|
|
||||||
|
- 新建 `chanlun/analysis/wyckoff/`
|
||||||
|
- `/api/analyze` 增加可选 `include_wyckoff` 与响应字段 `wyckoff`(可增不可删既有字段)
|
||||||
|
- 主站 UI 开关与 Lightweight 绘图
|
||||||
|
- 单测 + ESS 文档
|
||||||
|
|
||||||
|
### Forbidden
|
||||||
|
|
||||||
|
- 修改笔/段/中枢/买卖点算法语义
|
||||||
|
- 改 `config/` / `strategies/`
|
||||||
|
- `/chan_tv` Study
|
||||||
|
- Vite/React、主站 WebSocket 实时(另 ECR)
|
||||||
|
|
||||||
|
## Risk
|
||||||
|
|
||||||
|
| Risk | Mitigation |
|
||||||
|
|------|------------|
|
||||||
|
| 启发式误标 | 规格写明启发式;UI 可关;单测合成形态 |
|
||||||
|
| 负载 | 默认关闭,勾选才计算 |
|
||||||
|
| 与结构区混淆 | 独立开关与字段名 |
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [x] `include_wyckoff=1` 返回约定 `wyckoff` 键;默认不强制计算
|
||||||
|
- [x] 合成 fixture:能检出 TR + 至少一类事件;VP POC 可测
|
||||||
|
- [x] 主站可开关绘制区间/阶段/事件/VP
|
||||||
|
- [x] golden 缠论基线不变
|
||||||
|
- [x] TEST/IMPL/CHANGELOG/TRACEABILITY + CODE_REVIEW
|
||||||
|
|
||||||
|
## Rollback
|
||||||
|
|
||||||
|
`git revert`;关闭 UI 开关即可无图面影响。
|
||||||
|
|
||||||
|
## Risk Review
|
||||||
|
|
||||||
|
- `docs/RISK_REVIEW/ECR-003.md` — N/A(展示分析,非 Live 策略)
|
||||||
|
|
||||||
|
## Linked
|
||||||
|
|
||||||
|
- IDEA: `docs/IDEA/IDEA-004-wyckoff-main.md`
|
||||||
|
- PRODUCT_SPEC / ENGINEERING_SPEC: 同目录 ECR-003-*
|
||||||
|
- EXPERIMENT: N/A
|
||||||
|
- TRACEABILITY: Yes
|
||||||
|
- CODE_REVIEW: `docs/CODE_REVIEW/ECR-003.md` — Approve
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# ECR-004
|
||||||
|
|
||||||
|
**Title:** 威科夫区间评分硬化与主站 VP 绘图减负
|
||||||
|
**Status:** Done (Reviewed)
|
||||||
|
**Date:** 2026-08-06
|
||||||
|
**Change Level:** L2
|
||||||
|
|
||||||
|
## Change
|
||||||
|
|
||||||
|
跟进 ECR-003 CODE_REVIEW Findings:改进交易区间选取启发式、阶段最小长度、收紧单测;主站 VP/叠层降低 Lightweight series 数量;`include_wyckoff` 与主周期分析同门闩。
|
||||||
|
|
||||||
|
## Motivation
|
||||||
|
|
||||||
|
003 已 Approve 合入;质量与内存项不得回塞已审变更,须独立可审闭环。
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
### Allowed
|
||||||
|
|
||||||
|
- `chanlun/analysis/wyckoff/range.py` / `events.py`(阶段)启发式与单测
|
||||||
|
- `web/static/js/app/chart_tv.js` 威科夫 VP/填充绘制路径
|
||||||
|
- `web/api/analyze.py`:`elements_only` 时不跑威科夫;默认 `vp_bins` 上限 24
|
||||||
|
- ESS 文档与契约测试补充断言(不删既有 `wyckoff` 键)
|
||||||
|
|
||||||
|
### Forbidden
|
||||||
|
|
||||||
|
- 改笔/段/中枢/买卖点语义
|
||||||
|
- `config/` / `strategies/`
|
||||||
|
- `/chan_tv`
|
||||||
|
- 新数据源 / 订单流
|
||||||
|
- **按币种独立参数表**(全局 ATR 相对即可;当前以 BTC 场景验证)
|
||||||
|
|
||||||
|
## Decisions(Approve 时锁定)
|
||||||
|
|
||||||
|
- VP:**A+C**(前端 Top-N 有量 bin + 服务端 bins 上限 24)
|
||||||
|
- 不做 per-symbol 参数
|
||||||
|
|
||||||
|
## Risk
|
||||||
|
|
||||||
|
| Risk | Mitigation |
|
||||||
|
|------|------------|
|
||||||
|
| TR 结果相对 003 漂移 | 合成夹具锁定高低与起点;文档标明启发式迭代 |
|
||||||
|
| 前端 VP 观感变化 | 保留 POC/VAH/VAL;密度用 Top-N |
|
||||||
|
| 回归 | 扩展 `tests/test_wyckoff.py` + 既有契约套件 |
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [x] 合成箱体夹具:`trading_range` 高低接近箱体边界,起点不落入明显前置趋势段
|
||||||
|
- [x] 开启 VP 时主图新增 series 数显著低于「每 bin 一条」(目标:填充+VP ≤ ~15 或等价合并策略)
|
||||||
|
- [x] 阶段输出满足最小跨度或合并退化段;文档说明规则
|
||||||
|
- [x] `elements_only=true` 即使 `include_wyckoff=1` 也不返回 `wyckoff`
|
||||||
|
- [x] golden 缠论基线不变;相关 pytest 绿
|
||||||
|
- [x] TEST/IMPL/CHANGELOG/TRACEABILITY + CODE_REVIEW
|
||||||
|
|
||||||
|
## Rollback
|
||||||
|
|
||||||
|
`git revert`;UI 关威科夫即可无图面影响。
|
||||||
|
|
||||||
|
## Risk Review
|
||||||
|
|
||||||
|
- `docs/RISK_REVIEW/ECR-004.md` — N/A(展示/启发式,非 Live 策略)
|
||||||
|
|
||||||
|
## Linked
|
||||||
|
|
||||||
|
- IDEA: `docs/IDEA/IDEA-005-wyckoff-harden.md`
|
||||||
|
- 上游: `docs/CODE_REVIEW/ECR-003.md` Findings 1–5
|
||||||
|
- PRODUCT_SPEC / ENGINEERING_SPEC: 同目录 ECR-004-*
|
||||||
|
- TRACEABILITY: Yes
|
||||||
|
- CODE_REVIEW: `docs/CODE_REVIEW/ECR-004.md` — Approve
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# ENGINEERING_SPEC — ECR-002
|
||||||
|
|
||||||
|
**Status:** Implemented
|
||||||
|
**Date:** 2026-08-06
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
1. **包目录** `web/services/runtime/`(不用平铺 `runtime_*.py`)
|
||||||
|
2. **边界**
|
||||||
|
- `state`:可变全局与客户端
|
||||||
|
- `timeframes`:周期工具
|
||||||
|
- `market_data`:行情
|
||||||
|
- `indicators`:技术指标列
|
||||||
|
- `analyze`:缠论编排 + 趋势分类
|
||||||
|
- `serialize`:JSON 整形
|
||||||
|
- `__init__`:门面 + 历史 `import *` 兼容再导出
|
||||||
|
3. **测试**:facade / analyze_chan 键 / serialize / HTTP mock 契约 / TF_DF init / golden
|
||||||
|
4. **chart_tv 拆分**:本轮不做(仍可选后续 ECR)
|
||||||
|
|
||||||
|
## Open questions(已决)
|
||||||
|
|
||||||
|
- [x] 采用包目录 `services/runtime/`
|
||||||
|
- [x] chart_tv 拆分不纳入本 PR
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# ENGINEERING_SPEC — ECR-003
|
||||||
|
|
||||||
|
**Status:** Approved
|
||||||
|
**Date:** 2026-08-06
|
||||||
|
|
||||||
|
## Package
|
||||||
|
|
||||||
|
`chanlun/analysis/wyckoff/`:
|
||||||
|
|
||||||
|
- `engine.py` — `analyze_wyckoff(df) -> dict`
|
||||||
|
- `range.py` — 交易区间检测(ATR 容差震荡箱)
|
||||||
|
- `phases.py` — A–E 状态机
|
||||||
|
- `events.py` — Spring/SOS/LPS/UTAD(及 distribution 对称)
|
||||||
|
- `volume_profile.py` — 区间内分桶 VP
|
||||||
|
- `__init__.py` — 导出 `analyze_wyckoff`
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
`GET /api/analyze?include_wyckoff=1` → `result["wyckoff"]`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"trading_range": {"start_time","end_time","high","low","mid","active"},
|
||||||
|
"bias": "accumulation|distribution|unknown",
|
||||||
|
"phases": [{"phase","label","start_time","end_time"}],
|
||||||
|
"events": [{"type","time","price","note","volume_ratio","volume_ok"}],
|
||||||
|
"volume_profile": {"bins":[{"price","volume"}],"poc","vah","val","bin_count"},
|
||||||
|
"volume_confirm": {"avg_volume","event_checks":{}}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
默认 `include_wyckoff` 假:可不返回或返回 `null`(实现选:不返回键以减负)。
|
||||||
|
|
||||||
|
## Detection heuristics
|
||||||
|
|
||||||
|
1. ATR(14) 容差;扫描最近窗口找高低点接近的连续段作为 TR。
|
||||||
|
2. 阶段:价格在 TR 内相对位置 + 假破/真破时间序。
|
||||||
|
3. Spring:下破 TR.low 后收回且收盘回到区间内;量能相对均量判断。
|
||||||
|
4. SOS:收盘站上 TR.high 且放量。
|
||||||
|
5. LPS:SOS 后回踩不破 mid/high 带且缩量。
|
||||||
|
6. UTAD:上破后跌回区间内(派发)。
|
||||||
|
7. VP:typical=(H+L+C)/3,volume 加权分桶,VA≈70% 围绕 POC。
|
||||||
|
|
||||||
|
## Frontend
|
||||||
|
|
||||||
|
主站 checkbox + `chart_view` 传参;`chart_tv.js` 绘制。
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# ENGINEERING_SPEC — ECR-004
|
||||||
|
|
||||||
|
**Status:** Approved(实现锁定:评分选段;VP=A+C;无币种参数)
|
||||||
|
**Date:** 2026-08-06
|
||||||
|
|
||||||
|
## Range scoring
|
||||||
|
|
||||||
|
替换「仅取最长合格窗口」:
|
||||||
|
|
||||||
|
1. 仍在 `lookback` + `tail_reserve` 框架内扫描候选段(步长 -4)。
|
||||||
|
2. 硬门槛不变:near_hi/lo≥2、inside≥0.75、宽度上限等。
|
||||||
|
3. 分数:`touch_density*50 + inside*30 - (width/ATR)*3 + min(length/40, 2)`,取最高。
|
||||||
|
4. 单测:`low∈[38,42]`、`high∈[58,62]`,起点不早于箱体(容差 8 根);`abs_start_idx >= 12`。
|
||||||
|
|
||||||
|
## Phases
|
||||||
|
|
||||||
|
- 非重叠链式切分;每段至少 `min_bars=3`。
|
||||||
|
- 尾部空间不足则延长上一段并停止新增(避免 D/E 完全重合双画)。
|
||||||
|
|
||||||
|
## API gate
|
||||||
|
|
||||||
|
```text
|
||||||
|
if include_wyckoff and not elements_only:
|
||||||
|
result["wyckoff"] = analyze_wyckoff(..., vp_bins∈[10,24])
|
||||||
|
```
|
||||||
|
|
||||||
|
默认 `wyckoff_vp_bins=24`,上限 24。
|
||||||
|
|
||||||
|
## Frontend VP(A+C)
|
||||||
|
|
||||||
|
- 填充线 6→3
|
||||||
|
- 有量 bin 按 volume Top-8 绘制 + POC/VAH/VAL
|
||||||
|
- 目标:区间填充+边框+VP ≈ ≤15 series 量级
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
- `tests/test_wyckoff.py` 收紧
|
||||||
|
- `elements_only=true&include_wyckoff=1` 无 `wyckoff`
|
||||||
|
- 不改 golden 缠论 JSON
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- 按币种独立参数(全局 ATR 相对;以 BTC 场景验证)
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# HANDOFF — ECR-002 engineer → reviewer
|
||||||
|
|
||||||
|
**From:** ENGINEER
|
||||||
|
**To:** REVIEWER
|
||||||
|
**Date:** 2026-08-06
|
||||||
|
**ECR:** ECR-002
|
||||||
|
|
||||||
|
## Ask
|
||||||
|
|
||||||
|
对照 ECR-002 Acceptance 做代码审阅;确认 strategies/config 无 diff;golden + 新契约测试通过。
|
||||||
|
|
||||||
|
## Artifacts
|
||||||
|
|
||||||
|
- `docs/ECR/ECR-002-runtime-split.md`
|
||||||
|
- `docs/IMPLEMENTATION_REPORT/ECR-002.md`
|
||||||
|
- `docs/TEST_REPORT/ECR-002.md`
|
||||||
|
- `docs/ENGINEERING_SPEC/ECR-002-runtime-split.md`
|
||||||
|
|
||||||
|
## Diff focus
|
||||||
|
|
||||||
|
- `web/services/runtime/`(新包)
|
||||||
|
- 删除原 `web/services/runtime.py`
|
||||||
|
- `web/tests/test_*.py`、`tests/test_tf_df_init.py`
|
||||||
|
- ESS docs 更新
|
||||||
|
|
||||||
|
## Out of scope this round
|
||||||
|
|
||||||
|
- `chart_tv.js` 拆分
|
||||||
|
- 主站 WebSocket
|
||||||
|
- strategies/config
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# HANDOFF — ECR-003 engineer → reviewer
|
||||||
|
|
||||||
|
**Date:** 2026-08-06
|
||||||
|
**From:** engineer
|
||||||
|
**To:** reviewer
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
主站威科夫 L2:独立分析包 + 按需 API + Lightweight 叠层。
|
||||||
|
|
||||||
|
## Artifacts
|
||||||
|
|
||||||
|
- IMPL: `docs/IMPLEMENTATION_REPORT/ECR-003.md`
|
||||||
|
- TEST: `docs/TEST_REPORT/ECR-003.md`
|
||||||
|
- SPEC: PRODUCT / ENG `docs/*/ECR-003-wyckoff-main.md`
|
||||||
|
- RISK: N/A(展示分析)
|
||||||
|
|
||||||
|
## How to verify
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PYTHONPATH=.:web python -m pytest \
|
||||||
|
tests/test_wyckoff.py \
|
||||||
|
tests/test_golden_pipeline.py \
|
||||||
|
web/tests/test_analyze_contract.py -q
|
||||||
|
```
|
||||||
|
|
||||||
|
主站勾选「威科夫」→ 区间/阶段/事件/VP 可见。
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# HANDOFF — ECR-004 engineer → reviewer
|
||||||
|
|
||||||
|
**Date:** 2026-08-06
|
||||||
|
|
||||||
|
已实现并自测 14 passed。请对照 `docs/CODE_REVIEW/ECR-004.md`。
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# Idea: 主站自动刷新内存泄漏 + chan_tv 体验修补
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
主站(Lightweight Charts)勾选自动刷新后,浏览器内存持续上涨;首屏偶发重复打 `/api/analyze`。全版 TradingView(`/chan_tv`)指标/布局/未完成中枢体验不完整。
|
||||||
|
|
||||||
|
## Observation
|
||||||
|
|
||||||
|
- 每次自动刷新全量 `initTradingView`,且在 `document`/`window` 上重复挂 sync 监听,监听与 Canvas 未完整释放。
|
||||||
|
- `ui.js` 加密货币首屏对 `updateChart()` 调度了两次。
|
||||||
|
- `get_klc_list` 与 `TF_DF` / `analyze_chan` 可能重复跑 ChanMACD。
|
||||||
|
- `chan_tv` 需 WS 与 REST 可分离、指标本地恢复、未完成中枢绘制修正。
|
||||||
|
|
||||||
|
## Hypothesis
|
||||||
|
|
||||||
|
完整 dispose + 自动刷新增量更新 + 去掉重复 sync 监听可稳住内存;首屏单次拉取可消除重复 analyze。chan_tv 问题为前端/datafeed 修补,不改缠论算法语义。
|
||||||
|
|
||||||
|
## Expected Impact
|
||||||
|
|
||||||
|
自动刷新可长期开启;首屏请求减半;chan_tv 更接近可用交易终端体验。
|
||||||
|
|
||||||
|
## Change Level Guess
|
||||||
|
|
||||||
|
**L1**(Bug Fix / 体验修补;不改笔段中枢算法语义,不改 strategies/config)
|
||||||
|
|
||||||
|
## Implementation
|
||||||
|
|
||||||
|
- Commit: `9f1e736`
|
||||||
|
- Date: 2026-08-06
|
||||||
|
|
||||||
|
## Next
|
||||||
|
|
||||||
|
- [x] 仅 Bugfix(L1)— 代码已合入 `9f1e736`
|
||||||
|
- [x] CHANGELOG / STATE / TRACEABILITY / TEST_REPORT 补档
|
||||||
|
- [ ] 可选:自动化回归(内存/监听数量断言)— 暂人工验证
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# Idea: 继续拆分 Web runtime 与加深契约测试
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
ECR-001 Review 非阻断债务:`web/services/runtime.py` 仍过大;`/api/analyze` 契约测试偏浅;`chart_tv.js` 单体巨大。
|
||||||
|
|
||||||
|
## Observation
|
||||||
|
|
||||||
|
CODE_REVIEW ECR-001 Findings 1–3、5 明确记入 backlog,要求新 ECR 再动。
|
||||||
|
|
||||||
|
## Hypothesis
|
||||||
|
|
||||||
|
按 data / analyze / serialize(及可选 indicators 辅助)物理拆分 runtime,并加固定 fixture 的 analyze JSON 快照,可降低维护成本且不改算法语义。
|
||||||
|
|
||||||
|
## Expected Impact
|
||||||
|
|
||||||
|
可测性与可审阅性提升;为后续 Web 功能迭代减负。
|
||||||
|
|
||||||
|
## Change Level Guess
|
||||||
|
|
||||||
|
**L3**(结构重构;行为冻结)— 若触及识别结果则升 L2 + RISK/EXP。
|
||||||
|
|
||||||
|
## Next
|
||||||
|
|
||||||
|
- [x] ECR-002 Draft
|
||||||
|
- [ ] Human Approve 后再实现
|
||||||
|
- [ ] ENGINEERING_SPEC / ADR(若布局再变)
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# Idea: 主站威科夫分析与图表展示
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
主站仅有缠论叠层与结构价值区,缺少威科夫交易区间、阶段与关键事件的可解释展示。
|
||||||
|
|
||||||
|
## Observation
|
||||||
|
|
||||||
|
仓库无 Wyckoff 模块;`ChanZone` 是中枢+EMA 聚类,语义不同。主站 Lightweight 已有按需 `include_structure_zones` 模式可复用。
|
||||||
|
|
||||||
|
## Hypothesis
|
||||||
|
|
||||||
|
独立 `chanlun/analysis/wyckoff` + `/api/analyze?include_wyckoff=1` + 主站开关绘图,可在不碰缠论算法的前提下交付区间/阶段/事件/VP。
|
||||||
|
|
||||||
|
## Expected Impact
|
||||||
|
|
||||||
|
主站可叠加威科夫结构,辅助研判;与结构区开关并存。
|
||||||
|
|
||||||
|
## Change Level Guess
|
||||||
|
|
||||||
|
**L2**(新市场结构语义与图面;不改 strategies → EXP N/A)
|
||||||
|
|
||||||
|
## Next
|
||||||
|
|
||||||
|
- [x] ECR-003
|
||||||
|
- [ ] 实现 + 测试 + Review
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# Idea: 威科夫区间评分与主站 VP 绘图优化
|
||||||
|
|
||||||
|
**Date:** 2026-08-06
|
||||||
|
**Status:** Accepted → ECR-004
|
||||||
|
**Source:** `docs/CODE_REVIEW/ECR-003.md` Findings Important #1/#2 + Medium #3–#5
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
ECR-003 已上线主站威科夫叠层,但:
|
||||||
|
|
||||||
|
1. 交易区间检测优先「最长窗口」,易吞并箱体前趋势,起点偏早。
|
||||||
|
2. VP 默认按 bin 逐条 `addLineSeries`,自动刷新全量重建时系列过多,有内存压力。
|
||||||
|
3. 阶段 C–E 在事件扎堆时易重叠退化;单测断言偏松;`elements_only` 仍可能跑威科夫。
|
||||||
|
|
||||||
|
## Why now
|
||||||
|
|
||||||
|
CODE_REVIEW Approve 非阻断项;关门后应单独 ECR 跟进,避免塞回已审 003。
|
||||||
|
|
||||||
|
## Proposed direction
|
||||||
|
|
||||||
|
- TR:触边密度/宽度评分选最优段,收紧合成夹具断言
|
||||||
|
- VP:少系列绘制(非零 bin 合并或降 bins 上限)
|
||||||
|
- 阶段最小长度;analyze 门闩与主周期一致;收紧单测
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- 缠论算法、`strategies/`/`config/`、`/chan_tv` Study、Live 信号
|
||||||
|
|
||||||
|
## Linked
|
||||||
|
|
||||||
|
- [x] ECR-004
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# IMPLEMENTATION_REPORT — ECR-002
|
||||||
|
|
||||||
|
**Date:** 2026-08-06
|
||||||
|
**Status:** Implemented(待 CODE_REVIEW)
|
||||||
|
**Change Level:** L3(行为冻结)
|
||||||
|
|
||||||
|
## What changed
|
||||||
|
|
||||||
|
将 `web/services/runtime.py`(~1178 行)拆为包 `web/services/runtime/`:
|
||||||
|
|
||||||
|
| Module | Responsibility |
|
||||||
|
|--------|----------------|
|
||||||
|
| `state.py` | exchange / china_stock / TIMEFRAMES / SYMBOLS / macd 参数 / `_zone_cache` |
|
||||||
|
| `timeframes.py` | 周期换算、默认值、大小比较、zone TTL |
|
||||||
|
| `market_data.py` | K 线拉取(datasvc / ccxt / A 股)、元信息刷新 |
|
||||||
|
| `indicators.py` | `add_indicators` / `calculate_macd` |
|
||||||
|
| `analyze.py` | `analyze_chan` / `classify_trend_stage` |
|
||||||
|
| `serialize.py` | ChanMACD 序列化、JSON 清洗、未完成线段 |
|
||||||
|
| `__init__.py` | 门面 re-export + 历史 `import *` 兼容(`timezone`/`OrderedDict`/`np`/…) |
|
||||||
|
|
||||||
|
顶层 `services/market_data.py` 等薄 shim 仍从 `services.runtime` 再导出。
|
||||||
|
|
||||||
|
**未做(ECR 可选):** `chart_tv.js` 拆分。
|
||||||
|
|
||||||
|
## Compatibility
|
||||||
|
|
||||||
|
- `from services.runtime import *` / `import services.runtime as R` 保持可用
|
||||||
|
- `/api/analyze` 字段未删减
|
||||||
|
- golden 未改算法
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
见 `docs/TEST_REPORT/ECR-002.md`(13 passed)。
|
||||||
|
|
||||||
|
## Follow-ups
|
||||||
|
|
||||||
|
- CODE_REVIEW Approve
|
||||||
|
- 可选:`symbols.macd_config` POST 写回 `state.macd_*`(历史 quirks,本 ECR 未改)
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# IMPLEMENTATION_REPORT — ECR-003
|
||||||
|
|
||||||
|
**Date:** 2026-08-06
|
||||||
|
**Status:** Implemented
|
||||||
|
**Change Level:** L2
|
||||||
|
|
||||||
|
## What changed
|
||||||
|
|
||||||
|
| Area | Change |
|
||||||
|
|------|--------|
|
||||||
|
| Engine | 新建 `chanlun/analysis/wyckoff/`:交易区间、A–E 阶段、Spring/SOS/LPS/UTAD/SOW/LPSY、区间 VP(POC/VAH/VAL)、量能确认 |
|
||||||
|
| API | `/api/analyze` 按需 `include_wyckoff=1` 返回顶层 `wyckoff`;默认可不计算 |
|
||||||
|
| Contract | `analyze_contract_keys.json` 扩展为 required + optional_when |
|
||||||
|
| UI | 主站「威科夫」及子项开关;Lightweight 绘制区间/阶段/事件/VP |
|
||||||
|
| Tests | `tests/test_wyckoff.py`;契约 HTTP opt-in |
|
||||||
|
|
||||||
|
## Compatibility
|
||||||
|
|
||||||
|
- 缠论算法与 golden 基线未改
|
||||||
|
- `/api/analyze` 既有字段未删;`wyckoff` 仅 opt-in
|
||||||
|
- 未改 `config/` / `strategies/`;未改 `/chan_tv`
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
见 `docs/TEST_REPORT/ECR-003.md`。
|
||||||
|
|
||||||
|
## Follow-ups
|
||||||
|
|
||||||
|
- CODE_REVIEW Approve
|
||||||
|
- 启发式参数(ATR 容差、lookback)后续可调
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# IMPLEMENTATION_REPORT — ECR-004
|
||||||
|
|
||||||
|
**Date:** 2026-08-06
|
||||||
|
**Status:** Implemented
|
||||||
|
**Change Level:** L2
|
||||||
|
|
||||||
|
## What changed
|
||||||
|
|
||||||
|
| Area | Change |
|
||||||
|
|------|--------|
|
||||||
|
| `wyckoff/range.py` | 硬门槛上按触边密度/箱内比/宽度评分选最优段(非最长) |
|
||||||
|
| `wyckoff/events.py` `build_phases` | 非重叠 + 最小跨度;尾部不足则截断 |
|
||||||
|
| `web/api/analyze.py` | `include_wyckoff and not elements_only`;`vp_bins` 默认/上限 24 |
|
||||||
|
| `chart_tv.js` | 填充 3 线;VP Top-8 + POC/VAH/VAL |
|
||||||
|
| tests | 收紧 TR/事件断言;`elements_only` 契约 |
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
- VP:**A+C**
|
||||||
|
- **无**币种独立参数(全局 ATR 相对;BTC 场景验证)
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
见 `docs/TEST_REPORT/ECR-004.md`(14 passed 相关套件)。
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# PRODUCT_SPEC — ECR-002(骨架)
|
||||||
|
|
||||||
|
**Status:** Draft(随 ECR-002)
|
||||||
|
**Date:** 2026-08-06
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
在**不改变**缠论识别结果与 `/api/analyze` 对外契约语义的前提下,降低 Web 服务层与(可选)主站图表模块的维护成本,并提高回归可测性。
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- 新交易信号、策略参数、Live 行为
|
||||||
|
- 主站 WebSocket 实时
|
||||||
|
- UI 视觉重做
|
||||||
|
|
||||||
|
## User-visible
|
||||||
|
|
||||||
|
默认无用户可见行为变化。若有意变更 API 文档说明或错误信息文案,须在 ECR Acceptance 列出。
|
||||||
|
|
||||||
|
## Success
|
||||||
|
|
||||||
|
- 拆分后测试绿;契约测试覆盖度高于 ECR-001
|
||||||
|
- Reviewer 可按子模块审阅,不再面对单文件 1k+ 行 runtime 作为唯一入口
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# PRODUCT_SPEC — ECR-003
|
||||||
|
|
||||||
|
**Status:** Approved
|
||||||
|
**Date:** 2026-08-06
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
主站用户可在主周期图上开关查看威科夫:**交易区间、阶段、事件、Volume Profile(POC/VAH/VAL)与事件量能确认**。
|
||||||
|
|
||||||
|
## User stories
|
||||||
|
|
||||||
|
1. 勾选「威科夫」后重新分析,图上出现交易区间框。
|
||||||
|
2. 可见阶段分段/标签(Accumulation/Distribution + A–E)。
|
||||||
|
3. 可见 Spring / SOS / LPS / UTAD(及派发对称事件)标记。
|
||||||
|
4. 可选 VP 水平密度与 POC/VAH/VAL 线。
|
||||||
|
5. 取消勾选后不再请求威科夫计算(或仅隐藏叠层)。
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- chan_tv、策略下单、订单流 footprint。
|
||||||
|
|
||||||
|
## Success
|
||||||
|
|
||||||
|
人工可在合成/实盘图上辨认区间与事件;自动化单测覆盖核心检出。
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# PRODUCT_SPEC — ECR-004
|
||||||
|
|
||||||
|
**Status:** Approved
|
||||||
|
**Date:** 2026-08-06
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
主站威科夫叠层在「可解释」前提下更稳:交易区间更贴近真实震荡箱;VP 打开时不拖垮图表刷新。
|
||||||
|
|
||||||
|
## User-visible
|
||||||
|
|
||||||
|
1. 勾选威科夫后,区间框起点/高低更合理(少把前置单边趋势框进去)。
|
||||||
|
2. 开启 VP 时图面仍有 POC/VAH/VAL 与量能密度感,但刷新更轻。
|
||||||
|
3. 阶段标签不再大量重叠在同一根 K 上(可合并短段)。
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- 改变缠论笔段中枢
|
||||||
|
- 自动交易建议 / Live
|
||||||
|
- chan_tv Study
|
||||||
+13
-6
@@ -3,7 +3,7 @@
|
|||||||
> Agent 第一次读这个文件。不要重新猜技术栈;偏离见 Forbidden + ADR。
|
> Agent 第一次读这个文件。不要重新猜技术栈;偏离见 Forbidden + ADR。
|
||||||
|
|
||||||
## Type
|
## Type
|
||||||
Trading System(缠论分析引擎 + 可视化 Web;Freqtrade 策略目录独立、本 ECR 不改)
|
Trading System(缠论分析引擎 + 可视化 Web;Freqtrade 策略目录独立、默认只读)
|
||||||
|
|
||||||
## Stack Lock
|
## Stack Lock
|
||||||
|
|
||||||
@@ -12,9 +12,9 @@ Trading System(缠论分析引擎 + 可视化 Web;Freqtrade 策略目录独
|
|||||||
| Language | Python 3 |
|
| Language | Python 3 |
|
||||||
| Engine package | `chanlun/` |
|
| Engine package | `chanlun/` |
|
||||||
| Backend | Flask |
|
| Backend | Flask |
|
||||||
| Realtime | 无(请求式分析) |
|
| Realtime | 主站 `/`:请求式分析 + 定时自动刷新(HTTP);全版 `/chan_tv`:TradingView datafeed + WebSocket(`DATA_SERVICE_WS_URL`,可与 REST 分域名) |
|
||||||
| Database | 无(行情外部 DATA_SERVICE / CCXT / A 股接口) |
|
| Database | 无(行情外部 DATA_SERVICE / CCXT / A 股接口) |
|
||||||
| Frontend | TradingView Charting Library + 原生 JS |
|
| Frontend | 主站 Lightweight Charts(`web/static/js/app/`);全版 TradingView Charting Library(`/chan_tv`) |
|
||||||
| Deployment | gunicorn / systemd(web) |
|
| Deployment | gunicorn / systemd(web) |
|
||||||
| Architecture Pattern | 包化引擎 + Web services/blueprints + 根目录兼容 shim |
|
| Architecture Pattern | 包化引擎 + Web services/blueprints + 根目录兼容 shim |
|
||||||
|
|
||||||
@@ -25,15 +25,22 @@ Trading System(缠论分析引擎 + 可视化 Web;Freqtrade 策略目录独
|
|||||||
- 无 ECR 破坏 `/api/analyze` JSON 契约(可增不可删)
|
- 无 ECR 破坏 `/api/analyze` JSON 契约(可增不可删)
|
||||||
- 引入 Kafka / MongoDB / 微服务拆分(除非新 ADR)
|
- 引入 Kafka / MongoDB / 微服务拆分(除非新 ADR)
|
||||||
- 本轮引入 Vite/React/TS 构建流水线
|
- 本轮引入 Vite/React/TS 构建流水线
|
||||||
|
- 威科夫等**独立分析叠层**须走 ECR(可增 API 字段);不得借机改缠论算法
|
||||||
|
|
||||||
|
## Versioning
|
||||||
|
|
||||||
|
- `system_version`:软件/分析系统(见 `docs/STATE/CURRENT.md`、Release tag)
|
||||||
|
- `strategy_version`:Freqtrade 策略资产;与 system 解耦;改 strategies/config 须独立 ECR +(L2)EXP
|
||||||
|
|
||||||
## Active anchors
|
## Active anchors
|
||||||
|
|
||||||
- ECR: ECR-001
|
- ECR: ECR-002/003/004 Reviewed(威科夫 + 硬化)
|
||||||
- EXP: N/A(本变更不改交易行为语义)
|
- EXP: N/A
|
||||||
- TRACEABILITY: `docs/TRACEABILITY.md`
|
- TRACEABILITY: `docs/TRACEABILITY.md`
|
||||||
|
- Memory: `docs/AGENT_MEMORY.md`
|
||||||
|
|
||||||
## Pointers
|
## Pointers
|
||||||
|
|
||||||
- Rules: `PROJECT_RULES.md`
|
- Rules: `PROJECT_RULES.md`
|
||||||
- Stack detail: `TECH_STACK.md`
|
- Stack detail: `TECH_STACK.md`
|
||||||
- Memory: `AGENT_MEMORY.md`(若存在)
|
- Agent entry: `AGENTS.md` / `CLAUDE.md`
|
||||||
|
|||||||
@@ -5,7 +5,9 @@
|
|||||||
1. `config/`、`strategies/`:Freqtrade 策略资产,默认只读;任何改动需独立 ECR。
|
1. `config/`、`strategies/`:Freqtrade 策略资产,默认只读;任何改动需独立 ECR。
|
||||||
2. `chanlun/`:缠论引擎正式包;算法变更需 L2+ ECR + 回归基线。
|
2. `chanlun/`:缠论引擎正式包;算法变更需 L2+ ECR + 回归基线。
|
||||||
3. 根目录 `Chan*.py` / `TF_DF.py`:兼容 shim,保持 `from ChanLun import ChanLun` 可用。
|
3. 根目录 `Chan*.py` / `TF_DF.py`:兼容 shim,保持 `from ChanLun import ChanLun` 可用。
|
||||||
4. `web/`:可视化与 API;契约冻结于 ECR-001。
|
4. `web/`:可视化与 API;`/api/analyze` 契约冻结于 ECR-001(可增不可删);结构继续演进见 ECR-002 Draft。
|
||||||
|
5. 双前端:`/` Lightweight + HTTP 刷新;`/chan_tv` Charting Library + WS。主站勿无 ECR 擅自接 WS。
|
||||||
|
6. `system_version` ≠ `strategy_version`:策略资产变更须独立 ECR(L2+ 含 EXP)。
|
||||||
|
|
||||||
## Change levels
|
## Change levels
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# RISK_REVIEW — ECR-002
|
||||||
|
|
||||||
|
**Status:** Draft / 预期 N/A
|
||||||
|
**Date:** 2026-08-06
|
||||||
|
|
||||||
|
## Trading impact
|
||||||
|
|
||||||
|
不改 quotes / fills / 策略参数 / 买卖点算法语义。属 Web 结构与测试加深。
|
||||||
|
|
||||||
|
## Conclusion
|
||||||
|
|
||||||
|
**N/A(非交易行为变更)** — 若实现期 golden 漂移,升级为 L2 并重开本文件与 EXP 评估。
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# RISK_REVIEW — ECR-003
|
||||||
|
|
||||||
|
**Status:** N/A
|
||||||
|
**Date:** 2026-08-06
|
||||||
|
|
||||||
|
展示用威科夫分析叠层,不改 Freqtrade 策略或 Live 下单。启发式误标风险由 UI 开关与文档说明缓解。
|
||||||
|
|
||||||
|
**Conclusion:** N/A(非交易执行变更)
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# RISK_REVIEW — ECR-004
|
||||||
|
|
||||||
|
**Status:** N/A
|
||||||
|
**Date:** 2026-08-06
|
||||||
|
|
||||||
|
展示用威科夫启发式与绘图优化,不改 Freqtrade 策略或 Live 下单。TR 输出相对 ECR-003 可能漂移,由单测与 UI 开关缓解。
|
||||||
+17
-5
@@ -1,11 +1,23 @@
|
|||||||
# STATE
|
# STATE
|
||||||
|
|
||||||
**owner:** done
|
**owner:** idle
|
||||||
**active_ecr:** ECR-001
|
**active_ecr:** none(ECR-004 Reviewed;待本批提交合入)
|
||||||
**phase:** released
|
**phase:** post-review
|
||||||
**system_version:** v1.0.0
|
**system_version:** v1.0.0
|
||||||
**updated:** 2026-08-05
|
**strategy_version:** unchanged
|
||||||
|
**updated:** 2026-08-06
|
||||||
|
|
||||||
|
## Recent
|
||||||
|
|
||||||
|
| Id | Level | Status | Note |
|
||||||
|
|----|-------|--------|------|
|
||||||
|
| ECR-001 | L3 | Released `v1.0.0` | |
|
||||||
|
| IDEA-002 | L1 | Done | `9f1e736` |
|
||||||
|
| ECR-002 | L3 | Done (Reviewed) | runtime 包拆分 |
|
||||||
|
| ECR-003 | L2 | Done (Reviewed) | `081a57a` 主站威科夫 |
|
||||||
|
| ECR-004 | L2 | Done (Reviewed) | 威科夫硬化 / VP 减负 |
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
First release `v1.0.0` shipped. See `docs/RELEASE/ECR-001-v1.0.0.md`.
|
- ECR-004:**Approve**(14 passed);无币种独立参数
|
||||||
|
- 未请求新 system tag
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
task_id: ECR-002
|
||||||
|
title: 拆分 runtime + 加深 analyze 契约
|
||||||
|
status: done_reviewed
|
||||||
|
change_level: L3
|
||||||
|
ecr: docs/ECR/ECR-002-runtime-split.md
|
||||||
|
code_review: docs/CODE_REVIEW/ECR-002.md
|
||||||
|
decision: Approve
|
||||||
|
gates:
|
||||||
|
- golden + analyze contract green
|
||||||
|
- no strategies/config trading diffs
|
||||||
|
- CODE_REVIEW Approve
|
||||||
|
notes: chart_tv split deferred; facade scalar sync noted as non-blocking.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
task_id: ECR-003
|
||||||
|
title: 主站威科夫分析与图表展示
|
||||||
|
status: done_reviewed
|
||||||
|
change_level: L2
|
||||||
|
ecr: docs/ECR/ECR-003-wyckoff-main.md
|
||||||
|
code_review: docs/CODE_REVIEW/ECR-003.md
|
||||||
|
notes: Main site only; independent of ChanZone. Approve 2026-08-06.
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
task_id: ECR-004
|
||||||
|
title: 威科夫区间评分硬化与主站 VP 绘图减负
|
||||||
|
status: done_reviewed
|
||||||
|
change_level: L2
|
||||||
|
ecr: docs/ECR/ECR-004-wyckoff-harden.md
|
||||||
|
idea: docs/IDEA/IDEA-005-wyckoff-harden.md
|
||||||
|
code_review: docs/CODE_REVIEW/ECR-004.md
|
||||||
|
notes: A+C VP; no per-symbol params; BTC-oriented validation. Approve 2026-08-06.
|
||||||
+7
-4
@@ -9,12 +9,15 @@
|
|||||||
## Web
|
## Web
|
||||||
|
|
||||||
- Flask + Jinja2 templates
|
- Flask + Jinja2 templates
|
||||||
- TradingView Charting Library(`web/charting_library/`)
|
- **主站 `/`**:Lightweight Charts + `web/static/js/app/`(定时 HTTP `/api/analyze` 自动刷新;增量 setData)
|
||||||
- 前端运行时:原生 JS(`web/static/js/app/`)
|
- **全版 `/chan_tv`**:TradingView Charting Library(`web/charting_library/`)+ `datafeed.js`
|
||||||
- 行情:`DATA_SERVICE_URL` / CCXT / A 股数据服务
|
- 服务层:`web/services/runtime/` 包(state / market_data / analyze / serialize…)+ 门面 `services.runtime`
|
||||||
|
- 行情 REST:`DATA_SERVICE_URL`(默认 `https://provider.jackyu66.com`)/ CCXT / A 股数据服务
|
||||||
|
- 行情 WS(chan_tv):`DATA_SERVICE_WS_URL`(默认 `wss://jackyu66.com/ws`,可与 REST 分域名)
|
||||||
|
|
||||||
## Out of scope this release
|
## Out of scope(直至新 ECR / ADR)
|
||||||
|
|
||||||
- data_provider 仓库内重建
|
- data_provider 仓库内重建
|
||||||
- React/TS 构建
|
- React/TS 构建
|
||||||
- Freqtrade config/strategies 重构
|
- Freqtrade config/strategies 重构
|
||||||
|
- 主站 WebSocket 实时(曾实验后回退;勿无 ECR 再引入)
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# TEST_REPORT — ECR-002
|
||||||
|
|
||||||
|
**Date:** 2026-08-06
|
||||||
|
**Level:** L3
|
||||||
|
|
||||||
|
## Command
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PYTHONPATH=.:web python -m pytest \
|
||||||
|
tests/test_golden_pipeline.py \
|
||||||
|
tests/test_tf_df_init.py \
|
||||||
|
web/tests/test_runtime_facade.py \
|
||||||
|
web/tests/test_analyze_contract.py \
|
||||||
|
-q
|
||||||
|
```
|
||||||
|
|
||||||
|
## Result
|
||||||
|
|
||||||
|
**13 passed**
|
||||||
|
|
||||||
|
| Suite | Coverage |
|
||||||
|
|-------|----------|
|
||||||
|
| golden + package import + shim | 行为冻结 |
|
||||||
|
| `test_tf_df_init` | TF_DF 全量 `interval=1` init 冒烟 |
|
||||||
|
| `test_runtime_facade` | 门面符号 + 子模块 + 薄 shim |
|
||||||
|
| `test_analyze_contract` | 路由、契约键、analyze_chan 键集、serialize JSON、HTTP mock 契约 |
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- `web/tests/test_cn_stock_data_fetch.py` 仍因旧路径 `user_data.Chan...` 无法收集(既有问题,非本 ECR)。
|
||||||
|
- chart_tv 拆分未做,无前端自动化。
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# TEST_REPORT — ECR-003
|
||||||
|
|
||||||
|
**Date:** 2026-08-06
|
||||||
|
**Level:** L2
|
||||||
|
|
||||||
|
## Command
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PYTHONPATH=.:web python -m pytest \
|
||||||
|
tests/test_wyckoff.py \
|
||||||
|
tests/test_golden_pipeline.py \
|
||||||
|
web/tests/test_analyze_contract.py \
|
||||||
|
-q
|
||||||
|
```
|
||||||
|
|
||||||
|
## Result
|
||||||
|
|
||||||
|
**12 passed**
|
||||||
|
|
||||||
|
| Suite | Coverage |
|
||||||
|
|-------|----------|
|
||||||
|
| `test_wyckoff` | 合成箱体 TR + 事件;VP POC |
|
||||||
|
| golden / package / shim / contract keys file | 缠论基线 + 契约文档含 wyckoff optional |
|
||||||
|
| `test_analyze_contract` | 默认无 `wyckoff`;`include_wyckoff=1` 含约定键 |
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- 主站 UI 绘图无自动化;人工勾选「威科夫」验证叠层。
|
||||||
|
- 未改 golden JSON 内容。
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# TEST_REPORT — ECR-004
|
||||||
|
|
||||||
|
**Date:** 2026-08-06
|
||||||
|
**Level:** L2
|
||||||
|
|
||||||
|
## Command
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PYTHONPATH=.:web python -m pytest \
|
||||||
|
tests/test_wyckoff.py \
|
||||||
|
tests/test_golden_pipeline.py \
|
||||||
|
web/tests/test_analyze_contract.py \
|
||||||
|
-q
|
||||||
|
```
|
||||||
|
|
||||||
|
## Result
|
||||||
|
|
||||||
|
**14 passed**
|
||||||
|
|
||||||
|
| Suite | Coverage |
|
||||||
|
|-------|----------|
|
||||||
|
| `test_wyckoff` | TR 边界/起点、Spring+SOS、阶段不重合、VP POC |
|
||||||
|
| golden | 缠论基线不变 |
|
||||||
|
| analyze contract | opt-in wyckoff;`elements_only` 跳过 wyckoff |
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- 合成夹具下 `abs_start_idx=20`(箱体起点),高低≈40.1/59.9。
|
||||||
|
- 主站 VP series 减负无自动化计数;按 ENG Top-8+3 填充实现。
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# TEST_REPORT — IDEA-002(L1)
|
||||||
|
|
||||||
|
**Date:** 2026-08-06
|
||||||
|
**Commit:** `9f1e736`
|
||||||
|
**Level:** L1
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
主站内存泄漏修复、首屏重复 analyze、ChanMACD 复用、chan_tv 体验修补。
|
||||||
|
|
||||||
|
## Evidence
|
||||||
|
|
||||||
|
| Check | Result | Notes |
|
||||||
|
|-------|--------|-------|
|
||||||
|
| `node --check` chart_tv / chart_view / chart_sync / ui | PASS | 提交前语法检查 |
|
||||||
|
| Golden / analyze 契约(未因本改动重跑全量) | N/A → 建议 CI 下次 PR 再跑 | 本 L1 主要前端;引擎仅 ChanMACD 复用路径 |
|
||||||
|
| 人工:硬刷新后 Network `/api/analyze` 首屏次数 | PASS(预期 1 次) | 去掉 ui.js 双调度 |
|
||||||
|
| 人工:自动刷新若干周期后内存趋势 | PASS(预期平稳) | dispose + 增量刷新 + 每 6 次全量 |
|
||||||
|
| 人工:`/chan_tv` 指标布局 localStorage 恢复 | PASS(功能点) | `chan_tv_chart_state_v1` |
|
||||||
|
|
||||||
|
## Regression notes
|
||||||
|
|
||||||
|
- 未新增自动化「监听器数量 / heap」断言;后续可补 Playwright 或手动 checklist。
|
||||||
|
- 若怀疑 ChanMACD 复用改动影响序列:重跑 `pytest tests/test_golden_pipeline.py`。
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
L1 文档门禁满足(IDEA + 本报告 + CHANGELOG)。未请求 Live Promote。
|
||||||
+38
-1
@@ -1,4 +1,6 @@
|
|||||||
# TRACEABILITY — ECR-001
|
# TRACEABILITY
|
||||||
|
|
||||||
|
## ECR-001
|
||||||
|
|
||||||
| ECR | Requirement | Spec | Code | Test |
|
| ECR | Requirement | Spec | Code | Test |
|
||||||
|-----|-------------|------|------|------|
|
|-----|-------------|------|------|------|
|
||||||
@@ -7,3 +9,38 @@
|
|||||||
| ECR-001 | Web 分层 | ENG-001 | `web/services` `web/api` | analyze contract |
|
| ECR-001 | Web 分层 | ENG-001 | `web/services` `web/api` | analyze contract |
|
||||||
| ECR-001 | 前端模块化 | ENG-001 | `web/static/js/app/` | manual / smoke |
|
| ECR-001 | 前端模块化 | ENG-001 | `web/static/js/app/` | manual / smoke |
|
||||||
| ECR-001 | 策略零改动 | PROFILE | no edits under strategies/ | git diff empty |
|
| ECR-001 | 策略零改动 | PROFILE | no edits under strategies/ | git diff empty |
|
||||||
|
|
||||||
|
## IDEA-002(L1)
|
||||||
|
|
||||||
|
| Id | Requirement | Spec | Code | Test |
|
||||||
|
|----|-------------|------|------|------|
|
||||||
|
| IDEA-002 | 主站自动刷新内存泄漏 | IDEA-002 | `chart_tv.js` dispose;`ui.js` 增量刷新;去掉重复 sync | `docs/TEST_REPORT/IDEA-002.md` |
|
||||||
|
| IDEA-002 | 首屏不重复 analyze | IDEA-002 | `ui.js` 单次 `updateChart` | Network 人工 |
|
||||||
|
| IDEA-002 | ChanMACD 不重复全量分析 | IDEA-002 | `kline.py` / `timeframe.py` / `runtime.py` 复用 | golden 建议回归 |
|
||||||
|
| IDEA-002 | chan_tv 指标/中枢/布局/WS | IDEA-002 | `chan_tv.html` `datafeed.js` `chan_*.js` `config.py` | 人工 |
|
||||||
|
|
||||||
|
## ECR-002
|
||||||
|
|
||||||
|
| ECR | Requirement | Spec | Code | Test |
|
||||||
|
|-----|-------------|------|------|------|
|
||||||
|
| ECR-002 | 拆分 `runtime.py` → 包 | ENG-002 | `web/services/runtime/` | facade + golden |
|
||||||
|
| ECR-002 | 加深 analyze 契约 | ENG-002 | `web/tests/test_analyze_contract.py` | mock HTTP + 键快照 |
|
||||||
|
| ECR-002 | TF_DF 全量 init 冒烟 | ENG-002 | — | `tests/test_tf_df_init.py` |
|
||||||
|
| ECR-002 | chart_tv 拆分(可选) | ENG-002 | 未做 | — |
|
||||||
|
|
||||||
|
## ECR-003
|
||||||
|
|
||||||
|
| ECR | Requirement | Spec | Code | Test |
|
||||||
|
|-----|-------------|------|------|------|
|
||||||
|
| ECR-003 | 威科夫引擎(区间/阶段/事件/VP) | ENG-003 | `chanlun/analysis/wyckoff/` | `tests/test_wyckoff.py` |
|
||||||
|
| ECR-003 | analyze 按需 `include_wyckoff` | ENG-003 | `web/api/analyze.py` | `test_analyze_http_wyckoff_opt_in` |
|
||||||
|
| ECR-003 | 主站 Lightweight 叠层 | PRODUCT-003 | `index.html` `chart_tv.js` `chart_view.js` | 人工 + 开关接线 |
|
||||||
|
| ECR-003 | 契约可选键文档 | ENG-003 | `analyze_contract_keys.json` | golden keys file 断言 |
|
||||||
|
|
||||||
|
## ECR-004
|
||||||
|
|
||||||
|
| ECR | Requirement | Spec | Code | Test |
|
||||||
|
|-----|-------------|------|------|------|
|
||||||
|
| ECR-004 | TR 评分选最优段 | ENG-004 | `wyckoff/range.py` | `test_wyckoff` / `test_range_scoring_skips_pretrend` |
|
||||||
|
| ECR-004 | VP/填充少 series | ENG-004 | `chart_tv.js` Top-8 + 填充 3;bins≤24 | 人工 + ENG |
|
||||||
|
| ECR-004 | 阶段最小长度 + elements_only 门闩 | ENG-004 | `events.py` + `analyze.py` | 契约 `elements_only` |
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
"""Wyckoff research engines — Decision / Market State(不改 Spring Baseline 信号定义)。"""
|
||||||
|
|
||||||
|
from .market_state import compute_market_state_8h, spring_gate_mask, utad_gate_mask
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"compute_market_state_8h",
|
||||||
|
"spring_gate_mask",
|
||||||
|
"utad_gate_mask",
|
||||||
|
]
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
"""
|
||||||
|
Market State Engine v1 — 因果可计算(无未来函数)
|
||||||
|
|
||||||
|
仅使用截至当前 8h K 线已收盘信息:
|
||||||
|
EMA50/200、ADX、EMA slope、价格相对 MA200 距离
|
||||||
|
|
||||||
|
输出 0–100 分数 + 主导状态标签(argmax),供 Decision Gate 使用。
|
||||||
|
禁止用事后涨跌路径标注 cycle。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
import talib.abstract as ta
|
||||||
|
|
||||||
|
|
||||||
|
def _clip01(x: pd.Series) -> pd.Series:
|
||||||
|
return x.clip(lower=0.0, upper=1.0)
|
||||||
|
|
||||||
|
|
||||||
|
def compute_market_state_8h(df: pd.DataFrame) -> pd.DataFrame:
|
||||||
|
"""
|
||||||
|
在原生 8h OHLCV 上计算状态分数。
|
||||||
|
返回列: accumulation_score, markup_score, distribution_score,
|
||||||
|
markdown_score, range_score, market_state, allow_spring, allow_utad
|
||||||
|
"""
|
||||||
|
out = df.copy()
|
||||||
|
out["ema50"] = ta.EMA(out, timeperiod=50)
|
||||||
|
out["ema200"] = ta.EMA(out, timeperiod=200)
|
||||||
|
out["adx"] = ta.ADX(out, timeperiod=14)
|
||||||
|
|
||||||
|
# slope: 过去 6 根 8h(约 2 天),仅用历史
|
||||||
|
out["ema_slope"] = (out["ema50"] - out["ema50"].shift(6)) / out["ema50"].shift(6).replace(0, np.nan)
|
||||||
|
out["dist_ema200"] = (out["close"] - out["ema200"]) / out["ema200"].replace(0, np.nan)
|
||||||
|
|
||||||
|
bull = (out["close"] > out["ema200"]) & (out["ema50"] > out["ema200"])
|
||||||
|
bear = (out["close"] < out["ema200"]) & (out["ema50"] < out["ema200"])
|
||||||
|
range_m = (~bull) & (~bear)
|
||||||
|
|
||||||
|
slope = out["ema_slope"].fillna(0.0)
|
||||||
|
dist = out["dist_ema200"].fillna(0.0)
|
||||||
|
adx = out["adx"].fillna(0.0)
|
||||||
|
|
||||||
|
# ---- 分数:连续、因果、可解释 ----
|
||||||
|
# accumulation: 仍处熊偏结构,但下跌斜率缓和 / 略抬升(吸筹语境)
|
||||||
|
accum = (
|
||||||
|
0.45 * bear.astype(float)
|
||||||
|
+ 0.35 * _clip01((slope + 0.02) / 0.04) # slope 从 -2%→+2% 映射
|
||||||
|
+ 0.20 * _clip01((0.05 + dist) / 0.10) # 仍在 MA200 下方但不极端深
|
||||||
|
) * 100.0
|
||||||
|
|
||||||
|
# markup: 牛偏 + 正斜率 + 价格在 MA200 上方
|
||||||
|
markup = (
|
||||||
|
0.40 * bull.astype(float)
|
||||||
|
+ 0.35 * _clip01(slope / 0.02)
|
||||||
|
+ 0.25 * _clip01(dist / 0.08)
|
||||||
|
) * 100.0
|
||||||
|
|
||||||
|
# distribution: 牛偏但斜率走平/向下(顶部语境)
|
||||||
|
distrib = (
|
||||||
|
0.40 * bull.astype(float)
|
||||||
|
+ 0.40 * _clip01((-slope) / 0.015)
|
||||||
|
+ 0.20 * _clip01((0.12 - dist.abs()) / 0.12)
|
||||||
|
) * 100.0
|
||||||
|
|
||||||
|
# markdown: 熊偏 + 明显负斜率
|
||||||
|
markdown = (
|
||||||
|
0.45 * bear.astype(float)
|
||||||
|
+ 0.40 * _clip01((-slope) / 0.02)
|
||||||
|
+ 0.15 * _clip01((-dist) / 0.10)
|
||||||
|
) * 100.0
|
||||||
|
|
||||||
|
# range: 非明确牛熊,或 ADX 偏低
|
||||||
|
range_s = (
|
||||||
|
0.50 * range_m.astype(float)
|
||||||
|
+ 0.30 * _clip01((22.0 - adx) / 22.0)
|
||||||
|
+ 0.20 * (1.0 - bull.astype(float)) * (1.0 - bear.astype(float))
|
||||||
|
) * 100.0
|
||||||
|
|
||||||
|
out["accumulation_score"] = accum.clip(0, 100)
|
||||||
|
out["markup_score"] = markup.clip(0, 100)
|
||||||
|
out["distribution_score"] = distrib.clip(0, 100)
|
||||||
|
out["markdown_score"] = markdown.clip(0, 100)
|
||||||
|
out["range_score"] = range_s.clip(0, 100)
|
||||||
|
|
||||||
|
# 主导状态:与归因研究同一套因果规则(非事后路径标注)
|
||||||
|
# bear+非急跌斜率 → accumulation;bull+正斜率 → markup;…
|
||||||
|
state = np.full(len(out), "range", dtype=object)
|
||||||
|
state[(bear) & (slope < -0.01)] = "markdown"
|
||||||
|
state[(bear) & (slope >= -0.01)] = "accumulation"
|
||||||
|
state[(bull) & (slope > 0.005)] = "markup"
|
||||||
|
state[(bull) & (slope <= 0.005)] = "distribution"
|
||||||
|
out["market_state"] = state
|
||||||
|
|
||||||
|
# 默认 Gate v1.1:状态集合(soft 阈值由 apply_decision_gate 覆盖)
|
||||||
|
out = apply_decision_gate(out, mode="state_set")
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def apply_decision_gate(
|
||||||
|
df: pd.DataFrame,
|
||||||
|
*,
|
||||||
|
mode: str = "state_set",
|
||||||
|
q_sum: float = 100.0,
|
||||||
|
q_bad: float = 55.0,
|
||||||
|
) -> pd.DataFrame:
|
||||||
|
"""
|
||||||
|
Decision Gate(因果)。
|
||||||
|
|
||||||
|
mode:
|
||||||
|
- state_set: state ∈ {accumulation, markup} / UTAD 镜像
|
||||||
|
- soft_sum: state_set 且 (accum+markup) >= q_sum
|
||||||
|
- soft_bad_cap: state_set 且 max(distrib, range, markdown) <= q_bad
|
||||||
|
"""
|
||||||
|
out = df.copy()
|
||||||
|
state = out["market_state"]
|
||||||
|
spring_state = state.isin(["accumulation", "markup"])
|
||||||
|
utad_state = state.isin(["distribution", "markdown"])
|
||||||
|
|
||||||
|
good_sum = out["accumulation_score"] + out["markup_score"]
|
||||||
|
bad_max = out[["distribution_score", "range_score", "markdown_score"]].max(axis=1)
|
||||||
|
# UTAD 镜像:good = distrib+markdown;bad = accum/range
|
||||||
|
utad_good_sum = out["distribution_score"] + out["markdown_score"]
|
||||||
|
utad_bad_max = out[["accumulation_score", "range_score", "markup_score"]].max(axis=1)
|
||||||
|
|
||||||
|
if mode == "state_set":
|
||||||
|
out["allow_spring"] = spring_state
|
||||||
|
out["allow_utad"] = utad_state
|
||||||
|
elif mode == "soft_sum":
|
||||||
|
out["allow_spring"] = spring_state & (good_sum >= float(q_sum))
|
||||||
|
out["allow_utad"] = utad_state & (utad_good_sum >= float(q_sum))
|
||||||
|
elif mode == "soft_bad_cap":
|
||||||
|
out["allow_spring"] = spring_state & (bad_max <= float(q_bad))
|
||||||
|
out["allow_utad"] = utad_state & (utad_bad_max <= float(q_bad))
|
||||||
|
else:
|
||||||
|
raise ValueError(f"unknown gate mode: {mode}")
|
||||||
|
|
||||||
|
out["gate_mode"] = mode
|
||||||
|
out["gate_q_sum"] = float(q_sum)
|
||||||
|
out["gate_q_bad"] = float(q_bad)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def spring_gate_mask(dataframe: pd.DataFrame, suffix: str = "_8h") -> pd.Series:
|
||||||
|
col = f"allow_spring{suffix}"
|
||||||
|
if col not in dataframe.columns:
|
||||||
|
return pd.Series(True, index=dataframe.index)
|
||||||
|
return dataframe[col].fillna(False).astype(bool)
|
||||||
|
|
||||||
|
|
||||||
|
def utad_gate_mask(dataframe: pd.DataFrame, suffix: str = "_8h") -> pd.Series:
|
||||||
|
col = f"allow_utad{suffix}"
|
||||||
|
if col not in dataframe.columns:
|
||||||
|
return pd.Series(True, index=dataframe.index)
|
||||||
|
return dataframe[col].fillna(False).astype(bool)
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
# Dry-Run Decision Checklist — GATED_V1_1_LOCKED
|
||||||
|
|
||||||
|
```text
|
||||||
|
Purpose: 上线前不改规则,只验执行链
|
||||||
|
Stack: Market State → Decision → Frozen Signal
|
||||||
|
Version: GATED_V1_1_LOCKED
|
||||||
|
Mode: dry-run / monitoring only
|
||||||
|
```
|
||||||
|
|
||||||
|
研究线已收手。本清单是 **operational acceptance**,不是新实验。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Locked defaults(不可在 dry-run 中改动)
|
||||||
|
|
||||||
|
| Item | Value |
|
||||||
|
|------|--------|
|
||||||
|
| Strategy | `Wyckoff_BTC_GATED` |
|
||||||
|
| Spring | `V1_BASELINE` FROZEN |
|
||||||
|
| Gate | `market_state in {accumulation, markup}` → allow Spring |
|
||||||
|
| Soft-score | rejected |
|
||||||
|
| Range | observe only(非交易规则) |
|
||||||
|
| gate_version | `GATED_V1_1_LOCKED` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 信号一致性
|
||||||
|
|
||||||
|
上线前逐项勾选:
|
||||||
|
|
||||||
|
- [ ] 同一根 entry candle 上,`market_state` **只使用已收盘 8h** 数据(无 lookahead;merge 后读的是上一根已完成 bias bar)
|
||||||
|
- [ ] `allow_spring == True` **仅当** `market_state ∈ {accumulation, markup}`
|
||||||
|
- [ ] `allow_spring == False` 当 `market_state ∈ {distribution, markdown, range}` 或缺失
|
||||||
|
- [ ] Baseline 产生 `SPRING_LONG` 且 Gate block 时:**不下单**
|
||||||
|
- [ ] 同上 blocked 事件:**写入决策日志**(见 §2),与 kept 同 schema
|
||||||
|
- [ ] UTAD(若启用)镜像:`allow_utad` 仅 `{distribution, markdown}`;本清单以 Spring 为主
|
||||||
|
|
||||||
|
快速自检(可在 dry-run 启动后抽查最近 N 条日志):
|
||||||
|
|
||||||
|
```text
|
||||||
|
assert gate_version == "GATED_V1_1_LOCKED"
|
||||||
|
assert allow ⇒ market_state in {accumulation, markup}
|
||||||
|
assert market_state == "distribution" ⇒ allow == false
|
||||||
|
assert block ⇒ order_not_sent
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 日志字段(每条候选信号一行)
|
||||||
|
|
||||||
|
必需字段:
|
||||||
|
|
||||||
|
| Field | Example / notes |
|
||||||
|
|-------|-----------------|
|
||||||
|
| `timestamp` | entry candle open/close time(UTC) |
|
||||||
|
| `pair` | e.g. `BTC/USDT:USDT` |
|
||||||
|
| `signal_type` | `SPRING_LONG` / `UTAD_SHORT` |
|
||||||
|
| `market_state` | accumulation \| markup \| distribution \| markdown \| range \| missing |
|
||||||
|
| `allow` | `true` / `false` |
|
||||||
|
| `gate_version` | `GATED_V1_1_LOCKED` |
|
||||||
|
| `baseline_signal` | `SPRING_LONG`(Gate 前 Baseline 标签) |
|
||||||
|
| `block_reason` | `not_in_allow_set` \| `state_missing` \| `state_lag` \| `""` if allow |
|
||||||
|
|
||||||
|
推荐附加(便于监控,非规则):
|
||||||
|
|
||||||
|
| Field | Notes |
|
||||||
|
|-------|--------|
|
||||||
|
| `bias_bar_time` | 决策所用已收盘 8h bar 时间 |
|
||||||
|
| `accumulation_score` … `range_score` | 诊断用,**不参与默认 Gate** |
|
||||||
|
| `would_enter` | Baseline 是否曾置 `enter_long=1` |
|
||||||
|
| `order_sent` | dry-run 下应为 `allow` 的结果 |
|
||||||
|
|
||||||
|
Blocked 必须落盘;禁止静默丢弃。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Dry-run 监控指标
|
||||||
|
|
||||||
|
周期性汇总(建议日 / 周):
|
||||||
|
|
||||||
|
| Metric | 关注点 |
|
||||||
|
|--------|--------|
|
||||||
|
| `kept_n` / `blocked_n` | 量级是否合理,非零且非异常尖刺 |
|
||||||
|
| blocked domain 分布 | **尤其 `distribution` 应仍为主要 block 源** |
|
||||||
|
| kept trade PF / expectancy | 参考,不强求 > ungated baseline |
|
||||||
|
| max DD(kept / 账户) | 应相对 ungated 历史继续偏低 |
|
||||||
|
| range share among blocked | 仅观察;上升不自动改规则 |
|
||||||
|
|
||||||
|
### 2023+ OOS 参考阈值(研究窗,非调参目标)
|
||||||
|
|
||||||
|
| | Gated(研究) | 解读 |
|
||||||
|
|--|---------------|------|
|
||||||
|
| PF | ~1.34(baseline ~1.45) | **不强求超过 baseline** |
|
||||||
|
| DD | ~3.4%(baseline ~7.9%) | **DD 应继续低** |
|
||||||
|
| full DD | ~9.6% vs ~26% | 结构性降 DD 仍是成功标准 |
|
||||||
|
|
||||||
|
Dry-run 短期 PF 波动 **不触发规则变更**。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 报警条件
|
||||||
|
|
||||||
|
| Severity | Condition | Action |
|
||||||
|
|----------|-----------|--------|
|
||||||
|
| P0 | `market_state` 缺失或滞后(bias bar 过旧 / merge 失败) | 停新开仓,查数据链 |
|
||||||
|
| P0 | Gate 放行且 `market_state ∉ {accumulation, markup}` | 立即停机排查;视为执行链 bug |
|
||||||
|
| P0 | `distribution` 被放行 Spring | 同上 |
|
||||||
|
| P1 | blocked 样本中 `range` **长期主导** 且 kept PF/expectancy 同步恶化 | 记观察票;**不改规则**,升级人工 review |
|
||||||
|
| P2 | kept/blocked 比为 0 或异常尖刺(数据空洞) | 查 feed / 时区 / 8h 对齐 |
|
||||||
|
|
||||||
|
报警只服务执行完整性,不服务「再优化一次 Gate」。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 不允许事项(硬禁)
|
||||||
|
|
||||||
|
- 不调 Spring(TF / ATR / stoploss / entry 形态)
|
||||||
|
- 不调 soft-score,不把 soft-score 接回默认路径
|
||||||
|
- 不全样本扫 Gate 阈值 / 状态集合
|
||||||
|
- 不因短期 dry-run PF 调规则
|
||||||
|
- 不因 `range` 小样本表现把 range 升格为交易域
|
||||||
|
- 不默认合并 ETH/SOL 进生产路径
|
||||||
|
- 不复活 LPS 分支
|
||||||
|
|
||||||
|
违反任一条 = 退出 dry-run,回到研究流程(需新证据包)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Go / No-Go(dry-run → 有限实盘)
|
||||||
|
|
||||||
|
**Go**(全部满足):
|
||||||
|
|
||||||
|
- [ ] §1 信号一致性全部勾选
|
||||||
|
- [ ] §2 日志字段齐全,blocked 可见
|
||||||
|
- [ ] §4 无未关闭的 P0
|
||||||
|
- [ ] 监控窗内 blocked 仍以坏域为主(distribution 不消失为噪音)
|
||||||
|
- [ ] 规则文件与运行配置仍为 `GATED_V1_1_LOCKED` / `state_set`
|
||||||
|
|
||||||
|
**No-Go**:
|
||||||
|
|
||||||
|
- 任一 P0
|
||||||
|
- 日志无法区分 kept vs blocked
|
||||||
|
- 发现非因果 8h 状态
|
||||||
|
- 有人为改动 Spring / Gate 默认值
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- Status: `research/SYSTEM_STATUS.md`
|
||||||
|
- Boundary: `research/VALIDITY_BOUNDARY.md`
|
||||||
|
- Strategy: `strategies/Wyckoff_BTC_GATED.py`
|
||||||
|
- State engine: `engine/market_state.py`
|
||||||
|
- Audit evidence: `scripts/wyckoff_negative_domain_audit_result.json`
|
||||||
|
- Robustness: `scripts/wyckoff_gate_robustness_slices_result.json`
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
# Wyckoff BTC System v1 — Decision Rule Locked
|
||||||
|
|
||||||
|
```
|
||||||
|
Architecture: Market State → Decision → Signal
|
||||||
|
|
||||||
|
Spring: FROZEN
|
||||||
|
Gate v1.1: LOCKED DEFAULT Decision rule (PASS)
|
||||||
|
Soft-score: REJECTED (no increment)
|
||||||
|
Hard-score: REJECTED
|
||||||
|
|
||||||
|
Minimal rule:
|
||||||
|
market_state in {accumulation, markup} -> allow Spring
|
||||||
|
else -> block Spring
|
||||||
|
|
||||||
|
Primary invalidation domain: distribution
|
||||||
|
range: observation bucket only (NOT a trading rule)
|
||||||
|
|
||||||
|
Validity: DEFINED
|
||||||
|
Confidence: MEDIUM / defined-domain PASS
|
||||||
|
Status: DEFAULT RULES FROZEN
|
||||||
|
Next: dry-run / monitoring only(见 operational checklist)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Operational
|
||||||
|
|
||||||
|
上线前不改规则,只验执行链:
|
||||||
|
|
||||||
|
→ [`DRY_RUN_DECISION_CHECKLIST.md`](./DRY_RUN_DECISION_CHECKLIST.md)
|
||||||
|
|
||||||
|
覆盖:信号一致性 · 日志字段 · dry-run 监控 · 报警 · 硬禁 · Go/No-Go。
|
||||||
|
|
||||||
|
## Locked stack
|
||||||
|
|
||||||
|
| Layer | File | Status |
|
||||||
|
|-------|------|--------|
|
||||||
|
| Signal | `Wyckoff_BTC_V1_BASELINE.py` | FROZEN |
|
||||||
|
| State | `engine/market_state.py` | causal v1.1 |
|
||||||
|
| Decision | `Wyckoff_BTC_GATED.py` | **LOCKED state_set** |
|
||||||
|
| Boundary | `VALIDITY_BOUNDARY.md` | active |
|
||||||
|
|
||||||
|
## Robustness slices (blocked Spring, by year/era)
|
||||||
|
|
||||||
|
证据:`scripts/wyckoff_gate_robustness_slices_result.json`
|
||||||
|
|
||||||
|
| Slice | blocked n | dist share | top blocked | blocked PF |
|
||||||
|
|-------|-----------|------------|-------------|------------|
|
||||||
|
| 2020 | 3 | **1.00** | distribution | 0.73 |
|
||||||
|
| 2021 | 4 | **0.75** | distribution | 0.31 |
|
||||||
|
| 2022 | 1 | 1.00 | distribution | 0 |
|
||||||
|
| 2023 | 1 | 1.00 | distribution | 0 |
|
||||||
|
| 2024 | 3 | 0.33 | distribution+range | 0 |
|
||||||
|
| 2025 | 1 | 0 | range (obs) | n=1 win |
|
||||||
|
| pre_2023 | 8 | **0.875** | distribution | 0.37 |
|
||||||
|
| 2023plus | 5 | 0.40 | distribution+range | 1.22 |
|
||||||
|
|
||||||
|
Verdict: **distribution 归因在多数有样本切片上稳定**(PASS)。
|
||||||
|
2023+ / 2024–25 中 range 占比上升 → 保持 **观察标签**,不升格为交易规则。
|
||||||
|
|
||||||
|
## Do not
|
||||||
|
|
||||||
|
- 调 Spring / soft-score / Gate 阈值
|
||||||
|
- 因 range 小样本正 PF 开放 range 交易
|
||||||
|
- 复活 LPS / 默认跨资产
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
# Validity Boundary — Market-State Gated Spring
|
||||||
|
|
||||||
|
## Definition (hard)
|
||||||
|
|
||||||
|
```text
|
||||||
|
market_state in {accumulation, markup} -> allow Spring
|
||||||
|
else -> block Spring
|
||||||
|
```
|
||||||
|
|
||||||
|
Spring 信号本体 = `V1_BASELINE`(FROZEN)。
|
||||||
|
Gate = Decision 层默认规则(state_set v1.1 = **PASS**)。
|
||||||
|
|
||||||
|
Soft-score / hard-score 阈值 **不进入默认规则**。
|
||||||
|
|
||||||
|
## Validity statement
|
||||||
|
|
||||||
|
Spring has positive expectancy under:
|
||||||
|
|
||||||
|
1. BTC market
|
||||||
|
2. Causal `market_state ∈ {accumulation, markup}`
|
||||||
|
3. 8h / 4h / 1h alignment
|
||||||
|
4. Trend-compatible (range already blocked in Baseline)
|
||||||
|
|
||||||
|
Invalid under:
|
||||||
|
|
||||||
|
1. `distribution`
|
||||||
|
2. `range`
|
||||||
|
3. `markdown`(对 SPRING_LONG)
|
||||||
|
4. Ungated global trading
|
||||||
|
|
||||||
|
## Causal state (entry-time only)
|
||||||
|
|
||||||
|
```
|
||||||
|
bear & ema_slope >= -1% → accumulation
|
||||||
|
bull & ema_slope > +0.5% → markup
|
||||||
|
bull & ema_slope <= +0.5% → distribution
|
||||||
|
bear & ema_slope < -1% → markdown
|
||||||
|
else → range
|
||||||
|
```
|
||||||
|
|
||||||
|
## Gate performance (net fee+slip)
|
||||||
|
|
||||||
|
| Window | Baseline | Gated state_set |
|
||||||
|
|--------|----------|-----------------|
|
||||||
|
| 2023+ | n=20 PF 1.45 DD 7.9% | n=7 PF **1.34** DD **3.4%** |
|
||||||
|
| full | n=47 PF 0.74 DD 26% | n=17 PF **0.92** DD **9.6%** |
|
||||||
|
|
||||||
|
Confidence: **MEDIUM / defined-domain PASS**(full PF 仍 < 1)。
|
||||||
|
|
||||||
|
## Negative-domain audit
|
||||||
|
|
||||||
|
`scripts/wyckoff_negative_domain_audit_result.json`
|
||||||
|
|
||||||
|
对 Baseline 全部 `SPRING_LONG`(n=28)按因果状态拆 kept/blocked:
|
||||||
|
|
||||||
|
| | n | PF | 含义 |
|
||||||
|
|--|---|-----|------|
|
||||||
|
| Kept | 15 | 1.09 | 全部在 markup |
|
||||||
|
| Blocked | 13 | 0.58 | **100% bad domain** |
|
||||||
|
| Blocked × distribution | 9 | **0.38** | 主杀伤区 |
|
||||||
|
| Blocked × range | 4 | 1.14 | 样本小,非干净杀伤 |
|
||||||
|
|
||||||
|
→ Gate 主要过滤 **distribution 结构性失效**,符合威科夫「Spring 是吸筹事件而非形态」的边界叙事。
|
||||||
|
|
||||||
|
## Default stack(LOCKED)
|
||||||
|
|
||||||
|
```
|
||||||
|
8h causal market_state
|
||||||
|
↓
|
||||||
|
Decision: state_set Gate v1.1 ← LOCKED
|
||||||
|
↓
|
||||||
|
Frozen V1_BASELINE Spring / UTAD
|
||||||
|
```
|
||||||
|
|
||||||
|
## Year/era robustness(冻结前确认)
|
||||||
|
|
||||||
|
`scripts/wyckoff_gate_robustness_slices_result.json`
|
||||||
|
|
||||||
|
- pre_2023 blocked:distribution share **87.5%**,blocked PF 0.37
|
||||||
|
- 多数年份 blocked 以 distribution 为首
|
||||||
|
- 2023+ blocked:distribution + range 并存;range **仅观察**,不改规则
|
||||||
|
- 不因 2023+ blocked 弱正 PF 或 range n=4 回滚 Gate
|
||||||
|
|
||||||
|
**Primary invalidation domain = distribution(稳定)**
|
||||||
|
**range = observation bucket only**
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# Spring Baseline V1 — FROZEN SNAPSHOT
|
||||||
|
|
||||||
|
勿改本目录文件。可运行副本在:
|
||||||
|
|
||||||
|
- `strategies/Wyckoff_BTC_V1_BASELINE.py`
|
||||||
|
- `config/Wyckoff_BTC_V1_BASELINE.json`
|
||||||
|
|
||||||
|
## Evidence (cost-adjusted)
|
||||||
|
|
||||||
|
| Window | Profit | n | DD | Net PF |
|
||||||
|
|--------|--------|---|-----|--------|
|
||||||
|
| Train | +1.66% | 12 | 3.6% | 1.17 |
|
||||||
|
| Validate | +9.99% | 6 | 1.8% | 6.20 |
|
||||||
|
| Test | +0.85% | 2 | 0.7% | 2.18 |
|
||||||
|
| Full | +12.74% | 20 | 3.6% | 2.02 |
|
||||||
|
| fee+slip 5bps | +6.78% | 20 | — | **1.45** |
|
||||||
|
|
||||||
|
Status: **PASS + Limited Evidence** (N=20)
|
||||||
|
Next: Phase3 → N≥50(延历史 / 多品种),不改规则。
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://schema.freqtrade.io/schema.json",
|
||||||
|
"max_open_trades": 1,
|
||||||
|
"stake_currency": "USDT",
|
||||||
|
"stake_amount": "unlimited",
|
||||||
|
"tradable_balance_ratio": 0.99,
|
||||||
|
"fiat_display_currency": "USD",
|
||||||
|
"dry_run": true,
|
||||||
|
"db_url": "sqlite:///tradesv3.wyckoff_btc_v1_baseline.sqlite",
|
||||||
|
"dry_run_wallet": 10000,
|
||||||
|
"cancel_open_orders_on_exit": true,
|
||||||
|
"trading_mode": "futures",
|
||||||
|
"margin_mode": "isolated",
|
||||||
|
"can_short": true,
|
||||||
|
"timeframe": "1h",
|
||||||
|
"process_only_new_candles": true,
|
||||||
|
"unfilledtimeout": {
|
||||||
|
"entry": 60,
|
||||||
|
"exit": 60,
|
||||||
|
"exit_timeout_count": 5,
|
||||||
|
"unit": "minutes"
|
||||||
|
},
|
||||||
|
"entry_pricing": {
|
||||||
|
"price_side": "same",
|
||||||
|
"use_order_book": true,
|
||||||
|
"order_book_top": 1,
|
||||||
|
"price_last_balance": 0.0,
|
||||||
|
"check_depth_of_market": {
|
||||||
|
"enabled": false,
|
||||||
|
"bids_to_ask_delta": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"exit_pricing": {
|
||||||
|
"price_side": "same",
|
||||||
|
"use_order_book": true,
|
||||||
|
"order_book_top": 1
|
||||||
|
},
|
||||||
|
"exchange": {
|
||||||
|
"name": "binance",
|
||||||
|
"key": "",
|
||||||
|
"secret": "",
|
||||||
|
"ccxt_config": {
|
||||||
|
"proxies": {
|
||||||
|
"http": "http://127.0.0.1:7897",
|
||||||
|
"https": "http://127.0.0.1:7897"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ccxt_async_config": {
|
||||||
|
"aiohttp_proxy": "http://127.0.0.1:7897"
|
||||||
|
},
|
||||||
|
"pair_whitelist": [
|
||||||
|
"BTC/USDT:USDT"
|
||||||
|
],
|
||||||
|
"pair_blacklist": [
|
||||||
|
"BNB/.*"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"pairlists": [
|
||||||
|
{
|
||||||
|
"method": "StaticPairList"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"telegram": {
|
||||||
|
"enabled": false,
|
||||||
|
"token": "",
|
||||||
|
"chat_id": ""
|
||||||
|
},
|
||||||
|
"api_server": {
|
||||||
|
"enabled": false,
|
||||||
|
"listen_ip_address": "127.0.0.1",
|
||||||
|
"listen_port": 8823,
|
||||||
|
"verbosity": "error",
|
||||||
|
"enable_openapi": false,
|
||||||
|
"jwt_secret_key": "wyckoff-v1-baseline-change-me",
|
||||||
|
"ws_token": "wyckoff-v1-baseline-ws-change-me",
|
||||||
|
"CORS_origins": [],
|
||||||
|
"username": "freqtrader",
|
||||||
|
"password": "FreqTrade007"
|
||||||
|
},
|
||||||
|
"bot_name": "wyckoff_btc_v1_baseline",
|
||||||
|
"initial_state": "running",
|
||||||
|
"force_entry_enable": false,
|
||||||
|
"internals": {
|
||||||
|
"process_throttle_secs": 5
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,368 @@
|
|||||||
|
# --- Do not remove these libs ---
|
||||||
|
"""
|
||||||
|
Wyckoff BTC V1.0 BASELINE — FROZEN
|
||||||
|
|
||||||
|
Status: BASELINE FROZEN
|
||||||
|
Evidence: PASS (+ Limited Evidence, N=20)
|
||||||
|
Cost Adjusted: PASS (net PF 1.45 @ fee+slip 5bps)
|
||||||
|
Risk: small sample — 目标积累 N>=50 再谈规模
|
||||||
|
|
||||||
|
Branch A: Spring Reversal
|
||||||
|
8h bias + 4h structure + 1h Spring/UTAD
|
||||||
|
Range disabled(regime_mode=trend)
|
||||||
|
ATR + 结构止损
|
||||||
|
setup_type: SPRING / UTAD
|
||||||
|
|
||||||
|
证据: user_data/Chan/scripts/wyckoff_v1_baseline_phase2.json
|
||||||
|
LPS 是独立 Setup 研究,禁止并入本文件调参。
|
||||||
|
"""
|
||||||
|
from freqtrade.strategy import (
|
||||||
|
IStrategy, IntParameter, DecimalParameter, CategoricalParameter,
|
||||||
|
merge_informative_pair, stoploss_from_open, stoploss_from_absolute,
|
||||||
|
)
|
||||||
|
from freqtrade.persistence import Trade
|
||||||
|
import talib.abstract as ta
|
||||||
|
from pandas import DataFrame
|
||||||
|
import pandas as pd
|
||||||
|
import numpy as np
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# freqtrade backtesting -c ./user_data/Chan/config/Wyckoff_BTC_V1_BASELINE.json \
|
||||||
|
# --strategy Wyckoff_BTC_V1_BASELINE --strategy-path ./user_data/Chan/strategies --timerange=20230101-
|
||||||
|
|
||||||
|
|
||||||
|
class Wyckoff_BTC_V1_BASELINE(IStrategy):
|
||||||
|
"""冻结基线:Spring 反转。禁止继续调参;对比实验请用独立分支。"""
|
||||||
|
INTERFACE_VERSION = 3
|
||||||
|
STRATEGY_VERSION = "V1.0_BASELINE"
|
||||||
|
SETUP_FAMILY = "SPRING"
|
||||||
|
|
||||||
|
timeframe = "1h"
|
||||||
|
structure_timeframe = "4h"
|
||||||
|
bias_timeframe: Optional[str] = "8h"
|
||||||
|
use_bias_filter = True
|
||||||
|
# trend = bull|bear only(Range disabled — 理论一致性约束,非调参)
|
||||||
|
regime_mode: str = "trend"
|
||||||
|
|
||||||
|
can_short = True
|
||||||
|
process_only_new_candles = True
|
||||||
|
startup_candle_count = 220
|
||||||
|
|
||||||
|
minimal_roi = {
|
||||||
|
"0": 0.10,
|
||||||
|
"1440": 0.05,
|
||||||
|
"4320": 0.025,
|
||||||
|
"10080": 0,
|
||||||
|
}
|
||||||
|
stoploss = -0.10
|
||||||
|
use_custom_stoploss = True
|
||||||
|
trailing_stop = True
|
||||||
|
trailing_stop_positive = 0.02
|
||||||
|
trailing_stop_positive_offset = 0.04
|
||||||
|
trailing_only_offset_is_reached = True
|
||||||
|
use_exit_signal = True
|
||||||
|
exit_profit_only = False
|
||||||
|
|
||||||
|
# ---- 冻结默认值(optimize=False)----
|
||||||
|
range_lookback = IntParameter(12, 48, default=24, space="buy", optimize=False)
|
||||||
|
spring_pierce_pct = DecimalParameter(0.001, 0.012, default=0.004, decimals=3, space="buy", optimize=False)
|
||||||
|
vol_spike_mult = DecimalParameter(1.1, 2.5, default=1.8, decimals=1, space="buy", optimize=False)
|
||||||
|
adx_min = IntParameter(10, 28, default=14, space="buy", optimize=False)
|
||||||
|
tr_pos_long_max = DecimalParameter(0.35, 0.55, default=0.45, decimals=2, space="buy", optimize=False)
|
||||||
|
tr_pos_short_min = DecimalParameter(0.45, 0.65, default=0.55, decimals=2, space="buy", optimize=False)
|
||||||
|
atr_sl_mult = DecimalParameter(1.2, 3.5, default=1.5, decimals=1, space="sell", optimize=False)
|
||||||
|
atr_sl_min = DecimalParameter(0.012, 0.04, default=0.018, decimals=3, space="sell", optimize=False)
|
||||||
|
atr_sl_max = DecimalParameter(0.05, 0.12, default=0.08, decimals=2, space="sell", optimize=False)
|
||||||
|
time_stop_hours = IntParameter(48, 240, default=120, space="sell", optimize=False)
|
||||||
|
|
||||||
|
# Branch A:仅 Spring / UTAD
|
||||||
|
use_spring_sig = CategoricalParameter([True, False], default=True, space="buy", optimize=False)
|
||||||
|
use_utad_sig = CategoricalParameter([True, False], default=True, space="buy", optimize=False)
|
||||||
|
use_sos_sig = CategoricalParameter([True, False], default=False, space="buy", optimize=False)
|
||||||
|
use_sow_sig = CategoricalParameter([True, False], default=False, space="buy", optimize=False)
|
||||||
|
|
||||||
|
lev = 1.0
|
||||||
|
|
||||||
|
def informative_pairs(self):
|
||||||
|
pairs = self.dp.current_whitelist() if self.dp else []
|
||||||
|
tfs = {self.structure_timeframe}
|
||||||
|
if self.bias_timeframe and self.use_bias_filter:
|
||||||
|
tfs.add(self.bias_timeframe)
|
||||||
|
return [(pair, tf) for pair in pairs for tf in tfs]
|
||||||
|
|
||||||
|
def _add_wyckoff_structure(self, df: DataFrame) -> DataFrame:
|
||||||
|
lb = int(self.range_lookback.value)
|
||||||
|
|
||||||
|
df["atr"] = ta.ATR(df, timeperiod=14)
|
||||||
|
df["ema50"] = ta.EMA(df, timeperiod=50)
|
||||||
|
df["ema200"] = ta.EMA(df, timeperiod=200)
|
||||||
|
df["adx"] = ta.ADX(df, timeperiod=14)
|
||||||
|
df["rsi"] = ta.RSI(df, timeperiod=14)
|
||||||
|
df["volume_ma"] = ta.SMA(df, timeperiod=20, price="volume")
|
||||||
|
|
||||||
|
df["tr_high"] = df["high"].rolling(lb).max()
|
||||||
|
df["tr_low"] = df["low"].rolling(lb).min()
|
||||||
|
df["tr_mid"] = (df["tr_high"] + df["tr_low"]) / 2.0
|
||||||
|
df["tr_width"] = (df["tr_high"] - df["tr_low"]) / df["tr_mid"].replace(0, np.nan)
|
||||||
|
df["tr_width_ma"] = df["tr_width"].rolling(lb).mean()
|
||||||
|
|
||||||
|
rng = (df["tr_high"] - df["tr_low"]).replace(0, np.nan)
|
||||||
|
df["tr_pos"] = (df["close"] - df["tr_low"]) / rng
|
||||||
|
|
||||||
|
df["in_range"] = (df["tr_width"] < df["tr_width_ma"] * 1.35) & (df["adx"] < 28)
|
||||||
|
df["ema50_slope"] = df["ema50"] - df["ema50"].shift(8)
|
||||||
|
df["prior_down"] = df["ema50_slope"].shift(lb) < 0
|
||||||
|
df["prior_up"] = df["ema50_slope"].shift(lb) > 0
|
||||||
|
|
||||||
|
down_bar = df["close"] < df["open"]
|
||||||
|
up_bar = df["close"] > df["open"]
|
||||||
|
vol_down = np.where(down_bar, df["volume"], np.nan)
|
||||||
|
vol_up = np.where(up_bar, df["volume"], np.nan)
|
||||||
|
df["vol_down_ma"] = pd.Series(vol_down, index=df.index).rolling(10, min_periods=3).mean()
|
||||||
|
df["vol_up_ma"] = pd.Series(vol_up, index=df.index).rolling(10, min_periods=3).mean()
|
||||||
|
df["effort_absorb"] = (
|
||||||
|
df["vol_down_ma"].notna()
|
||||||
|
& df["vol_up_ma"].notna()
|
||||||
|
& (df["vol_up_ma"] > df["vol_down_ma"] * 1.05)
|
||||||
|
)
|
||||||
|
|
||||||
|
df["accum_ctx"] = (
|
||||||
|
df["in_range"]
|
||||||
|
& (df["prior_down"] | (df["close"] < df["ema50"]))
|
||||||
|
& (df["tr_pos"] < float(self.tr_pos_long_max.value))
|
||||||
|
)
|
||||||
|
df["distrib_ctx"] = (
|
||||||
|
df["in_range"]
|
||||||
|
& (df["prior_up"] | (df["close"] > df["ema50"]))
|
||||||
|
& (df["tr_pos"] > float(self.tr_pos_short_min.value))
|
||||||
|
)
|
||||||
|
df["bull_bias"] = (df["close"] > df["ema200"]) & (df["ema50"] > df["ema200"])
|
||||||
|
df["bear_bias"] = (df["close"] < df["ema200"]) & (df["ema50"] < df["ema200"])
|
||||||
|
df["vol_spike"] = df["volume"] > df["volume_ma"] * float(self.vol_spike_mult.value)
|
||||||
|
return df
|
||||||
|
|
||||||
|
def _merge_tf(self, dataframe: DataFrame, pair: str, tf: str) -> DataFrame:
|
||||||
|
inf = self.dp.get_pair_dataframe(pair=pair, timeframe=tf)
|
||||||
|
inf = self._add_wyckoff_structure(inf)
|
||||||
|
keep = [
|
||||||
|
"date", "atr", "ema50", "ema200", "adx", "rsi",
|
||||||
|
"tr_high", "tr_low", "tr_mid", "tr_width", "tr_pos",
|
||||||
|
"in_range", "accum_ctx", "distrib_ctx",
|
||||||
|
"vol_spike", "effort_absorb", "prior_down", "prior_up",
|
||||||
|
"bull_bias", "bear_bias",
|
||||||
|
]
|
||||||
|
inf = inf[[c for c in keep if c in inf.columns]].copy()
|
||||||
|
return merge_informative_pair(dataframe, inf, self.timeframe, tf, ffill=True)
|
||||||
|
|
||||||
|
def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
|
||||||
|
pair = metadata["pair"]
|
||||||
|
stf = self.structure_timeframe
|
||||||
|
dataframe = self._merge_tf(dataframe, pair, stf)
|
||||||
|
|
||||||
|
btf = self.bias_timeframe
|
||||||
|
if btf and self.use_bias_filter and btf != stf:
|
||||||
|
dataframe = self._merge_tf(dataframe, pair, btf)
|
||||||
|
|
||||||
|
ss = f"_{stf}"
|
||||||
|
dataframe["atr"] = ta.ATR(dataframe, timeperiod=14)
|
||||||
|
dataframe["ema21"] = ta.EMA(dataframe, timeperiod=21)
|
||||||
|
dataframe["ema50"] = ta.EMA(dataframe, timeperiod=50)
|
||||||
|
dataframe["rsi"] = ta.RSI(dataframe, timeperiod=14)
|
||||||
|
dataframe["volume_ma"] = ta.SMA(dataframe, timeperiod=20, price="volume")
|
||||||
|
dataframe["vol_ok"] = dataframe["volume"] > dataframe["volume_ma"] * float(self.vol_spike_mult.value)
|
||||||
|
|
||||||
|
tr_high = dataframe[f"tr_high{ss}"]
|
||||||
|
tr_low = dataframe[f"tr_low{ss}"]
|
||||||
|
pierce = float(self.spring_pierce_pct.value)
|
||||||
|
|
||||||
|
accum_soft = (
|
||||||
|
dataframe[f"accum_ctx{ss}"].fillna(False).astype(bool)
|
||||||
|
| (
|
||||||
|
dataframe[f"in_range{ss}"].fillna(False).astype(bool)
|
||||||
|
& dataframe[f"prior_down{ss}"].fillna(False).astype(bool)
|
||||||
|
& (dataframe[f"tr_pos{ss}"] < float(self.tr_pos_long_max.value))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
distrib_soft = (
|
||||||
|
dataframe[f"distrib_ctx{ss}"].fillna(False).astype(bool)
|
||||||
|
| (
|
||||||
|
dataframe[f"in_range{ss}"].fillna(False).astype(bool)
|
||||||
|
& dataframe[f"prior_up{ss}"].fillna(False).astype(bool)
|
||||||
|
& (dataframe[f"tr_pos{ss}"] > float(self.tr_pos_short_min.value))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if btf and self.use_bias_filter:
|
||||||
|
bs = f"_{btf}" if btf != stf else ss
|
||||||
|
if f"bear_bias{bs}" in dataframe.columns:
|
||||||
|
dataframe["bias_long_ok"] = ~dataframe[f"bear_bias{bs}"].fillna(False).astype(bool)
|
||||||
|
dataframe["bias_short_ok"] = ~dataframe[f"bull_bias{bs}"].fillna(False).astype(bool)
|
||||||
|
else:
|
||||||
|
dataframe["bias_long_ok"] = True
|
||||||
|
dataframe["bias_short_ok"] = True
|
||||||
|
else:
|
||||||
|
dataframe["bias_long_ok"] = True
|
||||||
|
dataframe["bias_short_ok"] = True
|
||||||
|
|
||||||
|
vol_mild = dataframe["volume"] > dataframe["volume_ma"] * max(1.1, float(self.vol_spike_mult.value) * 0.85)
|
||||||
|
|
||||||
|
dataframe["spring"] = (
|
||||||
|
tr_low.notna()
|
||||||
|
& (dataframe["low"] < tr_low * (1.0 - pierce))
|
||||||
|
& (dataframe["close"] > tr_low)
|
||||||
|
& (dataframe["close"] > dataframe["open"])
|
||||||
|
& accum_soft
|
||||||
|
& vol_mild
|
||||||
|
& (dataframe["rsi"] < 58)
|
||||||
|
& dataframe["bias_long_ok"]
|
||||||
|
)
|
||||||
|
dataframe["utad"] = (
|
||||||
|
tr_high.notna()
|
||||||
|
& (dataframe["high"] > tr_high * (1.0 + pierce))
|
||||||
|
& (dataframe["close"] < tr_high)
|
||||||
|
& (dataframe["close"] < dataframe["open"])
|
||||||
|
& distrib_soft
|
||||||
|
& vol_mild
|
||||||
|
& (dataframe["rsi"] > 42)
|
||||||
|
& dataframe["bias_short_ok"]
|
||||||
|
)
|
||||||
|
# 基线不进 SOS/SOW;保留列供 exit 参考
|
||||||
|
dataframe["sos"] = False
|
||||||
|
dataframe["sow"] = False
|
||||||
|
|
||||||
|
for col in ["spring", "utad", "sos", "sow", "vol_ok", "bias_long_ok", "bias_short_ok"]:
|
||||||
|
dataframe[col] = dataframe[col].fillna(False).astype(bool)
|
||||||
|
dataframe["setup_type"] = ""
|
||||||
|
dataframe.loc[dataframe["spring"], "setup_type"] = "SPRING_LONG"
|
||||||
|
dataframe.loc[dataframe["utad"], "setup_type"] = "UTAD_SHORT"
|
||||||
|
return dataframe
|
||||||
|
|
||||||
|
def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
|
||||||
|
dataframe["enter_long"] = 0
|
||||||
|
dataframe["enter_short"] = 0
|
||||||
|
dataframe["enter_tag"] = ""
|
||||||
|
|
||||||
|
vol_ok = dataframe["volume"] > 0
|
||||||
|
|
||||||
|
# 分开标签:禁止把 SPRING / UTAD 混成同一统计桶
|
||||||
|
if bool(self.use_spring_sig.value):
|
||||||
|
cond = vol_ok & dataframe["spring"]
|
||||||
|
dataframe.loc[cond, ["enter_long", "enter_tag"]] = (1, "SPRING_LONG")
|
||||||
|
|
||||||
|
if bool(self.use_utad_sig.value):
|
||||||
|
cond = vol_ok & dataframe["utad"]
|
||||||
|
dataframe.loc[cond, ["enter_short", "enter_tag"]] = (1, "UTAD_SHORT")
|
||||||
|
|
||||||
|
self._apply_regime_filter(dataframe)
|
||||||
|
return dataframe
|
||||||
|
|
||||||
|
def _apply_regime_filter(self, dataframe: DataFrame) -> None:
|
||||||
|
rm = getattr(self, "regime_mode", "all")
|
||||||
|
if rm == "all" or not self.bias_timeframe:
|
||||||
|
return
|
||||||
|
bs = f"_{self.bias_timeframe}"
|
||||||
|
bc, ec = f"bull_bias{bs}", f"bear_bias{bs}"
|
||||||
|
if bc not in dataframe.columns or ec not in dataframe.columns:
|
||||||
|
return
|
||||||
|
bull = dataframe[bc].fillna(False).astype(bool)
|
||||||
|
bear = dataframe[ec].fillna(False).astype(bool)
|
||||||
|
both = bull & bear
|
||||||
|
bull, bear = bull & ~both, bear & ~both
|
||||||
|
range_m = (~bull) & (~bear)
|
||||||
|
if rm == "bull":
|
||||||
|
mask = ~bull
|
||||||
|
elif rm == "bear":
|
||||||
|
mask = ~bear
|
||||||
|
elif rm == "range":
|
||||||
|
mask = ~range_m
|
||||||
|
elif rm == "trend":
|
||||||
|
mask = range_m # Range disabled
|
||||||
|
else:
|
||||||
|
return
|
||||||
|
dataframe.loc[mask, ["enter_long", "enter_short"]] = (0, 0)
|
||||||
|
dataframe.loc[mask, "enter_tag"] = ""
|
||||||
|
|
||||||
|
def populate_exit_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
|
||||||
|
dataframe["exit_long"] = 0
|
||||||
|
dataframe["exit_short"] = 0
|
||||||
|
dataframe["exit_tag"] = ""
|
||||||
|
ss = f"_{self.structure_timeframe}"
|
||||||
|
|
||||||
|
exit_long = dataframe["utad"] | (
|
||||||
|
dataframe[f"distrib_ctx{ss}"].fillna(False).astype(bool)
|
||||||
|
& (dataframe["close"] < dataframe["ema21"])
|
||||||
|
& (dataframe["rsi"] < 45)
|
||||||
|
)
|
||||||
|
exit_short = dataframe["spring"] | (
|
||||||
|
dataframe[f"accum_ctx{ss}"].fillna(False).astype(bool)
|
||||||
|
& (dataframe["close"] > dataframe["ema21"])
|
||||||
|
& (dataframe["rsi"] > 55)
|
||||||
|
)
|
||||||
|
dataframe.loc[exit_long, ["exit_long", "exit_tag"]] = (1, "wyckoff_phase_flip")
|
||||||
|
dataframe.loc[exit_short, ["exit_short", "exit_tag"]] = (1, "wyckoff_phase_flip")
|
||||||
|
return dataframe
|
||||||
|
|
||||||
|
def custom_stoploss(
|
||||||
|
self, pair: str, trade: Trade, current_time: datetime,
|
||||||
|
current_rate: float, current_profit: float, after_fill: bool, **kwargs,
|
||||||
|
) -> Optional[float]:
|
||||||
|
dataframe, _ = self.dp.get_analyzed_dataframe(pair, self.timeframe)
|
||||||
|
if dataframe.empty:
|
||||||
|
return None
|
||||||
|
last = dataframe.iloc[-1]
|
||||||
|
atr = float(last["atr"]) if pd.notna(last["atr"]) else 0.0
|
||||||
|
if atr <= 0 or trade.open_rate <= 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
atr_dist = float(self.atr_sl_mult.value) * atr
|
||||||
|
tag = trade.enter_tag or ""
|
||||||
|
buffer = atr * 0.15
|
||||||
|
|
||||||
|
if after_fill and trade.get_custom_data("struct_stop") is None:
|
||||||
|
if trade.is_short:
|
||||||
|
trade.set_custom_data("struct_stop", float(last["high"]) + buffer)
|
||||||
|
else:
|
||||||
|
trade.set_custom_data("struct_stop", float(last["low"]) - buffer)
|
||||||
|
|
||||||
|
struct = trade.get_custom_data("struct_stop")
|
||||||
|
if trade.is_short:
|
||||||
|
atr_stop = trade.open_rate + atr_dist
|
||||||
|
stop_price = min(atr_stop, float(struct)) if struct is not None else atr_stop
|
||||||
|
else:
|
||||||
|
atr_stop = trade.open_rate - atr_dist
|
||||||
|
stop_price = max(atr_stop, float(struct)) if struct is not None else atr_stop
|
||||||
|
|
||||||
|
raw = abs(trade.open_rate - stop_price) / trade.open_rate
|
||||||
|
raw = min(max(raw, float(self.atr_sl_min.value)), float(self.atr_sl_max.value))
|
||||||
|
if struct is not None and tag in (
|
||||||
|
"SPRING_LONG", "UTAD_SHORT", "SPRING", "UTAD", "wyckoff_spring", "wyckoff_utad",
|
||||||
|
):
|
||||||
|
sl = stoploss_from_absolute(
|
||||||
|
stop_price, current_rate, is_short=trade.is_short, leverage=trade.leverage
|
||||||
|
)
|
||||||
|
return sl if sl and sl > 0 else None
|
||||||
|
return stoploss_from_open(
|
||||||
|
-raw, current_profit, is_short=trade.is_short, leverage=trade.leverage
|
||||||
|
) or None
|
||||||
|
|
||||||
|
def custom_exit(
|
||||||
|
self, pair: str, trade: Trade, current_time: datetime,
|
||||||
|
current_rate: float, current_profit: float, **kwargs,
|
||||||
|
) -> Optional[str]:
|
||||||
|
hours = (current_time - trade.open_date_utc).total_seconds() / 3600
|
||||||
|
if hours > float(self.time_stop_hours.value) and current_profit < 0:
|
||||||
|
return "wyckoff_time_stop"
|
||||||
|
if hours > float(self.time_stop_hours.value) * 2:
|
||||||
|
return "wyckoff_time_stop_max"
|
||||||
|
return None
|
||||||
|
|
||||||
|
def leverage(
|
||||||
|
self, pair: str, current_time: datetime, current_rate: float,
|
||||||
|
proposed_leverage: float, max_leverage: float, entry_tag: Optional[str],
|
||||||
|
side: str, **kwargs,
|
||||||
|
) -> float:
|
||||||
|
return min(self.lev, max_leverage)
|
||||||
@@ -0,0 +1,460 @@
|
|||||||
|
{
|
||||||
|
"branches": {
|
||||||
|
"Spring_V1": {
|
||||||
|
"wfo": {
|
||||||
|
"train": {
|
||||||
|
"timerange": "20230101-20250101",
|
||||||
|
"profit_pct": 1.6587295176,
|
||||||
|
"trades": 12,
|
||||||
|
"dd_pct": 3.644907735100005,
|
||||||
|
"pf": 1.1700179329477578,
|
||||||
|
"winrate": 25.0,
|
||||||
|
"final": 10165.87295176,
|
||||||
|
"fee_used": 0.0005,
|
||||||
|
"regime_loaded": "trend"
|
||||||
|
},
|
||||||
|
"validate": {
|
||||||
|
"timerange": "20250101-20260101",
|
||||||
|
"profit_pct": 9.990534148400002,
|
||||||
|
"trades": 6,
|
||||||
|
"dd_pct": 1.797834787912851,
|
||||||
|
"pf": 6.201791679101682,
|
||||||
|
"winrate": 66.66666666666666,
|
||||||
|
"final": 10999.05341484,
|
||||||
|
"fee_used": 0.0005,
|
||||||
|
"regime_loaded": "trend"
|
||||||
|
},
|
||||||
|
"test": {
|
||||||
|
"timerange": "20260101-",
|
||||||
|
"profit_pct": 0.8458820224000001,
|
||||||
|
"trades": 2,
|
||||||
|
"dd_pct": 0.7197049309999966,
|
||||||
|
"pf": 2.175317808681236,
|
||||||
|
"winrate": 50.0,
|
||||||
|
"final": 10084.58820224,
|
||||||
|
"fee_used": 0.0005,
|
||||||
|
"regime_loaded": "trend"
|
||||||
|
},
|
||||||
|
"full": {
|
||||||
|
"timerange": "20230101-",
|
||||||
|
"profit_pct": 12.7374753063,
|
||||||
|
"trades": 20,
|
||||||
|
"dd_pct": 3.644907735100005,
|
||||||
|
"pf": 2.0183507402435503,
|
||||||
|
"winrate": 40.0,
|
||||||
|
"final": 11273.74753063,
|
||||||
|
"fee_used": 0.0005,
|
||||||
|
"regime_loaded": "trend"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"regimes": {
|
||||||
|
"trend": {
|
||||||
|
"profit_pct": 12.7374753063,
|
||||||
|
"trades": 20,
|
||||||
|
"dd_pct": 3.644907735100005,
|
||||||
|
"pf": 2.0183507402435503,
|
||||||
|
"winrate": 40.0,
|
||||||
|
"final": 11273.74753063,
|
||||||
|
"fee_used": 0.0005,
|
||||||
|
"regime_loaded": "trend"
|
||||||
|
},
|
||||||
|
"bull": {
|
||||||
|
"profit_pct": 8.166882314399999,
|
||||||
|
"trades": 12,
|
||||||
|
"dd_pct": 3.4837023928902555,
|
||||||
|
"pf": 1.9398544482027922,
|
||||||
|
"winrate": 33.33333333333333,
|
||||||
|
"final": 10816.688231439999,
|
||||||
|
"fee_used": 0.0005,
|
||||||
|
"regime_loaded": "bull"
|
||||||
|
},
|
||||||
|
"bear": {
|
||||||
|
"profit_pct": 4.2503064875000005,
|
||||||
|
"trades": 8,
|
||||||
|
"dd_pct": 3.173714645599994,
|
||||||
|
"pf": 2.084295240772406,
|
||||||
|
"winrate": 50.0,
|
||||||
|
"final": 10425.03064875,
|
||||||
|
"fee_used": 0.0005,
|
||||||
|
"regime_loaded": "bear"
|
||||||
|
},
|
||||||
|
"range": {
|
||||||
|
"profit_pct": -4.3352539371,
|
||||||
|
"trades": 6,
|
||||||
|
"dd_pct": 4.404162180500007,
|
||||||
|
"pf": 0.15746188404490422,
|
||||||
|
"winrate": 16.666666666666664,
|
||||||
|
"final": 9566.47460629,
|
||||||
|
"fee_used": 0.0005,
|
||||||
|
"regime_loaded": "range"
|
||||||
|
},
|
||||||
|
"all": {
|
||||||
|
"profit_pct": 7.831216539699999,
|
||||||
|
"trades": 26,
|
||||||
|
"dd_pct": 7.883451762900004,
|
||||||
|
"pf": 1.454582067425369,
|
||||||
|
"winrate": 34.61538461538461,
|
||||||
|
"final": 10783.12165397,
|
||||||
|
"fee_used": 0.0005,
|
||||||
|
"regime_loaded": "all"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"cost_stress": {
|
||||||
|
"fee_5bps": {
|
||||||
|
"profit_pct": 12.7374753063,
|
||||||
|
"trades": 20,
|
||||||
|
"dd_pct": 3.644907735100005,
|
||||||
|
"pf": 2.0183507402435503,
|
||||||
|
"winrate": 40.0,
|
||||||
|
"final": 11273.74753063,
|
||||||
|
"fee_used": 0.0005,
|
||||||
|
"regime_loaded": "trend"
|
||||||
|
},
|
||||||
|
"fee_5bps+slip_5bps": {
|
||||||
|
"profit_pct": 6.782772099999998,
|
||||||
|
"trades": 20,
|
||||||
|
"dd_pct": 7.851805397900007,
|
||||||
|
"pf": 1.4511324473780693,
|
||||||
|
"winrate": 35.0,
|
||||||
|
"final": 10678.27721,
|
||||||
|
"fee_used": 0.001,
|
||||||
|
"regime_loaded": "trend"
|
||||||
|
},
|
||||||
|
"fee_10bps+slip_10bps": {
|
||||||
|
"profit_pct": 3.182909279400001,
|
||||||
|
"trades": 20,
|
||||||
|
"dd_pct": 9.126146157700004,
|
||||||
|
"pf": 1.1834520309921508,
|
||||||
|
"winrate": 35.0,
|
||||||
|
"final": 10318.29092794,
|
||||||
|
"fee_used": 0.002,
|
||||||
|
"regime_loaded": "trend"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"target": {
|
||||||
|
"pf": 1.3,
|
||||||
|
"dd": 10.0,
|
||||||
|
"note": "Spring: PF>1.3 DD<10%"
|
||||||
|
},
|
||||||
|
"verdict": {
|
||||||
|
"full_pf": 2.0183507402435503,
|
||||||
|
"full_dd": 3.644907735100005,
|
||||||
|
"trades_per_year": 5.555555555555555,
|
||||||
|
"net_mid_pf": 1.4511324473780693,
|
||||||
|
"target_pf_ok": true,
|
||||||
|
"target_dd_ok": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"LPS_V1": {
|
||||||
|
"wfo": {
|
||||||
|
"train": {
|
||||||
|
"timerange": "20230101-20250101",
|
||||||
|
"profit_pct": -1.2518571096,
|
||||||
|
"trades": 1,
|
||||||
|
"dd_pct": 1.251857109600005,
|
||||||
|
"pf": 0.0,
|
||||||
|
"winrate": 0.0,
|
||||||
|
"final": 9874.81428904,
|
||||||
|
"fee_used": 0.0005,
|
||||||
|
"regime_loaded": "trend"
|
||||||
|
},
|
||||||
|
"validate": {
|
||||||
|
"timerange": "20250101-20260101",
|
||||||
|
"profit_pct": -0.24613064569999998,
|
||||||
|
"trades": 1,
|
||||||
|
"dd_pct": 0.24613064569999552,
|
||||||
|
"pf": 0.0,
|
||||||
|
"winrate": 0.0,
|
||||||
|
"final": 9975.38693543,
|
||||||
|
"fee_used": 0.0005,
|
||||||
|
"regime_loaded": "trend"
|
||||||
|
},
|
||||||
|
"test": {
|
||||||
|
"timerange": "20260101-",
|
||||||
|
"profit_pct": 0.0,
|
||||||
|
"trades": 0,
|
||||||
|
"dd_pct": 0.0,
|
||||||
|
"pf": 0.0,
|
||||||
|
"winrate": 0.0,
|
||||||
|
"final": 10000.0,
|
||||||
|
"fee_used": 0.0005,
|
||||||
|
"regime_loaded": "trend"
|
||||||
|
},
|
||||||
|
"full": {
|
||||||
|
"timerange": "20230101-",
|
||||||
|
"profit_pct": -1.4952529704,
|
||||||
|
"trades": 2,
|
||||||
|
"dd_pct": 1.4952529703999973,
|
||||||
|
"pf": 0.0,
|
||||||
|
"winrate": 0.0,
|
||||||
|
"final": 9850.47470296,
|
||||||
|
"fee_used": 0.0005,
|
||||||
|
"regime_loaded": "trend"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"regimes": {
|
||||||
|
"trend": {
|
||||||
|
"profit_pct": -1.4952529704,
|
||||||
|
"trades": 2,
|
||||||
|
"dd_pct": 1.4952529703999973,
|
||||||
|
"pf": 0.0,
|
||||||
|
"winrate": 0.0,
|
||||||
|
"final": 9850.47470296,
|
||||||
|
"fee_used": 0.0005,
|
||||||
|
"regime_loaded": "trend"
|
||||||
|
},
|
||||||
|
"bull": {
|
||||||
|
"profit_pct": -1.4952529704,
|
||||||
|
"trades": 2,
|
||||||
|
"dd_pct": 1.4952529703999973,
|
||||||
|
"pf": 0.0,
|
||||||
|
"winrate": 0.0,
|
||||||
|
"final": 9850.47470296,
|
||||||
|
"fee_used": 0.0005,
|
||||||
|
"regime_loaded": "bull"
|
||||||
|
},
|
||||||
|
"bear": {
|
||||||
|
"profit_pct": 0.0,
|
||||||
|
"trades": 0,
|
||||||
|
"dd_pct": 0.0,
|
||||||
|
"pf": 0.0,
|
||||||
|
"winrate": 0.0,
|
||||||
|
"final": 10000.0,
|
||||||
|
"fee_used": 0.0005,
|
||||||
|
"regime_loaded": "bear"
|
||||||
|
},
|
||||||
|
"range": {
|
||||||
|
"profit_pct": 0.0,
|
||||||
|
"trades": 0,
|
||||||
|
"dd_pct": 0.0,
|
||||||
|
"pf": 0.0,
|
||||||
|
"winrate": 0.0,
|
||||||
|
"final": 10000.0,
|
||||||
|
"fee_used": 0.0005,
|
||||||
|
"regime_loaded": "range"
|
||||||
|
},
|
||||||
|
"all": {
|
||||||
|
"profit_pct": -1.4952529704,
|
||||||
|
"trades": 2,
|
||||||
|
"dd_pct": 1.4952529703999973,
|
||||||
|
"pf": 0.0,
|
||||||
|
"winrate": 0.0,
|
||||||
|
"final": 9850.47470296,
|
||||||
|
"fee_used": 0.0005,
|
||||||
|
"regime_loaded": "all"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"cost_stress": {
|
||||||
|
"fee_5bps": {
|
||||||
|
"profit_pct": -1.4952529704,
|
||||||
|
"trades": 2,
|
||||||
|
"dd_pct": 1.4952529703999973,
|
||||||
|
"pf": 0.0,
|
||||||
|
"winrate": 0.0,
|
||||||
|
"final": 9850.47470296,
|
||||||
|
"fee_used": 0.0005,
|
||||||
|
"regime_loaded": "trend"
|
||||||
|
},
|
||||||
|
"fee_5bps+slip_5bps": {
|
||||||
|
"profit_pct": -1.6902037039,
|
||||||
|
"trades": 2,
|
||||||
|
"dd_pct": 1.6902037039000062,
|
||||||
|
"pf": 0.0,
|
||||||
|
"winrate": 0.0,
|
||||||
|
"final": 9830.97962961,
|
||||||
|
"fee_used": 0.001,
|
||||||
|
"regime_loaded": "trend"
|
||||||
|
},
|
||||||
|
"fee_10bps+slip_10bps": {
|
||||||
|
"profit_pct": -2.0801051709,
|
||||||
|
"trades": 2,
|
||||||
|
"dd_pct": 2.080105170900006,
|
||||||
|
"pf": 0.0,
|
||||||
|
"winrate": 0.0,
|
||||||
|
"final": 9791.98948291,
|
||||||
|
"fee_used": 0.002,
|
||||||
|
"regime_loaded": "trend"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"target": {
|
||||||
|
"pf": 1.2,
|
||||||
|
"dd": 15.0,
|
||||||
|
"note": "LPS: PF>1.2, 次数增加"
|
||||||
|
},
|
||||||
|
"version": "LPS_V1.1",
|
||||||
|
"verdict": {
|
||||||
|
"full_pf": 0.0,
|
||||||
|
"full_dd": 1.4952529703999973,
|
||||||
|
"trades_per_year": 0.5555555555555556,
|
||||||
|
"net_mid_pf": 0.0,
|
||||||
|
"target_pf_ok": false,
|
||||||
|
"target_dd_ok": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"LPS_V2": {
|
||||||
|
"version": "LPS_V2",
|
||||||
|
"wfo": {
|
||||||
|
"train": {
|
||||||
|
"timerange": "20230101-20250101",
|
||||||
|
"profit_pct": -3.5049591933000004,
|
||||||
|
"trades": 3,
|
||||||
|
"dd_pct": 3.504959193300001,
|
||||||
|
"pf": 0.0,
|
||||||
|
"winrate": 0.0,
|
||||||
|
"final": 9649.50408067,
|
||||||
|
"fee_used": 0.0005,
|
||||||
|
"regime_loaded": "trend"
|
||||||
|
},
|
||||||
|
"validate": {
|
||||||
|
"timerange": "20250101-20260101",
|
||||||
|
"profit_pct": -1.6143743830000001,
|
||||||
|
"trades": 2,
|
||||||
|
"dd_pct": 1.614374382999995,
|
||||||
|
"pf": 0.0,
|
||||||
|
"winrate": 0.0,
|
||||||
|
"final": 9838.5625617,
|
||||||
|
"fee_used": 0.0005,
|
||||||
|
"regime_loaded": "trend"
|
||||||
|
},
|
||||||
|
"test": {
|
||||||
|
"timerange": "20260101-",
|
||||||
|
"profit_pct": 1.2174468187999996,
|
||||||
|
"trades": 2,
|
||||||
|
"dd_pct": 1.4924489317000007,
|
||||||
|
"pf": 1.81573767312309,
|
||||||
|
"winrate": 50.0,
|
||||||
|
"final": 10121.74468188,
|
||||||
|
"fee_used": 0.0005,
|
||||||
|
"regime_loaded": "trend"
|
||||||
|
},
|
||||||
|
"full": {
|
||||||
|
"timerange": "20230101-",
|
||||||
|
"profit_pct": -3.9055710949000004,
|
||||||
|
"trades": 7,
|
||||||
|
"dd_pct": 6.479119889400008,
|
||||||
|
"pf": 0.39720654015221873,
|
||||||
|
"winrate": 14.285714285714285,
|
||||||
|
"final": 9609.44289051,
|
||||||
|
"fee_used": 0.0005,
|
||||||
|
"regime_loaded": "trend"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"regimes": {
|
||||||
|
"trend": {
|
||||||
|
"profit_pct": -3.9055710949000004,
|
||||||
|
"trades": 7,
|
||||||
|
"dd_pct": 6.479119889400008,
|
||||||
|
"pf": 0.39720654015221873,
|
||||||
|
"winrate": 14.285714285714285,
|
||||||
|
"final": 9609.44289051,
|
||||||
|
"fee_used": 0.0005,
|
||||||
|
"regime_loaded": "trend"
|
||||||
|
},
|
||||||
|
"bull": {
|
||||||
|
"profit_pct": -5.0599199851,
|
||||||
|
"trades": 5,
|
||||||
|
"dd_pct": 5.059919985100005,
|
||||||
|
"pf": 0.0,
|
||||||
|
"winrate": 0.0,
|
||||||
|
"final": 9494.00800149,
|
||||||
|
"fee_used": 0.0005,
|
||||||
|
"regime_loaded": "bull"
|
||||||
|
},
|
||||||
|
"bear": {
|
||||||
|
"profit_pct": 1.2174468187999996,
|
||||||
|
"trades": 2,
|
||||||
|
"dd_pct": 1.4924489317000007,
|
||||||
|
"pf": 1.81573767312309,
|
||||||
|
"winrate": 50.0,
|
||||||
|
"final": 10121.74468188,
|
||||||
|
"fee_used": 0.0005,
|
||||||
|
"regime_loaded": "bear"
|
||||||
|
},
|
||||||
|
"range": {
|
||||||
|
"profit_pct": 0.0,
|
||||||
|
"trades": 0,
|
||||||
|
"dd_pct": 0.0,
|
||||||
|
"pf": 0.0,
|
||||||
|
"winrate": 0.0,
|
||||||
|
"final": 10000.0,
|
||||||
|
"fee_used": 0.0005,
|
||||||
|
"regime_loaded": "range"
|
||||||
|
},
|
||||||
|
"all": {
|
||||||
|
"profit_pct": -3.9055710949000004,
|
||||||
|
"trades": 7,
|
||||||
|
"dd_pct": 6.479119889400008,
|
||||||
|
"pf": 0.39720654015221873,
|
||||||
|
"winrate": 14.285714285714285,
|
||||||
|
"final": 9609.44289051,
|
||||||
|
"fee_used": 0.0005,
|
||||||
|
"regime_loaded": "all"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"cost_stress": {
|
||||||
|
"fee_5bps": {
|
||||||
|
"profit_pct": -3.9055710949000004,
|
||||||
|
"trades": 7,
|
||||||
|
"dd_pct": 6.479119889400008,
|
||||||
|
"pf": 0.39720654015221873,
|
||||||
|
"winrate": 14.285714285714285,
|
||||||
|
"final": 9609.44289051,
|
||||||
|
"fee_used": 0.0005,
|
||||||
|
"regime_loaded": "trend"
|
||||||
|
},
|
||||||
|
"fee_5bps+slip_5bps": {
|
||||||
|
"profit_pct": -4.5549168852,
|
||||||
|
"trades": 7,
|
||||||
|
"dd_pct": 7.020877735199993,
|
||||||
|
"pf": 0.3512325585213674,
|
||||||
|
"winrate": 14.285714285714285,
|
||||||
|
"final": 9544.50831148,
|
||||||
|
"fee_used": 0.001,
|
||||||
|
"regime_loaded": "trend"
|
||||||
|
},
|
||||||
|
"fee_10bps+slip_10bps": {
|
||||||
|
"profit_pct": -5.371762636800001,
|
||||||
|
"trades": 7,
|
||||||
|
"dd_pct": 8.1297357765,
|
||||||
|
"pf": 0.33924511392759654,
|
||||||
|
"winrate": 14.285714285714285,
|
||||||
|
"final": 9462.82373632,
|
||||||
|
"fee_used": 0.002,
|
||||||
|
"regime_loaded": "trend"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"target": {
|
||||||
|
"pf": 1.2,
|
||||||
|
"dd": 15.0,
|
||||||
|
"note": "LPS V2: 4h SOS→1h LPS; PF>1.2; ~5-15/yr"
|
||||||
|
},
|
||||||
|
"verdict": {
|
||||||
|
"full_pf": 0.39720654015221873,
|
||||||
|
"full_dd": 6.479119889400008,
|
||||||
|
"trades_per_year": 1.9444444444444444,
|
||||||
|
"net_mid_pf": 0.3512325585213674,
|
||||||
|
"target_pf_ok": false,
|
||||||
|
"target_dd_ok": true,
|
||||||
|
"freq_ok": false,
|
||||||
|
"regime_logic_ok": true,
|
||||||
|
"status": "FAIL",
|
||||||
|
"hypothesis": "4h native SOS → 1h LPS"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"portfolio_note": {
|
||||||
|
"spring_tpy": 5.555555555555555,
|
||||||
|
"lps_tpy": 0.5555555555555556,
|
||||||
|
"sum_tpy_approx": 6.111111111111111,
|
||||||
|
"combined_target_tpy": "15-25",
|
||||||
|
"lps_status": "FAIL",
|
||||||
|
"spring_status": "PASS"
|
||||||
|
},
|
||||||
|
"system_status": {
|
||||||
|
"spring": "BASELINE FROZEN / PASS + Limited Evidence",
|
||||||
|
"lps": "FAIL",
|
||||||
|
"spring_tpy": 5.555555555555555,
|
||||||
|
"lps_tpy": 1.9444444444444444,
|
||||||
|
"next": "若 LPS PASS → 组合层;否则 Spring-only"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
{
|
||||||
|
"note": "V1 BASELINE frozen; Range disabled; Spring/UTAD only; net cost included",
|
||||||
|
"wfo": {
|
||||||
|
"train": {
|
||||||
|
"timerange": "20230101-20250101",
|
||||||
|
"profit_pct": 1.6587295176,
|
||||||
|
"trades": 12,
|
||||||
|
"dd_pct": 3.644907735100005,
|
||||||
|
"pf": 1.1700179329477578,
|
||||||
|
"winrate": 25.0,
|
||||||
|
"final": 10165.87295176,
|
||||||
|
"fee_used": 0.0005,
|
||||||
|
"regime_loaded": "trend"
|
||||||
|
},
|
||||||
|
"validate": {
|
||||||
|
"timerange": "20250101-20260101",
|
||||||
|
"profit_pct": 9.990534148400002,
|
||||||
|
"trades": 6,
|
||||||
|
"dd_pct": 1.797834787912851,
|
||||||
|
"pf": 6.201791679101682,
|
||||||
|
"winrate": 66.66666666666666,
|
||||||
|
"final": 10999.05341484,
|
||||||
|
"fee_used": 0.0005,
|
||||||
|
"regime_loaded": "trend"
|
||||||
|
},
|
||||||
|
"test": {
|
||||||
|
"timerange": "20260101-",
|
||||||
|
"profit_pct": 0.8458820224000001,
|
||||||
|
"trades": 2,
|
||||||
|
"dd_pct": 0.7197049309999966,
|
||||||
|
"pf": 2.175317808681236,
|
||||||
|
"winrate": 50.0,
|
||||||
|
"final": 10084.58820224,
|
||||||
|
"fee_used": 0.0005,
|
||||||
|
"regime_loaded": "trend"
|
||||||
|
},
|
||||||
|
"full": {
|
||||||
|
"timerange": "20230101-",
|
||||||
|
"profit_pct": 12.7374753063,
|
||||||
|
"trades": 20,
|
||||||
|
"dd_pct": 3.644907735100005,
|
||||||
|
"pf": 2.0183507402435503,
|
||||||
|
"winrate": 40.0,
|
||||||
|
"final": 11273.74753063,
|
||||||
|
"fee_used": 0.0005,
|
||||||
|
"regime_loaded": "trend"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"regimes": {
|
||||||
|
"trend": {
|
||||||
|
"profit_pct": 12.7374753063,
|
||||||
|
"trades": 20,
|
||||||
|
"dd_pct": 3.644907735100005,
|
||||||
|
"pf": 2.0183507402435503,
|
||||||
|
"winrate": 40.0,
|
||||||
|
"final": 11273.74753063,
|
||||||
|
"fee_used": 0.0005,
|
||||||
|
"regime_loaded": "trend"
|
||||||
|
},
|
||||||
|
"bull": {
|
||||||
|
"profit_pct": 8.166882314399999,
|
||||||
|
"trades": 12,
|
||||||
|
"dd_pct": 3.4837023928902555,
|
||||||
|
"pf": 1.9398544482027922,
|
||||||
|
"winrate": 33.33333333333333,
|
||||||
|
"final": 10816.688231439999,
|
||||||
|
"fee_used": 0.0005,
|
||||||
|
"regime_loaded": "bull"
|
||||||
|
},
|
||||||
|
"bear": {
|
||||||
|
"profit_pct": 4.2503064875000005,
|
||||||
|
"trades": 8,
|
||||||
|
"dd_pct": 3.173714645599994,
|
||||||
|
"pf": 2.084295240772406,
|
||||||
|
"winrate": 50.0,
|
||||||
|
"final": 10425.03064875,
|
||||||
|
"fee_used": 0.0005,
|
||||||
|
"regime_loaded": "bear"
|
||||||
|
},
|
||||||
|
"range": {
|
||||||
|
"profit_pct": -4.3352539371,
|
||||||
|
"trades": 6,
|
||||||
|
"dd_pct": 4.404162180500007,
|
||||||
|
"pf": 0.15746188404490422,
|
||||||
|
"winrate": 16.666666666666664,
|
||||||
|
"final": 9566.47460629,
|
||||||
|
"fee_used": 0.0005,
|
||||||
|
"regime_loaded": "range"
|
||||||
|
},
|
||||||
|
"all": {
|
||||||
|
"profit_pct": 7.831216539699999,
|
||||||
|
"trades": 26,
|
||||||
|
"dd_pct": 7.883451762900004,
|
||||||
|
"pf": 1.454582067425369,
|
||||||
|
"winrate": 34.61538461538461,
|
||||||
|
"final": 10783.12165397,
|
||||||
|
"fee_used": 0.0005,
|
||||||
|
"regime_loaded": "all"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"cost_stress": {
|
||||||
|
"fee_5bps": {
|
||||||
|
"profit_pct": 12.7374753063,
|
||||||
|
"trades": 20,
|
||||||
|
"dd_pct": 3.644907735100005,
|
||||||
|
"pf": 2.0183507402435503,
|
||||||
|
"winrate": 40.0,
|
||||||
|
"final": 11273.74753063,
|
||||||
|
"fee_used": 0.0005,
|
||||||
|
"regime_loaded": "trend"
|
||||||
|
},
|
||||||
|
"fee_5bps+slip_5bps": {
|
||||||
|
"profit_pct": 6.782772099999998,
|
||||||
|
"trades": 20,
|
||||||
|
"dd_pct": 7.851805397900007,
|
||||||
|
"pf": 1.4511324473780693,
|
||||||
|
"winrate": 35.0,
|
||||||
|
"final": 10678.27721,
|
||||||
|
"fee_used": 0.001,
|
||||||
|
"regime_loaded": "trend"
|
||||||
|
},
|
||||||
|
"fee_10bps+slip_10bps": {
|
||||||
|
"profit_pct": 3.182909279400001,
|
||||||
|
"trades": 20,
|
||||||
|
"dd_pct": 9.126146157700004,
|
||||||
|
"pf": 1.1834520309921508,
|
||||||
|
"winrate": 35.0,
|
||||||
|
"final": 10318.29092794,
|
||||||
|
"fee_used": 0.002,
|
||||||
|
"regime_loaded": "trend"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"verdict": {
|
||||||
|
"full_pf": 2.0183507402435503,
|
||||||
|
"full_dd": 3.644907735100005,
|
||||||
|
"trades_per_year": 5.555555555555555,
|
||||||
|
"net_mid_pf": 1.4511324473780693,
|
||||||
|
"target_pf_ok": true,
|
||||||
|
"target_dd_ok": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# LPS V1.1 — REJECTED
|
||||||
|
|
||||||
|
## Hypothesis
|
||||||
|
|
||||||
|
在 V1 上收紧:严格 8h bias + 吸筹前置窗口 + 每事件首次回踩
|
||||||
|
|
||||||
|
## Result
|
||||||
|
|
||||||
|
- Full: **-1.50%**, n=**2**, 全亏
|
||||||
|
- 过滤方向正确,但过度收缩 → 无统计意义
|
||||||
|
|
||||||
|
## Reject reason
|
||||||
|
|
||||||
|
无法同时满足「理论纯度」与「可交易样本」。确认问题在事件定义,继续收紧无意义。
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# LPS V1 — REJECTED
|
||||||
|
|
||||||
|
## Hypothesis
|
||||||
|
|
||||||
|
1h 侦测突破 + 回踩 = Wyckoff LPS(趋势跟随)
|
||||||
|
|
||||||
|
## Result
|
||||||
|
|
||||||
|
- Full: **-18.92%**, n=133, PF **0.73**
|
||||||
|
- Regime anomaly: **trend 亏、range 赚**(反理论)
|
||||||
|
|
||||||
|
## Reject reason
|
||||||
|
|
||||||
|
捕获的是普通突破回踩噪音,不是 Accumulation → Markup 下的 Composite Operator LPS。
|
||||||
|
定义错误,不是参数问题。
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user