12 Commits
Author SHA1 Message Date
jackyu66git 7e16edd2c9 fix: pipeline MACD 参数统一为标准 12/26/9(与 web/交易所一致) 2026-09-12 02:14:20 +08:00
jackyu66gitandCursor 5c10e35b76 refactor(web): 移除主图威科夫选项与叠层
去掉区间/阶段/时间/VP 开关、Cycle 摘要面板及绘制逻辑;分析请求默认 include_wyckoff=0。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-26 01:27:51 +08:00
jackyu66gitandCursor 8c165f11cd fix(web): 未完成笔/线段终点对齐图表最新 K 线
各周期使用对应 kline 数据,终点时间 snap 到 candles,优先使用分析 end_price。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-26 00:40:41 +08:00
jackyu66gitandCursor 97e77847d0 fix(web): 开关缠论元素保留视窗;分周期 Trend 涨跌配色
本地重绘统一冻结视窗;次/次次周期 Trend 上涨下跌使用独立颜色。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 23:56:55 +08:00
jackyu66gitandCursor 90499533fb fix(web): 分析/自动刷新后保留 K 线视窗位置
拆分手动分析与自动刷新拉数路径;全量重建用 logical 优先恢复视窗,
增量 recent 用 scroll+barDelta;避免 barSpacing 重锚与重复冻结导致往右跳。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 23:38:20 +08:00
jackyu66gitandCursor 8ee11317d3 fix(web): 自动刷新保留 K 线视窗;威科夫与图表增量更新
自动刷新改用 tail update 与 scrollToPosition 恢复视窗,避免 setData 后跳到最右;拆分 chart_tv 模块并扩展 analyze/recent API。同步威科夫分析、pipeline 增量构建及相关策略与配置。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 22:57:43 +08:00
jackyu66gitandCursor 1e60ab3bfa docs: 补强 ECR-004 CODE_REVIEW 复审记录
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 18:47:18 +08:00
jackyu66gitandCursor d3188ca83c fix: ECR-004 威科夫区间评分硬化与 VP 绘图减负(已审)
评分选 TR、阶段最小跨度、elements_only 门闩、Top-8 VP;无币种独立参数。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 18:46:08 +08:00
jackyu66gitandCursor ac6be80278 docs: 开启 ECR-004 威科夫硬化与 VP 减负(Draft)
跟进 ECR-003 Review Findings;待 Approve 后实现。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 18:35:25 +08:00
jackyu66gitandCursor 081a57a90e feat: ECR-003 主站威科夫分析与图表叠层(已审)
独立 wyckoff 引擎 + 按需 include_wyckoff;主站 Lightweight 绘制区间/阶段/事件/VP。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 18:33:57 +08:00
jackyu66gitandCursor df27b4dde8 refactor: ECR-002 拆分 runtime 包并加深 analyze 契约(已审)
将 web/services/runtime.py 拆为 runtime/ 子模块并保持门面兼容;补齐 ESS 文档、门面/契约/TF_DF 测试与 CODE_REVIEW Approve。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 18:15:23 +08:00
jackyu66gitandCursor 9f1e7361b6 fix: 修复主站自动刷新内存泄漏,并完善 chan_tv 图表体验
主站重建前完整 dispose、去掉重复 sync 监听,自动刷新默认增量更新;顺带消除首屏重复 analyze、复用 ChanMACD,以及全版 TV 指标/未完成中枢/布局本地缓存。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 16:09:48 +08:00
166 changed files with 24816 additions and 6551 deletions
+33
View File
@@ -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`
+3 -1
View File
@@ -8,9 +8,11 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## 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 ...`
- 根目录 `Chan*.py` / `TF_DF.py` 仍为 **兼容 shim**(旧脚本可用)
- 变更分级:无 ECR 不改 strategies/config;无 ADR 不改缠论算法语义
## Core Architecture
+7
View File
@@ -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"]
+196
View File
@@ -0,0 +1,196 @@
"""威科夫分析入口:Cycle → Phase → Event → VP + LiveMULTI-CYCLE / LIVE-STRUCTURE)。
range.py 只产 TradingRangeConfirmed 走 events.pyLive 走 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"),
}
+369
View File
@@ -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
+258
View File
@@ -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",
}
+442
View File
@@ -0,0 +1,442 @@
"""交易区间检测:仅负责 TradingRange(起止/高低/结构分)。
WYCKOFF-MULTI-CYCLE-001Phase/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_offsetslice 相对父 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,
}
+171
View File
@@ -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)
+2 -2
View File
@@ -55,8 +55,8 @@ class IndicatorsBuilderMixin:
return None
def add_indicators(self, df):
fast = 26
slow = 52
fast = 12
slow = 26
period = 9
macd = ta.MACD(df, fastperiod=fast, slowperiod=slow, signalperiod=period)
bb365 = ta.BBANDS(df, timeperiod=365, nbdevup=3.0, nbdevdn=3.0, matype=0)
+43 -37
View File
@@ -86,7 +86,8 @@ class KlineBuilderMixin:
return Chan_FX_TYPE.UNKNOWN
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.close > klc.ema52 or klc.next.close > klc.next.ema52) and klc.macd > 0:
klc.set_fx(Chan_FX_TYPE.TOP)
@@ -171,11 +172,50 @@ class KlineBuilderMixin:
def get_kl_data(self, dataframe:DataFrame):
return self.cal_kl_data(dataframe)
def _push_klu_into_klc_list(self, klc_list, klu, last_klu):
"""把一根 KLU 并入包含K线列表。与 get_klc_list 的几何规则相同。"""
if len(klc_list) > 0:
last_klc = klc_list[-1]
if klu.exception:
ddir = Chan_KLINE_DIR.DOWN
if last_klc.high < klu.high:
ddir = Chan_KLINE_DIR.UP
klc = ChanKLC(klu, index=len(klc_list), ddir=ddir)
klc.high = klu.close if klu.close > klu.open else klu.open
klc.low = klu.open if klu.close > klu.open else klu.close
klc_list.append(klc)
last_klc.set_next(klc)
klc.set_pre(last_klc)
last_klc.set_end_klu(last_klu)
klc.set_pre_fx()
else:
included = last_klc.check_klu_included(klu)
if not included:
ddir = Chan_KLINE_DIR.DOWN
if last_klc.high < klu.high:
ddir = Chan_KLINE_DIR.UP
klc = ChanKLC(klu, index=len(klc_list), ddir=ddir)
klc_list.append(klc)
last_klc.set_next(klc)
klc.set_pre(last_klc)
last_klc.set_end_klu(last_klu)
klc.set_pre_fx()
else:
last_klc.add_klu(klu)
else:
ddir = Chan_KLINE_DIR.UP
if klu.open > klu.close:
ddir = Chan_KLINE_DIR.DOWN
klc = ChanKLC(klu, 0, ddir)
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.cal_macd_state()
klu_list = macd.klu_list
self._last_chan_macd = macd
ema_up_list = []
ema_down_list = []
ema_up_count = 0
@@ -196,41 +236,7 @@ class KlineBuilderMixin:
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:
last_klc = klc_list[-1]
if klu.exception:
ddir = Chan_KLINE_DIR.DOWN
if last_klc.high < klu.high:
ddir = Chan_KLINE_DIR.UP
klc = ChanKLC(klu, index=len(klc_list), ddir=ddir)
klc.high = klu.close if klu.close > klu.open else klu.open
klc.low = klu.open if klu.close > klu.open else klu.close
klc_list.append(klc)
last_klc.set_next(klc)
klc.set_pre(last_klc)
last_klc.set_end_klu(last_klu)
klc.set_pre_fx()
#print(klu.time, klu.high, klu.low, klu.close, klu.open, klu.exception)
else:
included = last_klc.check_klu_included(klu)
if not included:
ddir = Chan_KLINE_DIR.DOWN
if last_klc.high < klu.high:
ddir = Chan_KLINE_DIR.UP
klc = ChanKLC(klu, index=len(klc_list), ddir=ddir)
klc_list.append(klc)
last_klc.set_next(klc)
klc.set_pre(last_klc)
last_klc.set_end_klu(last_klu)
klc.set_pre_fx()
else:
last_klc.add_klu(klu)
else:
ddir = Chan_KLINE_DIR.UP
if klu.open > klu.close:
ddir = Chan_KLINE_DIR.DOWN
klc = ChanKLC(klu, 0, ddir)
klc_list.append(klc)
self._push_klu_into_klc_list(klc_list, klu, last_klu)
last_klu = klu
klc_list = self.cal_trend(klc_list)
#print(ema52_up_list, ema52_down_list)
+11 -6
View File
@@ -355,9 +355,12 @@ class ZsBuilderMixin:
return bi_zs_list
def get_zs_range(bis):
zg = min(bi.high for bi in bis)
zd = max(bi.low for bi in bis)
return zg, zd
bis_list = bis[0:3]
zg = min(bi.high for bi in bis_list)
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):
return bi.high >= zd and bi.low <= zg
@@ -375,8 +378,8 @@ class ZsBuilderMixin:
zs.bi_list = list(bis)
for bi in zs.bi_list:
bi.set_bi_zs(zs)
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_gg(max(bi.high for bi in zs.bi_list))
#zs.set_dd(min(bi.low for bi in zs.bi_list))
zs.classify_zs()
last_zs = None
@@ -394,7 +397,7 @@ class ZsBuilderMixin:
start_idx += 1
continue
zg, zd = get_zs_range([bi1, bi2, bi3])
zg, zd, dd, gg = get_zs_range([bi1, bi2, bi3])
if zg <= zd:
start_idx += 1
continue
@@ -420,6 +423,8 @@ class ZsBuilderMixin:
zs = ChanBIZS(bi1, len(bi_zs_list), zs_dir)
zs.set_zg(zg)
zs.set_zd(zd)
zs.set_dd(dd)
zs.set_gg(gg)
set_zs_bi_list(zs, bis_for_zs)
zs.set_end_bi(bis_for_zs[-1], bis_for_zs[-1].sure_time)
+9
View File
@@ -178,6 +178,15 @@ class ChanLun():
def cal_bi_zs_list(self, bi_list):
#return self.tf_df.cal_bi_zs(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):
return self.tf_df.get_bi_zs_list(bi_list)
def get_decimal(self, value):
+9 -3
View File
@@ -31,12 +31,13 @@ from chanlun.core.ChanZS import ChanZS, ChanZS_Big
from chanlun.indicators.ChanMACD import ChanMACD
from chanlun.pipeline.builders.bi import BiBuilderMixin
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.kline import KlineBuilderMixin
from chanlun.pipeline.builders.seg import SegBuilderMixin
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):
if df is not None:
self.init_TF_DF(df, interval, timeframe)
@@ -59,17 +60,22 @@ class TF_DF(IndicatorsBuilderMixin, KlineBuilderMixin, BiBuilderMixin, SegBuilde
self.klc_list = []
self.bi_list = []
self.zs_list = []
self.bi_zs_list = []
self.bsp_list = []
self.seg_list = []
self.klc_fx_list = []
self.klu_list = self.cal_kl_data(self.dataframe)
self.klc_list = self.get_klc_list(self.klu_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.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.chanmacd = ChanMACD(self.klu_list)
self.klu_list = self.chanmacd.cal_macd_state()
# get_klc_list 内已算过 ChanMACD,直接复用
self.chanmacd = getattr(self, '_last_chan_macd', None)
if self.chanmacd is None:
self.chanmacd = ChanMACD(self.klu_list)
self.klu_list = self.chanmacd.klu_list
def get_current_klc(self):
+1
View File
@@ -0,0 +1 @@
from __future__ import annotations
+141
View File
@@ -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()
+98
View File
@@ -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
}
}
+98
View File
@@ -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
}
}
+9 -2
View File
@@ -39,8 +39,15 @@
"name": "binance",
"key": "hvoXanRExQvcN4tyGFvEnsSF4gqxXp6ZJnBu5lnhvlVuHaDbj2PhLBQGCLkkyeI8",
"secret": "3UKA2oyDj7OoXrausmnaLwLlNfXmlNf2imBdmQqqKHArcJfk6X9xjaUF19wzu82l",
"ccxt_config": {},
"ccxt_async_config": {},
"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"
],
+91
View File
@@ -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
}
}
+86
View File
@@ -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
}
}
+86
View File
@@ -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
}
}
+86
View File
@@ -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
}
}
+86
View File
@@ -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
}
}
+86
View File
@@ -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
}
}
+5
View File
@@ -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"]
+342
View File
@@ -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]
+248
View File
@@ -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)
+102
View File
@@ -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,
},
)
+195
View File
@@ -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},
},
},
)
+154
View File
@@ -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)
+149
View File
@@ -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 SCARST 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,
},
)
+206
View File
@@ -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,
)
+363
View File
@@ -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 []
+78
View File
@@ -0,0 +1,78 @@
"""Phase Engine — Phase AE 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,
},
)
+181
View File
@@ -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
+78
View File
@@ -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,
},
)
+3
View File
@@ -0,0 +1,3 @@
from crypto_wyckoff.rules.registry import rule_registry
__all__ = ["rule_registry"]
+33
View File
@@ -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."""
+126
View File
@@ -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(),
]
+254
View File
@@ -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(),
]
+163
View File
@@ -0,0 +1,163 @@
"""Phase AE 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()]
+39
View File
@@ -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()
+127
View File
@@ -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()
+35
View File
@@ -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", []),
},
)
+236
View File
@@ -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()
+51
View File
@@ -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 → BTC1000PEPE/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}
+4
View File
@@ -0,0 +1,4 @@
"""Wyckoff Screener engine version — bump when rules change."""
WYCKOFF_ENGINE_VERSION = "v1.0.0"
ARCHITECTURE_VERSION = "1.0"
+39
View File
@@ -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 ReviewedTR 评分硬化 + VP 少系列 + 阶段/门闩/单测(无币种参数)
## 硬约束提醒
- `/api/analyze` 字段可增不可删
- 无 ADR 不改笔/段/中枢/买卖点语义
- 威科夫为独立叠层(ECR-003);勿借机改缠论算法
- 交易 L2+ → RISK_REVIEW + EXPLive 须 Human
## 已知债务
- `chart_tv.js` 单体巨大 → 后续可选 ECR
- analyze 契约已加深(mock HTTP + wyckoff opt-in);可再加固定 JSON 快照文件
- 内存泄漏尚无自动化 heap/监听断言
- `macd_config` POST 写本地 global 的历史 quirks(未改)
- 威科夫启发式参数未做 UI 调参
+42
View File
@@ -1,5 +1,47 @@
# CHANGELOG
## Unreleased — 2026-08-06
### ECR-004L2Reviewed
- 威科夫 TR 评分选段(防吞前置趋势);阶段非重叠最小跨度
- 主站 VP Top-8 + bins≤24;填充线减负
- `elements_only` 时不跑威科夫;收紧单测(无币种独立参数)
### ECR-003L2Reviewed
- 新增 `chanlun/analysis/wyckoff/`:交易区间、阶段 AE、Spring/SOS/LPS/UTAD 等事件、区间 VPPOC/VAH/VAL)、量能确认
- `/api/analyze` 按需 `include_wyckoff=1` 返回顶层 `wyckoff`
- 主站「威科夫」开关与 Lightweight 叠层(区间/阶段/事件/VP)
- 单测与 analyze 契约 opt-in 断言
### ECR-002L3Reviewed
- 拆分 `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-002L1 补档)
对应 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
- ESSIDEA-002、AGENT_MEMORY、AGENTSECR-002 实现与报告
---
## v1.0.0 — 2026-08-05(首个正式 Release
对应 ECR-001 / tag `v1.0.0`。详见 `docs/RELEASE/ECR-001-v1.0.0.md`
+4 -4
View File
@@ -45,11 +45,11 @@ pytest tests/test_golden_pipeline.py web/tests/test_analyze_contract.py → 6 pa
### Non-blocking(记入债务,需新 ECR 再动)
1. **`web/services/runtime.py` ~1176 行** — 已从 app 抽出但仍是大模块;facade 再导出符合计划,建议 ECR-002 继续按 data/analyze/serialize 物理拆分
2. **`web/static/js/app/chart_tv.js` ~4664 行** — `initTradingView` 单体;行为冻结下可接受。
3. **`/api/analyze` 契约测试偏浅** — 仅关键字段清单 + 路由存在;无固定 fixture 的端到端 JSON 快照(需 mock 行情)。
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` 单体;行为冻结下可接受ECR-002 可选范围
3. **`/api/analyze` 契约测试偏浅** — 仅关键字段清单 + 路由存在;无固定 fixture 的端到端 JSON 快照(需 mock 行情)→ ECR-002
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
+74
View File
@@ -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 可标 DoneReviewed);不强制新 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) |
+77
View File
@@ -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`
- ESSECR/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 绘图优化。
+53
View File
@@ -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/VALAPI bins≤24 |
| 阶段最小跨度 / 不重合 | PASS | 链式 cursorunique (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. **阶段常截断为 AC**
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 累计。
+74
View File
@@ -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 13、5
+62
View File
@@ -0,0 +1,62 @@
# ECR-003
**Title:** 主站威科夫分析与图表展示
**Status:** Done (Reviewed)
**Date:** 2026-08-06
**Change Level:** L2
## Change
在主站 `/` 增加威科夫交易区间、阶段(A–E)、关键事件(Spring/SOS/LPS/UTAD 等)、区间内简易 VPPOC/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
+69
View File
@@ -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 场景验证)
## DecisionsApprove 时锁定)
- 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 15
- 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` — AE 状态机
- `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. LPSSOS 后回踩不破 mid/high 带且缩量。
6. UTAD:上破后跌回区间内(派发)。
7. VPtypical=(H+L+C)/3volume 加权分桶,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 VPA+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 无 diffgolden + 新契约测试通过。
## 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] 仅 BugfixL1)— 代码已合入 `9f1e736`
- [x] CHANGELOG / STATE / TRACEABILITY / TEST_REPORT 补档
- [ ] 可选:自动化回归(内存/监听数量断言)— 暂人工验证
+27
View File
@@ -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 13、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(若布局再变)
+26
View File
@@ -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
+31
View File
@@ -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
+38
View File
@@ -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 未改)
+30
View File
@@ -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/`:交易区间、AE 阶段、Spring/SOS/LPS/UTAD/SOW/LPSY、区间 VPPOC/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)后续可调
+24
View File
@@ -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 作为唯一入口
+24
View File
@@ -0,0 +1,24 @@
# PRODUCT_SPEC — ECR-003
**Status:** Approved
**Date:** 2026-08-06
## Goal
主站用户可在主周期图上开关查看威科夫:**交易区间、阶段、事件、Volume ProfilePOC/VAH/VAL)与事件量能确认**。
## User stories
1. 勾选「威科夫」后重新分析,图上出现交易区间框。
2. 可见阶段分段/标签(Accumulation/Distribution + AE)。
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
View File
@@ -3,7 +3,7 @@
> Agent 第一次读这个文件。不要重新猜技术栈;偏离见 Forbidden + ADR。
## Type
Trading System(缠论分析引擎 + 可视化 Web;Freqtrade 策略目录独立、本 ECR 不改
Trading System(缠论分析引擎 + 可视化 Web;Freqtrade 策略目录独立、默认只读
## Stack Lock
@@ -12,9 +12,9 @@ Trading System(缠论分析引擎 + 可视化 Web;Freqtrade 策略目录独
| Language | Python 3 |
| Engine package | `chanlun/` |
| Backend | Flask |
| Realtime | 无(请求式分析 |
| Realtime | 主站 `/`:请求式分析 + 定时自动刷新(HTTP);全版 `/chan_tv`TradingView datafeed + WebSocket`DATA_SERVICE_WS_URL`,可与 REST 分域名 |
| Database | 无(行情外部 DATA_SERVICE / CCXT / A 股接口) |
| Frontend | TradingView Charting Library + 原生 JS |
| Frontend | 主站 Lightweight Charts`web/static/js/app/`);全版 TradingView Charting Library`/chan_tv` |
| Deployment | gunicorn / systemdweb |
| Architecture Pattern | 包化引擎 + Web services/blueprints + 根目录兼容 shim |
@@ -25,15 +25,22 @@ Trading System(缠论分析引擎 + 可视化 Web;Freqtrade 策略目录独
- 无 ECR 破坏 `/api/analyze` JSON 契约(可增不可删)
- 引入 Kafka / MongoDB / 微服务拆分(除非新 ADR)
- 本轮引入 Vite/React/TS 构建流水线
- 威科夫等**独立分析叠层**须走 ECR(可增 API 字段);不得借机改缠论算法
## Versioning
- `system_version`:软件/分析系统(见 `docs/STATE/CURRENT.md`、Release tag
- `strategy_version`Freqtrade 策略资产;与 system 解耦;改 strategies/config 须独立 ECR +L2EXP
## Active anchors
- ECR: ECR-001
- EXP: N/A(本变更不改交易行为语义)
- ECR: ECR-002/003/004 Reviewed(威科夫 + 硬化)
- EXP: N/A
- TRACEABILITY: `docs/TRACEABILITY.md`
- Memory: `docs/AGENT_MEMORY.md`
## Pointers
- Rules: `PROJECT_RULES.md`
- Stack detail: `TECH_STACK.md`
- Memory: `AGENT_MEMORY.md`(若存在)
- Agent entry: `AGENTS.md` / `CLAUDE.md`
+3 -1
View File
@@ -5,7 +5,9 @@
1. `config/``strategies/`:Freqtrade 策略资产,默认只读;任何改动需独立 ECR。
2. `chanlun/`:缠论引擎正式包;算法变更需 L2+ ECR + 回归基线。
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
+12
View File
@@ -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 评估。
+8
View File
@@ -0,0 +1,8 @@
# RISK_REVIEW — ECR-003
**Status:** N/A
**Date:** 2026-08-06
展示用威科夫分析叠层,不改 Freqtrade 策略或 Live 下单。启发式误标风险由 UI 开关与文档说明缓解。
**Conclusion:** N/A(非交易执行变更)
+6
View File
@@ -0,0 +1,6 @@
# RISK_REVIEW — ECR-004
**Status:** N/A
**Date:** 2026-08-06
展示用威科夫启发式与绘图优化,不改 Freqtrade 策略或 Live 下单。TR 输出相对 ECR-003 可能漂移,由单测与 UI 开关缓解。
+17 -5
View File
@@ -1,11 +1,23 @@
# STATE
**owner:** done
**active_ecr:** ECR-001
**phase:** released
**owner:** idle
**active_ecr:** noneECR-004 Reviewed;待本批提交合入)
**phase:** post-review
**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
First release `v1.0.0` shipped. See `docs/RELEASE/ECR-001-v1.0.0.md`.
- ECR-004**Approve**14 passed);无币种独立参数
- 未请求新 system tag
+12
View File
@@ -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.
+7
View File
@@ -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.
+8
View File
@@ -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
View File
@@ -9,12 +9,15 @@
## Web
- Flask + Jinja2 templates
- TradingView Charting Library`web/charting_library/`
- 前端运行时:原生 JS`web/static/js/app/`
- 行情:`DATA_SERVICE_URL` / CCXT / A 股数据服务
- **主站 `/`**Lightweight Charts + `web/static/js/app/`(定时 HTTP `/api/analyze` 自动刷新;增量 setData
- **全版 `/chan_tv`**TradingView Charting Library`web/charting_library/`+ `datafeed.js`
- 服务层:`web/services/runtime/` 包(state / market_data / analyze / serialize…)+ 门面 `services.runtime`
- 行情 REST`DATA_SERVICE_URL`(默认 `https://provider.jackyu66.com`/ CCXT / A 股数据服务
- 行情 WSchan_tv):`DATA_SERVICE_WS_URL`(默认 `wss://jackyu66.com/ws`,可与 REST 分域名)
## Out of scope this release
## Out of scope(直至新 ECR / ADR
- data_provider 仓库内重建
- React/TS 构建
- Freqtrade config/strategies 重构
- 主站 WebSocket 实时(曾实验后回退;勿无 ECR 再引入)
+31
View File
@@ -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 拆分未做,无前端自动化。
+29
View File
@@ -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 内容。
+29
View File
@@ -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 填充实现。
+28
View File
@@ -0,0 +1,28 @@
# TEST_REPORT — IDEA-002L1
**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
View File
@@ -1,4 +1,6 @@
# TRACEABILITY — ECR-001
# TRACEABILITY
## ECR-001
| ECR | Requirement | Spec | Code | Test |
|-----|-------------|------|------|------|
@@ -7,3 +9,38 @@
| ECR-001 | Web 分层 | ENG-001 | `web/services` `web/api` | analyze contract |
| ECR-001 | 前端模块化 | ENG-001 | `web/static/js/app/` | manual / smoke |
| ECR-001 | 策略零改动 | PROFILE | no edits under strategies/ | git diff empty |
## IDEA-002L1
| 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 + 填充 3bins≤24 | 人工 + ENG |
| ECR-004 | 阶段最小长度 + elements_only 门闩 | ENG-004 | `events.py` + `analyze.py` | 契约 `elements_only` |
+9
View File
@@ -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",
]
+155
View File
@@ -0,0 +1,155 @@
"""
Market State Engine v1 因果可计算无未来函数
仅使用截至当前 8h K 线已收盘信息
EMA50/200ADXEMA slope价格相对 MA200 距离
输出 0100 分数 + 主导状态标签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+非急跌斜率 → accumulationbull+正斜率 → 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+markdownbad = 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)
+155
View File
@@ -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** 数据(无 lookaheadmerge 后读的是上一根已完成 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 timeUTC |
| `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 DDkept / 账户) | 应相对 ungated 历史继续偏低 |
| range share among blocked | 仅观察;上升不自动改规则 |
### 2023+ OOS 参考阈值(研究窗,非调参目标)
| | Gated(研究) | 解读 |
|--|---------------|------|
| PF | ~1.34baseline ~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. 不允许事项(硬禁)
- 不调 SpringTF / ATR / stoploss / entry 形态)
- 不调 soft-score,不把 soft-score 接回默认路径
- 不全样本扫 Gate 阈值 / 状态集合
- 不因短期 dry-run PF 调规则
- 不因 `range` 小样本表现把 range 升格为交易域
- 不默认合并 ETH/SOL 进生产路径
- 不复活 LPS 分支
违反任一条 = 退出 dry-run,回到研究流程(需新证据包)。
---
## 6. Go / No-Godry-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`
+63
View File
@@ -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+ / 202425 中 range 占比上升 → 保持 **观察标签**,不升格为交易规则。
## Do not
- 调 Spring / soft-score / Gate 阈值
- 因 range 小样本正 PF 开放 range 交易
- 复活 LPS / 默认跨资产
+85
View File
@@ -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 stackLOCKED
```
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 blockeddistribution share **87.5%**blocked PF 0.37
- 多数年份 blocked 以 distribution 为首
- 2023+ blockeddistribution + range 并存;range **仅观察**,不改规则
- 不因 2023+ blocked 弱正 PF 或 range n=4 回滚 Gate
**Primary invalidation domain = distribution(稳定)**
**range = observation bucket only**
+19
View File
@@ -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 disabledregime_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 onlyRange 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
}
}
+14
View File
@@ -0,0 +1,14 @@
# LPS V1.1 — REJECTED
## Hypothesis
在 V1 上收紧:严格 8h bias + 吸筹前置窗口 + 每事件首次回踩
## Result
- Full: **-1.50%**, n=**2**, 全亏
- 过滤方向正确,但过度收缩 → 无统计意义
## Reject reason
无法同时满足「理论纯度」与「可交易样本」。确认问题在事件定义,继续收紧无意义。
+15
View File
@@ -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