Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e0fa9ac375 | ||
|
|
6c627f009a | ||
|
|
dbb6202325 | ||
|
|
efad2bb333 | ||
|
|
2964d6f230 | ||
|
|
7991a6b2bf | ||
|
|
276481e02c | ||
|
|
1e60ab3bfa | ||
|
|
d3188ca83c | ||
|
|
ac6be80278 | ||
|
|
081a57a90e | ||
|
|
df27b4dde8 | ||
|
|
9f1e7361b6 |
@@ -40,3 +40,7 @@ feature_meta
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
data_provider/._config.json
|
data_provider/._config.json
|
||||||
.gstack/
|
.gstack/
|
||||||
|
|
||||||
|
# ESS gate / engineering-loop working dirs(归档进 docs/runs/)
|
||||||
|
.gates/
|
||||||
|
loop/
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# chan — Agent Entry
|
||||||
|
|
||||||
|
本仓受 ESS 约束。不要一上来扫全库或加载全部 governance。
|
||||||
|
|
||||||
|
## Boot
|
||||||
|
|
||||||
|
1. `docs/PROJECT_PROFILE.md`
|
||||||
|
2. `docs/PROJECT_RULES.md`
|
||||||
|
3. `docs/STATE/CURRENT.md` + `docs/AGENT_MEMORY.md`
|
||||||
|
4. 有进行中任务再读 `docs/TASKS/` / 对应 ECR / HANDOFF
|
||||||
|
5. 角色文件:ESS 根目录 `agents/{ARCHITECT|ENGINEER|REVIEWER|RELEASE_MANAGER}.md`
|
||||||
|
|
||||||
|
## Roles(选一)
|
||||||
|
|
||||||
|
| 意图 | 角色 |
|
||||||
|
|------|------|
|
||||||
|
| 规格 / 架构 / ECR | ARCHITECT |
|
||||||
|
| 实现 / 修 bug | ENGINEER |
|
||||||
|
| 审阅 | REVIEWER |
|
||||||
|
| 发版 / tag | RELEASE_MANAGER |
|
||||||
|
|
||||||
|
## Never
|
||||||
|
|
||||||
|
- 无 ECR 改 `config/` / `strategies/` 交易逻辑
|
||||||
|
- 无 ADR 改缠论算法语义
|
||||||
|
- 无 ECR 删减 `/api/analyze` 字段
|
||||||
|
- 把聊天记录当成完成;阶段结束须落盘 `docs/`
|
||||||
|
|
||||||
|
## Pointers
|
||||||
|
|
||||||
|
- TRACEABILITY: `docs/TRACEABILITY.md`
|
||||||
|
- CHANGELOG: `docs/CHANGELOG/CHANGELOG.md`
|
||||||
|
- 人类向导:`CLAUDE.md`
|
||||||
@@ -8,9 +8,11 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||||||
|
|
||||||
## Governance
|
## Governance
|
||||||
|
|
||||||
- ESS 文档:`docs/PROJECT_PROFILE.md`、`docs/ECR/`、`docs/ENGINEERING_SPEC/`
|
- Agent 入口:`AGENTS.md`(boot 顺序)· `docs/PROJECT_PROFILE.md` · `docs/AGENT_MEMORY.md` · `docs/STATE/CURRENT.md`
|
||||||
|
- ESS 文档:`docs/ECR/`、`docs/ENGINEERING_SPEC/`、`docs/TRACEABILITY.md`、`docs/CHANGELOG/`
|
||||||
- **正式引擎包**:`chanlun/`;strategies / web 已用 `from chanlun import ...`
|
- **正式引擎包**:`chanlun/`;strategies / web 已用 `from chanlun import ...`
|
||||||
- 根目录 `Chan*.py` / `TF_DF.py` 仍为 **兼容 shim**(旧脚本可用)
|
- 根目录 `Chan*.py` / `TF_DF.py` 仍为 **兼容 shim**(旧脚本可用)
|
||||||
|
- 变更分级:无 ECR 不改 strategies/config;无 ADR 不改缠论算法语义
|
||||||
|
|
||||||
## Core Architecture
|
## Core Architecture
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
"""威科夫分析(启发式):交易区间 / 阶段 / 事件 / Volume Profile / Live。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from .engine import analyze_wyckoff
|
||||||
|
from .live import execution_signal_from_wyckoff
|
||||||
|
|
||||||
|
__all__ = ["analyze_wyckoff", "execution_signal_from_wyckoff"]
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
"""威科夫分析入口:Cycle → Phase → Event → VP + Live(MULTI-CYCLE / LIVE-STRUCTURE)。
|
||||||
|
|
||||||
|
range.py 只产 TradingRange;Confirmed 走 events.py;Live 走 live.py。
|
||||||
|
cycles[0]=ACTIVE;禁止 cycles[-1] 取 active。
|
||||||
|
Execution 只消费 Confirmed(见 live.execution_signal_from_wyckoff)。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
from .events import build_phases, detect_bias_and_events
|
||||||
|
from .live import analyze_live_structure
|
||||||
|
from .range import detect_trading_ranges
|
||||||
|
from .volume_profile import compute_volume_profile
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt_time(v) -> Optional[str]:
|
||||||
|
if v is None:
|
||||||
|
return None
|
||||||
|
if hasattr(v, "isoformat"):
|
||||||
|
try:
|
||||||
|
return v.isoformat()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return str(v)
|
||||||
|
|
||||||
|
|
||||||
|
def _empty(vp_bins: int) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"cycles": [],
|
||||||
|
"trading_range": None,
|
||||||
|
"bias": "unknown",
|
||||||
|
"phases": [],
|
||||||
|
"events": [],
|
||||||
|
"volume_profile": {"bins": [], "poc": None, "vah": None, "val": None, "bin_count": vp_bins},
|
||||||
|
"volume_confirm": {"avg_volume": 0.0, "event_checks": {}},
|
||||||
|
"live": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _confidence_for_confirmed(
|
||||||
|
tr: Dict[str, Any],
|
||||||
|
phases: List[Dict[str, Any]],
|
||||||
|
events: List[Dict[str, Any]],
|
||||||
|
) -> Dict[str, float]:
|
||||||
|
range_c = float(tr.get("range_confidence") or 0.5)
|
||||||
|
labels = {p.get("phase") for p in phases}
|
||||||
|
phase_c = 0.35
|
||||||
|
if "A" in labels and "B" in labels:
|
||||||
|
phase_c += 0.15
|
||||||
|
if "C" in labels:
|
||||||
|
phase_c += 0.2
|
||||||
|
if "D" in labels or "E" in labels:
|
||||||
|
phase_c += 0.15
|
||||||
|
phase_c = min(0.95, phase_c)
|
||||||
|
types = {e.get("type") for e in events}
|
||||||
|
event_c = 0.25
|
||||||
|
for t in ("Spring", "UTAD", "SOS", "SOW", "LPS", "LPSY"):
|
||||||
|
if t in types:
|
||||||
|
event_c += 0.12
|
||||||
|
event_c = min(0.95, event_c)
|
||||||
|
overall = 0.4 * range_c + 0.3 * phase_c + 0.3 * event_c
|
||||||
|
return {
|
||||||
|
"range": round(range_c, 3),
|
||||||
|
"phase": round(phase_c, 3),
|
||||||
|
"event": round(event_c, 3),
|
||||||
|
"overall": round(overall, 3),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _build_cycle(
|
||||||
|
work: pd.DataFrame,
|
||||||
|
tr: Dict[str, Any],
|
||||||
|
cycle_id: int,
|
||||||
|
vp_bins: int,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
bias, events, volume_confirm = detect_bias_and_events(work, tr)
|
||||||
|
phases = build_phases(work, tr, bias, events)
|
||||||
|
vp = compute_volume_profile(
|
||||||
|
work,
|
||||||
|
int(tr["abs_start_idx"]),
|
||||||
|
int(tr["abs_end_idx"]),
|
||||||
|
bin_count=vp_bins,
|
||||||
|
)
|
||||||
|
for ev in events:
|
||||||
|
ev["time"] = _fmt_time(ev.get("time"))
|
||||||
|
for ph in phases:
|
||||||
|
ph["start_time"] = _fmt_time(ph.get("start_time"))
|
||||||
|
ph["end_time"] = _fmt_time(ph.get("end_time"))
|
||||||
|
|
||||||
|
is_active = cycle_id == 0
|
||||||
|
trading_range = {
|
||||||
|
"start_time": _fmt_time(tr.get("start_time")),
|
||||||
|
"end_time": _fmt_time(tr.get("end_time")),
|
||||||
|
"high": float(tr["high"]),
|
||||||
|
"low": float(tr["low"]),
|
||||||
|
"mid": float(tr["mid"]),
|
||||||
|
"active": bool(is_active),
|
||||||
|
"bars": int(tr.get("bars", 0)),
|
||||||
|
}
|
||||||
|
conf = _confidence_for_confirmed(tr, phases, events)
|
||||||
|
|
||||||
|
# Live 层:仅 ACTIVE 周期做推演;历史周期归档为 COMPLETED
|
||||||
|
if is_active:
|
||||||
|
live = analyze_live_structure(
|
||||||
|
work, tr, confirmed_events=events, confirmed_phases=phases, bias=bias,
|
||||||
|
)
|
||||||
|
lifecycle = live.get("lifecycle") or "FORMING"
|
||||||
|
else:
|
||||||
|
live = None
|
||||||
|
lifecycle = "COMPLETED"
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": int(cycle_id),
|
||||||
|
"role": "latest" if is_active else "historical",
|
||||||
|
# MULTI-CYCLE:时间线角色
|
||||||
|
"status": "ACTIVE" if is_active else "HISTORICAL",
|
||||||
|
# LIVE-STRUCTURE:生命周期
|
||||||
|
"lifecycle": lifecycle,
|
||||||
|
"direction": "latest" if is_active else "historical",
|
||||||
|
"period": {
|
||||||
|
"start_time": _fmt_time(tr.get("start_time")),
|
||||||
|
"end_time": _fmt_time(tr.get("end_time")),
|
||||||
|
"bars": int(tr.get("bars", 0)),
|
||||||
|
},
|
||||||
|
"confidence": conf,
|
||||||
|
"trading_range": trading_range,
|
||||||
|
"bias": bias,
|
||||||
|
# 兼容旧读法:顶层 phases/events = confirmed
|
||||||
|
"phases": phases,
|
||||||
|
"events": events,
|
||||||
|
"confirmed": {
|
||||||
|
"phases": phases,
|
||||||
|
"events": events,
|
||||||
|
"volume_confirm": volume_confirm,
|
||||||
|
},
|
||||||
|
"live": live,
|
||||||
|
"volume_profile": vp,
|
||||||
|
"volume_confirm": volume_confirm,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def analyze_wyckoff(
|
||||||
|
df: pd.DataFrame,
|
||||||
|
lookback: int = 120,
|
||||||
|
vp_bins: int = 50,
|
||||||
|
min_bars: int = 24,
|
||||||
|
atr_mult: float = 1.2,
|
||||||
|
range_start_time=None,
|
||||||
|
prefer_start_time=None,
|
||||||
|
max_cycles: int = 8,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
多周期威科夫分析。
|
||||||
|
cycles[0] = ACTIVE;顶层 phases/events 只镜像 Confirmed。
|
||||||
|
顶层 live 镜像 cycles[0].live。
|
||||||
|
"""
|
||||||
|
empty = _empty(vp_bins)
|
||||||
|
if df is None or len(df) < 30:
|
||||||
|
return empty
|
||||||
|
if not all(c in df.columns for c in ("open", "high", "low", "close")):
|
||||||
|
return empty
|
||||||
|
work = df.copy()
|
||||||
|
if "volume" not in work.columns:
|
||||||
|
work["volume"] = 1.0
|
||||||
|
|
||||||
|
trs = detect_trading_ranges(
|
||||||
|
work,
|
||||||
|
lookback=lookback,
|
||||||
|
min_bars=max(8, int(min_bars)),
|
||||||
|
atr_mult=atr_mult,
|
||||||
|
max_cycles=max(1, min(8, int(max_cycles))),
|
||||||
|
prefer_start_time=prefer_start_time,
|
||||||
|
range_start_time=range_start_time,
|
||||||
|
)
|
||||||
|
if not trs:
|
||||||
|
return empty
|
||||||
|
|
||||||
|
cycles: List[Dict[str, Any]] = []
|
||||||
|
for i, tr in enumerate(trs):
|
||||||
|
cycles.append(_build_cycle(work, tr, cycle_id=i, vp_bins=vp_bins))
|
||||||
|
|
||||||
|
active = cycles[0]
|
||||||
|
return {
|
||||||
|
"cycles": cycles,
|
||||||
|
"trading_range": active["trading_range"],
|
||||||
|
"bias": active["bias"],
|
||||||
|
"phases": active["confirmed"]["phases"],
|
||||||
|
"events": active["confirmed"]["events"],
|
||||||
|
"volume_profile": active["volume_profile"],
|
||||||
|
"volume_confirm": active["volume_confirm"],
|
||||||
|
"live": active.get("live"),
|
||||||
|
"lifecycle": active.get("lifecycle"),
|
||||||
|
}
|
||||||
@@ -0,0 +1,369 @@
|
|||||||
|
"""威科夫阶段与事件(启发式)。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
|
||||||
|
def _bar_time(df: pd.DataFrame, i: int):
|
||||||
|
row = df.iloc[i]
|
||||||
|
if "date" in df.columns and pd.notna(row["date"]):
|
||||||
|
return row["date"]
|
||||||
|
if "timestamp" in df.columns:
|
||||||
|
return row["timestamp"]
|
||||||
|
return i
|
||||||
|
|
||||||
|
|
||||||
|
def _avg_vol(df: pd.DataFrame, i: int, win: int = 20) -> float:
|
||||||
|
a = max(0, i - win + 1)
|
||||||
|
v = df["volume"].astype(float).iloc[a : i + 1]
|
||||||
|
m = float(v.mean()) if len(v) else 0.0
|
||||||
|
return m if m > 0 else 1.0
|
||||||
|
|
||||||
|
|
||||||
|
def detect_bias_and_events(
|
||||||
|
df: pd.DataFrame,
|
||||||
|
tr: Dict[str, Any],
|
||||||
|
) -> Tuple[str, List[Dict[str, Any]], Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
返回 bias、events、volume_confirm。
|
||||||
|
|
||||||
|
Spring/UTAD 相对「结构高低」判定:取区间内次低/次高(剔除单根极值),
|
||||||
|
避免箱体把假破低点吃进 lo 后永远刺不破、从而无 C 阶段。
|
||||||
|
"""
|
||||||
|
hi = float(tr["high"])
|
||||||
|
lo = float(tr["low"])
|
||||||
|
mid = float(tr["mid"])
|
||||||
|
tol = float(tr.get("tol") or (hi - lo) * 0.05)
|
||||||
|
s = int(tr["abs_start_idx"])
|
||||||
|
e = int(tr["abs_end_idx"])
|
||||||
|
events: List[Dict[str, Any]] = []
|
||||||
|
|
||||||
|
# 结构边界:用次低/次高作假破参照(至少 8 根才启用)
|
||||||
|
seg = df.iloc[s : e + 1]
|
||||||
|
event_lo, event_hi = lo, hi
|
||||||
|
if len(seg) >= 8:
|
||||||
|
lows = seg["low"].astype(float)
|
||||||
|
highs = seg["high"].astype(float)
|
||||||
|
# nsmallest(2) 的较大者 = 次低;nlargest(2) 的较小者 = 次高
|
||||||
|
event_lo = float(lows.nsmallest(min(2, len(lows))).iloc[-1])
|
||||||
|
event_hi = float(highs.nlargest(min(2, len(highs))).iloc[-1])
|
||||||
|
# 勿比公布箱沿更「松」:结构带应在箱内
|
||||||
|
event_lo = max(event_lo, lo)
|
||||||
|
event_hi = min(event_hi, hi)
|
||||||
|
# 若次低仍等于极值(多根同价),略抬参照便于识别收回
|
||||||
|
if abs(event_lo - lo) < 1e-12:
|
||||||
|
event_lo = lo + max(tol * 0.35, (hi - lo) * 0.02)
|
||||||
|
if abs(event_hi - hi) < 1e-12:
|
||||||
|
event_hi = hi - max(tol * 0.35, (hi - lo) * 0.02)
|
||||||
|
|
||||||
|
# 扫描区间内及之后(含 tail_reserve)
|
||||||
|
scan_end = int(tr.get("abs_scan_end_idx", min(len(df) - 1, e + 15)))
|
||||||
|
scan_end = min(len(df) - 1, max(scan_end, e))
|
||||||
|
spring = None
|
||||||
|
utad = None
|
||||||
|
sos = None
|
||||||
|
sod = None # sign of weakness / distribution breakdown
|
||||||
|
lps = None
|
||||||
|
lpsy = None
|
||||||
|
|
||||||
|
for i in range(s + 2, scan_end + 1):
|
||||||
|
row = df.iloc[i]
|
||||||
|
low = float(row["low"])
|
||||||
|
high = float(row["high"])
|
||||||
|
close = float(row["close"])
|
||||||
|
vol = float(row["volume"]) if "volume" in df.columns else 0.0
|
||||||
|
avg_v = _avg_vol(df, i)
|
||||||
|
ratio = vol / avg_v if avg_v else 0.0
|
||||||
|
|
||||||
|
# Spring: pierce below structural support then close back
|
||||||
|
if spring is None and low < event_lo - tol * 0.35 and close >= event_lo - tol * 0.35:
|
||||||
|
vol_ok = ratio <= 1.35 or (i + 1 <= scan_end and float(df.iloc[min(i + 1, scan_end)]["volume"]) / avg_v < 1.2)
|
||||||
|
spring = {
|
||||||
|
"type": "Spring",
|
||||||
|
"time": _bar_time(df, i),
|
||||||
|
"price": low,
|
||||||
|
"note": "假破下沿后收回",
|
||||||
|
"volume_ratio": round(ratio, 3),
|
||||||
|
"volume_ok": bool(vol_ok),
|
||||||
|
"idx": i,
|
||||||
|
}
|
||||||
|
|
||||||
|
# UTAD: pierce above structural resistance then close back
|
||||||
|
if utad is None and high > event_hi + tol * 0.35 and close <= event_hi + tol * 0.35:
|
||||||
|
vol_ok = ratio >= 0.8
|
||||||
|
utad = {
|
||||||
|
"type": "UTAD",
|
||||||
|
"time": _bar_time(df, i),
|
||||||
|
"price": high,
|
||||||
|
"note": "假破上沿后跌回",
|
||||||
|
"volume_ratio": round(ratio, 3),
|
||||||
|
"volume_ok": bool(vol_ok),
|
||||||
|
"idx": i,
|
||||||
|
}
|
||||||
|
|
||||||
|
# SOS: close above high with volume
|
||||||
|
if sos is None and close > hi + tol * 0.15:
|
||||||
|
vol_ok = ratio >= 1.15
|
||||||
|
sos = {
|
||||||
|
"type": "SOS",
|
||||||
|
"time": _bar_time(df, i),
|
||||||
|
"price": close,
|
||||||
|
"note": "放量上破交易区间",
|
||||||
|
"volume_ratio": round(ratio, 3),
|
||||||
|
"volume_ok": bool(vol_ok),
|
||||||
|
"idx": i,
|
||||||
|
}
|
||||||
|
|
||||||
|
# SOW / breakdown
|
||||||
|
if sod is None and close < lo - tol * 0.15:
|
||||||
|
vol_ok = ratio >= 1.15
|
||||||
|
sod = {
|
||||||
|
"type": "SOW",
|
||||||
|
"time": _bar_time(df, i),
|
||||||
|
"price": close,
|
||||||
|
"note": "放量下破交易区间",
|
||||||
|
"volume_ratio": round(ratio, 3),
|
||||||
|
"volume_ok": bool(vol_ok),
|
||||||
|
"idx": i,
|
||||||
|
}
|
||||||
|
|
||||||
|
# LPS after SOS: pullback that holds above mid/high-band with lighter volume
|
||||||
|
if sos is not None:
|
||||||
|
si = int(sos["idx"])
|
||||||
|
for i in range(si + 1, min(len(df), si + 25)):
|
||||||
|
row = df.iloc[i]
|
||||||
|
low = float(row["low"])
|
||||||
|
close = float(row["close"])
|
||||||
|
vol = float(row["volume"]) if "volume" in df.columns else 0.0
|
||||||
|
avg_v = _avg_vol(df, i)
|
||||||
|
ratio = vol / avg_v if avg_v else 0.0
|
||||||
|
if low >= mid - tol and close >= hi - tol * 2:
|
||||||
|
vol_ok = ratio <= 1.05
|
||||||
|
lps = {
|
||||||
|
"type": "LPS",
|
||||||
|
"time": _bar_time(df, i),
|
||||||
|
"price": low,
|
||||||
|
"note": "突破后缩量回踩不破",
|
||||||
|
"volume_ratio": round(ratio, 3),
|
||||||
|
"volume_ok": bool(vol_ok),
|
||||||
|
"idx": i,
|
||||||
|
}
|
||||||
|
break
|
||||||
|
|
||||||
|
if sod is not None:
|
||||||
|
si = int(sod["idx"])
|
||||||
|
for i in range(si + 1, min(len(df), si + 25)):
|
||||||
|
row = df.iloc[i]
|
||||||
|
high = float(row["high"])
|
||||||
|
close = float(row["close"])
|
||||||
|
vol = float(row["volume"]) if "volume" in df.columns else 0.0
|
||||||
|
avg_v = _avg_vol(df, i)
|
||||||
|
ratio = vol / avg_v if avg_v else 0.0
|
||||||
|
if high <= mid + tol and close <= lo + tol * 2:
|
||||||
|
vol_ok = ratio <= 1.05
|
||||||
|
lpsy = {
|
||||||
|
"type": "LPSY",
|
||||||
|
"time": _bar_time(df, i),
|
||||||
|
"price": high,
|
||||||
|
"note": "下跌突破后缩量反抽不过",
|
||||||
|
"volume_ratio": round(ratio, 3),
|
||||||
|
"volume_ok": bool(vol_ok),
|
||||||
|
"idx": i,
|
||||||
|
}
|
||||||
|
break
|
||||||
|
|
||||||
|
# 冲突清理:已判定吸筹且有 SOS 时,丢弃更早的 UTAD(避免阶段/图面误导)
|
||||||
|
# 派发且有 SOW 时,丢弃更晚才合理的 Spring 假信号同理在偏置后再滤
|
||||||
|
keep = []
|
||||||
|
for ev in (spring, sos, lps, utad, sod, lpsy):
|
||||||
|
if not ev:
|
||||||
|
continue
|
||||||
|
keep.append(ev)
|
||||||
|
|
||||||
|
# bias(先算)
|
||||||
|
last_c = float(df["close"].iloc[-1])
|
||||||
|
bias = "unknown"
|
||||||
|
if sos and (not sod or int(sos.get("idx", 0)) >= int(sod.get("idx", 0))):
|
||||||
|
bias = "accumulation"
|
||||||
|
elif sod and (not sos or int(sod.get("idx", 0)) > int(sos.get("idx", 0))):
|
||||||
|
bias = "distribution"
|
||||||
|
elif spring and not utad:
|
||||||
|
bias = "accumulation"
|
||||||
|
elif utad and not spring:
|
||||||
|
bias = "distribution"
|
||||||
|
elif last_c >= mid:
|
||||||
|
bias = "accumulation"
|
||||||
|
else:
|
||||||
|
bias = "distribution"
|
||||||
|
|
||||||
|
filtered = []
|
||||||
|
for ev in keep:
|
||||||
|
if bias == "accumulation" and ev["type"] == "UTAD" and sos and int(ev["idx"]) <= int(sos["idx"]):
|
||||||
|
continue
|
||||||
|
if bias == "distribution" and ev["type"] == "Spring" and sod and int(ev["idx"]) <= int(sod["idx"]):
|
||||||
|
continue
|
||||||
|
filtered.append(ev)
|
||||||
|
|
||||||
|
events = [{k: v for k, v in ev.items() if k != "idx"} for ev in filtered]
|
||||||
|
|
||||||
|
avg_volume = float(df["volume"].astype(float).iloc[max(0, e - 20) : e + 1].mean()) if "volume" in df.columns else 0.0
|
||||||
|
volume_confirm = {
|
||||||
|
"avg_volume": avg_volume,
|
||||||
|
"event_checks": {ev["type"]: {"volume_ok": ev.get("volume_ok"), "volume_ratio": ev.get("volume_ratio")} for ev in events},
|
||||||
|
}
|
||||||
|
return bias, events, volume_confirm
|
||||||
|
|
||||||
|
|
||||||
|
def build_phases(
|
||||||
|
df: pd.DataFrame,
|
||||||
|
tr: Dict[str, Any],
|
||||||
|
bias: str,
|
||||||
|
events: List[Dict[str, Any]],
|
||||||
|
min_bars: int = 3,
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
按威科夫事件锚点切分 A–E(启发式)。
|
||||||
|
|
||||||
|
吸筹:A停止 → B筑底 → C测试(Spring) → D拉升(SOS…LPS) → E离开
|
||||||
|
派发:A停止 → B筑顶 → C测试(UTAD) → D派发(SOW…LPSY) → E离开
|
||||||
|
|
||||||
|
无 Spring/UTAD 时:若已有 SOS/SOW,用突破前末次沿带测试补 C;仍无则省略 C。
|
||||||
|
"""
|
||||||
|
s = int(tr["abs_start_idx"])
|
||||||
|
e = int(tr["abs_end_idx"])
|
||||||
|
hi = float(tr["high"])
|
||||||
|
lo = float(tr["low"])
|
||||||
|
n_last = len(df) - 1
|
||||||
|
min_span = max(2, min_bars - 1)
|
||||||
|
range_len = max(1, e - s)
|
||||||
|
|
||||||
|
def _match_idx(t) -> Optional[int]:
|
||||||
|
if t is None:
|
||||||
|
return None
|
||||||
|
lo = max(0, s - 2)
|
||||||
|
hi = min(len(df), e + 40)
|
||||||
|
for i in range(lo, hi):
|
||||||
|
if _bar_time(df, i) == t:
|
||||||
|
return i
|
||||||
|
try:
|
||||||
|
tt = pd.Timestamp(t)
|
||||||
|
sample = None
|
||||||
|
if "date" in df.columns and len(df):
|
||||||
|
sample = df["date"].iloc[min(s, n_last)]
|
||||||
|
if sample is not None and getattr(sample, "tzinfo", None) is not None and tt.tzinfo is None:
|
||||||
|
tt = tt.tz_localize(sample.tzinfo)
|
||||||
|
for i in range(lo, hi):
|
||||||
|
bt = _bar_time(df, i)
|
||||||
|
try:
|
||||||
|
if abs((pd.Timestamp(bt) - tt).total_seconds()) <= 1:
|
||||||
|
return i
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
event_idx: Dict[str, int] = {}
|
||||||
|
for ev in events:
|
||||||
|
idx = _match_idx(ev.get("time"))
|
||||||
|
if idx is not None:
|
||||||
|
event_idx[str(ev.get("type"))] = idx
|
||||||
|
|
||||||
|
accum = bias != "distribution"
|
||||||
|
if accum:
|
||||||
|
c_ev = event_idx.get("Spring")
|
||||||
|
d_ev = event_idx.get("SOS")
|
||||||
|
d_tail = event_idx.get("LPS") or d_ev
|
||||||
|
else:
|
||||||
|
c_ev = event_idx.get("UTAD")
|
||||||
|
d_ev = event_idx.get("SOW")
|
||||||
|
d_tail = event_idx.get("LPSY") or d_ev
|
||||||
|
|
||||||
|
# 有 D 无明确测试事件时:用突破前最后一次触及下/上沿作为 C(次级测试)
|
||||||
|
if c_ev is None and d_ev is not None:
|
||||||
|
band = lo + (hi - lo) * 0.28 if accum else hi - (hi - lo) * 0.28
|
||||||
|
for i in range(int(d_ev) - 1, s + 1, -1):
|
||||||
|
row = df.iloc[i]
|
||||||
|
if accum and float(row["low"]) <= band:
|
||||||
|
c_ev = i
|
||||||
|
break
|
||||||
|
if not accum and float(row["high"]) >= band:
|
||||||
|
c_ev = i
|
||||||
|
break
|
||||||
|
|
||||||
|
def _lab(phase: str) -> str:
|
||||||
|
if accum:
|
||||||
|
m = {"A": "A停止下跌", "B": "B筑底", "C": "C测试", "D": "D拉升", "E": "E离开"}
|
||||||
|
else:
|
||||||
|
m = {"A": "A停止上涨", "B": "B筑顶", "C": "C测试", "D": "D派发", "E": "E离开"}
|
||||||
|
return m.get(phase, phase)
|
||||||
|
|
||||||
|
a_end = s + max(min_bars, range_len // 5)
|
||||||
|
|
||||||
|
c_start = c_end = None
|
||||||
|
if c_ev is not None:
|
||||||
|
c_start = max(s, int(c_ev) - 1)
|
||||||
|
c_end = min(n_last, int(c_ev) + 1)
|
||||||
|
|
||||||
|
if d_ev is not None:
|
||||||
|
d_start = int(d_ev)
|
||||||
|
d_end = min(n_last, max(int(d_tail or d_ev), d_start) + max(min_bars, range_len // 8))
|
||||||
|
if d_tail is not None:
|
||||||
|
d_end = max(d_end, min(n_last, int(d_tail) + 1))
|
||||||
|
else:
|
||||||
|
d_start = d_end = None
|
||||||
|
|
||||||
|
if c_start is not None:
|
||||||
|
b_end = max(a_end + 1, c_start)
|
||||||
|
elif d_start is not None:
|
||||||
|
b_end = max(a_end + 1, d_start)
|
||||||
|
else:
|
||||||
|
b_end = max(a_end + 1, e)
|
||||||
|
|
||||||
|
if d_end is not None:
|
||||||
|
e_start = min(n_last, d_end)
|
||||||
|
e_end = n_last
|
||||||
|
else:
|
||||||
|
e_start = e_end = None
|
||||||
|
|
||||||
|
raw = [("A", s, a_end), ("B", a_end, b_end)]
|
||||||
|
if c_start is not None and c_end is not None:
|
||||||
|
raw.append(("C", c_start, c_end))
|
||||||
|
if d_start is not None and d_end is not None:
|
||||||
|
raw.append(("D", d_start, d_end))
|
||||||
|
if e_start is not None and e_end is not None and e_end > e_start:
|
||||||
|
raw.append(("E", e_start, e_end))
|
||||||
|
|
||||||
|
phases: List[Dict[str, Any]] = []
|
||||||
|
cursor = s
|
||||||
|
for phase, _a, _b in raw:
|
||||||
|
if cursor >= n_last:
|
||||||
|
break
|
||||||
|
a = max(int(_a), cursor)
|
||||||
|
b = int(max(int(_b), a))
|
||||||
|
need = 1 if phase == "C" else min_span
|
||||||
|
if b < a + need:
|
||||||
|
b = min(n_last, a + need)
|
||||||
|
b = int(np.clip(b, a, n_last))
|
||||||
|
if b < a:
|
||||||
|
continue
|
||||||
|
if phases and phases[-1].get("_a") == a and phases[-1].get("_b") == b:
|
||||||
|
continue
|
||||||
|
phases.append(
|
||||||
|
{
|
||||||
|
"phase": phase,
|
||||||
|
"label": _lab(phase),
|
||||||
|
"start_time": _bar_time(df, a),
|
||||||
|
"end_time": _bar_time(df, b),
|
||||||
|
"_a": a,
|
||||||
|
"_b": b,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
cursor = b
|
||||||
|
for p in phases:
|
||||||
|
p.pop("_a", None)
|
||||||
|
p.pop("_b", None)
|
||||||
|
return phases
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
"""威科夫 Live / Developing 层(WYCKOFF-LIVE-STRUCTURE-001)。
|
||||||
|
|
||||||
|
独立于 Confirmed Engine:不修改 events 确认条件,不写入 confirmed.events。
|
||||||
|
Execution 不得消费本模块输出。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Dict, List, Optional, Set
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
|
||||||
|
def _avg_vol(df: pd.DataFrame, i: int, win: int = 20) -> float:
|
||||||
|
a = max(0, i - win + 1)
|
||||||
|
v = df["volume"].astype(float).iloc[a : i + 1]
|
||||||
|
m = float(v.mean()) if len(v) else 0.0
|
||||||
|
return m if m > 0 else 1.0
|
||||||
|
|
||||||
|
|
||||||
|
def _empty_live() -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"lifecycle": "UNKNOWN",
|
||||||
|
"range_formation": None,
|
||||||
|
"phase_candidate": None,
|
||||||
|
"event_candidates": [],
|
||||||
|
"next_expected": None,
|
||||||
|
"confidence": {
|
||||||
|
"cycle": 0.0,
|
||||||
|
"phase": 0.0,
|
||||||
|
"event": 0.0,
|
||||||
|
"structure": 0.0,
|
||||||
|
"volume": 0.0,
|
||||||
|
"overall": 0.0,
|
||||||
|
},
|
||||||
|
"note": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def analyze_live_structure(
|
||||||
|
df: pd.DataFrame,
|
||||||
|
tr: Optional[Dict[str, Any]],
|
||||||
|
confirmed_events: Optional[List[Dict[str, Any]]] = None,
|
||||||
|
confirmed_phases: Optional[List[Dict[str, Any]]] = None,
|
||||||
|
bias: str = "unknown",
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
基于当前 TradingRange 与已确认事件,推演 Live candidates。
|
||||||
|
confirmed_* 只读,用于避免重复提示已确认事件,不修改之。
|
||||||
|
"""
|
||||||
|
out = _empty_live()
|
||||||
|
if df is None or len(df) < 20 or tr is None:
|
||||||
|
out["note"] = "insufficient structure"
|
||||||
|
return out
|
||||||
|
|
||||||
|
confirmed_events = confirmed_events or []
|
||||||
|
confirmed_phases = confirmed_phases or []
|
||||||
|
confirmed_types: Set[str] = {str(e.get("type")) for e in confirmed_events if e.get("type")}
|
||||||
|
|
||||||
|
s = int(tr["abs_start_idx"])
|
||||||
|
e = int(tr["abs_end_idx"])
|
||||||
|
scan_end = int(tr.get("abs_scan_end_idx", len(df) - 1))
|
||||||
|
scan_end = min(len(df) - 1, max(scan_end, e))
|
||||||
|
hi = float(tr["high"])
|
||||||
|
lo = float(tr["low"])
|
||||||
|
mid = float(tr["mid"])
|
||||||
|
tol = float(tr.get("tol") or (hi - lo) * 0.05)
|
||||||
|
atr = float(tr.get("atr") or max((hi - lo) * 0.2, 1e-9))
|
||||||
|
|
||||||
|
seg = df.iloc[s : e + 1]
|
||||||
|
if len(seg) < 8:
|
||||||
|
out["note"] = "range too short"
|
||||||
|
return out
|
||||||
|
|
||||||
|
# —— Range Formation(横盘 / 波动收敛)——
|
||||||
|
closes = seg["close"].astype(float)
|
||||||
|
highs = seg["high"].astype(float)
|
||||||
|
lows = seg["low"].astype(float)
|
||||||
|
vols = seg["volume"].astype(float) if "volume" in seg.columns else pd.Series([1.0] * len(seg))
|
||||||
|
half = max(4, len(seg) // 2)
|
||||||
|
vol_early = float(np.std(closes.iloc[:half])) if half > 1 else 0.0
|
||||||
|
vol_late = float(np.std(closes.iloc[-half:])) if half > 1 else 0.0
|
||||||
|
width = hi - lo
|
||||||
|
width_atr = width / atr if atr > 0 else 99.0
|
||||||
|
converging = vol_early > 1e-12 and vol_late < vol_early * 0.85
|
||||||
|
range_ok = 1.2 <= width_atr <= 10.0 and len(seg) >= 16
|
||||||
|
structure_score = 0.35
|
||||||
|
if range_ok:
|
||||||
|
structure_score += 0.25
|
||||||
|
if converging:
|
||||||
|
structure_score += 0.2
|
||||||
|
if width_atr <= 6.0:
|
||||||
|
structure_score += 0.1
|
||||||
|
structure_score = float(min(0.95, structure_score))
|
||||||
|
|
||||||
|
out["range_formation"] = {
|
||||||
|
"potential_trading_range": bool(range_ok),
|
||||||
|
"converging": bool(converging),
|
||||||
|
"width_atr": round(width_atr, 3),
|
||||||
|
"bars": int(len(seg)),
|
||||||
|
}
|
||||||
|
|
||||||
|
# —— 最近 K 形态(Phase C / Event candidates)——
|
||||||
|
i = scan_end
|
||||||
|
row = df.iloc[i]
|
||||||
|
o = float(row["open"])
|
||||||
|
h = float(row["high"])
|
||||||
|
l = float(row["low"])
|
||||||
|
c = float(row["close"])
|
||||||
|
rng = max(h - l, 1e-9)
|
||||||
|
lower_wick = min(o, c) - l
|
||||||
|
upper_wick = h - max(o, c)
|
||||||
|
avg_v = _avg_vol(df, i)
|
||||||
|
vol = float(row["volume"]) if "volume" in df.columns else avg_v
|
||||||
|
vol_ratio = vol / avg_v if avg_v else 1.0
|
||||||
|
volume_score = float(np.clip(1.1 - abs(vol_ratio - 1.0) * 0.35, 0.2, 0.95))
|
||||||
|
|
||||||
|
phase_candidate = None
|
||||||
|
phase_conf = 0.0
|
||||||
|
# Phase C:测低 + 下影 + 缩量(吸筹语境)
|
||||||
|
near_lo = l <= lo + tol * 1.2
|
||||||
|
test_low = l < mid and lower_wick >= rng * 0.35
|
||||||
|
vol_contract = vol_ratio <= 1.05
|
||||||
|
if bias != "distribution" and near_lo and test_low and vol_contract:
|
||||||
|
phase_candidate = "C"
|
||||||
|
phase_conf = 0.55 + (0.1 if lower_wick >= rng * 0.5 else 0) + (0.08 if vol_ratio < 0.9 else 0)
|
||||||
|
# Phase D 候选:价格在箱上半、有上破意图但未确认 SOS
|
||||||
|
elif c >= mid and (h >= hi - tol or c > hi - tol * 0.5):
|
||||||
|
phase_candidate = "D"
|
||||||
|
phase_conf = 0.5 + (0.1 if c > mid else 0)
|
||||||
|
elif c < mid and (l <= lo + tol):
|
||||||
|
phase_candidate = "B"
|
||||||
|
phase_conf = 0.45
|
||||||
|
|
||||||
|
# 已有 confirmed phase 时,candidate 取「下一阶段」提示,不覆盖事实
|
||||||
|
confirmed_phase_set = {str(p.get("phase")) for p in confirmed_phases}
|
||||||
|
if "E" in confirmed_phase_set:
|
||||||
|
phase_candidate = phase_candidate or "E"
|
||||||
|
phase_conf = max(phase_conf, 0.7)
|
||||||
|
elif "D" in confirmed_phase_set and phase_candidate is None:
|
||||||
|
phase_candidate = "D"
|
||||||
|
phase_conf = max(phase_conf, 0.65)
|
||||||
|
|
||||||
|
out["phase_candidate"] = phase_candidate
|
||||||
|
phase_conf = float(min(0.92, phase_conf))
|
||||||
|
|
||||||
|
# —— Event candidates(仅 Spring / SOS / LPS / UTAD)——
|
||||||
|
candidates: List[Dict[str, Any]] = []
|
||||||
|
|
||||||
|
def _add(typ: str, conf: float, note: str) -> None:
|
||||||
|
if typ in confirmed_types:
|
||||||
|
return # 已确认则不再作为 candidate
|
||||||
|
candidates.append(
|
||||||
|
{
|
||||||
|
"type": typ,
|
||||||
|
"confidence": round(float(min(0.9, conf)), 3),
|
||||||
|
"confirmed": False,
|
||||||
|
"note": note,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Spring candidate:刺破或贴近下沿,收盘收回,但未达 Confirmed 规则(或不在 confirmed)
|
||||||
|
pierce_lo = l < lo - tol * 0.15
|
||||||
|
close_back = c >= lo - tol * 0.5
|
||||||
|
if pierce_lo and close_back:
|
||||||
|
_add("Spring", 0.5 + (0.12 if vol_ratio <= 1.2 else 0) + (0.08 if close_back else 0), "假破下沿收回(未确认)")
|
||||||
|
elif l <= lo + tol * 0.35 and close_back and lower_wick >= rng * 0.4:
|
||||||
|
_add("Spring", 0.45 + (0.1 if vol_contract else 0), "测下沿长下影(未确认)")
|
||||||
|
|
||||||
|
# UTAD candidate
|
||||||
|
pierce_hi = h > hi + tol * 0.15
|
||||||
|
close_back_dn = c <= hi + tol * 0.5
|
||||||
|
if pierce_hi and close_back_dn:
|
||||||
|
_add("UTAD", 0.5 + (0.1 if vol_ratio >= 0.9 else 0), "假破上沿跌回(未确认)")
|
||||||
|
|
||||||
|
# SOS candidate:接近/轻破上沿,量能一般,未确认
|
||||||
|
if c > hi - tol * 0.4 or h >= hi:
|
||||||
|
sos_conf = 0.48 + (0.12 if c > hi else 0) + (0.1 if vol_ratio >= 1.05 else 0)
|
||||||
|
_add("SOS", sos_conf, "上破/逼近箱顶(未确认)")
|
||||||
|
|
||||||
|
# LPS candidate:站上 mid/上沿带后回踩
|
||||||
|
if c >= mid and l >= mid - tol * 1.5 and l > lo + (hi - lo) * 0.25:
|
||||||
|
_add("LPS", 0.46 + (0.1 if vol_ratio <= 1.0 else 0), "箱内上沿带回踩(未确认)")
|
||||||
|
|
||||||
|
candidates.sort(key=lambda x: x["confidence"], reverse=True)
|
||||||
|
out["event_candidates"] = candidates[:4]
|
||||||
|
|
||||||
|
event_score = float(candidates[0]["confidence"]) if candidates else 0.25
|
||||||
|
|
||||||
|
# next_expected(简规则)
|
||||||
|
next_exp = None
|
||||||
|
if "Spring" in confirmed_types and "SOS" not in confirmed_types:
|
||||||
|
next_exp = "SOS"
|
||||||
|
elif "SOS" in confirmed_types and "LPS" not in confirmed_types:
|
||||||
|
next_exp = "LPS"
|
||||||
|
elif "UTAD" in confirmed_types and "SOW" not in confirmed_types:
|
||||||
|
next_exp = "SOW"
|
||||||
|
elif any(c["type"] == "Spring" for c in candidates):
|
||||||
|
next_exp = "Test"
|
||||||
|
elif any(c["type"] == "SOS" for c in candidates):
|
||||||
|
next_exp = "LPS"
|
||||||
|
out["next_expected"] = next_exp
|
||||||
|
|
||||||
|
# —— lifecycle ——
|
||||||
|
key_confirmed = confirmed_types & {"Spring", "SOS", "UTAD", "SOW", "LPS", "LPSY"}
|
||||||
|
if key_confirmed:
|
||||||
|
lifecycle = "CONFIRMED"
|
||||||
|
elif range_ok or phase_candidate or candidates:
|
||||||
|
lifecycle = "FORMING"
|
||||||
|
else:
|
||||||
|
lifecycle = "UNKNOWN"
|
||||||
|
out["lifecycle"] = lifecycle
|
||||||
|
|
||||||
|
cycle_c = structure_score
|
||||||
|
overall = 0.35 * cycle_c + 0.25 * phase_conf + 0.25 * event_score + 0.15 * volume_score
|
||||||
|
out["confidence"] = {
|
||||||
|
"cycle": round(cycle_c, 3),
|
||||||
|
"phase": round(phase_conf, 3),
|
||||||
|
"event": round(event_score, 3),
|
||||||
|
"structure": round(structure_score, 3),
|
||||||
|
"volume": round(volume_score, 3),
|
||||||
|
"overall": round(float(overall), 3),
|
||||||
|
}
|
||||||
|
parts = []
|
||||||
|
if out["range_formation"]["potential_trading_range"]:
|
||||||
|
parts.append("Potential TR")
|
||||||
|
if phase_candidate:
|
||||||
|
parts.append(f"Phase {phase_candidate} candidate")
|
||||||
|
if candidates:
|
||||||
|
parts.append(f"{candidates[0]['type']} candidate")
|
||||||
|
out["note"] = "; ".join(parts) if parts else "observing"
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def execution_signal_from_wyckoff(payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Execution 边界:只允许 Confirmed。
|
||||||
|
返回 source='confirmed' 的信号描述;Live-only 时返回 None。
|
||||||
|
"""
|
||||||
|
if not payload:
|
||||||
|
return None
|
||||||
|
cycles = payload.get("cycles") or []
|
||||||
|
active = cycles[0] if cycles else None
|
||||||
|
events = []
|
||||||
|
if active and isinstance(active.get("confirmed"), dict):
|
||||||
|
events = list(active["confirmed"].get("events") or [])
|
||||||
|
if not events:
|
||||||
|
# 兼容旧顶层 events(均为 confirmed 镜像)
|
||||||
|
events = list(payload.get("events") or [])
|
||||||
|
if not events:
|
||||||
|
return None
|
||||||
|
last = events[-1]
|
||||||
|
return {
|
||||||
|
"source": "confirmed",
|
||||||
|
"type": last.get("type"),
|
||||||
|
"time": last.get("time"),
|
||||||
|
"lifecycle": (active or {}).get("lifecycle") or "CONFIRMED",
|
||||||
|
}
|
||||||
@@ -0,0 +1,442 @@
|
|||||||
|
"""交易区间检测:仅负责 TradingRange(起止/高低/结构分)。
|
||||||
|
|
||||||
|
WYCKOFF-MULTI-CYCLE-001:Phase/Event/VP 不得进入本模块。
|
||||||
|
过滤顺序固定:detect → quality → trend → overlap(<0.2) → accept → mask。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
MAX_CYCLES = 8
|
||||||
|
OVERLAP_RATIO_MAX = 0.2
|
||||||
|
|
||||||
|
|
||||||
|
def _atr(df: pd.DataFrame, period: int = 14) -> pd.Series:
|
||||||
|
high = df["high"].astype(float)
|
||||||
|
low = df["low"].astype(float)
|
||||||
|
close = df["close"].astype(float)
|
||||||
|
prev_close = close.shift(1)
|
||||||
|
tr = pd.concat(
|
||||||
|
[
|
||||||
|
(high - low).abs(),
|
||||||
|
(high - prev_close).abs(),
|
||||||
|
(low - prev_close).abs(),
|
||||||
|
],
|
||||||
|
axis=1,
|
||||||
|
).max(axis=1)
|
||||||
|
return tr.rolling(period, min_periods=max(3, period // 2)).mean()
|
||||||
|
|
||||||
|
|
||||||
|
def _robust_width(seg: pd.DataFrame) -> float:
|
||||||
|
"""用 90/10 分位估宽,避免单根影线把长窗卡死。"""
|
||||||
|
h = seg["high"].astype(float)
|
||||||
|
l = seg["low"].astype(float)
|
||||||
|
if len(seg) < 6:
|
||||||
|
return float(h.max() - l.min())
|
||||||
|
return float(np.nanpercentile(h, 90) - np.nanpercentile(l, 10))
|
||||||
|
|
||||||
|
|
||||||
|
def _score_segment(
|
||||||
|
length: int,
|
||||||
|
near_hi: int,
|
||||||
|
near_lo: int,
|
||||||
|
inside: float,
|
||||||
|
width: float,
|
||||||
|
atr: float,
|
||||||
|
) -> float:
|
||||||
|
"""结构质量分(非 Phase/Event)。"""
|
||||||
|
touch = min(near_hi, 6) + min(near_lo, 6)
|
||||||
|
width_pen = (width / atr) if atr > 0 else width
|
||||||
|
return float(touch) * 4.0 + float(inside) * 25.0 - width_pen * 3.0 + min(length / 40.0, 2.0)
|
||||||
|
|
||||||
|
|
||||||
|
def _time_col(df: pd.DataFrame) -> Optional[str]:
|
||||||
|
if "date" in df.columns:
|
||||||
|
return "date"
|
||||||
|
if "timestamp" in df.columns:
|
||||||
|
return "timestamp"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _bar_index_at_or_after(work: pd.DataFrame, ts: Any) -> Optional[int]:
|
||||||
|
col = _time_col(work)
|
||||||
|
if col is None or ts is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
target = pd.Timestamp(ts)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
series = pd.to_datetime(work[col], utc=True, errors="coerce")
|
||||||
|
if target.tzinfo is None:
|
||||||
|
target = target.tz_localize("UTC")
|
||||||
|
else:
|
||||||
|
target = target.tz_convert("UTC")
|
||||||
|
if series.isna().all():
|
||||||
|
return None
|
||||||
|
ge = series >= target
|
||||||
|
if ge.any():
|
||||||
|
return int(np.flatnonzero(ge.to_numpy())[0])
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _pack_range(
|
||||||
|
work: pd.DataFrame,
|
||||||
|
df: pd.DataFrame,
|
||||||
|
start_i: int,
|
||||||
|
end_i: int,
|
||||||
|
hi: float,
|
||||||
|
lo: float,
|
||||||
|
tol: float,
|
||||||
|
last_atr: float,
|
||||||
|
score: float,
|
||||||
|
n: int,
|
||||||
|
window_offset: int = 0,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""组装 TradingRange(仅结构字段)。"""
|
||||||
|
mid = (hi + lo) / 2.0
|
||||||
|
last_c = float(work["close"].iloc[min(end_i, len(work) - 1)])
|
||||||
|
price_in_box = (lo - tol * 1.5) <= last_c <= (hi + tol * 1.5)
|
||||||
|
bars = int(end_i - start_i + 1)
|
||||||
|
# 结构置信:归一化 score(启发式)
|
||||||
|
range_conf = float(np.clip(score / 55.0, 0.05, 0.99))
|
||||||
|
best = {
|
||||||
|
"start_idx": int(start_i),
|
||||||
|
"end_idx": int(end_i),
|
||||||
|
"high": float(hi),
|
||||||
|
"low": float(lo),
|
||||||
|
"mid": float(mid),
|
||||||
|
"active": bool(price_in_box),
|
||||||
|
"atr": float(last_atr),
|
||||||
|
"tol": float(tol),
|
||||||
|
"bars": bars,
|
||||||
|
"score": float(score),
|
||||||
|
"quality": float(score),
|
||||||
|
"range_confidence": range_conf,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _ts(row) -> Any:
|
||||||
|
col = _time_col(work)
|
||||||
|
if col and pd.notna(row[col]):
|
||||||
|
return row[col]
|
||||||
|
return None
|
||||||
|
|
||||||
|
best["start_time"] = _ts(work.iloc[best["start_idx"]])
|
||||||
|
best["end_time"] = _ts(work.iloc[best["end_idx"]])
|
||||||
|
# window_offset:slice 相对父 DataFrame 的起点;勿用 len(df)-len(work)
|
||||||
|
offset = int(window_offset)
|
||||||
|
best["abs_start_idx"] = offset + best["start_idx"]
|
||||||
|
best["abs_end_idx"] = offset + best["end_idx"]
|
||||||
|
best["abs_scan_end_idx"] = offset + n - 1
|
||||||
|
return best
|
||||||
|
|
||||||
|
|
||||||
|
def _overlap_ratio(a0: int, a1: int, b0: int, b1: int) -> float:
|
||||||
|
"""两闭区间重叠长度 / 较短区间长度。"""
|
||||||
|
lo = max(a0, b0)
|
||||||
|
hi = min(a1, b1)
|
||||||
|
if hi < lo:
|
||||||
|
return 0.0
|
||||||
|
overlap = hi - lo + 1
|
||||||
|
shorter = min(a1 - a0 + 1, b1 - b0 + 1)
|
||||||
|
if shorter <= 0:
|
||||||
|
return 0.0
|
||||||
|
return float(overlap) / float(shorter)
|
||||||
|
|
||||||
|
|
||||||
|
def _passes_quality(tr: Dict[str, Any], min_bars: int) -> bool:
|
||||||
|
if tr is None:
|
||||||
|
return False
|
||||||
|
if int(tr.get("bars") or 0) < max(8, min_bars // 2):
|
||||||
|
return False
|
||||||
|
if float(tr.get("score") or 0) < 12.0:
|
||||||
|
return False
|
||||||
|
hi = float(tr["high"])
|
||||||
|
lo = float(tr["low"])
|
||||||
|
atr = float(tr.get("atr") or 0) or 1.0
|
||||||
|
if (hi - lo) / atr > 12.0:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _passes_trend_filter(work: pd.DataFrame, tr: Dict[str, Any]) -> bool:
|
||||||
|
"""趋势污染:定向位移过大则非震荡箱。"""
|
||||||
|
s = int(tr["start_idx"])
|
||||||
|
e = int(tr["end_idx"])
|
||||||
|
seg = work.iloc[s : e + 1]
|
||||||
|
if len(seg) < 8:
|
||||||
|
return False
|
||||||
|
c0 = float(seg["close"].iloc[0])
|
||||||
|
c1 = float(seg["close"].iloc[-1])
|
||||||
|
atr = float(tr.get("atr") or 0) or 1.0
|
||||||
|
drift = abs(c1 - c0) / atr
|
||||||
|
# 相对箱宽:漂移占箱宽过大 → 趋势
|
||||||
|
width = max(float(tr["high"]) - float(tr["low"]), atr)
|
||||||
|
drift_frac = abs(c1 - c0) / width
|
||||||
|
if drift > 6.0 and drift_frac > 0.55:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _detect_in_window(
|
||||||
|
df: pd.DataFrame,
|
||||||
|
win_start: int,
|
||||||
|
win_end: int,
|
||||||
|
min_bars: int = 24,
|
||||||
|
atr_mult: float = 1.2,
|
||||||
|
tail_reserve: int = 12,
|
||||||
|
prefer_start_time: Any = None,
|
||||||
|
range_start_time: Any = None,
|
||||||
|
) -> Optional[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
在 df[win_start:win_end+1] 内检测单个 TradingRange。
|
||||||
|
只返回箱体结构,不含 Phase/Event/VP。
|
||||||
|
"""
|
||||||
|
if df is None or win_end < win_start:
|
||||||
|
return None
|
||||||
|
slice_df = df.iloc[win_start : win_end + 1].reset_index(drop=True)
|
||||||
|
lookback = len(slice_df)
|
||||||
|
if lookback < min_bars + 5:
|
||||||
|
return None
|
||||||
|
|
||||||
|
work = slice_df
|
||||||
|
n = len(work)
|
||||||
|
reserve = min(tail_reserve, max(0, n - min_bars - 2))
|
||||||
|
core_end = n - reserve if reserve > 0 else n
|
||||||
|
core = work.iloc[:core_end]
|
||||||
|
if len(core) < min_bars:
|
||||||
|
core = work
|
||||||
|
core_end = n
|
||||||
|
reserve = 0
|
||||||
|
|
||||||
|
atr = _atr(work)
|
||||||
|
last_atr = float(atr.iloc[core_end - 1]) if atr.notna().iloc[:core_end].any() else float(
|
||||||
|
(core["high"] - core["low"]).mean()
|
||||||
|
)
|
||||||
|
if not np.isfinite(last_atr) or last_atr <= 0:
|
||||||
|
last_atr = float(core["close"].iloc[-1]) * 0.01
|
||||||
|
|
||||||
|
eff_atr_mult = float(atr_mult)
|
||||||
|
if lookback >= 280:
|
||||||
|
eff_atr_mult = atr_mult * 1.7
|
||||||
|
elif lookback >= 160:
|
||||||
|
eff_atr_mult = atr_mult * 1.3
|
||||||
|
width_factor = 3.8 + min(2.2, max(0.0, (lookback - 80) / 100.0))
|
||||||
|
max_width = last_atr * eff_atr_mult * width_factor
|
||||||
|
tol = last_atr * eff_atr_mult * 0.35
|
||||||
|
|
||||||
|
prefer_i = None
|
||||||
|
if prefer_start_time is not None:
|
||||||
|
prefer_i = _bar_index_at_or_after(work, prefer_start_time)
|
||||||
|
|
||||||
|
if range_start_time is not None:
|
||||||
|
start_i = _bar_index_at_or_after(work, range_start_time)
|
||||||
|
if start_i is not None and start_i <= core_end - 8:
|
||||||
|
seg = work.iloc[start_i:core_end]
|
||||||
|
hi = float(seg["high"].max())
|
||||||
|
lo = float(seg["low"].min())
|
||||||
|
rw = _robust_width(seg)
|
||||||
|
if 0 < rw <= max_width * 1.15:
|
||||||
|
near_hi = int((seg["high"] >= hi - tol).sum())
|
||||||
|
near_lo = int((seg["low"] <= lo + tol).sum())
|
||||||
|
inside = float(((seg["close"] >= lo - tol) & (seg["close"] <= hi + tol)).mean())
|
||||||
|
if near_hi >= 2 and near_lo >= 2 and inside >= 0.70:
|
||||||
|
score = _score_segment(len(seg), near_hi, near_lo, inside, rw, last_atr)
|
||||||
|
return _pack_range(
|
||||||
|
work, df, start_i, core_end - 1, hi, lo, tol, last_atr, score, n,
|
||||||
|
window_offset=win_start,
|
||||||
|
)
|
||||||
|
|
||||||
|
eff_min_bars = max(8, int(min_bars))
|
||||||
|
cn = len(core)
|
||||||
|
max_bars = min(cn, max(eff_min_bars * 2, min(96, max(eff_min_bars + 8, int(cn * 0.5)))))
|
||||||
|
cands: List[Tuple[float, int, int, int, float, float, float]] = []
|
||||||
|
|
||||||
|
def _try_seg(start_i: int, end_i: int, prefer_boost: float = 0.0) -> None:
|
||||||
|
if end_i - start_i + 1 < eff_min_bars:
|
||||||
|
return
|
||||||
|
if start_i < 0 or end_i >= cn or start_i > end_i:
|
||||||
|
return
|
||||||
|
seg = work.iloc[start_i : end_i + 1]
|
||||||
|
hi = float(seg["high"].max())
|
||||||
|
lo = float(seg["low"].min())
|
||||||
|
rw = _robust_width(seg)
|
||||||
|
if rw <= 0 or rw > max_width:
|
||||||
|
return
|
||||||
|
raw_w = hi - lo
|
||||||
|
if raw_w > max_width * 1.35:
|
||||||
|
return
|
||||||
|
near_hi = int((seg["high"] >= hi - tol).sum())
|
||||||
|
near_lo = int((seg["low"] <= lo + tol).sum())
|
||||||
|
if near_hi < 2 or near_lo < 2:
|
||||||
|
return
|
||||||
|
inside = float(((seg["close"] >= lo - tol) & (seg["close"] <= hi + tol)).mean())
|
||||||
|
if inside < 0.72:
|
||||||
|
return
|
||||||
|
length = end_i - start_i + 1
|
||||||
|
score = _score_segment(length, near_hi, near_lo, inside, rw, last_atr) + prefer_boost
|
||||||
|
cands.append((score, length, start_i, end_i, hi, lo, rw))
|
||||||
|
|
||||||
|
for length in range(min(cn, max_bars), eff_min_bars - 1, -4):
|
||||||
|
start_i = cn - length
|
||||||
|
boost = 0.0
|
||||||
|
if prefer_i is not None:
|
||||||
|
dist = abs(start_i - int(prefer_i))
|
||||||
|
if dist <= 6:
|
||||||
|
boost = 10.0
|
||||||
|
elif dist <= 14:
|
||||||
|
boost = 4.0
|
||||||
|
elif start_i > int(prefer_i) + 16:
|
||||||
|
boost = -10.0
|
||||||
|
_try_seg(start_i, cn - 1, boost)
|
||||||
|
|
||||||
|
if prefer_i is not None:
|
||||||
|
pi = int(prefer_i)
|
||||||
|
if 0 <= pi < cn:
|
||||||
|
align_max = min(cn, max(max_bars, int(cn * 0.65)))
|
||||||
|
alen = cn - pi
|
||||||
|
if eff_min_bars <= alen <= align_max:
|
||||||
|
_try_seg(pi, cn - 1, prefer_boost=18.0)
|
||||||
|
elif alen > align_max:
|
||||||
|
start_i = max(0, cn - align_max)
|
||||||
|
if start_i > pi:
|
||||||
|
start_i = pi
|
||||||
|
end_i = min(cn - 1, pi + align_max - 1)
|
||||||
|
else:
|
||||||
|
end_i = cn - 1
|
||||||
|
_try_seg(start_i, end_i, prefer_boost=12.0)
|
||||||
|
|
||||||
|
if not cands:
|
||||||
|
return None
|
||||||
|
|
||||||
|
cands.sort(key=lambda x: x[0], reverse=True)
|
||||||
|
best_score = cands[0][0]
|
||||||
|
band = max(4.0, abs(best_score) * 0.10)
|
||||||
|
near = [c for c in cands if c[0] >= best_score - band]
|
||||||
|
chosen = max(near, key=lambda x: (x[1], x[0]))
|
||||||
|
score, _length, start_i, end_i, hi, lo, _rw = chosen
|
||||||
|
return _pack_range(work, df, start_i, end_i, hi, lo, tol, last_atr, score, n, window_offset=win_start)
|
||||||
|
|
||||||
|
|
||||||
|
def detect_trading_ranges(
|
||||||
|
df: pd.DataFrame,
|
||||||
|
lookback: Optional[int] = None,
|
||||||
|
min_bars: int = 24,
|
||||||
|
atr_mult: float = 1.2,
|
||||||
|
tail_reserve: int = 12,
|
||||||
|
max_cycles: int = MAX_CYCLES,
|
||||||
|
prefer_start_time: Any = None,
|
||||||
|
range_start_time: Any = None,
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
倒序切多段 TradingRange(近→远)。
|
||||||
|
过滤顺序:detect → quality → trend → overlap → accept → mask。
|
||||||
|
返回列表已按时间倒序,调用方将 [0] 标为 ACTIVE。
|
||||||
|
"""
|
||||||
|
if df is None or len(df) < min_bars + 5:
|
||||||
|
return []
|
||||||
|
lb = int(lookback) if lookback is not None else len(df)
|
||||||
|
work = df.tail(lb).reset_index(drop=True)
|
||||||
|
n = len(work)
|
||||||
|
occupied: List[Dict[str, Any]] = []
|
||||||
|
accepted: List[Dict[str, Any]] = []
|
||||||
|
|
||||||
|
# 搜索右端从 n-1 往左收缩;每接受一段后右端移到该段 start 之前
|
||||||
|
search_end = n - 1
|
||||||
|
prefer = prefer_start_time
|
||||||
|
hard_start = range_start_time
|
||||||
|
|
||||||
|
while len(accepted) < max(1, int(max_cycles)) and search_end >= min_bars + 4:
|
||||||
|
# 在剩余历史内从右往左试多个右边界,避免历史箱必须贴住 search_end
|
||||||
|
# (否则中间趋势会挡住更早的真实箱)
|
||||||
|
cand = None
|
||||||
|
step = max(4, min(12, (search_end - min_bars) // 10 or 4))
|
||||||
|
for end_try in range(search_end, min_bars + 4, -step):
|
||||||
|
trial = _detect_in_window(
|
||||||
|
work,
|
||||||
|
0,
|
||||||
|
end_try,
|
||||||
|
min_bars=min_bars,
|
||||||
|
atr_mult=atr_mult,
|
||||||
|
tail_reserve=tail_reserve,
|
||||||
|
prefer_start_time=prefer if len(accepted) == 0 and end_try == search_end else None,
|
||||||
|
range_start_time=hard_start if len(accepted) == 0 and end_try == search_end else None,
|
||||||
|
)
|
||||||
|
# 1) detect
|
||||||
|
if trial is None:
|
||||||
|
continue
|
||||||
|
# 2) quality
|
||||||
|
if not _passes_quality(trial, min_bars):
|
||||||
|
continue
|
||||||
|
# 3) trend contamination
|
||||||
|
if not _passes_trend_filter(work, trial):
|
||||||
|
continue
|
||||||
|
# 4) overlap with accepted
|
||||||
|
a0, a1 = int(trial["abs_start_idx"]), int(trial["abs_end_idx"])
|
||||||
|
overlap_bad = False
|
||||||
|
for occ in occupied:
|
||||||
|
ratio = _overlap_ratio(a0, a1, int(occ["start"]), int(occ["end"]))
|
||||||
|
if ratio >= OVERLAP_RATIO_MAX:
|
||||||
|
overlap_bad = True
|
||||||
|
break
|
||||||
|
if overlap_bad:
|
||||||
|
continue
|
||||||
|
# 取最靠右的合格箱(倒序第一段)
|
||||||
|
cand = trial
|
||||||
|
break
|
||||||
|
|
||||||
|
if cand is None:
|
||||||
|
break
|
||||||
|
|
||||||
|
# 5) accept
|
||||||
|
accepted.append(cand)
|
||||||
|
a0, a1 = int(cand["abs_start_idx"]), int(cand["abs_end_idx"])
|
||||||
|
# 6) mask
|
||||||
|
occupied.append(
|
||||||
|
{
|
||||||
|
"start": a0,
|
||||||
|
"end": max(a1, int(cand.get("abs_scan_end_idx", a1))),
|
||||||
|
"quality": float(cand.get("quality") or 0),
|
||||||
|
"high": float(cand["high"]),
|
||||||
|
"low": float(cand["low"]),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
# 下一轮只在更早窗口搜
|
||||||
|
search_end = int(cand["abs_start_idx"]) - 1
|
||||||
|
hard_start = None
|
||||||
|
prefer = None
|
||||||
|
|
||||||
|
# abs_* 目前相对 work;若 df 比 work 长需加 offset
|
||||||
|
offset = len(df) - len(work)
|
||||||
|
if offset:
|
||||||
|
for tr in accepted:
|
||||||
|
tr["abs_start_idx"] = int(tr["abs_start_idx"]) + offset
|
||||||
|
tr["abs_end_idx"] = int(tr["abs_end_idx"]) + offset
|
||||||
|
tr["abs_scan_end_idx"] = int(tr["abs_scan_end_idx"]) + offset
|
||||||
|
|
||||||
|
return accepted
|
||||||
|
|
||||||
|
|
||||||
|
def detect_trading_range(
|
||||||
|
df: pd.DataFrame,
|
||||||
|
lookback: int = 120,
|
||||||
|
min_bars: int = 24,
|
||||||
|
atr_mult: float = 1.2,
|
||||||
|
tail_reserve: int = 12,
|
||||||
|
range_start_time: Any = None,
|
||||||
|
prefer_start_time: Any = None,
|
||||||
|
) -> Optional[Dict[str, Any]]:
|
||||||
|
"""兼容旧接口:返回倒序列表中的第一段(ACTIVE 候选)。"""
|
||||||
|
ranges = detect_trading_ranges(
|
||||||
|
df,
|
||||||
|
lookback=lookback,
|
||||||
|
min_bars=min_bars,
|
||||||
|
atr_mult=atr_mult,
|
||||||
|
tail_reserve=tail_reserve,
|
||||||
|
max_cycles=1,
|
||||||
|
prefer_start_time=prefer_start_time,
|
||||||
|
range_start_time=range_start_time,
|
||||||
|
)
|
||||||
|
return ranges[0] if ranges else None
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
"""区间内 Volume Profile。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Dict, List
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
|
||||||
|
def compute_volume_profile(
|
||||||
|
df: pd.DataFrame,
|
||||||
|
start_idx: int,
|
||||||
|
end_idx: int,
|
||||||
|
bin_count: int = 50,
|
||||||
|
value_area_pct: float = 0.70,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
seg = df.iloc[start_idx : end_idx + 1]
|
||||||
|
if seg.empty:
|
||||||
|
return {"bins": [], "poc": None, "vah": None, "val": None, "bin_count": bin_count}
|
||||||
|
|
||||||
|
typical = (seg["high"].astype(float) + seg["low"].astype(float) + seg["close"].astype(float)) / 3.0
|
||||||
|
vol = seg["volume"].astype(float).fillna(0.0)
|
||||||
|
lo = float(seg["low"].min())
|
||||||
|
hi = float(seg["high"].max())
|
||||||
|
if not np.isfinite(lo) or not np.isfinite(hi) or hi <= lo:
|
||||||
|
mid = float(seg["close"].iloc[-1])
|
||||||
|
return {
|
||||||
|
"bins": [{"price": mid, "volume": float(vol.sum())}],
|
||||||
|
"poc": mid,
|
||||||
|
"vah": mid,
|
||||||
|
"val": mid,
|
||||||
|
"bin_count": 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
edges = np.linspace(lo, hi, bin_count + 1)
|
||||||
|
# 右开最后一桶闭合
|
||||||
|
idx = np.clip(np.digitize(typical.values, edges) - 1, 0, bin_count - 1)
|
||||||
|
vols = np.zeros(bin_count, dtype=float)
|
||||||
|
for i, v in zip(idx, vol.values):
|
||||||
|
vols[i] += float(v)
|
||||||
|
|
||||||
|
centers = (edges[:-1] + edges[1:]) / 2.0
|
||||||
|
poc_i = int(np.argmax(vols)) if vols.sum() > 0 else bin_count // 2
|
||||||
|
poc = float(centers[poc_i])
|
||||||
|
|
||||||
|
# Value Area:从 POC 向两侧扩展直到累计 >= value_area_pct
|
||||||
|
total = float(vols.sum()) or 1.0
|
||||||
|
target = total * value_area_pct
|
||||||
|
left = right = poc_i
|
||||||
|
acc = float(vols[poc_i])
|
||||||
|
while acc < target and (left > 0 or right < bin_count - 1):
|
||||||
|
left_v = vols[left - 1] if left > 0 else -1.0
|
||||||
|
right_v = vols[right + 1] if right < bin_count - 1 else -1.0
|
||||||
|
if right_v >= left_v and right < bin_count - 1:
|
||||||
|
right += 1
|
||||||
|
acc += float(vols[right])
|
||||||
|
elif left > 0:
|
||||||
|
left -= 1
|
||||||
|
acc += float(vols[left])
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
|
||||||
|
bins: List[Dict[str, float]] = [
|
||||||
|
{"price": float(centers[i]), "volume": float(vols[i])} for i in range(bin_count)
|
||||||
|
]
|
||||||
|
return {
|
||||||
|
"bins": bins,
|
||||||
|
"poc": poc,
|
||||||
|
"vah": float(centers[right]),
|
||||||
|
"val": float(centers[left]),
|
||||||
|
"bin_count": bin_count,
|
||||||
|
}
|
||||||
@@ -55,8 +55,8 @@ class IndicatorsBuilderMixin:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
def add_indicators(self, df):
|
def add_indicators(self, df):
|
||||||
fast = 26
|
fast = 12
|
||||||
slow = 52
|
slow = 26
|
||||||
period = 9
|
period = 9
|
||||||
macd = ta.MACD(df, fastperiod=fast, slowperiod=slow, signalperiod=period)
|
macd = ta.MACD(df, fastperiod=fast, slowperiod=slow, signalperiod=period)
|
||||||
bb365 = ta.BBANDS(df, timeperiod=365, nbdevup=3.0, nbdevdn=3.0, matype=0)
|
bb365 = ta.BBANDS(df, timeperiod=365, nbdevup=3.0, nbdevdn=3.0, matype=0)
|
||||||
|
|||||||
@@ -174,8 +174,10 @@ class KlineBuilderMixin:
|
|||||||
def get_klc_list(self, klu_list):
|
def get_klc_list(self, klu_list):
|
||||||
klc_list = []
|
klc_list = []
|
||||||
last_klu = None
|
last_klu = None
|
||||||
|
# ChanMACD.__init__ 已调用 cal_macd_state,切勿再调一次(会重复堆积 seg/unittf)
|
||||||
macd = ChanMACD(klu_list)
|
macd = ChanMACD(klu_list)
|
||||||
klu_list = macd.cal_macd_state()
|
klu_list = macd.klu_list
|
||||||
|
self._last_chan_macd = macd
|
||||||
ema_up_list = []
|
ema_up_list = []
|
||||||
ema_down_list = []
|
ema_down_list = []
|
||||||
ema_up_count = 0
|
ema_up_count = 0
|
||||||
|
|||||||
@@ -68,8 +68,11 @@ class TF_DF(IndicatorsBuilderMixin, KlineBuilderMixin, BiBuilderMixin, SegBuilde
|
|||||||
self.seg_list = self.get_seg_list(self.bi_list)
|
self.seg_list = self.get_seg_list(self.bi_list)
|
||||||
self.zs_list = self.get_zs_list(self.bi_list, self.seg_list)
|
self.zs_list = self.get_zs_list(self.bi_list, self.seg_list)
|
||||||
self.big_zs_list = self.get_big_zs_list(self.zs_list)
|
self.big_zs_list = self.get_big_zs_list(self.zs_list)
|
||||||
|
# get_klc_list 内已算过 ChanMACD,直接复用
|
||||||
|
self.chanmacd = getattr(self, '_last_chan_macd', None)
|
||||||
|
if self.chanmacd is None:
|
||||||
self.chanmacd = ChanMACD(self.klu_list)
|
self.chanmacd = ChanMACD(self.klu_list)
|
||||||
self.klu_list = self.chanmacd.cal_macd_state()
|
self.klu_list = self.chanmacd.klu_list
|
||||||
|
|
||||||
|
|
||||||
def get_current_klc(self):
|
def get_current_klc(self):
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# AGENT_MEMORY — chan
|
||||||
|
|
||||||
|
> Agent 短记忆。先读 `PROJECT_PROFILE.md`,再读本文件。不要把猜测写进这里。
|
||||||
|
|
||||||
|
## 双前端
|
||||||
|
|
||||||
|
| 入口 | 引擎 | 实时 |
|
||||||
|
|------|------|------|
|
||||||
|
| `/` | Lightweight Charts | HTTP 定时自动刷新(增量 + 每 6 次全量) |
|
||||||
|
| `/chan_tv` | Charting Library 全版 | datafeed `subscribeBars` → WS |
|
||||||
|
|
||||||
|
勿把主站 `live_feed` 方案与 chan_tv datafeed 混为一谈;主站 WS 实时已回退。
|
||||||
|
|
||||||
|
## 版本
|
||||||
|
|
||||||
|
- `system_version`:`v1.0.0`(ECR-001)
|
||||||
|
- `strategy_version`:与 system 解耦;默认不改 `config/` / `strategies/`
|
||||||
|
|
||||||
|
## 近期变更
|
||||||
|
|
||||||
|
- IDEA-002 / `9f1e736`:主站内存泄漏 dispose、首屏单次 analyze、ChanMACD 复用、chan_tv 体验
|
||||||
|
- ECR-002 Reviewed:拆 `web/services/runtime/`、加深 analyze 契约
|
||||||
|
- ECR-003 Reviewed:主站威科夫叠层(`chanlun/analysis/wyckoff/` + `include_wyckoff`)→ `081a57a`
|
||||||
|
- ECR-004 Reviewed:TR 评分硬化 + VP 少系列 + 阶段/门闩/单测(无币种参数)
|
||||||
|
- ECR-007 Final Approval / `276481e`:Wyckoff Live Structure(`live.py`);Confirmed ≠ Live;execution 仅 confirmed
|
||||||
|
- ECR-008 Reviewed:主站 `chart_tv.js` → `chart_tv_{lifecycle,shell,indicators,chan,overlays,finalize}.js` + 薄门面
|
||||||
|
- 威科夫数据随主 analyze 默认返回;UI 开关仅显隐叠层
|
||||||
|
- Live 观察:主图左下角 Cycle Summary(「形成中」= FORMING);无单独 Live 图层
|
||||||
|
|
||||||
|
## 硬约束提醒
|
||||||
|
|
||||||
|
- `/api/analyze` 字段可增不可删
|
||||||
|
- 无 ADR 不改笔/段/中枢/买卖点语义
|
||||||
|
- 威科夫为独立叠层(ECR-003/007);勿借机改缠论算法
|
||||||
|
- Live candidate **不得**进入 execution;交易 L2+ → RISK_REVIEW + EXP;Live 须 Human
|
||||||
|
|
||||||
|
## 已知债务
|
||||||
|
|
||||||
|
- analyze 契约已加深(mock HTTP + wyckoff opt-in);可再加固定 JSON 快照文件
|
||||||
|
- 内存泄漏尚无自动化 heap/监听断言
|
||||||
|
- `macd_config` POST 写本地 global 的历史 quirks(未改)
|
||||||
|
- 威科夫启发式参数未做 UI 调参
|
||||||
|
- ECR-007 待 Human 在 Gitea 开 PR 合入 `dev`
|
||||||
|
- `chart_tv_overlays.js` 仍偏大,可后续再拆
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
# Backend Design: ECR-007 Wyckoff Live Structure
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
|-------|-------|
|
||||||
|
| ID | BD-2026-007 |
|
||||||
|
| ECR | ECR-007 |
|
||||||
|
| Change Level | L2 |
|
||||||
|
| Status | Approved |
|
||||||
|
| Author | Architect (LOOP-RUN-005 Planner) |
|
||||||
|
| Date | 2026-08-07 |
|
||||||
|
| Risk | High (domain / execution boundary) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
- 问题:Confirmed 引擎已存在;需要独立 Live 推演层供观察,且不得成为交易执行输入。
|
||||||
|
- 非目标:改 Confirmed 门槛;自动交易;策略。
|
||||||
|
- 依赖:ECR-003/004 威科夫;WYCKOFF-LIVE-STRUCTURE-001(FROZEN)。
|
||||||
|
|
||||||
|
## Architecture Change / Change Boundary
|
||||||
|
|
||||||
|
```text
|
||||||
|
OHLCV
|
||||||
|
→ detect_trading_ranges (Confirmed path)
|
||||||
|
→ detect_bias_and_events / build_phases ← Confirmed(阈值不降)
|
||||||
|
→ analyze_live_structure ← Live(只读 confirmed)
|
||||||
|
→ cycles[i] = { lifecycle, confirmed, live }
|
||||||
|
→ API analyze + Summary UI
|
||||||
|
→ execution_signal_from_wyckoff(confirmed only)
|
||||||
|
```
|
||||||
|
|
||||||
|
| Layer | May change | Must not |
|
||||||
|
|-------|------------|----------|
|
||||||
|
| Confirmed | assemble into `confirmed{}` | relax Spring/SOS rules |
|
||||||
|
| Live | `live.py` heuristics | write into confirmed.events |
|
||||||
|
| Execution helper | source=confirmed gate | consume candidates |
|
||||||
|
| UI | Summary partition | treat Live as order |
|
||||||
|
|
||||||
|
## Backend Change Boundary
|
||||||
|
|
||||||
|
Live outputs are **observation**. Execution boundary:
|
||||||
|
|
||||||
|
```python
|
||||||
|
assert execution_signal.source == "confirmed"
|
||||||
|
# live-only payload → None
|
||||||
|
```
|
||||||
|
|
||||||
|
## Data contract
|
||||||
|
|
||||||
|
See WYCKOFF-LIVE-STRUCTURE-001. Top-level `phases`/`events` mirror **Confirmed** only.
|
||||||
|
|
||||||
|
## delivery_constraints
|
||||||
|
|
||||||
|
- BD Status Approved
|
||||||
|
- TEST_REPORT commands/result/date
|
||||||
|
- CODE_REVIEW handoff
|
||||||
|
- TRACEABILITY commit
|
||||||
|
- out_of_scope + execution_source_confirmed_only
|
||||||
|
|
||||||
|
## Test Plan
|
||||||
|
|
||||||
|
1. Live candidates not in confirmed.events
|
||||||
|
2. CONFIRMED lifecycle when Spring+SOS confirmed
|
||||||
|
3. execution_signal source=confirmed; live-only → None
|
||||||
|
4. analyze contract keys include live/lifecycle
|
||||||
|
|
||||||
|
## Rollback
|
||||||
|
|
||||||
|
Remove live assembly path; Summary falls back to confirmed-only.
|
||||||
@@ -1,5 +1,62 @@
|
|||||||
# CHANGELOG
|
# CHANGELOG
|
||||||
|
|
||||||
|
## Unreleased — 2026-08-07
|
||||||
|
|
||||||
|
### ECR-008(L3,Reviewed)
|
||||||
|
|
||||||
|
- 主站 `chart_tv.js` 拆为 lifecycle / shell / indicators / chan / overlays / finalize + 薄门面
|
||||||
|
- 行为冻结;`initTradingView` / `disposeTradingViewCharts` 对外不变;无 Vite/TS
|
||||||
|
|
||||||
|
### ECR-007(L2,LOOP-RUN-005)
|
||||||
|
|
||||||
|
- Wyckoff **Live Structure**:`live.py` + engine 组装 `lifecycle` / `confirmed` / `live`
|
||||||
|
- Event candidates(Spring/SOS/LPS/UTAD)+ 可解释 confidence;Summary Confirmed/Live 分区
|
||||||
|
- `execution_signal_from_wyckoff` **仅** `source=confirmed`;Live-only → None
|
||||||
|
- **No** Confirmed 门槛降低;**No** strategies / 自动交易
|
||||||
|
|
||||||
|
## Unreleased — 2026-08-06
|
||||||
|
|
||||||
|
### ECR-004(L2,Reviewed)
|
||||||
|
|
||||||
|
- 威科夫 TR 评分选段(防吞前置趋势);阶段非重叠最小跨度
|
||||||
|
- 主站 VP Top-8 + bins≤24;填充线减负
|
||||||
|
- `elements_only` 时不跑威科夫;收紧单测(无币种独立参数)
|
||||||
|
- **后续**:威科夫随主 `/api/analyze` 默认一并返回;前端开关只控制绘制(不再勾选才加载)
|
||||||
|
|
||||||
|
### ECR-003(L2,Reviewed)
|
||||||
|
|
||||||
|
- 新增 `chanlun/analysis/wyckoff/`:交易区间、阶段 A–E、Spring/SOS/LPS/UTAD 等事件、区间 VP(POC/VAH/VAL)、量能确认
|
||||||
|
- `/api/analyze` 按需 `include_wyckoff=1` 返回顶层 `wyckoff`
|
||||||
|
- 主站「威科夫」开关与 Lightweight 叠层(区间/阶段/事件/VP)
|
||||||
|
- 单测与 analyze 契约 opt-in 断言
|
||||||
|
|
||||||
|
### ECR-002(L3,Reviewed)
|
||||||
|
|
||||||
|
- 拆分 `web/services/runtime.py` 为包 `web/services/runtime/`(state / timeframes / market_data / indicators / analyze / serialize)
|
||||||
|
- 加深 analyze 契约测试(mock HTTP + analyze_chan 键集 + serialize JSON)
|
||||||
|
- 新增 TF_DF 全量 init 冒烟与 runtime 门面测试
|
||||||
|
|
||||||
|
### IDEA-002(L1 补档)
|
||||||
|
|
||||||
|
对应 commit `9f1e736`。无新 system tag(仍为 `v1.0.0`)。
|
||||||
|
|
||||||
|
#### Fixed
|
||||||
|
|
||||||
|
- 主站自动刷新内存泄漏:`disposeTradingViewCharts`、去掉重复 sync 监听、默认增量刷新(每 6 次全量重建笔/段/中枢)
|
||||||
|
- 加密货币首屏重复调用 `/api/analyze`
|
||||||
|
- ChanMACD 同周期重复全量分析(复用 `get_klc_list` 结果)
|
||||||
|
|
||||||
|
#### Changed
|
||||||
|
|
||||||
|
- `/chan_tv`:WS/REST 可分离配置、指标布局 localStorage、未完成中枢与 datafeed 实时 tick 行为完善
|
||||||
|
- `PROJECT_PROFILE` Realtime 条目与 chan_tv WS 对齐(文档)
|
||||||
|
|
||||||
|
#### Docs
|
||||||
|
|
||||||
|
- ESS:IDEA-002、AGENT_MEMORY、AGENTS;ECR-002 实现与报告
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## v1.0.0 — 2026-08-05(首个正式 Release)
|
## v1.0.0 — 2026-08-05(首个正式 Release)
|
||||||
|
|
||||||
对应 ECR-001 / tag `v1.0.0`。详见 `docs/RELEASE/ECR-001-v1.0.0.md`。
|
对应 ECR-001 / tag `v1.0.0`。详见 `docs/RELEASE/ECR-001-v1.0.0.md`。
|
||||||
|
|||||||
@@ -45,11 +45,11 @@ pytest tests/test_golden_pipeline.py web/tests/test_analyze_contract.py → 6 pa
|
|||||||
|
|
||||||
### Non-blocking(记入债务,需新 ECR 再动)
|
### Non-blocking(记入债务,需新 ECR 再动)
|
||||||
|
|
||||||
1. **`web/services/runtime.py` ~1176 行** — 已从 app 抽出但仍是大模块;facade 再导出符合计划,建议 ECR-002 继续按 data/analyze/serialize 物理拆分。
|
1. **`web/services/runtime.py` ~1176 行** — 已从 app 抽出但仍是大模块;facade 再导出符合计划 → **已起草 `docs/ECR/ECR-002-runtime-split.md`(Draft)**。
|
||||||
2. **`web/static/js/app/chart_tv.js` ~4664 行** — `initTradingView` 单体;行为冻结下可接受。
|
2. **`web/static/js/app/chart_tv.js` ~4664 行** — `initTradingView` 单体;行为冻结下可接受;ECR-002 可选范围。
|
||||||
3. **`/api/analyze` 契约测试偏浅** — 仅关键字段清单 + 路由存在;无固定 fixture 的端到端 JSON 快照(需 mock 行情)。
|
3. **`/api/analyze` 契约测试偏浅** — 仅关键字段清单 + 路由存在;无固定 fixture 的端到端 JSON 快照(需 mock 行情)→ ECR-002。
|
||||||
4. **TEST_REPORT 写「5 passed」** — 现为 6(含 shim 兼容测);Release 前可改正文(L0 docs)。
|
4. **TEST_REPORT 写「5 passed」** — 现为 6(含 shim 兼容测);Release 前可改正文(L0 docs)。
|
||||||
5. **L1:`TF_DF.get_zs_list` 恢复** — 合理兼容修复;golden 走 analyze 路径未覆盖 `TF_DF(df,...)` 全量 `__init__`,建议后续加一条 init 冒烟(非阻断)。
|
5. **L1:`TF_DF.get_zs_list` 恢复** — 合理兼容修复;golden 走 analyze 路径未覆盖 `TF_DF(df,...)` 全量 `__init__` → ECR-002 Acceptance。
|
||||||
|
|
||||||
### No blockers
|
### No blockers
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
# CODE_REVIEW — ECR-002
|
||||||
|
|
||||||
|
**Role:** REVIEWER
|
||||||
|
**Date:** 2026-08-06
|
||||||
|
**Scope:** 工作区未提交实现(相对 `HEAD`/`9f1e736`);包 `web/services/runtime/` + 测试 + ESS 文档
|
||||||
|
**Decision:** Approve
|
||||||
|
|
||||||
|
## Evidence loaded
|
||||||
|
|
||||||
|
- `docs/ECR/ECR-002-runtime-split.md`
|
||||||
|
- `docs/ENGINEERING_SPEC/ECR-002-runtime-split.md`
|
||||||
|
- `docs/IMPLEMENTATION_REPORT/ECR-002.md`
|
||||||
|
- `docs/TEST_REPORT/ECR-002.md`
|
||||||
|
- `docs/HANDOFF/ECR-002-engineer-to-reviewer.md`
|
||||||
|
- 包源码:`web/services/runtime/{__init__,state,timeframes,market_data,indicators,analyze,serialize}.py`
|
||||||
|
- Diff:删除 `web/services/runtime.py`;新增包与测试
|
||||||
|
|
||||||
|
## Acceptance ↔ Evidence
|
||||||
|
|
||||||
|
| Acceptance | Verdict | Evidence |
|
||||||
|
|------------|---------|----------|
|
||||||
|
| runtime 门面公开符号兼容(含历史 `import *` 漏出) | PASS | 手工核对 api 所需符号;`timezone`/`OrderedDict`/`np`/`StructureZone*`/`ThreadPoolExecutor` 等在门面;`test_runtime_facade` |
|
||||||
|
| Golden 通过 | PASS | 复跑 `tests/test_golden_pipeline.py` |
|
||||||
|
| Analyze 契约加深 | PASS | `test_analyze_contract`:键清单 + analyze_chan 键集 + serialize JSON + mock HTTP |
|
||||||
|
| TF_DF 全量 init 冒烟 | PASS | `tests/test_tf_df_init.py`(`interval=1`) |
|
||||||
|
| config/strategies 无交易逻辑 diff | PASS | 工作区无 `config/`/`strategies/` 变更 |
|
||||||
|
| IMPL / TEST / CHANGELOG / TRACEABILITY | PASS | docs 已落盘 |
|
||||||
|
| CODE_REVIEW Approve | PASS | 本文件 |
|
||||||
|
|
||||||
|
## 复跑结果(Reviewer)
|
||||||
|
|
||||||
|
```text
|
||||||
|
PYTHONPATH=.:web python -m pytest \
|
||||||
|
tests/test_golden_pipeline.py \
|
||||||
|
tests/test_tf_df_init.py \
|
||||||
|
web/tests/test_runtime_facade.py \
|
||||||
|
web/tests/test_analyze_contract.py -q
|
||||||
|
→ 13 passed
|
||||||
|
```
|
||||||
|
|
||||||
|
算法冻结抽查:`analyze.py` 仍为 `cal_bi_zs(seg_list)` + `_last_chan_macd` 复用;未改笔段中枢语义。
|
||||||
|
|
||||||
|
## Findings
|
||||||
|
|
||||||
|
### Non-blocking(不挡 Approve)
|
||||||
|
|
||||||
|
1. **门面标量同步只做一次** — `__init__` 在首次 `refresh` 后把 `DATA_SERVICE_AVAILABLE` / `macd_*` 写入模块 dict;之后 `refresh_data_service_metadata` 只改 `state.*`。通过 `R.DATA_SERVICE_AVAILABLE` 读取可能与 state 短期不一致;`from services.runtime import *` 的 bool 拷贝问题在 monolith 时代已存在。建议后续 L1:在 `refresh` 末尾同步写回门面模块,或让标量只经 `state`/`__getattr__` 暴露。
|
||||||
|
2. **`__getattr__` 对已绑定名无效** — 与上条相关;属清理项。
|
||||||
|
3. **`chart_tv.js` 拆分未做** — ECR 明确可选;继续记入 backlog。
|
||||||
|
4. **契约测试仍无「固定 JSON 快照文件」** — 已有 mock HTTP + 键集,比 ECR-001 深;完整响应快照可另开 L1/ECR。
|
||||||
|
5. **`web/tests/test_cn_stock_data_fetch.py` 仍因旧 `user_data.Chan...` 路径无法收集** — 既有问题,非本 ECR 引入。
|
||||||
|
|
||||||
|
### No blockers
|
||||||
|
|
||||||
|
未发现违反「算法语义冻结 / API 可增不可删 / 无 Vite-React / 未动 strategies·config / 未引主站 WS」的证据。
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
**Approve**
|
||||||
|
|
||||||
|
- ECR-002 可标 Done(Reviewed);不强制新 system tag(仍为 `v1.0.0` Unreleased 文档变更)。
|
||||||
|
- 非阻断项进 backlog;不阻塞合并本实现。
|
||||||
|
|
||||||
|
## Next owner
|
||||||
|
|
||||||
|
`engineer` / Human — 提交合并;若要发版再交 `release_manager`(本 ECR 未要求 bump tag)。
|
||||||
|
|
||||||
|
## Traceability
|
||||||
|
|
||||||
|
| Item | Updated |
|
||||||
|
|------|---------|
|
||||||
|
| Acceptance mapping | 本文件 |
|
||||||
|
| STATE.owner | → idle / merge |
|
||||||
|
| ECR Status | → Done (Reviewed) |
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
# CODE_REVIEW — ECR-003
|
||||||
|
|
||||||
|
**Role:** REVIEWER
|
||||||
|
**Date:** 2026-08-06
|
||||||
|
**Scope:** 工作区未提交 ECR-003(相对 `origin/dev` @ `df27b4d`)
|
||||||
|
**Decision:** Approve(带非阻断 Findings;建议合并前勿提交 `.DS_Store`)
|
||||||
|
|
||||||
|
## Evidence loaded
|
||||||
|
|
||||||
|
- `chanlun/analysis/wyckoff/{engine,range,events,volume_profile}.py`
|
||||||
|
- `web/api/analyze.py`(`include_wyckoff`)
|
||||||
|
- `web/templates/index.html`、`chart_view.js`、`macd_ui.js`、`chart_tv.js` 威科夫块
|
||||||
|
- `tests/test_wyckoff.py`、`web/tests/test_analyze_contract.py`
|
||||||
|
- ESS:ECR/PRODUCT/ENG/IMPL/TEST/HANDOFF
|
||||||
|
|
||||||
|
## Acceptance ↔ Evidence
|
||||||
|
|
||||||
|
| Acceptance | Verdict | Evidence |
|
||||||
|
|------------|---------|----------|
|
||||||
|
| `include_wyckoff=1` 返回约定键;默认不强制 | PASS | 契约测试;默认无 `wyckoff` 键 |
|
||||||
|
| 合成 TR + 事件;VP POC | PASS | `test_wyckoff.py`(12 相关套件全绿) |
|
||||||
|
| 主站可开关绘制 | PASS | 主开关按需拉取;子项本地重绘 |
|
||||||
|
| golden 不变 | PASS | `test_golden_pipeline` |
|
||||||
|
| 未改缠论算法 / strategies / chan_tv | PASS | diff 范围核对 |
|
||||||
|
| ESS 闭环 | PASS | IMPL/TEST/TRACE/CHANGELOG/本文件 |
|
||||||
|
|
||||||
|
## 复跑
|
||||||
|
|
||||||
|
```text
|
||||||
|
PYTHONPATH=.:web python -m pytest \
|
||||||
|
tests/test_wyckoff.py tests/test_golden_pipeline.py \
|
||||||
|
web/tests/test_analyze_contract.py -q
|
||||||
|
→ 12 passed
|
||||||
|
```
|
||||||
|
|
||||||
|
## Findings
|
||||||
|
|
||||||
|
### Important(不挡 Approve,建议跟进)
|
||||||
|
|
||||||
|
1. **交易区间易吞并前置趋势**
|
||||||
|
`detect_trading_range` 从最长窗口向下搜,合成夹具下 `abs_start_idx=0`,箱体前下跌段被算进 TR。单测只断言「有区间 + 有事件」,未锁定高低/起点。
|
||||||
|
*建议:* 用「宽度/触边密度」评分取最优段,或要求近端触边;测试断言 `high≈60/low≈40` 与起点靠近箱体。
|
||||||
|
|
||||||
|
2. **VP 叠层系列数偏多,可能加压自动刷新内存**
|
||||||
|
开启 VP 时约每个 bin 一条 `addLineSeries`(默认 ~50),再加区间填充/阶段。与 IDEA-002 内存修复同路径全量重建时放大。
|
||||||
|
*建议:* 只画非零 bin 或合并为少量 series / histogram;或限制 `vp_bins` 上限到 24。
|
||||||
|
|
||||||
|
### Medium
|
||||||
|
|
||||||
|
3. **阶段 C–E 在事件扎堆时常退化重叠**
|
||||||
|
夹具输出中 D/E 起止几乎相同;状态机按事件锚点硬切,缺少最小阶段长度。展示可用,语义偏弱。
|
||||||
|
|
||||||
|
4. **`elements_only=true` 仍可能跑威科夫**
|
||||||
|
威科夫挂在路由末尾,不依赖 `not elements_only`。主站当前不这么发,但契约上奇怪;建议与主周期分析同门闩。
|
||||||
|
|
||||||
|
5. **单测断言偏松**
|
||||||
|
`Spring in types or SOS`、`abs(poc-50)<2` 对回归保护不足。
|
||||||
|
|
||||||
|
### Low
|
||||||
|
|
||||||
|
6. 失败时 `wyckoff.error` 回传异常字符串(与结构区 print 风格一致,信息暴露轻微)。
|
||||||
|
7. 事件 marker 一律 `arrowUp`(跌破类也可 `arrowDown`)。
|
||||||
|
8. 工作区 `.DS_Store` 脏文件——**勿纳入 commit**。
|
||||||
|
|
||||||
|
### No blockers
|
||||||
|
|
||||||
|
未发现:契约删键、缠论语义改动、策略/config 改动、未鉴权危险写操作、主站误引 WS。
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
**Approve**
|
||||||
|
|
||||||
|
可合并提交(排除 `.DS_Store`)。Important #1/#2 可开后续 L1/L2,不阻塞本 ECR 着陆。
|
||||||
|
|
||||||
|
## Next owner
|
||||||
|
|
||||||
|
`engineer` / Human — commit(勿含 `.DS_Store`);可选跟进 TR 评分与 VP 绘图优化。
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# CODE_REVIEW — ECR-004
|
||||||
|
|
||||||
|
**Role:** REVIEWER
|
||||||
|
**Date:** 2026-08-06
|
||||||
|
**Scope:** `d3188ca`(相对 ECR-003)威科夫硬化
|
||||||
|
**Decision:** Approve
|
||||||
|
|
||||||
|
## Evidence loaded
|
||||||
|
|
||||||
|
- Diff `d3188ca`:`range.py` / `events.py` / `analyze.py` / `chart_tv.js` / tests / ESS
|
||||||
|
- 复跑:`tests/test_wyckoff.py` + golden + analyze contract → **14 passed**
|
||||||
|
- 合成夹具抽查:`abs_start_idx=20`,low/high≈40.1/59.9(相对 003 的 bar0 已修好)
|
||||||
|
|
||||||
|
## Acceptance ↔ Evidence
|
||||||
|
|
||||||
|
| Acceptance | Verdict | Evidence |
|
||||||
|
|------------|---------|----------|
|
||||||
|
| TR 不吞明显前置趋势;边界近箱体 | PASS | 评分选段;单测 low/high 带 + `abs_start≥12` + start 时间容差 |
|
||||||
|
| VP series 减负 | PASS | Top-8 + 填充 3 + POC/VAH/VAL;API bins≤24 |
|
||||||
|
| 阶段最小跨度 / 不重合 | PASS | 链式 cursor;unique (start,end) 断言 |
|
||||||
|
| elements_only 门闩 | PASS | `include_wyckoff and not elements_only` + 契约测试 |
|
||||||
|
| golden 不变 / 无策略改动 / 无币种表 | PASS | golden 绿;diff 无 config/strategies |
|
||||||
|
|
||||||
|
## Findings
|
||||||
|
|
||||||
|
### Medium(不挡 Approve)
|
||||||
|
|
||||||
|
1. **同分 tie-break 偏向更长窗口**
|
||||||
|
循环从长到短,`score <= best_score` 时保留已有(更长)。多数情况分数拉开;若实盘出现「长窗与短窗同分」,仍可能略偏长。可选:同分取更短,或加 `1/length` 微项。
|
||||||
|
|
||||||
|
2. **阶段常截断为 A–C**
|
||||||
|
Spring/SOS 落在尾部时 D/E 因 `min_span` 被吃掉——与 ENG「空间不足截断」一致,但 UI 勾选「阶段」时用户可能期望总见 D/E。属产品预期,非缺陷;可在 UI/文档标明「尾部不足则省略」。
|
||||||
|
|
||||||
|
### Low
|
||||||
|
|
||||||
|
3. **`abs_start_idx >= 12` 弱于「箱体起点」** —— 主测已用时间容差;该断言可再收紧到 `>= 16` 一类。
|
||||||
|
4. **VP Top-N 无自动化 series 计数** —— 靠代码审查 + ENG 约定。
|
||||||
|
5. 事件 marker 仍一律 `arrowUp`(003 遗留)。
|
||||||
|
6. 失败路径仍回传 `wyckoff.error` 字符串。
|
||||||
|
|
||||||
|
### No blockers
|
||||||
|
|
||||||
|
未发现契约删键、缠论语义改动、策略改动、或回归红灯。
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
**Approve**
|
||||||
|
|
||||||
|
ECR-004 可维持 Done (Reviewed)。Medium 项进 backlog,不必立刻新 ECR,除非实盘 TR 仍偏长。
|
||||||
|
|
||||||
|
## Next owner
|
||||||
|
|
||||||
|
Human — 主站 BTC 勾选威科夫目测;无发版要求则保持 `v1.0.0` Unreleased 累计。
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# CODE_REVIEW — ECR-008
|
||||||
|
|
||||||
|
**Role:** REVIEWER
|
||||||
|
**Date:** 2026-08-07
|
||||||
|
**Scope:** chart_tv 物理拆分
|
||||||
|
**Decision:** Approve
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
|
||||||
|
| Item | Result | Notes |
|
||||||
|
|------|--------|-------|
|
||||||
|
| 行为冻结(仅搬移) | PASS | ctx 编排;无绘制算法改写意图 |
|
||||||
|
| 对外 API | PASS | `initTradingView` / `disposeTradingViewCharts` 保留 |
|
||||||
|
| Forbidden | PASS | 无 Vite/TS;无 strategies/config;无 analyze 契约改动 |
|
||||||
|
| script 顺序 | PASS | lifecycle→shell→indicators→chan→overlays→finalize→门面→sync |
|
||||||
|
| 测试证据 | PASS | `node --check` ALL_CHECK_OK |
|
||||||
|
|
||||||
|
## Findings
|
||||||
|
|
||||||
|
1. **Low:** 浏览器硬刷新冒烟仍建议 Human 点一次(自动刷新 + Cycle Summary)。不挡 Approve。
|
||||||
|
2. **Low:** `chart_tv_overlays.js` 仍偏大(~2.3k 行);可后续再拆,非本 ECR 范围。
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
**Approve**
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
# ECR-002
|
||||||
|
|
||||||
|
**Title:** 拆分 `web/services/runtime.py` + 加深 `/api/analyze` 契约测试
|
||||||
|
**Status:** Done (Reviewed)
|
||||||
|
**Date:** 2026-08-06
|
||||||
|
**Change Level:** L3(行为冻结;若 golden 漂移则升 L2)
|
||||||
|
|
||||||
|
## Change
|
||||||
|
|
||||||
|
将仍偏大的 `web/services/runtime.py` 按职责拆为可维护子模块;加深 analyze API 契约/快照测试;可选拆分主站巨型 `chart_tv.js`(本轮未做)。
|
||||||
|
|
||||||
|
## Motivation
|
||||||
|
|
||||||
|
ECR-001 CODE_REVIEW 非阻断债务:runtime 过大、契约测试偏浅、chart_tv 单体。不处理会继续抬高 Web 改动风险。
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
### Allowed
|
||||||
|
|
||||||
|
- 物理拆分 `web/services/runtime.py` → 包 `web/services/runtime/`(state / timeframes / market_data / indicators / analyze / serialize + 门面)
|
||||||
|
- 加深 `web/tests/`:固定 fixture / mock 行情下的关键字段快照与契约
|
||||||
|
- 补 `TF_DF(..., interval=1)` 全量 `__init__` 冒烟
|
||||||
|
- 更新 TECH_STACK / TRACEABILITY / CHANGELOG
|
||||||
|
|
||||||
|
### Forbidden
|
||||||
|
|
||||||
|
- 修改笔 / 线段 / 中枢 / 买卖点算法语义
|
||||||
|
- 破坏 `/api/analyze` JSON 字段(可增不可删)
|
||||||
|
- 修改 `config/`、`strategies/` 交易逻辑或参数
|
||||||
|
- 引入 Vite/React/TS 构建
|
||||||
|
- 为主站重新引入 WebSocket 实时(须另 ECR)
|
||||||
|
- 无 Approve 即大规模改前端视觉
|
||||||
|
|
||||||
|
## Risk
|
||||||
|
|
||||||
|
| Risk | Mitigation |
|
||||||
|
|------|------------|
|
||||||
|
| 拆文件隐式改行为 | 仅搬移;golden + analyze 契约/快照 |
|
||||||
|
| 门面漏导出 | 保留 `runtime` re-export + 历史 import * 兼容符号 |
|
||||||
|
| 测试依赖真实行情 | mock / fixture;不绑生产 WS |
|
||||||
|
| chart_tv 拆分漏事件 | 本轮不做 |
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [x] `runtime` 门面公开符号与拆分前兼容(含 `timezone`/`OrderedDict`/`np`/StructureZone 等历史漏出)
|
||||||
|
- [x] Golden:`pytest tests/test_golden_pipeline.py` 通过
|
||||||
|
- [x] Analyze 契约/快照测试通过且覆盖关键字段清单以上
|
||||||
|
- [x] TF_DF 全量 init 冒烟通过
|
||||||
|
- [x] `config/` / `strategies/` 无交易逻辑 diff
|
||||||
|
- [x] IMPLEMENTATION_REPORT / TEST_REPORT / CHANGELOG / TRACEABILITY 更新
|
||||||
|
- [x] CODE_REVIEW Approve
|
||||||
|
|
||||||
|
## Rollback
|
||||||
|
|
||||||
|
`git revert` 本 ECR 提交;门面保留期可整包回滚。
|
||||||
|
|
||||||
|
## Risk Review
|
||||||
|
|
||||||
|
- Path: `docs/RISK_REVIEW/ECR-002.md` — N/A(不改交易决策语义)
|
||||||
|
|
||||||
|
## Linked
|
||||||
|
|
||||||
|
- IDEA: `docs/IDEA/IDEA-003-runtime-split.md`
|
||||||
|
- PRODUCT_SPEC: `docs/PRODUCT_SPEC/ECR-002-runtime-split.md`
|
||||||
|
- ENGINEERING_SPEC: `docs/ENGINEERING_SPEC/ECR-002-runtime-split.md`
|
||||||
|
- ADR: 引用 ADR-001(包内再拆,无新顶层布局 ADR)
|
||||||
|
- EXPERIMENT: N/A
|
||||||
|
- TRACEABILITY: Yes
|
||||||
|
- IMPLEMENTATION_REPORT: `docs/IMPLEMENTATION_REPORT/ECR-002.md`
|
||||||
|
- TEST_REPORT: `docs/TEST_REPORT/ECR-002.md`
|
||||||
|
|
||||||
|
## Origin
|
||||||
|
|
||||||
|
- `docs/CODE_REVIEW/ECR-001.md` Findings 1–3、5
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
# ECR-003
|
||||||
|
|
||||||
|
**Title:** 主站威科夫分析与图表展示
|
||||||
|
**Status:** Done (Reviewed)
|
||||||
|
**Date:** 2026-08-06
|
||||||
|
**Change Level:** L2
|
||||||
|
|
||||||
|
## Change
|
||||||
|
|
||||||
|
在主站 `/` 增加威科夫交易区间、阶段(A–E)、关键事件(Spring/SOS/LPS/UTAD 等)、区间内简易 VP(POC/VAH/VAL)与量能确认;按需接入 `/api/analyze`。
|
||||||
|
|
||||||
|
## Motivation
|
||||||
|
|
||||||
|
用户需要在缠论图上叠加威科夫结构解读;与现有结构区语义分离。
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
### Allowed
|
||||||
|
|
||||||
|
- 新建 `chanlun/analysis/wyckoff/`
|
||||||
|
- `/api/analyze` 增加可选 `include_wyckoff` 与响应字段 `wyckoff`(可增不可删既有字段)
|
||||||
|
- 主站 UI 开关与 Lightweight 绘图
|
||||||
|
- 单测 + ESS 文档
|
||||||
|
|
||||||
|
### Forbidden
|
||||||
|
|
||||||
|
- 修改笔/段/中枢/买卖点算法语义
|
||||||
|
- 改 `config/` / `strategies/`
|
||||||
|
- `/chan_tv` Study
|
||||||
|
- Vite/React、主站 WebSocket 实时(另 ECR)
|
||||||
|
|
||||||
|
## Risk
|
||||||
|
|
||||||
|
| Risk | Mitigation |
|
||||||
|
|------|------------|
|
||||||
|
| 启发式误标 | 规格写明启发式;UI 可关;单测合成形态 |
|
||||||
|
| 负载 | 默认关闭,勾选才计算 |
|
||||||
|
| 与结构区混淆 | 独立开关与字段名 |
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [x] `include_wyckoff=1` 返回约定 `wyckoff` 键;默认不强制计算
|
||||||
|
- [x] 合成 fixture:能检出 TR + 至少一类事件;VP POC 可测
|
||||||
|
- [x] 主站可开关绘制区间/阶段/事件/VP
|
||||||
|
- [x] golden 缠论基线不变
|
||||||
|
- [x] TEST/IMPL/CHANGELOG/TRACEABILITY + CODE_REVIEW
|
||||||
|
|
||||||
|
## Rollback
|
||||||
|
|
||||||
|
`git revert`;关闭 UI 开关即可无图面影响。
|
||||||
|
|
||||||
|
## Risk Review
|
||||||
|
|
||||||
|
- `docs/RISK_REVIEW/ECR-003.md` — N/A(展示分析,非 Live 策略)
|
||||||
|
|
||||||
|
## Linked
|
||||||
|
|
||||||
|
- IDEA: `docs/IDEA/IDEA-004-wyckoff-main.md`
|
||||||
|
- PRODUCT_SPEC / ENGINEERING_SPEC: 同目录 ECR-003-*
|
||||||
|
- EXPERIMENT: N/A
|
||||||
|
- TRACEABILITY: Yes
|
||||||
|
- CODE_REVIEW: `docs/CODE_REVIEW/ECR-003.md` — Approve
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# ECR-004
|
||||||
|
|
||||||
|
**Title:** 威科夫区间评分硬化与主站 VP 绘图减负
|
||||||
|
**Status:** Done (Reviewed)
|
||||||
|
**Date:** 2026-08-06
|
||||||
|
**Change Level:** L2
|
||||||
|
|
||||||
|
## Change
|
||||||
|
|
||||||
|
跟进 ECR-003 CODE_REVIEW Findings:改进交易区间选取启发式、阶段最小长度、收紧单测;主站 VP/叠层降低 Lightweight series 数量;`include_wyckoff` 与主周期分析同门闩。
|
||||||
|
|
||||||
|
## Motivation
|
||||||
|
|
||||||
|
003 已 Approve 合入;质量与内存项不得回塞已审变更,须独立可审闭环。
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
### Allowed
|
||||||
|
|
||||||
|
- `chanlun/analysis/wyckoff/range.py` / `events.py`(阶段)启发式与单测
|
||||||
|
- `web/static/js/app/chart_tv.js` 威科夫 VP/填充绘制路径
|
||||||
|
- `web/api/analyze.py`:`elements_only` 时不跑威科夫;默认 `vp_bins` 上限 24
|
||||||
|
- ESS 文档与契约测试补充断言(不删既有 `wyckoff` 键)
|
||||||
|
|
||||||
|
### Forbidden
|
||||||
|
|
||||||
|
- 改笔/段/中枢/买卖点语义
|
||||||
|
- `config/` / `strategies/`
|
||||||
|
- `/chan_tv`
|
||||||
|
- 新数据源 / 订单流
|
||||||
|
- **按币种独立参数表**(全局 ATR 相对即可;当前以 BTC 场景验证)
|
||||||
|
|
||||||
|
## Decisions(Approve 时锁定)
|
||||||
|
|
||||||
|
- VP:**A+C**(前端 Top-N 有量 bin + 服务端 bins 上限 24)
|
||||||
|
- 不做 per-symbol 参数
|
||||||
|
|
||||||
|
## Risk
|
||||||
|
|
||||||
|
| Risk | Mitigation |
|
||||||
|
|------|------------|
|
||||||
|
| TR 结果相对 003 漂移 | 合成夹具锁定高低与起点;文档标明启发式迭代 |
|
||||||
|
| 前端 VP 观感变化 | 保留 POC/VAH/VAL;密度用 Top-N |
|
||||||
|
| 回归 | 扩展 `tests/test_wyckoff.py` + 既有契约套件 |
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [x] 合成箱体夹具:`trading_range` 高低接近箱体边界,起点不落入明显前置趋势段
|
||||||
|
- [x] 开启 VP 时主图新增 series 数显著低于「每 bin 一条」(目标:填充+VP ≤ ~15 或等价合并策略)
|
||||||
|
- [x] 阶段输出满足最小跨度或合并退化段;文档说明规则
|
||||||
|
- [x] `elements_only=true` 即使 `include_wyckoff=1` 也不返回 `wyckoff`
|
||||||
|
- [x] golden 缠论基线不变;相关 pytest 绿
|
||||||
|
- [x] TEST/IMPL/CHANGELOG/TRACEABILITY + CODE_REVIEW
|
||||||
|
|
||||||
|
## Rollback
|
||||||
|
|
||||||
|
`git revert`;UI 关威科夫即可无图面影响。
|
||||||
|
|
||||||
|
## Risk Review
|
||||||
|
|
||||||
|
- `docs/RISK_REVIEW/ECR-004.md` — N/A(展示/启发式,非 Live 策略)
|
||||||
|
|
||||||
|
## Linked
|
||||||
|
|
||||||
|
- IDEA: `docs/IDEA/IDEA-005-wyckoff-harden.md`
|
||||||
|
- 上游: `docs/CODE_REVIEW/ECR-003.md` Findings 1–5
|
||||||
|
- PRODUCT_SPEC / ENGINEERING_SPEC: 同目录 ECR-004-*
|
||||||
|
- TRACEABILITY: Yes
|
||||||
|
- CODE_REVIEW: `docs/CODE_REVIEW/ECR-004.md` — Approve
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# ECR-007
|
||||||
|
|
||||||
|
**Title:** Wyckoff Live Structure
|
||||||
|
**Status:** Approved
|
||||||
|
**Date:** 2026-08-07
|
||||||
|
**Change Level:** L2
|
||||||
|
**Human:** Approved (LOOP-RUN-005 Start Authorization)
|
||||||
|
|
||||||
|
## Change
|
||||||
|
|
||||||
|
Add **Live / Developing** structure layer beside **Confirmed** Wyckoff engine: lifecycle, FORMING candidates (Spring/SOS/LPS/UTAD), explainable confidence, Summary partition. Keep Confirmed thresholds unchanged; execution may only consume Confirmed.
|
||||||
|
|
||||||
|
## Motivation
|
||||||
|
|
||||||
|
LOOP-RUN-005 — domain-state complexity under Adapter v0.1 STABLE (Confirmed ≠ Live ≠ execution).
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
### Allowed (IN)
|
||||||
|
|
||||||
|
- `chanlun/analysis/wyckoff/live.py` + engine assembly
|
||||||
|
- lifecycle / confirmed / live payload
|
||||||
|
- Event candidates + confidence
|
||||||
|
- API contract + Summary UI
|
||||||
|
- tests + docs notes (WYCKOFF-LIVE-STRUCTURE-001)
|
||||||
|
|
||||||
|
### Forbidden (OUT)
|
||||||
|
|
||||||
|
- execution signal automation / auto trading
|
||||||
|
- strategy / maker / decide_quotes / `strategies/**`
|
||||||
|
- lowering Confirmed thresholds
|
||||||
|
- Live candidate replacing Confirmed
|
||||||
|
- ESS / Loop / Adapter changes
|
||||||
|
|
||||||
|
## Risk
|
||||||
|
|
||||||
|
| Risk | Mitigation |
|
||||||
|
|------|------------|
|
||||||
|
| Live → execution | `execution_signal_from_wyckoff` source=confirmed only; live-only → None |
|
||||||
|
| Confirmed pollution | candidates never written to confirmed.events |
|
||||||
|
| Domain confusion in UI | Summary Confirmed vs Live partitions |
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] Approved BD-2026-007
|
||||||
|
- [ ] Confirmed logic not relaxed
|
||||||
|
- [ ] Live ≠ execution signal (tests)
|
||||||
|
- [ ] Lifecycle verifiable
|
||||||
|
- [ ] Artifact chain + Gate PASS
|
||||||
|
|
||||||
|
## Rollback
|
||||||
|
|
||||||
|
- Disable live assembly; remove live.py; revert Summary partition
|
||||||
|
|
||||||
|
## Linked
|
||||||
|
|
||||||
|
- Note: `docs/notes/WYCKOFF-LIVE-STRUCTURE-001.md` (FROZEN)
|
||||||
|
- BACKEND_DESIGN: `docs/BACKEND_DESIGN/BD-2026-007-wyckoff-live-structure.md`
|
||||||
|
- ENGINEERING_SPEC: `docs/ENGINEERING_SPEC/ECR-007-wyckoff-live-structure.md`
|
||||||
|
- Loop: LOOP-RUN-005
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
# ECR-008
|
||||||
|
|
||||||
|
**Title:** 拆分主站巨型 `chart_tv.js`(行为冻结)
|
||||||
|
**Status:** Done (Reviewed)
|
||||||
|
**Date:** 2026-08-07
|
||||||
|
**Change Level:** L3(结构重构;行为冻结)
|
||||||
|
|
||||||
|
## Change
|
||||||
|
|
||||||
|
将 `web/static/js/app/chart_tv.js`(≈4700 行)按职责拆为多个无打包 script;薄门面保留 `initTradingView` / `disposeTradingViewCharts` 供 `ui.js` 调用。
|
||||||
|
|
||||||
|
## Motivation
|
||||||
|
|
||||||
|
ECR-001/002 CODE_REVIEW 非阻断债务;威科夫与 Live 叠层继续堆入单体,审阅与回归成本上升。
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
### Allowed
|
||||||
|
|
||||||
|
- 新增:`chart_tv_lifecycle.js` / `chart_tv_shell.js` / `chart_tv_indicators.js` / `chart_tv_chan.js` / `chart_tv_overlays.js` / `chart_tv_finalize.js`
|
||||||
|
- `chart_tv.js` 改为编排门面;`index.html` 调整 script 顺序与 cache bust
|
||||||
|
- `node --check`;主站手动冒烟
|
||||||
|
|
||||||
|
### Forbidden
|
||||||
|
|
||||||
|
- Vite / React / TS 构建流水线
|
||||||
|
- 修改笔 / 线段 / 中枢 / 买卖点算法语义或绘制语义(仅搬移)
|
||||||
|
- 破坏 `/api/analyze` JSON 字段
|
||||||
|
- 修改 `config/` / `strategies/`
|
||||||
|
- 为主站重新引入 WebSocket 实时
|
||||||
|
|
||||||
|
## Risk
|
||||||
|
|
||||||
|
| Risk | Mitigation |
|
||||||
|
|------|------------|
|
||||||
|
| 拆分漏变量 / 作用域错误 | ctx 显式传参;冒烟 dispose + 三周期元素 + 威科夫 |
|
||||||
|
| script 顺序错误 | index.html 固定 lifecycle→…→门面→sync |
|
||||||
|
| 缓存旧单体 | bump `?v=` |
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [x] `initTradingView` / `disposeTradingViewCharts` 仍可被 `ui.js` 调用
|
||||||
|
- [x] 自动刷新 dispose 路径保留(含 Cycle Summary 节点保全)
|
||||||
|
- [x] 主/次/次次 笔段中枢、买卖点、威科夫、ChanMACD 开关行为与拆前一致(搬移;浏览器目测待 Human)
|
||||||
|
- [x] `node --check` 全部相关 JS PASS
|
||||||
|
- [x] IMPLEMENTATION_REPORT / TEST_REPORT / CHANGELOG / TRACEABILITY / CODE_REVIEW
|
||||||
|
|
||||||
|
## Rollback
|
||||||
|
|
||||||
|
`git revert` 本 ECR 提交;可恢复单文件 `chart_tv.js`。
|
||||||
|
|
||||||
|
## Risk Review
|
||||||
|
|
||||||
|
N/A(不改交易决策语义)
|
||||||
|
|
||||||
|
## Linked
|
||||||
|
|
||||||
|
- IDEA: `docs/IDEA/IDEA-006-chart-tv-split.md`
|
||||||
|
- ENGINEERING_SPEC: `docs/ENGINEERING_SPEC/ECR-008-chart-tv-split.md`
|
||||||
|
- HANDOFF: `docs/HANDOFF/ECR-008-architect-to-engineer.md`
|
||||||
|
- TRACEABILITY: Yes
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# ENGINEERING_SPEC — ECR-002
|
||||||
|
|
||||||
|
**Status:** Implemented
|
||||||
|
**Date:** 2026-08-06
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
1. **包目录** `web/services/runtime/`(不用平铺 `runtime_*.py`)
|
||||||
|
2. **边界**
|
||||||
|
- `state`:可变全局与客户端
|
||||||
|
- `timeframes`:周期工具
|
||||||
|
- `market_data`:行情
|
||||||
|
- `indicators`:技术指标列
|
||||||
|
- `analyze`:缠论编排 + 趋势分类
|
||||||
|
- `serialize`:JSON 整形
|
||||||
|
- `__init__`:门面 + 历史 `import *` 兼容再导出
|
||||||
|
3. **测试**:facade / analyze_chan 键 / serialize / HTTP mock 契约 / TF_DF init / golden
|
||||||
|
4. **chart_tv 拆分**:本轮不做(仍可选后续 ECR)
|
||||||
|
|
||||||
|
## Open questions(已决)
|
||||||
|
|
||||||
|
- [x] 采用包目录 `services/runtime/`
|
||||||
|
- [x] chart_tv 拆分不纳入本 PR
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# ENGINEERING_SPEC — ECR-003
|
||||||
|
|
||||||
|
**Status:** Approved
|
||||||
|
**Date:** 2026-08-06
|
||||||
|
|
||||||
|
## Package
|
||||||
|
|
||||||
|
`chanlun/analysis/wyckoff/`:
|
||||||
|
|
||||||
|
- `engine.py` — `analyze_wyckoff(df) -> dict`
|
||||||
|
- `range.py` — 交易区间检测(ATR 容差震荡箱)
|
||||||
|
- `phases.py` — A–E 状态机
|
||||||
|
- `events.py` — Spring/SOS/LPS/UTAD(及 distribution 对称)
|
||||||
|
- `volume_profile.py` — 区间内分桶 VP
|
||||||
|
- `__init__.py` — 导出 `analyze_wyckoff`
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
`GET /api/analyze?include_wyckoff=1` → `result["wyckoff"]`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"trading_range": {"start_time","end_time","high","low","mid","active"},
|
||||||
|
"bias": "accumulation|distribution|unknown",
|
||||||
|
"phases": [{"phase","label","start_time","end_time"}],
|
||||||
|
"events": [{"type","time","price","note","volume_ratio","volume_ok"}],
|
||||||
|
"volume_profile": {"bins":[{"price","volume"}],"poc","vah","val","bin_count"},
|
||||||
|
"volume_confirm": {"avg_volume","event_checks":{}}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
默认 `include_wyckoff` 假:可不返回或返回 `null`(实现选:不返回键以减负)。
|
||||||
|
|
||||||
|
## Detection heuristics
|
||||||
|
|
||||||
|
1. ATR(14) 容差;扫描最近窗口找高低点接近的连续段作为 TR。
|
||||||
|
2. 阶段:价格在 TR 内相对位置 + 假破/真破时间序。
|
||||||
|
3. Spring:下破 TR.low 后收回且收盘回到区间内;量能相对均量判断。
|
||||||
|
4. SOS:收盘站上 TR.high 且放量。
|
||||||
|
5. LPS:SOS 后回踩不破 mid/high 带且缩量。
|
||||||
|
6. UTAD:上破后跌回区间内(派发)。
|
||||||
|
7. VP:typical=(H+L+C)/3,volume 加权分桶,VA≈70% 围绕 POC。
|
||||||
|
|
||||||
|
## Frontend
|
||||||
|
|
||||||
|
主站 checkbox + `chart_view` 传参;`chart_tv.js` 绘制。
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# ENGINEERING_SPEC — ECR-004
|
||||||
|
|
||||||
|
**Status:** Approved(实现锁定:评分选段;VP=A+C;无币种参数)
|
||||||
|
**Date:** 2026-08-06
|
||||||
|
|
||||||
|
## Range scoring
|
||||||
|
|
||||||
|
替换「仅取最长合格窗口」:
|
||||||
|
|
||||||
|
1. 仍在 `lookback` + `tail_reserve` 框架内扫描候选段(步长 -4)。
|
||||||
|
2. 硬门槛不变:near_hi/lo≥2、inside≥0.75、宽度上限等。
|
||||||
|
3. 分数:`touch_density*50 + inside*30 - (width/ATR)*3 + min(length/40, 2)`,取最高。
|
||||||
|
4. 单测:`low∈[38,42]`、`high∈[58,62]`,起点不早于箱体(容差 8 根);`abs_start_idx >= 12`。
|
||||||
|
|
||||||
|
## Phases
|
||||||
|
|
||||||
|
- 非重叠链式切分;每段至少 `min_bars=3`。
|
||||||
|
- 尾部空间不足则延长上一段并停止新增(避免 D/E 完全重合双画)。
|
||||||
|
|
||||||
|
## API gate
|
||||||
|
|
||||||
|
```text
|
||||||
|
if include_wyckoff and not elements_only:
|
||||||
|
result["wyckoff"] = analyze_wyckoff(..., vp_bins∈[10,24])
|
||||||
|
```
|
||||||
|
|
||||||
|
默认 `wyckoff_vp_bins=24`,上限 24。
|
||||||
|
|
||||||
|
## Frontend VP(A+C)
|
||||||
|
|
||||||
|
- 填充线 6→3
|
||||||
|
- 有量 bin 按 volume Top-8 绘制 + POC/VAH/VAL
|
||||||
|
- 目标:区间填充+边框+VP ≈ ≤15 series 量级
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
- `tests/test_wyckoff.py` 收紧
|
||||||
|
- `elements_only=true&include_wyckoff=1` 无 `wyckoff`
|
||||||
|
- 不改 golden 缠论 JSON
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- 按币种独立参数(全局 ATR 相对;以 BTC 场景验证)
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# ENGINEERING_SPEC — ECR-007 Wyckoff Live Structure
|
||||||
|
|
||||||
|
**ECR:** ECR-007
|
||||||
|
**BD:** BD-2026-007
|
||||||
|
**Status:** Approved
|
||||||
|
|
||||||
|
## Intent
|
||||||
|
|
||||||
|
Operators observe FORMING Wyckoff structure without feeding Live into execution.
|
||||||
|
|
||||||
|
## Modules
|
||||||
|
|
||||||
|
| Module | Role |
|
||||||
|
|--------|------|
|
||||||
|
| `events.py` / `range.py` | Confirmed facts |
|
||||||
|
| `live.py` | Live candidates + confidence + lifecycle hint |
|
||||||
|
| `engine.py` | Assemble cycles[].confirmed / .live |
|
||||||
|
| `execution_signal_from_wyckoff` | Confirmed-only gate |
|
||||||
|
|
||||||
|
## Lifecycle
|
||||||
|
|
||||||
|
`UNKNOWN → FORMING → CONFIRMED → COMPLETED`
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
strategies, maker, Live-as-signal, Confirmed threshold cuts.
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# ENGINEERING_SPEC — ECR-008 chart_tv 拆分
|
||||||
|
|
||||||
|
**ECR:** ECR-008
|
||||||
|
**Level:** L3 · 行为冻结
|
||||||
|
**Date:** 2026-08-07
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
物理拆分主站 Lightweight Charts 绘制单体,不改变可见行为。
|
||||||
|
|
||||||
|
## Module map
|
||||||
|
|
||||||
|
| File | Responsibility |
|
||||||
|
|------|----------------|
|
||||||
|
| `chart_tv_lifecycle.js` | `disposeTradingViewCharts`;cleanup 数组与 chart.remove |
|
||||||
|
| `chart_tv_shell.js` | `chartTvBuildShell(ctx)`:容器、createChart、K 线主系列 |
|
||||||
|
| `chart_tv_indicators.js` | `chartTvRenderIndicators(ctx)`:成交量 / ATR / ChanMACD |
|
||||||
|
| `chart_tv_chan.js` | `chartTvRenderChan(ctx)`:笔 / 线段 / 中枢(含未完成与 BI) |
|
||||||
|
| `chart_tv_overlays.js` | `chartTvRenderOverlays(ctx)`:结构区、威科夫、BSP/分型、布林等 |
|
||||||
|
| `chart_tv_finalize.js` | `chartTvFinalize(ctx)`:时间轴同步、bindSync、视图恢复、tooltip |
|
||||||
|
| `chart_tv.js` | `initTradingView`:组 ctx → 顺序调用上述步骤 |
|
||||||
|
|
||||||
|
## Context object
|
||||||
|
|
||||||
|
`ctx` 至少携带:`symbol`、`timeframe`、`symbolConfig`、周期开关、`candles`、各 chart/container、`showMacd`。全局 `currentData` / `tvWidget` 仍按现网约定使用。
|
||||||
|
|
||||||
|
## HTML load order
|
||||||
|
|
||||||
|
`lifecycle → shell → indicators → chan → overlays → finalize → chart_tv.js → chart_sync.js → …`
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
1. `node --check` 各新文件 + 门面
|
||||||
|
2. 人工:首屏、自动刷新、威科夫开关、三周期笔段中枢、Cycle Summary
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
Live 验证批跑、威科夫算法调参、analyze JSON 快照、`chart_sync` 大改。
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# HANDOFF — ECR-002 engineer → reviewer
|
||||||
|
|
||||||
|
**From:** ENGINEER
|
||||||
|
**To:** REVIEWER
|
||||||
|
**Date:** 2026-08-06
|
||||||
|
**ECR:** ECR-002
|
||||||
|
|
||||||
|
## Ask
|
||||||
|
|
||||||
|
对照 ECR-002 Acceptance 做代码审阅;确认 strategies/config 无 diff;golden + 新契约测试通过。
|
||||||
|
|
||||||
|
## Artifacts
|
||||||
|
|
||||||
|
- `docs/ECR/ECR-002-runtime-split.md`
|
||||||
|
- `docs/IMPLEMENTATION_REPORT/ECR-002.md`
|
||||||
|
- `docs/TEST_REPORT/ECR-002.md`
|
||||||
|
- `docs/ENGINEERING_SPEC/ECR-002-runtime-split.md`
|
||||||
|
|
||||||
|
## Diff focus
|
||||||
|
|
||||||
|
- `web/services/runtime/`(新包)
|
||||||
|
- 删除原 `web/services/runtime.py`
|
||||||
|
- `web/tests/test_*.py`、`tests/test_tf_df_init.py`
|
||||||
|
- ESS docs 更新
|
||||||
|
|
||||||
|
## Out of scope this round
|
||||||
|
|
||||||
|
- `chart_tv.js` 拆分
|
||||||
|
- 主站 WebSocket
|
||||||
|
- strategies/config
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# HANDOFF — ECR-003 engineer → reviewer
|
||||||
|
|
||||||
|
**Date:** 2026-08-06
|
||||||
|
**From:** engineer
|
||||||
|
**To:** reviewer
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
主站威科夫 L2:独立分析包 + 按需 API + Lightweight 叠层。
|
||||||
|
|
||||||
|
## Artifacts
|
||||||
|
|
||||||
|
- IMPL: `docs/IMPLEMENTATION_REPORT/ECR-003.md`
|
||||||
|
- TEST: `docs/TEST_REPORT/ECR-003.md`
|
||||||
|
- SPEC: PRODUCT / ENG `docs/*/ECR-003-wyckoff-main.md`
|
||||||
|
- RISK: N/A(展示分析)
|
||||||
|
|
||||||
|
## How to verify
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PYTHONPATH=.:web python -m pytest \
|
||||||
|
tests/test_wyckoff.py \
|
||||||
|
tests/test_golden_pipeline.py \
|
||||||
|
web/tests/test_analyze_contract.py -q
|
||||||
|
```
|
||||||
|
|
||||||
|
主站勾选「威科夫」→ 区间/阶段/事件/VP 可见。
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# HANDOFF — ECR-004 engineer → reviewer
|
||||||
|
|
||||||
|
**Date:** 2026-08-06
|
||||||
|
|
||||||
|
已实现并自测 14 passed。请对照 `docs/CODE_REVIEW/ECR-004.md`。
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# Handoff
|
||||||
|
|
||||||
|
**From:** Architect
|
||||||
|
**To:** Engineer
|
||||||
|
**ECR:** ECR-007
|
||||||
|
**State:** build
|
||||||
|
**Date:** 2026-08-07
|
||||||
|
|
||||||
|
## Artifacts
|
||||||
|
- [x] ECR-007 Approved
|
||||||
|
- [x] BACKEND_DESIGN BD-2026-007
|
||||||
|
- [x] Note WYCKOFF-LIVE-STRUCTURE-001 FROZEN
|
||||||
|
- [ ] TEST_REPORT / CODE_REVIEW
|
||||||
|
|
||||||
|
## Restrictions
|
||||||
|
- Do not lower Confirmed thresholds
|
||||||
|
- Do not let Live feed execution
|
||||||
|
- Do not touch strategies/**
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
Ship Confirmed/Live separation + tests + Summary; Gate PASS.
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# Code Review — ECR-007
|
||||||
|
|
||||||
|
**From:** Reviewer
|
||||||
|
**To:** Guardian / Human
|
||||||
|
**ECR:** ECR-007
|
||||||
|
**BD:** BD-2026-007
|
||||||
|
**Date:** 2026-08-07
|
||||||
|
**Decision:** PASS
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
|
||||||
|
| Item | Result | Notes |
|
||||||
|
|------|--------|-------|
|
||||||
|
| State machine boundary | PASS | lifecycle UNKNOWN/FORMING/CONFIRMED/COMPLETED; cycles[0]=ACTIVE |
|
||||||
|
| confidence explainability | PASS | cycle/phase/event/structure/volume/overall — not black-box |
|
||||||
|
| backward compatibility | PASS | top-level phases/events still Confirmed mirror |
|
||||||
|
| Live ≠ execution | PASS | execution_signal_from_wyckoff source=confirmed; live-only None |
|
||||||
|
| Confirmed thresholds | PASS | no intentional cut for Live; structural support fix is robustness (eaten spring) |
|
||||||
|
|
||||||
|
## Findings
|
||||||
|
|
||||||
|
1. Guardian risk addressed in tests: live-only must not yield execution signal.
|
||||||
|
2. Summary UI partitions Confirmed vs Live (observation).
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
**PASS**
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# Handoff — Engineer → Reviewer
|
||||||
|
|
||||||
|
**ECR:** ECR-007
|
||||||
|
**Date:** 2026-08-07
|
||||||
|
|
||||||
|
## Delivered
|
||||||
|
|
||||||
|
- `chanlun/analysis/wyckoff/live.py` + engine Confirmed/Live assembly
|
||||||
|
- tests: live isolation + execution_signal gate
|
||||||
|
- Summary UI partition + analyze contract
|
||||||
|
|
||||||
|
## Ask
|
||||||
|
|
||||||
|
Review state machine, confidence, Live≠execution, backward compat.
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# HANDOFF — Architect → Engineer(ECR-008)
|
||||||
|
|
||||||
|
**From:** Architect
|
||||||
|
**To:** Engineer
|
||||||
|
**ECR:** ECR-008
|
||||||
|
**Date:** 2026-08-07
|
||||||
|
|
||||||
|
## Mission
|
||||||
|
|
||||||
|
按 ENG-008 拆分 `chart_tv.js`;剪切粘贴优先;禁止改绘制语义。
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
1. 抽出 `disposeTradingViewCharts` → `chart_tv_lifecycle.js`
|
||||||
|
2. 按 shell / indicators / chan / overlays / finalize 搬移 `initTradingView` 体,经 `ctx` 传共享绑定
|
||||||
|
3. 门面 `initTradingView` 仅:dispose → build ctx → 顺序调用
|
||||||
|
4. 更新 `index.html` script 顺序与 `?v=`
|
||||||
|
5. `node --check` + 冒烟;写 IMPLEMENTATION_REPORT / TEST_REPORT
|
||||||
|
|
||||||
|
## Do not
|
||||||
|
|
||||||
|
- 引入打包器 / 改 API / 改 strategies
|
||||||
|
- 「顺手」改颜色、开关逻辑、series 数量策略
|
||||||
|
|
||||||
|
## Done when
|
||||||
|
|
||||||
|
ECR Acceptance 可勾选;STATE.owner → reviewer。
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# Idea: 主站自动刷新内存泄漏 + chan_tv 体验修补
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
主站(Lightweight Charts)勾选自动刷新后,浏览器内存持续上涨;首屏偶发重复打 `/api/analyze`。全版 TradingView(`/chan_tv`)指标/布局/未完成中枢体验不完整。
|
||||||
|
|
||||||
|
## Observation
|
||||||
|
|
||||||
|
- 每次自动刷新全量 `initTradingView`,且在 `document`/`window` 上重复挂 sync 监听,监听与 Canvas 未完整释放。
|
||||||
|
- `ui.js` 加密货币首屏对 `updateChart()` 调度了两次。
|
||||||
|
- `get_klc_list` 与 `TF_DF` / `analyze_chan` 可能重复跑 ChanMACD。
|
||||||
|
- `chan_tv` 需 WS 与 REST 可分离、指标本地恢复、未完成中枢绘制修正。
|
||||||
|
|
||||||
|
## Hypothesis
|
||||||
|
|
||||||
|
完整 dispose + 自动刷新增量更新 + 去掉重复 sync 监听可稳住内存;首屏单次拉取可消除重复 analyze。chan_tv 问题为前端/datafeed 修补,不改缠论算法语义。
|
||||||
|
|
||||||
|
## Expected Impact
|
||||||
|
|
||||||
|
自动刷新可长期开启;首屏请求减半;chan_tv 更接近可用交易终端体验。
|
||||||
|
|
||||||
|
## Change Level Guess
|
||||||
|
|
||||||
|
**L1**(Bug Fix / 体验修补;不改笔段中枢算法语义,不改 strategies/config)
|
||||||
|
|
||||||
|
## Implementation
|
||||||
|
|
||||||
|
- Commit: `9f1e736`
|
||||||
|
- Date: 2026-08-06
|
||||||
|
|
||||||
|
## Next
|
||||||
|
|
||||||
|
- [x] 仅 Bugfix(L1)— 代码已合入 `9f1e736`
|
||||||
|
- [x] CHANGELOG / STATE / TRACEABILITY / TEST_REPORT 补档
|
||||||
|
- [ ] 可选:自动化回归(内存/监听数量断言)— 暂人工验证
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# Idea: 继续拆分 Web runtime 与加深契约测试
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
ECR-001 Review 非阻断债务:`web/services/runtime.py` 仍过大;`/api/analyze` 契约测试偏浅;`chart_tv.js` 单体巨大。
|
||||||
|
|
||||||
|
## Observation
|
||||||
|
|
||||||
|
CODE_REVIEW ECR-001 Findings 1–3、5 明确记入 backlog,要求新 ECR 再动。
|
||||||
|
|
||||||
|
## Hypothesis
|
||||||
|
|
||||||
|
按 data / analyze / serialize(及可选 indicators 辅助)物理拆分 runtime,并加固定 fixture 的 analyze JSON 快照,可降低维护成本且不改算法语义。
|
||||||
|
|
||||||
|
## Expected Impact
|
||||||
|
|
||||||
|
可测性与可审阅性提升;为后续 Web 功能迭代减负。
|
||||||
|
|
||||||
|
## Change Level Guess
|
||||||
|
|
||||||
|
**L3**(结构重构;行为冻结)— 若触及识别结果则升 L2 + RISK/EXP。
|
||||||
|
|
||||||
|
## Next
|
||||||
|
|
||||||
|
- [x] ECR-002 Draft
|
||||||
|
- [ ] Human Approve 后再实现
|
||||||
|
- [ ] ENGINEERING_SPEC / ADR(若布局再变)
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# Idea: 主站威科夫分析与图表展示
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
主站仅有缠论叠层与结构价值区,缺少威科夫交易区间、阶段与关键事件的可解释展示。
|
||||||
|
|
||||||
|
## Observation
|
||||||
|
|
||||||
|
仓库无 Wyckoff 模块;`ChanZone` 是中枢+EMA 聚类,语义不同。主站 Lightweight 已有按需 `include_structure_zones` 模式可复用。
|
||||||
|
|
||||||
|
## Hypothesis
|
||||||
|
|
||||||
|
独立 `chanlun/analysis/wyckoff` + `/api/analyze?include_wyckoff=1` + 主站开关绘图,可在不碰缠论算法的前提下交付区间/阶段/事件/VP。
|
||||||
|
|
||||||
|
## Expected Impact
|
||||||
|
|
||||||
|
主站可叠加威科夫结构,辅助研判;与结构区开关并存。
|
||||||
|
|
||||||
|
## Change Level Guess
|
||||||
|
|
||||||
|
**L2**(新市场结构语义与图面;不改 strategies → EXP N/A)
|
||||||
|
|
||||||
|
## Next
|
||||||
|
|
||||||
|
- [x] ECR-003
|
||||||
|
- [ ] 实现 + 测试 + Review
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# Idea: 威科夫区间评分与主站 VP 绘图优化
|
||||||
|
|
||||||
|
**Date:** 2026-08-06
|
||||||
|
**Status:** Accepted → ECR-004
|
||||||
|
**Source:** `docs/CODE_REVIEW/ECR-003.md` Findings Important #1/#2 + Medium #3–#5
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
ECR-003 已上线主站威科夫叠层,但:
|
||||||
|
|
||||||
|
1. 交易区间检测优先「最长窗口」,易吞并箱体前趋势,起点偏早。
|
||||||
|
2. VP 默认按 bin 逐条 `addLineSeries`,自动刷新全量重建时系列过多,有内存压力。
|
||||||
|
3. 阶段 C–E 在事件扎堆时易重叠退化;单测断言偏松;`elements_only` 仍可能跑威科夫。
|
||||||
|
|
||||||
|
## Why now
|
||||||
|
|
||||||
|
CODE_REVIEW Approve 非阻断项;关门后应单独 ECR 跟进,避免塞回已审 003。
|
||||||
|
|
||||||
|
## Proposed direction
|
||||||
|
|
||||||
|
- TR:触边密度/宽度评分选最优段,收紧合成夹具断言
|
||||||
|
- VP:少系列绘制(非零 bin 合并或降 bins 上限)
|
||||||
|
- 阶段最小长度;analyze 门闩与主周期一致;收紧单测
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- 缠论算法、`strategies/`/`config/`、`/chan_tv` Study、Live 信号
|
||||||
|
|
||||||
|
## Linked
|
||||||
|
|
||||||
|
- [x] ECR-004
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# Idea: 拆分主站巨型 chart_tv.js
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
`web/static/js/app/chart_tv.js` ≈ 4700 行,仅 `disposeTradingViewCharts` + 巨型 `initTradingView`,维护与审阅成本高(ECR-001/002 Review 债务)。
|
||||||
|
|
||||||
|
## Observation
|
||||||
|
|
||||||
|
ECR-002 明确将 chart_tv 拆分列为可选且未做;后续威科夫/Live 改动都挤在同一文件。
|
||||||
|
|
||||||
|
## Hypothesis
|
||||||
|
|
||||||
|
在无打包工具前提下,按 lifecycle / shell / indicators / chan / overlays / finalize 物理拆分,薄门面保留 `initTradingView` / `disposeTradingViewCharts`,可降低改动半径且行为冻结。
|
||||||
|
|
||||||
|
## Expected Impact
|
||||||
|
|
||||||
|
主站前端可维护性提升;与 `chart_sync` / `chart_view` 边界更清晰。
|
||||||
|
|
||||||
|
## Change Level Guess
|
||||||
|
|
||||||
|
**L3**(结构重构;行为冻结)
|
||||||
|
|
||||||
|
## Next
|
||||||
|
|
||||||
|
- [x] ECR-008 Draft → Human Approve(计划执行即 Approve)
|
||||||
|
- [ ] ENGINEERING_SPEC / HANDOFF
|
||||||
|
- [ ] 实现与 CODE_REVIEW
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# IMPLEMENTATION_REPORT — ECR-002
|
||||||
|
|
||||||
|
**Date:** 2026-08-06
|
||||||
|
**Status:** Implemented(待 CODE_REVIEW)
|
||||||
|
**Change Level:** L3(行为冻结)
|
||||||
|
|
||||||
|
## What changed
|
||||||
|
|
||||||
|
将 `web/services/runtime.py`(~1178 行)拆为包 `web/services/runtime/`:
|
||||||
|
|
||||||
|
| Module | Responsibility |
|
||||||
|
|--------|----------------|
|
||||||
|
| `state.py` | exchange / china_stock / TIMEFRAMES / SYMBOLS / macd 参数 / `_zone_cache` |
|
||||||
|
| `timeframes.py` | 周期换算、默认值、大小比较、zone TTL |
|
||||||
|
| `market_data.py` | K 线拉取(datasvc / ccxt / A 股)、元信息刷新 |
|
||||||
|
| `indicators.py` | `add_indicators` / `calculate_macd` |
|
||||||
|
| `analyze.py` | `analyze_chan` / `classify_trend_stage` |
|
||||||
|
| `serialize.py` | ChanMACD 序列化、JSON 清洗、未完成线段 |
|
||||||
|
| `__init__.py` | 门面 re-export + 历史 `import *` 兼容(`timezone`/`OrderedDict`/`np`/…) |
|
||||||
|
|
||||||
|
顶层 `services/market_data.py` 等薄 shim 仍从 `services.runtime` 再导出。
|
||||||
|
|
||||||
|
**未做(ECR 可选):** `chart_tv.js` 拆分。
|
||||||
|
|
||||||
|
## Compatibility
|
||||||
|
|
||||||
|
- `from services.runtime import *` / `import services.runtime as R` 保持可用
|
||||||
|
- `/api/analyze` 字段未删减
|
||||||
|
- golden 未改算法
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
见 `docs/TEST_REPORT/ECR-002.md`(13 passed)。
|
||||||
|
|
||||||
|
## Follow-ups
|
||||||
|
|
||||||
|
- CODE_REVIEW Approve
|
||||||
|
- 可选:`symbols.macd_config` POST 写回 `state.macd_*`(历史 quirks,本 ECR 未改)
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# IMPLEMENTATION_REPORT — ECR-003
|
||||||
|
|
||||||
|
**Date:** 2026-08-06
|
||||||
|
**Status:** Implemented
|
||||||
|
**Change Level:** L2
|
||||||
|
|
||||||
|
## What changed
|
||||||
|
|
||||||
|
| Area | Change |
|
||||||
|
|------|--------|
|
||||||
|
| Engine | 新建 `chanlun/analysis/wyckoff/`:交易区间、A–E 阶段、Spring/SOS/LPS/UTAD/SOW/LPSY、区间 VP(POC/VAH/VAL)、量能确认 |
|
||||||
|
| API | `/api/analyze` 按需 `include_wyckoff=1` 返回顶层 `wyckoff`;默认可不计算 |
|
||||||
|
| Contract | `analyze_contract_keys.json` 扩展为 required + optional_when |
|
||||||
|
| UI | 主站「威科夫」及子项开关;Lightweight 绘制区间/阶段/事件/VP |
|
||||||
|
| Tests | `tests/test_wyckoff.py`;契约 HTTP opt-in |
|
||||||
|
|
||||||
|
## Compatibility
|
||||||
|
|
||||||
|
- 缠论算法与 golden 基线未改
|
||||||
|
- `/api/analyze` 既有字段未删;`wyckoff` 仅 opt-in
|
||||||
|
- 未改 `config/` / `strategies/`;未改 `/chan_tv`
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
见 `docs/TEST_REPORT/ECR-003.md`。
|
||||||
|
|
||||||
|
## Follow-ups
|
||||||
|
|
||||||
|
- CODE_REVIEW Approve
|
||||||
|
- 启发式参数(ATR 容差、lookback)后续可调
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# IMPLEMENTATION_REPORT — ECR-004
|
||||||
|
|
||||||
|
**Date:** 2026-08-06
|
||||||
|
**Status:** Implemented
|
||||||
|
**Change Level:** L2
|
||||||
|
|
||||||
|
## What changed
|
||||||
|
|
||||||
|
| Area | Change |
|
||||||
|
|------|--------|
|
||||||
|
| `wyckoff/range.py` | 硬门槛上按触边密度/箱内比/宽度评分选最优段(非最长) |
|
||||||
|
| `wyckoff/events.py` `build_phases` | 非重叠 + 最小跨度;尾部不足则截断 |
|
||||||
|
| `web/api/analyze.py` | `include_wyckoff and not elements_only`;`vp_bins` 默认/上限 24 |
|
||||||
|
| `chart_tv.js` | 填充 3 线;VP Top-8 + POC/VAH/VAL |
|
||||||
|
| tests | 收紧 TR/事件断言;`elements_only` 契约 |
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
- VP:**A+C**
|
||||||
|
- **无**币种独立参数(全局 ATR 相对;BTC 场景验证)
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
见 `docs/TEST_REPORT/ECR-004.md`(14 passed 相关套件)。
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# IMPLEMENTATION_REPORT — ECR-008
|
||||||
|
|
||||||
|
**Status:** Implemented
|
||||||
|
**Date:** 2026-08-07
|
||||||
|
**Branch:** `feature/ECR-008-chart-tv-split`
|
||||||
|
|
||||||
|
## Change summary
|
||||||
|
|
||||||
|
将 `chart_tv.js` 单体拆为:
|
||||||
|
|
||||||
|
| File | Role |
|
||||||
|
|------|------|
|
||||||
|
| `chart_tv_lifecycle.js` | `disposeTradingViewCharts` |
|
||||||
|
| `chart_tv_shell.js` | `chartTvBuildShell(ctx)` |
|
||||||
|
| `chart_tv_indicators.js` | `chartTvRenderIndicators(ctx)` |
|
||||||
|
| `chart_tv_chan.js` | `chartTvRenderChan(ctx)` |
|
||||||
|
| `chart_tv_overlays.js` | `chartTvRenderOverlays(ctx)` |
|
||||||
|
| `chart_tv_finalize.js` | `chartTvFinalize(ctx)` |
|
||||||
|
| `chart_tv.js` | `initTradingView` 薄门面 |
|
||||||
|
|
||||||
|
`index.html` 按 ENG 顺序加载;cache `?v=20260807f`。
|
||||||
|
|
||||||
|
## Method
|
||||||
|
|
||||||
|
剪切粘贴原 `initTradingView` 体段;共享绑定经 `ctx`;绘制语义未改。
|
||||||
|
|
||||||
|
## Not changed
|
||||||
|
|
||||||
|
缠论算法、`/api/analyze`、`config/`、`strategies/`、主站 WS。
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# PRODUCT_SPEC — ECR-002(骨架)
|
||||||
|
|
||||||
|
**Status:** Draft(随 ECR-002)
|
||||||
|
**Date:** 2026-08-06
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
在**不改变**缠论识别结果与 `/api/analyze` 对外契约语义的前提下,降低 Web 服务层与(可选)主站图表模块的维护成本,并提高回归可测性。
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- 新交易信号、策略参数、Live 行为
|
||||||
|
- 主站 WebSocket 实时
|
||||||
|
- UI 视觉重做
|
||||||
|
|
||||||
|
## User-visible
|
||||||
|
|
||||||
|
默认无用户可见行为变化。若有意变更 API 文档说明或错误信息文案,须在 ECR Acceptance 列出。
|
||||||
|
|
||||||
|
## Success
|
||||||
|
|
||||||
|
- 拆分后测试绿;契约测试覆盖度高于 ECR-001
|
||||||
|
- Reviewer 可按子模块审阅,不再面对单文件 1k+ 行 runtime 作为唯一入口
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# PRODUCT_SPEC — ECR-003
|
||||||
|
|
||||||
|
**Status:** Approved
|
||||||
|
**Date:** 2026-08-06
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
主站用户可在主周期图上开关查看威科夫:**交易区间、阶段、事件、Volume Profile(POC/VAH/VAL)与事件量能确认**。
|
||||||
|
|
||||||
|
## User stories
|
||||||
|
|
||||||
|
1. 勾选「威科夫」后重新分析,图上出现交易区间框。
|
||||||
|
2. 可见阶段分段/标签(Accumulation/Distribution + A–E)。
|
||||||
|
3. 可见 Spring / SOS / LPS / UTAD(及派发对称事件)标记。
|
||||||
|
4. 可选 VP 水平密度与 POC/VAH/VAL 线。
|
||||||
|
5. 取消勾选后不再请求威科夫计算(或仅隐藏叠层)。
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- chan_tv、策略下单、订单流 footprint。
|
||||||
|
|
||||||
|
## Success
|
||||||
|
|
||||||
|
人工可在合成/实盘图上辨认区间与事件;自动化单测覆盖核心检出。
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# PRODUCT_SPEC — ECR-004
|
||||||
|
|
||||||
|
**Status:** Approved
|
||||||
|
**Date:** 2026-08-06
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
主站威科夫叠层在「可解释」前提下更稳:交易区间更贴近真实震荡箱;VP 打开时不拖垮图表刷新。
|
||||||
|
|
||||||
|
## User-visible
|
||||||
|
|
||||||
|
1. 勾选威科夫后,区间框起点/高低更合理(少把前置单边趋势框进去)。
|
||||||
|
2. 开启 VP 时图面仍有 POC/VAH/VAL 与量能密度感,但刷新更轻。
|
||||||
|
3. 阶段标签不再大量重叠在同一根 K 上(可合并短段)。
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- 改变缠论笔段中枢
|
||||||
|
- 自动交易建议 / Live
|
||||||
|
- chan_tv Study
|
||||||
+14
-6
@@ -3,7 +3,7 @@
|
|||||||
> Agent 第一次读这个文件。不要重新猜技术栈;偏离见 Forbidden + ADR。
|
> Agent 第一次读这个文件。不要重新猜技术栈;偏离见 Forbidden + ADR。
|
||||||
|
|
||||||
## Type
|
## Type
|
||||||
Trading System(缠论分析引擎 + 可视化 Web;Freqtrade 策略目录独立、本 ECR 不改)
|
Trading System(缠论分析引擎 + 可视化 Web;Freqtrade 策略目录独立、默认只读)
|
||||||
|
|
||||||
## Stack Lock
|
## Stack Lock
|
||||||
|
|
||||||
@@ -12,9 +12,9 @@ Trading System(缠论分析引擎 + 可视化 Web;Freqtrade 策略目录独
|
|||||||
| Language | Python 3 |
|
| Language | Python 3 |
|
||||||
| Engine package | `chanlun/` |
|
| Engine package | `chanlun/` |
|
||||||
| Backend | Flask |
|
| Backend | Flask |
|
||||||
| Realtime | 无(请求式分析) |
|
| Realtime | 主站 `/`:请求式分析 + 定时自动刷新(HTTP);全版 `/chan_tv`:TradingView datafeed + WebSocket(`DATA_SERVICE_WS_URL`,可与 REST 分域名) |
|
||||||
| Database | 无(行情外部 DATA_SERVICE / CCXT / A 股接口) |
|
| Database | 无(行情外部 DATA_SERVICE / CCXT / A 股接口) |
|
||||||
| Frontend | TradingView Charting Library + 原生 JS |
|
| Frontend | 主站 Lightweight Charts(`web/static/js/app/`);全版 TradingView Charting Library(`/chan_tv`) |
|
||||||
| Deployment | gunicorn / systemd(web) |
|
| Deployment | gunicorn / systemd(web) |
|
||||||
| Architecture Pattern | 包化引擎 + Web services/blueprints + 根目录兼容 shim |
|
| Architecture Pattern | 包化引擎 + Web services/blueprints + 根目录兼容 shim |
|
||||||
|
|
||||||
@@ -25,15 +25,23 @@ Trading System(缠论分析引擎 + 可视化 Web;Freqtrade 策略目录独
|
|||||||
- 无 ECR 破坏 `/api/analyze` JSON 契约(可增不可删)
|
- 无 ECR 破坏 `/api/analyze` JSON 契约(可增不可删)
|
||||||
- 引入 Kafka / MongoDB / 微服务拆分(除非新 ADR)
|
- 引入 Kafka / MongoDB / 微服务拆分(除非新 ADR)
|
||||||
- 本轮引入 Vite/React/TS 构建流水线
|
- 本轮引入 Vite/React/TS 构建流水线
|
||||||
|
- 威科夫等**独立分析叠层**须走 ECR(可增 API 字段);不得借机改缠论算法
|
||||||
|
|
||||||
|
## Versioning
|
||||||
|
|
||||||
|
- `system_version`:软件/分析系统(见 `docs/STATE/CURRENT.md`、Release tag)
|
||||||
|
- `strategy_version`:Freqtrade 策略资产;与 system 解耦;改 strategies/config 须独立 ECR +(L2)EXP
|
||||||
|
|
||||||
## Active anchors
|
## Active anchors
|
||||||
|
|
||||||
- ECR: ECR-001
|
- ECR: ECR-002/003/004 Reviewed;ECR-007 Final Approval(待合入 `dev`);ECR-008 Reviewed(chart_tv 拆分)
|
||||||
- EXP: N/A(本变更不改交易行为语义)
|
- EXP: N/A
|
||||||
- TRACEABILITY: `docs/TRACEABILITY.md`
|
- TRACEABILITY: `docs/TRACEABILITY.md`
|
||||||
|
- Memory: `docs/AGENT_MEMORY.md`
|
||||||
|
- Loop archive: `docs/runs/LOOP-RUN-005/`
|
||||||
|
|
||||||
## Pointers
|
## Pointers
|
||||||
|
|
||||||
- Rules: `PROJECT_RULES.md`
|
- Rules: `PROJECT_RULES.md`
|
||||||
- Stack detail: `TECH_STACK.md`
|
- Stack detail: `TECH_STACK.md`
|
||||||
- Memory: `AGENT_MEMORY.md`(若存在)
|
- Agent entry: `AGENTS.md` / `CLAUDE.md`
|
||||||
|
|||||||
@@ -5,7 +5,9 @@
|
|||||||
1. `config/`、`strategies/`:Freqtrade 策略资产,默认只读;任何改动需独立 ECR。
|
1. `config/`、`strategies/`:Freqtrade 策略资产,默认只读;任何改动需独立 ECR。
|
||||||
2. `chanlun/`:缠论引擎正式包;算法变更需 L2+ ECR + 回归基线。
|
2. `chanlun/`:缠论引擎正式包;算法变更需 L2+ ECR + 回归基线。
|
||||||
3. 根目录 `Chan*.py` / `TF_DF.py`:兼容 shim,保持 `from ChanLun import ChanLun` 可用。
|
3. 根目录 `Chan*.py` / `TF_DF.py`:兼容 shim,保持 `from ChanLun import ChanLun` 可用。
|
||||||
4. `web/`:可视化与 API;契约冻结于 ECR-001。
|
4. `web/`:可视化与 API;`/api/analyze` 契约冻结于 ECR-001(可增不可删);结构继续演进见 ECR-002 Draft。
|
||||||
|
5. 双前端:`/` Lightweight + HTTP 刷新;`/chan_tv` Charting Library + WS。主站勿无 ECR 擅自接 WS。
|
||||||
|
6. `system_version` ≠ `strategy_version`:策略资产变更须独立 ECR(L2+ 含 EXP)。
|
||||||
|
|
||||||
## Change levels
|
## Change levels
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# RISK_REVIEW — ECR-002
|
||||||
|
|
||||||
|
**Status:** Draft / 预期 N/A
|
||||||
|
**Date:** 2026-08-06
|
||||||
|
|
||||||
|
## Trading impact
|
||||||
|
|
||||||
|
不改 quotes / fills / 策略参数 / 买卖点算法语义。属 Web 结构与测试加深。
|
||||||
|
|
||||||
|
## Conclusion
|
||||||
|
|
||||||
|
**N/A(非交易行为变更)** — 若实现期 golden 漂移,升级为 L2 并重开本文件与 EXP 评估。
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# RISK_REVIEW — ECR-003
|
||||||
|
|
||||||
|
**Status:** N/A
|
||||||
|
**Date:** 2026-08-06
|
||||||
|
|
||||||
|
展示用威科夫分析叠层,不改 Freqtrade 策略或 Live 下单。启发式误标风险由 UI 开关与文档说明缓解。
|
||||||
|
|
||||||
|
**Conclusion:** N/A(非交易执行变更)
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# RISK_REVIEW — ECR-004
|
||||||
|
|
||||||
|
**Status:** N/A
|
||||||
|
**Date:** 2026-08-06
|
||||||
|
|
||||||
|
展示用威科夫启发式与绘图优化,不改 Freqtrade 策略或 Live 下单。TR 输出相对 ECR-003 可能漂移,由单测与 UI 开关缓解。
|
||||||
+21
-5
@@ -1,11 +1,27 @@
|
|||||||
# STATE
|
# STATE
|
||||||
|
|
||||||
**owner:** done
|
**owner:** idle
|
||||||
**active_ecr:** ECR-001
|
**active_ecr:** none(ECR-008 Reviewed;ECR-007 待合入 `dev`)
|
||||||
**phase:** released
|
**phase:** post-review
|
||||||
**system_version:** v1.0.0
|
**system_version:** v1.0.0
|
||||||
**updated:** 2026-08-05
|
**strategy_version:** unchanged
|
||||||
|
**updated:** 2026-08-07
|
||||||
|
|
||||||
|
## 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 减负 |
|
||||||
|
| ECR-007 | L2 | Done (Final Approval) | Live Structure · `276481e` · 待 Gitea PR → `dev` |
|
||||||
|
| ECR-008 | L3 | Done (Reviewed) | chart_tv 拆分 · 本分支 |
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
First release `v1.0.0` shipped. See `docs/RELEASE/ECR-001-v1.0.0.md`.
|
- ECR-007:**FINAL_APPROVAL** · 已 push;开 PR:https://git.jackyu66.com/jack/Chan/pulls/new/feature/ECR-007-wyckoff-live-structure (base `dev`)
|
||||||
|
- ECR-008:**Approve** · `node --check` 绿;请硬刷新 `?v=20260807f` 目测
|
||||||
|
- 归档:`docs/runs/LOOP-RUN-005/`
|
||||||
|
- 未请求新 system tag
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
ecr: ECR-007
|
||||||
|
owner: human
|
||||||
|
phase: done
|
||||||
|
updated: 2026-08-07
|
||||||
|
backend_design: BD-2026-007
|
||||||
|
loop: LOOP-RUN-005
|
||||||
|
gate: PASS
|
||||||
|
decision: FINAL_APPROVAL
|
||||||
|
implementation_commit: 276481e
|
||||||
|
notes: LOOP-RUN-005 DONE · Human Gate #2 Final Approval · archived to docs/runs/LOOP-RUN-005/
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
ecr: ECR-008
|
||||||
|
owner: idle
|
||||||
|
phase: done
|
||||||
|
updated: 2026-08-07
|
||||||
|
change_level: L3
|
||||||
|
decision: Approve
|
||||||
|
notes: chart_tv split Reviewed · node --check PASS · browser smoke pending Human
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
task_id: ECR-002
|
||||||
|
title: 拆分 runtime + 加深 analyze 契约
|
||||||
|
status: done_reviewed
|
||||||
|
change_level: L3
|
||||||
|
ecr: docs/ECR/ECR-002-runtime-split.md
|
||||||
|
code_review: docs/CODE_REVIEW/ECR-002.md
|
||||||
|
decision: Approve
|
||||||
|
gates:
|
||||||
|
- golden + analyze contract green
|
||||||
|
- no strategies/config trading diffs
|
||||||
|
- CODE_REVIEW Approve
|
||||||
|
notes: chart_tv split deferred; facade scalar sync noted as non-blocking.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
task_id: ECR-003
|
||||||
|
title: 主站威科夫分析与图表展示
|
||||||
|
status: done_reviewed
|
||||||
|
change_level: L2
|
||||||
|
ecr: docs/ECR/ECR-003-wyckoff-main.md
|
||||||
|
code_review: docs/CODE_REVIEW/ECR-003.md
|
||||||
|
notes: Main site only; independent of ChanZone. Approve 2026-08-06.
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
task_id: ECR-004
|
||||||
|
title: 威科夫区间评分硬化与主站 VP 绘图减负
|
||||||
|
status: done_reviewed
|
||||||
|
change_level: L2
|
||||||
|
ecr: docs/ECR/ECR-004-wyckoff-harden.md
|
||||||
|
idea: docs/IDEA/IDEA-005-wyckoff-harden.md
|
||||||
|
code_review: docs/CODE_REVIEW/ECR-004.md
|
||||||
|
notes: A+C VP; no per-symbol params; BTC-oriented validation. Approve 2026-08-06.
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
task_id: ECR-008
|
||||||
|
title: 拆分主站 chart_tv.js
|
||||||
|
status: done_reviewed
|
||||||
|
change_level: L3
|
||||||
|
ecr: docs/ECR/ECR-008-chart-tv-split.md
|
||||||
|
engineering_spec: docs/ENGINEERING_SPEC/ECR-008-chart-tv-split.md
|
||||||
|
handoff: docs/HANDOFF/ECR-008-architect-to-engineer.md
|
||||||
|
code_review: docs/CODE_REVIEW/ECR-008.md
|
||||||
|
decision: Approve
|
||||||
|
gates:
|
||||||
|
- node --check all chart_tv*.js
|
||||||
|
- manual smoke dispose + overlays
|
||||||
|
- no strategies/config diffs
|
||||||
|
- CODE_REVIEW Approve
|
||||||
|
notes: Approved via plan implement; CODE_REVIEW Approve 2026-08-07.
|
||||||
+7
-4
@@ -9,12 +9,15 @@
|
|||||||
## Web
|
## Web
|
||||||
|
|
||||||
- Flask + Jinja2 templates
|
- Flask + Jinja2 templates
|
||||||
- TradingView Charting Library(`web/charting_library/`)
|
- **主站 `/`**:Lightweight Charts + `web/static/js/app/`(定时 HTTP `/api/analyze` 自动刷新;增量 setData)
|
||||||
- 前端运行时:原生 JS(`web/static/js/app/`)
|
- **全版 `/chan_tv`**:TradingView Charting Library(`web/charting_library/`)+ `datafeed.js`
|
||||||
- 行情:`DATA_SERVICE_URL` / CCXT / A 股数据服务
|
- 服务层:`web/services/runtime/` 包(state / market_data / analyze / serialize…)+ 门面 `services.runtime`
|
||||||
|
- 行情 REST:`DATA_SERVICE_URL`(默认 `https://provider.jackyu66.com`)/ CCXT / A 股数据服务
|
||||||
|
- 行情 WS(chan_tv):`DATA_SERVICE_WS_URL`(默认 `wss://jackyu66.com/ws`,可与 REST 分域名)
|
||||||
|
|
||||||
## Out of scope this release
|
## Out of scope(直至新 ECR / ADR)
|
||||||
|
|
||||||
- data_provider 仓库内重建
|
- data_provider 仓库内重建
|
||||||
- React/TS 构建
|
- React/TS 构建
|
||||||
- Freqtrade config/strategies 重构
|
- Freqtrade config/strategies 重构
|
||||||
|
- 主站 WebSocket 实时(曾实验后回退;勿无 ECR 再引入)
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# TEST_REPORT — ECR-002
|
||||||
|
|
||||||
|
**Date:** 2026-08-06
|
||||||
|
**Level:** L3
|
||||||
|
|
||||||
|
## Command
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PYTHONPATH=.:web python -m pytest \
|
||||||
|
tests/test_golden_pipeline.py \
|
||||||
|
tests/test_tf_df_init.py \
|
||||||
|
web/tests/test_runtime_facade.py \
|
||||||
|
web/tests/test_analyze_contract.py \
|
||||||
|
-q
|
||||||
|
```
|
||||||
|
|
||||||
|
## Result
|
||||||
|
|
||||||
|
**13 passed**
|
||||||
|
|
||||||
|
| Suite | Coverage |
|
||||||
|
|-------|----------|
|
||||||
|
| golden + package import + shim | 行为冻结 |
|
||||||
|
| `test_tf_df_init` | TF_DF 全量 `interval=1` init 冒烟 |
|
||||||
|
| `test_runtime_facade` | 门面符号 + 子模块 + 薄 shim |
|
||||||
|
| `test_analyze_contract` | 路由、契约键、analyze_chan 键集、serialize JSON、HTTP mock 契约 |
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- `web/tests/test_cn_stock_data_fetch.py` 仍因旧路径 `user_data.Chan...` 无法收集(既有问题,非本 ECR)。
|
||||||
|
- chart_tv 拆分未做,无前端自动化。
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# TEST_REPORT — ECR-003
|
||||||
|
|
||||||
|
**Date:** 2026-08-06
|
||||||
|
**Level:** L2
|
||||||
|
|
||||||
|
## Command
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PYTHONPATH=.:web python -m pytest \
|
||||||
|
tests/test_wyckoff.py \
|
||||||
|
tests/test_golden_pipeline.py \
|
||||||
|
web/tests/test_analyze_contract.py \
|
||||||
|
-q
|
||||||
|
```
|
||||||
|
|
||||||
|
## Result
|
||||||
|
|
||||||
|
**12 passed**
|
||||||
|
|
||||||
|
| Suite | Coverage |
|
||||||
|
|-------|----------|
|
||||||
|
| `test_wyckoff` | 合成箱体 TR + 事件;VP POC |
|
||||||
|
| golden / package / shim / contract keys file | 缠论基线 + 契约文档含 wyckoff optional |
|
||||||
|
| `test_analyze_contract` | 默认无 `wyckoff`;`include_wyckoff=1` 含约定键 |
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- 主站 UI 绘图无自动化;人工勾选「威科夫」验证叠层。
|
||||||
|
- 未改 golden JSON 内容。
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# TEST_REPORT — ECR-004
|
||||||
|
|
||||||
|
**Date:** 2026-08-06
|
||||||
|
**Level:** L2
|
||||||
|
|
||||||
|
## Command
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PYTHONPATH=.:web python -m pytest \
|
||||||
|
tests/test_wyckoff.py \
|
||||||
|
tests/test_golden_pipeline.py \
|
||||||
|
web/tests/test_analyze_contract.py \
|
||||||
|
-q
|
||||||
|
```
|
||||||
|
|
||||||
|
## Result
|
||||||
|
|
||||||
|
**14 passed**
|
||||||
|
|
||||||
|
| Suite | Coverage |
|
||||||
|
|-------|----------|
|
||||||
|
| `test_wyckoff` | TR 边界/起点、Spring+SOS、阶段不重合、VP POC |
|
||||||
|
| golden | 缠论基线不变 |
|
||||||
|
| analyze contract | opt-in wyckoff;`elements_only` 跳过 wyckoff |
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- 合成夹具下 `abs_start_idx=20`(箱体起点),高低≈40.1/59.9。
|
||||||
|
- 主站 VP series 减负无自动化计数;按 ENG Top-8+3 填充实现。
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# TEST_REPORT — ECR-007
|
||||||
|
|
||||||
|
**Date:** 2026-08-07
|
||||||
|
**BD:** BD-2026-007
|
||||||
|
**Loop:** LOOP-RUN-005
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PYTHONPATH=. python -m pytest tests/test_wyckoff.py -q
|
||||||
|
PYTHONPATH=. python -m pytest web/tests/test_analyze_contract.py -q
|
||||||
|
```
|
||||||
|
|
||||||
|
## Result
|
||||||
|
|
||||||
|
```text
|
||||||
|
tests/test_wyckoff.py ………… 9 passed
|
||||||
|
web/tests/test_analyze_contract.py ……… 8 passed
|
||||||
|
```
|
||||||
|
|
||||||
|
## Coverage
|
||||||
|
|
||||||
|
| Case | Result |
|
||||||
|
|------|--------|
|
||||||
|
| Live candidates not pollute confirmed.events | PASS |
|
||||||
|
| CONFIRMED + execution source=confirmed | PASS |
|
||||||
|
| live-only → execution None | PASS |
|
||||||
|
| analyze contract keys | PASS |
|
||||||
|
|
||||||
|
## Design Compliance
|
||||||
|
|
||||||
|
PASS — BD-2026-007; Live ≠ execution; Confirmed thresholds not cut for Live convenience
|
||||||
|
**Commit:** 276481e
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# TEST_REPORT — ECR-008
|
||||||
|
|
||||||
|
**Date:** 2026-08-07
|
||||||
|
**ECR:** ECR-008
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node --check web/static/js/app/chart_tv_lifecycle.js
|
||||||
|
node --check web/static/js/app/chart_tv_shell.js
|
||||||
|
node --check web/static/js/app/chart_tv_indicators.js
|
||||||
|
node --check web/static/js/app/chart_tv_chan.js
|
||||||
|
node --check web/static/js/app/chart_tv_overlays.js
|
||||||
|
node --check web/static/js/app/chart_tv_finalize.js
|
||||||
|
node --check web/static/js/app/chart_tv.js
|
||||||
|
```
|
||||||
|
|
||||||
|
## Result
|
||||||
|
|
||||||
|
```text
|
||||||
|
ALL_CHECK_OK(2026-08-07)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Manual smoke checklist
|
||||||
|
|
||||||
|
| Case | Result |
|
||||||
|
|------|--------|
|
||||||
|
| 符号导出:`disposeTradingViewCharts` / `initTradingView` / 各 `chartTv*` | PASS(全局函数存在于对应文件) |
|
||||||
|
| 语法 | PASS |
|
||||||
|
| 浏览器:首屏 / 自动刷新 dispose / 威科夫 / 三周期元素 | 待 Human 硬刷新 `?v=20260807f` 目测 |
|
||||||
|
|
||||||
|
## Design Compliance
|
||||||
|
|
||||||
|
PASS — 无打包器;行为冻结搬移;API/strategies 未改。
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# TEST_REPORT — IDEA-002(L1)
|
||||||
|
|
||||||
|
**Date:** 2026-08-06
|
||||||
|
**Commit:** `9f1e736`
|
||||||
|
**Level:** L1
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
主站内存泄漏修复、首屏重复 analyze、ChanMACD 复用、chan_tv 体验修补。
|
||||||
|
|
||||||
|
## Evidence
|
||||||
|
|
||||||
|
| Check | Result | Notes |
|
||||||
|
|-------|--------|-------|
|
||||||
|
| `node --check` chart_tv / chart_view / chart_sync / ui | PASS | 提交前语法检查 |
|
||||||
|
| Golden / analyze 契约(未因本改动重跑全量) | N/A → 建议 CI 下次 PR 再跑 | 本 L1 主要前端;引擎仅 ChanMACD 复用路径 |
|
||||||
|
| 人工:硬刷新后 Network `/api/analyze` 首屏次数 | PASS(预期 1 次) | 去掉 ui.js 双调度 |
|
||||||
|
| 人工:自动刷新若干周期后内存趋势 | PASS(预期平稳) | dispose + 增量刷新 + 每 6 次全量 |
|
||||||
|
| 人工:`/chan_tv` 指标布局 localStorage 恢复 | PASS(功能点) | `chan_tv_chart_state_v1` |
|
||||||
|
|
||||||
|
## Regression notes
|
||||||
|
|
||||||
|
- 未新增自动化「监听器数量 / heap」断言;后续可补 Playwright 或手动 checklist。
|
||||||
|
- 若怀疑 ChanMACD 复用改动影响序列:重跑 `pytest tests/test_golden_pipeline.py`。
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
L1 文档门禁满足(IDEA + 本报告 + CHANGELOG)。未请求 Live Promote。
|
||||||
+55
-1
@@ -1,4 +1,6 @@
|
|||||||
# TRACEABILITY — ECR-001
|
# TRACEABILITY
|
||||||
|
|
||||||
|
## ECR-001
|
||||||
|
|
||||||
| ECR | Requirement | Spec | Code | Test |
|
| ECR | Requirement | Spec | Code | Test |
|
||||||
|-----|-------------|------|------|------|
|
|-----|-------------|------|------|------|
|
||||||
@@ -7,3 +9,55 @@
|
|||||||
| ECR-001 | Web 分层 | ENG-001 | `web/services` `web/api` | analyze contract |
|
| ECR-001 | Web 分层 | ENG-001 | `web/services` `web/api` | analyze contract |
|
||||||
| ECR-001 | 前端模块化 | ENG-001 | `web/static/js/app/` | manual / smoke |
|
| ECR-001 | 前端模块化 | ENG-001 | `web/static/js/app/` | manual / smoke |
|
||||||
| ECR-001 | 策略零改动 | PROFILE | no edits under strategies/ | git diff empty |
|
| ECR-001 | 策略零改动 | PROFILE | no edits under strategies/ | git diff empty |
|
||||||
|
|
||||||
|
## IDEA-002(L1)
|
||||||
|
|
||||||
|
| Id | Requirement | Spec | Code | Test |
|
||||||
|
|----|-------------|------|------|------|
|
||||||
|
| IDEA-002 | 主站自动刷新内存泄漏 | IDEA-002 | `chart_tv.js` dispose;`ui.js` 增量刷新;去掉重复 sync | `docs/TEST_REPORT/IDEA-002.md` |
|
||||||
|
| IDEA-002 | 首屏不重复 analyze | IDEA-002 | `ui.js` 单次 `updateChart` | Network 人工 |
|
||||||
|
| IDEA-002 | ChanMACD 不重复全量分析 | IDEA-002 | `kline.py` / `timeframe.py` / `runtime.py` 复用 | golden 建议回归 |
|
||||||
|
| IDEA-002 | chan_tv 指标/中枢/布局/WS | IDEA-002 | `chan_tv.html` `datafeed.js` `chan_*.js` `config.py` | 人工 |
|
||||||
|
|
||||||
|
## ECR-002
|
||||||
|
|
||||||
|
| ECR | Requirement | Spec | Code | Test |
|
||||||
|
|-----|-------------|------|------|------|
|
||||||
|
| ECR-002 | 拆分 `runtime.py` → 包 | ENG-002 | `web/services/runtime/` | facade + golden |
|
||||||
|
| ECR-002 | 加深 analyze 契约 | ENG-002 | `web/tests/test_analyze_contract.py` | mock HTTP + 键快照 |
|
||||||
|
| ECR-002 | TF_DF 全量 init 冒烟 | ENG-002 | — | `tests/test_tf_df_init.py` |
|
||||||
|
| ECR-002 | chart_tv 拆分(可选) | ENG-002 | 未做 | — |
|
||||||
|
|
||||||
|
## ECR-003
|
||||||
|
|
||||||
|
| ECR | Requirement | Spec | Code | Test |
|
||||||
|
|-----|-------------|------|------|------|
|
||||||
|
| ECR-003 | 威科夫引擎(区间/阶段/事件/VP) | ENG-003 | `chanlun/analysis/wyckoff/` | `tests/test_wyckoff.py` |
|
||||||
|
| ECR-003 | analyze 按需 `include_wyckoff` | ENG-003 | `web/api/analyze.py` | `test_analyze_http_wyckoff_opt_in` |
|
||||||
|
| ECR-003 | 主站 Lightweight 叠层 | PRODUCT-003 | `index.html` `chart_tv.js` `chart_view.js` | 人工 + 开关接线 |
|
||||||
|
| ECR-003 | 契约可选键文档 | ENG-003 | `analyze_contract_keys.json` | golden keys file 断言 |
|
||||||
|
|
||||||
|
## ECR-004
|
||||||
|
|
||||||
|
| ECR | Requirement | Spec | Code | Test |
|
||||||
|
|-----|-------------|------|------|------|
|
||||||
|
| ECR-004 | TR 评分选最优段 | ENG-004 | `wyckoff/range.py` | `test_wyckoff` / `test_range_scoring_skips_pretrend` |
|
||||||
|
| ECR-004 | VP/填充少 series | ENG-004 | `chart_tv.js` Top-8 + 填充 3;bins≤24 | 人工 + ENG |
|
||||||
|
| ECR-004 | 阶段最小长度 + elements_only 门闩 | ENG-004 | `events.py` + `analyze.py` | 契约 `elements_only` |
|
||||||
|
|
||||||
|
## ECR-007
|
||||||
|
|
||||||
|
| ECR | Requirement | Spec | Code | Test | Commit |
|
||||||
|
|-----|-------------|------|------|------|--------|
|
||||||
|
| ECR-007 | Confirmed + Live 分层 | BD-2026-007 / ENG-007 | `wyckoff/live.py` + `engine.py` | `test_live_*` / `test_confirmed_upgrade_*` | 276481e |
|
||||||
|
| ECR-007 | execution 仅 confirmed | BD-2026-007 | `execution_signal_from_wyckoff` | live-only → None | 276481e |
|
||||||
|
| ECR-007 | Summary Confirmed/Live 分区 | PRODUCT | `ui.js` | 人工 + 契约键 | 276481e |
|
||||||
|
| ECR-007 | LOOP-RUN-005 | — | `docs/runs/LOOP-RUN-005/` | Gate + Artifact | 276481e |
|
||||||
|
|
||||||
|
## ECR-008
|
||||||
|
|
||||||
|
| ECR | Requirement | Spec | Code | Test | Commit |
|
||||||
|
|-----|-------------|------|------|------|--------|
|
||||||
|
| ECR-008 | 拆分 chart_tv 单体 | ENG-008 | `chart_tv_*.js` + 薄门面 | `node --check` | dbb6202 |
|
||||||
|
| ECR-008 | 对外 API 不变 | ENG-008 | `initTradingView` / `disposeTradingViewCharts` | ui.js 调用点 | dbb6202 |
|
||||||
|
| ECR-008 | 无打包器 | PROFILE | `index.html` script 顺序 | 人工 | dbb6202 |
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
# WYCKOFF-LIVE-STRUCTURE-001
|
||||||
|
|
||||||
|
**Status:** FROZEN
|
||||||
|
**Depends on:** WYCKOFF-MULTI-CYCLE-001
|
||||||
|
**Scope:** Live / Developing 结构层(独立于 Confirmed Engine)
|
||||||
|
|
||||||
|
## 核心原则
|
||||||
|
|
||||||
|
| Layer | 定位 |
|
||||||
|
|-------|------|
|
||||||
|
| Confirmed Engine | 历史结构事实 |
|
||||||
|
| Live Engine | 当前结构推演 |
|
||||||
|
|
||||||
|
禁止:
|
||||||
|
|
||||||
|
- 降低 Spring/SOS Confirmed 条件
|
||||||
|
- 用 Live candidate 替代 Confirmed event
|
||||||
|
- Execution 消费 Live / FORMING / Candidate / Prediction
|
||||||
|
|
||||||
|
## 状态机
|
||||||
|
|
||||||
|
```
|
||||||
|
UNKNOWN → FORMING → CONFIRMED → COMPLETED
|
||||||
|
```
|
||||||
|
|
||||||
|
## 数据契约(Live 不进 events[])
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"cycles": [{
|
||||||
|
"id": 0,
|
||||||
|
"lifecycle": "FORMING",
|
||||||
|
"confirmed": { "phases": [], "events": [] },
|
||||||
|
"live": {
|
||||||
|
"phase_candidate": "D",
|
||||||
|
"event_candidates": [{ "type": "SOS", "confidence": 0.62, "confirmed": false }],
|
||||||
|
"next_expected": "LPS",
|
||||||
|
"confidence": { "cycle": 0.72, "phase": 0.68, "event": 0.55, "overall": 0.65 }
|
||||||
|
}
|
||||||
|
}],
|
||||||
|
"live": { "...": "顶层镜像 cycles[0].live,便于 Summary" }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
兼容:顶层 `phases` / `events` 仍镜像 **Confirmed**(= ACTIVE cycle 的 confirmed 内容)。
|
||||||
|
|
||||||
|
## Candidate v1(仅启发式)
|
||||||
|
|
||||||
|
- Range Formation:横盘时长、波动收敛 → Potential Trading Range
|
||||||
|
- Phase C candidate:测低 / 下影 / 缩量
|
||||||
|
- Event candidates:Spring / SOS / LPS / UTAD only
|
||||||
|
|
||||||
|
## Confidence
|
||||||
|
|
||||||
|
可解释分层:`cycle` / `phase` / `event` / `overall`(structure+volume+event 加权),禁止黑盒 “AI probability”。
|
||||||
|
|
||||||
|
## Execution
|
||||||
|
|
||||||
|
```
|
||||||
|
assert execution_signal.source == "confirmed"
|
||||||
|
```
|
||||||
|
|
||||||
|
## No Change
|
||||||
|
|
||||||
|
- Confirmed 检测阈值、MULTI-CYCLE-001 排序、缠论 / strategies / chan_tv
|
||||||
|
|
||||||
|
## Only Change
|
||||||
|
|
||||||
|
- `chanlun/analysis/wyckoff/live.py`
|
||||||
|
- engine 组装 `lifecycle` / `confirmed` / `live`
|
||||||
|
- Summary 面板分区
|
||||||
|
- 测例
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
# WYCKOFF-LIVE-VALIDATION-001
|
||||||
|
|
||||||
|
**Status:** DRAFT(待确认执行后 FROZEN)
|
||||||
|
**Depends on:** WYCKOFF-LIVE-STRUCTURE-001(已 FROZEN)
|
||||||
|
**Goal:** 验证 Live 是否有预测价值,而非继续加事件规则
|
||||||
|
|
||||||
|
## 不做
|
||||||
|
|
||||||
|
- 不新增 BC / AR / ST / UT / UTAD(v1 已够)
|
||||||
|
- 不降低 Confirmed 门槛
|
||||||
|
- 不让 Execution 消费 Live
|
||||||
|
|
||||||
|
## 目标指标(先看演化,不看「准确率」口号)
|
||||||
|
|
||||||
|
### 1) Candidate → Confirmed 转化率
|
||||||
|
|
||||||
|
```
|
||||||
|
candidate_to_confirmed_rate = confirmed_count / candidate_count
|
||||||
|
```
|
||||||
|
|
||||||
|
按 event type 分组:Spring / SOS / LPS / UTAD。
|
||||||
|
|
||||||
|
### 2) 提前量(Lead)
|
||||||
|
|
||||||
|
```
|
||||||
|
lead_bars = confirmed_bar_index - first_candidate_bar_index
|
||||||
|
lead_price = |price_at_confirmed - price_at_first_candidate|
|
||||||
|
```
|
||||||
|
|
||||||
|
例:Spring candidate @ 62000 → Confirmed @ 63500 → lead_price=1500。
|
||||||
|
|
||||||
|
### 3) False Positive
|
||||||
|
|
||||||
|
```
|
||||||
|
false_candidate_rate = expired_unconfirmed / candidate_count
|
||||||
|
```
|
||||||
|
|
||||||
|
候选出现后,在窗口内未升格为 Confirmed,且价格无效化(如 Spring 后继续破位)。
|
||||||
|
|
||||||
|
## 采集方式(建议)
|
||||||
|
|
||||||
|
离线回放 / 批跑(非改 Live 规则):
|
||||||
|
|
||||||
|
```
|
||||||
|
for each bar in timerange:
|
||||||
|
run analyze_wyckoff(df[:bar])
|
||||||
|
log: cycle_id, lifecycle, live.candidates[], confirmed.events[]
|
||||||
|
```
|
||||||
|
|
||||||
|
输出:`reports/wyckoff_live_validation_{symbol}_{tf}_{date}.json` + 简表 CSV。
|
||||||
|
|
||||||
|
## Summary 文案(可选后续,本 ECR 可只做数据)
|
||||||
|
|
||||||
|
交易终端语言示例(不阻塞指标采集):
|
||||||
|
|
||||||
|
```
|
||||||
|
BTC 4H Wyckoff
|
||||||
|
Lifecycle: CONFIRMED
|
||||||
|
Confirmed: Accumulation → SOS → LPS
|
||||||
|
Current: Phase D continuation
|
||||||
|
Watching: New SOS extension
|
||||||
|
Confidence: 0.60
|
||||||
|
Risk: Below LPS invalidation
|
||||||
|
```
|
||||||
|
|
||||||
|
## 验收
|
||||||
|
|
||||||
|
1. 能对 BTC 4h(及可选 1h)跑出至少一类 Spring/SOS 的转化率与提前量
|
||||||
|
2. 报告可复现(固定 timerange + seed/数据快照说明)
|
||||||
|
3. 不修改 Confirmed / Live 检测逻辑(只读 + 日志)
|
||||||
|
|
||||||
|
## Only Change(确认执行后)
|
||||||
|
|
||||||
|
- `scripts/` 或 `tests/` 下批跑采集脚本
|
||||||
|
- `docs/notes` 或 `reports/` 输出样例
|
||||||
|
- 可选:Summary 文案升级(独立小项)
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
# WYCKOFF-MULTI-CYCLE-001
|
||||||
|
|
||||||
|
**Status:** FROZEN
|
||||||
|
**Scope:** Wyckoff Cycle Detection Layer
|
||||||
|
|
||||||
|
## No Change
|
||||||
|
|
||||||
|
- `chan.py` / 笔 / 段 / 中枢
|
||||||
|
- `strategies/`
|
||||||
|
- `/chan_tv`
|
||||||
|
|
||||||
|
## Only Change
|
||||||
|
|
||||||
|
- wyckoff range detection
|
||||||
|
- wyckoff engine payload
|
||||||
|
- API localization
|
||||||
|
- chart rendering
|
||||||
|
- tests
|
||||||
|
|
||||||
|
## Frozen Rules
|
||||||
|
|
||||||
|
1. 每个 TF 最大 8 个周期
|
||||||
|
2. `cycles[0]` 永远为 ACTIVE;`cycles[1:]` 为 HISTORICAL
|
||||||
|
3. **禁止**用 `cycles[-1]` 判断 active;唯一来源:`active_cycle = cycles[0]`
|
||||||
|
4. 周期不可重叠;按时间倒序(近 → 远)
|
||||||
|
5. 顶层字段只镜像 `cycles[0]`
|
||||||
|
6. 历史 cycle 只用于展示/分析,不参与当前交易决策
|
||||||
|
7. 多 TF 只同步 active cycle(`prefer_start_time` ← 主 TF `cycles[0]`)
|
||||||
|
8. 每个 cycle 必须可追溯:`period` / `status` / `role` / `confidence`
|
||||||
|
9. 嵌套箱:`overlap_ratio < 0.2` 才可并存;否则丢弃
|
||||||
|
10. 验收重点:历史周期稳定复现 + active 不漂移
|
||||||
|
|
||||||
|
## Layer Duties
|
||||||
|
|
||||||
|
```
|
||||||
|
range.py
|
||||||
|
_detect_in_window() → TradingRange # 仅起止、高低、结构分
|
||||||
|
detect_trading_ranges() → list[TR] # 倒序扫 + 过滤 + mask
|
||||||
|
|
||||||
|
engine.py
|
||||||
|
phases / events / VP / confidence aggregation → cycles[]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Filter Order(不可改)
|
||||||
|
|
||||||
|
```
|
||||||
|
candidate window
|
||||||
|
→ detect range
|
||||||
|
→ quality filter
|
||||||
|
→ trend contamination filter
|
||||||
|
→ overlap filter (<0.2)
|
||||||
|
→ accept cycle
|
||||||
|
→ mask
|
||||||
|
```
|
||||||
|
|
||||||
|
禁止先 mask 再判断质量。
|
||||||
|
|
||||||
|
## Display / Summary (2026-08-06)
|
||||||
|
|
||||||
|
- 图面阶段标记:`{TF} C{id} Phase {X}`;事件:`{TF} C{id} {Event}`
|
||||||
|
- Cycle Summary 面板:消费 `cycles[0]`,写入 `window.wyckoffCycleSummary`
|
||||||
|
- 检测算法本轮不改;质量阈值 / 历史层折叠为后续项
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"ecr": "ECR-007",
|
||||||
|
"result": "PASS",
|
||||||
|
"ess_version": "v1.0",
|
||||||
|
"gate_version": "0.1.2",
|
||||||
|
"project_profile": "unknown",
|
||||||
|
"checks": {
|
||||||
|
"artifact": true,
|
||||||
|
"role_boundary": true,
|
||||||
|
"backend_boundary": true,
|
||||||
|
"traceability": true,
|
||||||
|
"tests": true
|
||||||
|
},
|
||||||
|
"violations": [],
|
||||||
|
"errors": [],
|
||||||
|
"warnings": [],
|
||||||
|
"timestamp": "2026-08-06T19:14:19Z"
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# LOOP-RUN-005 — ECR-007 archive
|
||||||
|
|
||||||
|
**Feature:** WYCKOFF-LIVE-STRUCTURE
|
||||||
|
**ECR:** ECR-007 · **BD:** BD-2026-007
|
||||||
|
**Decision:** FINAL_APPROVAL · gate PASS
|
||||||
|
**Implementation:** `276481e`
|
||||||
|
|
||||||
|
## Contents
|
||||||
|
|
||||||
|
| Path | Note |
|
||||||
|
|------|------|
|
||||||
|
| `task.yaml` / `result.yaml` / `human_interventions.yaml` | Loop runner state |
|
||||||
|
| `ECR-007-gate-report.json` | ess-gate-check PASS |
|
||||||
|
| `artifacts/` | plan · gate · code_review · test_report |
|
||||||
|
|
||||||
|
Code diff 以 git commit `276481e` 为准(未归档 192KB `diff.patch`)。
|
||||||
|
|
||||||
|
Working dirs `.gates/` / `loop/` 已忽略,勿再提交。
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"stage": "code_reviewer",
|
||||||
|
"decision": "PASS",
|
||||||
|
"checks": {
|
||||||
|
"state_machine_boundary": "PASS",
|
||||||
|
"confidence_explainability": "PASS",
|
||||||
|
"backward_compatibility": "PASS",
|
||||||
|
"live_ne_execution": "PASS",
|
||||||
|
"confirmed_thresholds": "PASS"
|
||||||
|
},
|
||||||
|
"artifact": "docs/HANDOFF/ECR-007-code-review.md"
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"ecr": "ECR-007",
|
||||||
|
"result": "PASS",
|
||||||
|
"ess_version": "v1.0",
|
||||||
|
"gate_version": "0.1.2",
|
||||||
|
"project_profile": "unknown",
|
||||||
|
"checks": {
|
||||||
|
"artifact": true,
|
||||||
|
"role_boundary": true,
|
||||||
|
"backend_boundary": true,
|
||||||
|
"traceability": true,
|
||||||
|
"tests": true
|
||||||
|
},
|
||||||
|
"violations": [],
|
||||||
|
"errors": [],
|
||||||
|
"warnings": [],
|
||||||
|
"timestamp": "2026-08-06T19:14:19Z"
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
artifact_schema:
|
||||||
|
version: 1
|
||||||
|
|
||||||
|
# LOOP-RUN-005 Planner — domain-state complexity (observe Confirmed vs Live)
|
||||||
|
|
||||||
|
layers:
|
||||||
|
- id: confirmed_engine
|
||||||
|
role: historical structure facts (range/phases/events) — thresholds UNCHANGED
|
||||||
|
- id: live_engine
|
||||||
|
role: FORMING candidates + confidence — independent of Confirmed writes
|
||||||
|
- id: lifecycle
|
||||||
|
role: UNKNOWN → FORMING → CONFIRMED → COMPLETED per cycle
|
||||||
|
- id: api_contract
|
||||||
|
role: analyze payload cycles[].confirmed / cycles[].live / top-level live mirror
|
||||||
|
- id: summary_ui
|
||||||
|
role: Confirmed vs Live partitioned Summary (observation only)
|
||||||
|
|
||||||
|
delivery_constraints:
|
||||||
|
required:
|
||||||
|
- commit_exists_in_traceability_or_test_report
|
||||||
|
- bd_status_format_approved
|
||||||
|
- test_report_with_commands_result_date
|
||||||
|
- code_review_handoff
|
||||||
|
- out_of_scope_declared
|
||||||
|
- execution_source_confirmed_only
|
||||||
|
gate:
|
||||||
|
ecr: ECR-007
|
||||||
|
command: ess-gate-check --ecr ECR-007
|
||||||
|
|
||||||
|
out_of_scope:
|
||||||
|
- execution signal automation / auto trading
|
||||||
|
- strategy / maker / decide_quotes / strategies/**
|
||||||
|
- lowering Confirmed Spring/SOS thresholds
|
||||||
|
- using Live candidate as Confirmed event or execution input
|
||||||
|
- Subagents / Adapter v0.2 / auto-retry
|
||||||
|
- chan algorithm (笔/线段/中枢) changes
|
||||||
|
|
||||||
|
scope:
|
||||||
|
files:
|
||||||
|
- chanlun/analysis/wyckoff/live.py
|
||||||
|
- chanlun/analysis/wyckoff/engine.py
|
||||||
|
- chanlun/analysis/wyckoff/__init__.py
|
||||||
|
- chanlun/analysis/wyckoff/events.py
|
||||||
|
- chanlun/analysis/wyckoff/range.py
|
||||||
|
- tests/test_wyckoff.py
|
||||||
|
- web/api/analyze.py
|
||||||
|
- web/static/js/app/ui.js
|
||||||
|
- web/templates/index.html
|
||||||
|
- web/tests/test_analyze_contract.py
|
||||||
|
- tests/fixtures/analyze_contract_keys.json
|
||||||
|
- docs/notes/WYCKOFF-LIVE-STRUCTURE-001.md
|
||||||
|
- docs/ECR/ECR-007-wyckoff-live-structure.md
|
||||||
|
- docs/BACKEND_DESIGN/BD-2026-007-wyckoff-live-structure.md
|
||||||
|
- docs/ENGINEERING_SPEC/ECR-007-wyckoff-live-structure.md
|
||||||
|
- docs/HANDOFF/ECR-007-architect-to-engineer.md
|
||||||
|
- docs/HANDOFF/ECR-007-code-review.md
|
||||||
|
- docs/HANDOFF/ECR-007-engineer-to-reviewer.md
|
||||||
|
- docs/TEST_REPORT/ECR-007.md
|
||||||
|
- docs/STATE/ECR-007.md
|
||||||
|
- docs/TRACEABILITY.md
|
||||||
|
- docs/CHANGELOG/CHANGELOG.md
|
||||||
|
|
||||||
|
boundary:
|
||||||
|
forbidden:
|
||||||
|
- strategies/
|
||||||
|
- decide_quotes / maker
|
||||||
|
- Live → execution_signal
|
||||||
|
- ESS / Loop v1.1 / Adapter v0.1
|
||||||
|
|
||||||
|
acceptance:
|
||||||
|
- lifecycle + confirmed/live separation in analyze_wyckoff output
|
||||||
|
- event_candidates confirmed=false; not in top-level events unless Confirmed
|
||||||
|
- execution_signal_from_wyckoff source==confirmed; live-only → None
|
||||||
|
- Summary shows Confirmed vs Live partition
|
||||||
|
- pytest test_wyckoff + analyze_contract green
|
||||||
|
- ess-gate-check ECR-007
|
||||||
|
|
||||||
|
risks: |
|
||||||
|
Primary Guardian risk: Live candidate mistaken for execution signal.
|
||||||
|
Code Review: state machine boundary, confidence explainability, backward compat of phases/events.
|
||||||
|
|
||||||
|
notes: |
|
||||||
|
Planner must name Confirmed / Live / Lifecycle / Event Candidate explicitly.
|
||||||
|
delivery_constraints include execution_source_confirmed_only.
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"stage": "validator",
|
||||||
|
"result": "PASS",
|
||||||
|
"commands": [
|
||||||
|
"PYTHONPATH=. python -m pytest tests/test_wyckoff.py -q",
|
||||||
|
"PYTHONPATH=. python -m pytest web/tests/test_analyze_contract.py -q"
|
||||||
|
],
|
||||||
|
"summary": "17 passed (9 wyckoff + 8 contract)",
|
||||||
|
"notes": "Live isolation + execution_signal confirmed-only"
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
interventions:
|
||||||
|
- stage: START_AUTHORIZATION
|
||||||
|
reason: "authorize LOOP-RUN-005 ECR-007 Wyckoff Live Structure (supervised; Adapter v0.1 STABLE)"
|
||||||
|
note: "Human Gate #1 — Goal + Authorization merged"
|
||||||
|
- stage: FINAL_APPROVAL
|
||||||
|
reason: "LOOP-RUN-005 approved — proceed to --approve and archive"
|
||||||
|
note: "Human Gate #2"
|
||||||
|
notes: |
|
||||||
|
No Plan Mode; no mid-build confirm; no Subagents / Adapter v0.2 / auto-retry.
|
||||||
|
Live ≠ execution signal held; TR-COMMIT BLOCK→PASS retained as training signal.
|
||||||
|
Final Approval distinct from Start Authorization.
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
loop:
|
||||||
|
id: LOOP-RUN-005
|
||||||
|
feature: ECR-007-WYCKOFF-LIVE-STRUCTURE
|
||||||
|
ecr: ECR-007
|
||||||
|
current_state: DONE
|
||||||
|
retry_count: 0
|
||||||
|
history:
|
||||||
|
- state: CREATED
|
||||||
|
timestamp: '2026-08-06T19:12:00Z'
|
||||||
|
actor: runner
|
||||||
|
result: INIT
|
||||||
|
- state: CREATED
|
||||||
|
timestamp: '2026-08-06T19:13:48Z'
|
||||||
|
actor: runner
|
||||||
|
result: PASS
|
||||||
|
detail: →PLANNING
|
||||||
|
- state: PLANNING
|
||||||
|
timestamp: '2026-08-06T19:13:48Z'
|
||||||
|
actor: runner
|
||||||
|
result: PASS
|
||||||
|
detail: →BUILDING
|
||||||
|
- state: BUILDING
|
||||||
|
timestamp: '2026-08-06T19:14:34Z'
|
||||||
|
actor: runner
|
||||||
|
result: PASS
|
||||||
|
detail: →VALIDATING
|
||||||
|
- state: VALIDATING
|
||||||
|
timestamp: '2026-08-06T19:14:34Z'
|
||||||
|
actor: runner
|
||||||
|
result: PASS
|
||||||
|
detail: →CODE_REVIEW
|
||||||
|
- state: CODE_REVIEW
|
||||||
|
timestamp: '2026-08-06T19:14:34Z'
|
||||||
|
actor: runner
|
||||||
|
result: PASS
|
||||||
|
detail: →GUARDING
|
||||||
|
- state: GUARDING
|
||||||
|
timestamp: '2026-08-06T19:14:34Z'
|
||||||
|
actor: runner
|
||||||
|
result: PASS
|
||||||
|
detail: →READY_FOR_APPROVAL
|
||||||
|
- state: READY_FOR_APPROVAL
|
||||||
|
timestamp: '2026-08-06T19:19:41Z'
|
||||||
|
actor: runner
|
||||||
|
result: APPROVED
|
||||||
|
- state: DONE
|
||||||
|
timestamp: '2026-08-06T19:19:41Z'
|
||||||
|
actor: runner
|
||||||
|
result: DONE
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
# LOOP-RUN-005 — ECR-007 Wyckoff Live Structure
|
||||||
|
# Adapter v0.1 STABLE · single agent · supervised
|
||||||
|
# Human Gate #1: Start Authorization granted
|
||||||
|
|
||||||
|
id: LOOP-RUN-005
|
||||||
|
feature: ECR-007-WYCKOFF-LIVE-STRUCTURE
|
||||||
|
ecr: ECR-007
|
||||||
|
project_profile: "2026.08"
|
||||||
|
|
||||||
|
goal: |
|
||||||
|
验证 Engineering Loop v1.1 + Adapter v0.1 在高领域状态复杂度 Feature 下的执行稳定性。
|
||||||
|
实现 Wyckoff Confirmed + Live Structure 分层,观察层与执行层严格隔离。
|
||||||
|
|
||||||
|
authorization:
|
||||||
|
approved_by: human
|
||||||
|
feature: ECR-007
|
||||||
|
run: LOOP-RUN-005
|
||||||
|
constraints:
|
||||||
|
- no_ess_change
|
||||||
|
- no_loop_v1_1_change
|
||||||
|
- no_adapter_v0_1_change
|
||||||
|
- single_agent
|
||||||
|
- supervised
|
||||||
|
- no_subagents
|
||||||
|
- no_auto_retry
|
||||||
|
- no_live_as_execution_signal
|
||||||
|
- no_confirmed_threshold_lowering
|
||||||
|
|
||||||
|
constraints:
|
||||||
|
allowed:
|
||||||
|
- "chanlun/analysis/wyckoff/**"
|
||||||
|
- "tests/test_wyckoff.py"
|
||||||
|
- "tests/fixtures/**"
|
||||||
|
- "tests/generate_golden.py"
|
||||||
|
- "tests/test_golden_pipeline.py"
|
||||||
|
- "web/api/analyze.py"
|
||||||
|
- "web/api/pages.py"
|
||||||
|
- "web/static/js/app/**"
|
||||||
|
- "web/templates/index.html"
|
||||||
|
- "web/tests/**"
|
||||||
|
- "web/services/runtime/timeframes.py"
|
||||||
|
- "docs/**"
|
||||||
|
- "loop/**"
|
||||||
|
forbidden:
|
||||||
|
- "strategies/**"
|
||||||
|
- "**/decide_quotes*"
|
||||||
|
- "maker/**"
|
||||||
|
- "skills/engineering-spec-system/**"
|
||||||
|
- "docs/architecture/ENGINEERING-LOOP-V1.1.md"
|
||||||
|
notes:
|
||||||
|
- Confirmed detection thresholds UNCHANGED
|
||||||
|
- Live candidates must never replace Confirmed events
|
||||||
|
- execution_signal_from_wyckoff source must be confirmed only
|
||||||
|
|
||||||
|
acceptance:
|
||||||
|
criteria:
|
||||||
|
- Confirmed logic unchanged (events.py confirm rules not relaxed)
|
||||||
|
- execution only consumes confirmed
|
||||||
|
- Live ≠ execution signal
|
||||||
|
- lifecycle transitions verifiable (UNKNOWN/FORMING/CONFIRMED/COMPLETED)
|
||||||
|
- API contract + Summary display Confirmed/Live separation
|
||||||
|
- Artifact chain complete
|
||||||
|
commands:
|
||||||
|
- "PYTHONPATH=. python -m pytest tests/test_wyckoff.py -q"
|
||||||
|
- "PYTHONPATH=. python -m pytest web/tests/test_analyze_contract.py -q"
|
||||||
|
|
||||||
|
execution:
|
||||||
|
autonomy: supervised
|
||||||
|
adapter: none
|
||||||
|
|
||||||
|
ess:
|
||||||
|
gate_command: "python ${ESS_ROOT}/scripts/ess-gate-check.py --project . --ecr ECR-007"
|
||||||
|
|
||||||
|
observe:
|
||||||
|
planner_domain: Confirmed + Live + Lifecycle + Event Candidate
|
||||||
|
guardian_risk: live_candidate_must_not_become_execution_signal
|
||||||
|
human_gates: start_authorization + final_approval
|
||||||
+20
-2
@@ -1,4 +1,5 @@
|
|||||||
[
|
{
|
||||||
|
"required": [
|
||||||
"bi_list",
|
"bi_list",
|
||||||
"bi_zs_list",
|
"bi_zs_list",
|
||||||
"bsp_list",
|
"bsp_list",
|
||||||
@@ -13,5 +14,22 @@
|
|||||||
"uncompleted_bi_list",
|
"uncompleted_bi_list",
|
||||||
"uncompleted_seg_list",
|
"uncompleted_seg_list",
|
||||||
"uncompleted_zs_list",
|
"uncompleted_zs_list",
|
||||||
|
"wyckoff",
|
||||||
"zs_list"
|
"zs_list"
|
||||||
]
|
],
|
||||||
|
"optional_when": {
|
||||||
|
"include_structure_zones": ["structure_zones"]
|
||||||
|
},
|
||||||
|
"wyckoff_keys": [
|
||||||
|
"trading_range",
|
||||||
|
"bias",
|
||||||
|
"phases",
|
||||||
|
"events",
|
||||||
|
"volume_profile",
|
||||||
|
"volume_confirm",
|
||||||
|
"cycles",
|
||||||
|
"live",
|
||||||
|
"lifecycle"
|
||||||
|
],
|
||||||
|
"notes": "wyckoff 默认返回;cycles[0]=ACTIVE;phases/events=Confirmed;live=Developing(WYCKOFF-LIVE-STRUCTURE-001);Execution 仅 Confirmed;见 docs/notes/"
|
||||||
|
}
|
||||||
|
|||||||
@@ -128,9 +128,10 @@ def serialize_pipeline(tf) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def analyze_contract_keys() -> list:
|
def analyze_contract_keys() -> dict:
|
||||||
"""文档化 /api/analyze 主周期关键字段(契约冒烟用)。"""
|
"""文档化 /api/analyze 主周期关键字段(契约冒烟用)。"""
|
||||||
return sorted(
|
return {
|
||||||
|
"required": sorted(
|
||||||
[
|
[
|
||||||
"timezone",
|
"timezone",
|
||||||
"kline_data",
|
"kline_data",
|
||||||
@@ -147,8 +148,22 @@ def analyze_contract_keys() -> list:
|
|||||||
"macd",
|
"macd",
|
||||||
"chan_macd",
|
"chan_macd",
|
||||||
"klc_trend",
|
"klc_trend",
|
||||||
|
"wyckoff",
|
||||||
]
|
]
|
||||||
)
|
),
|
||||||
|
"optional_when": {
|
||||||
|
"include_structure_zones": ["structure_zones"],
|
||||||
|
},
|
||||||
|
"wyckoff_keys": [
|
||||||
|
"trading_range",
|
||||||
|
"bias",
|
||||||
|
"phases",
|
||||||
|
"events",
|
||||||
|
"volume_profile",
|
||||||
|
"volume_confirm",
|
||||||
|
],
|
||||||
|
"notes": "wyckoff 随主周期 analyze 默认返回;有次/次次周期时另附 element_wyckoff / sub_sub_wyckoff;include_wyckoff=0 可跳过;elements_only 时不返回",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def run_pipeline(df: pd.DataFrame):
|
def run_pipeline(df: pd.DataFrame):
|
||||||
|
|||||||
@@ -37,10 +37,15 @@ def test_compat_shim_still_works():
|
|||||||
|
|
||||||
|
|
||||||
def test_analyze_contract_keys_file():
|
def test_analyze_contract_keys_file():
|
||||||
keys = json.loads(
|
doc = json.loads(
|
||||||
(ROOT / "tests" / "fixtures" / "analyze_contract_keys.json").read_text(
|
(ROOT / "tests" / "fixtures" / "analyze_contract_keys.json").read_text(
|
||||||
encoding="utf-8"
|
encoding="utf-8"
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
for k in ("kline_data", "bi_list", "seg_list", "zs_list", "bsp_list"):
|
keys = doc["required"] if isinstance(doc, dict) and "required" in doc else doc
|
||||||
|
for k in ("kline_data", "bi_list", "seg_list", "zs_list", "bsp_list", "wyckoff"):
|
||||||
assert k in keys
|
assert k in keys
|
||||||
|
if isinstance(doc, dict):
|
||||||
|
assert "include_wyckoff" not in doc.get("optional_when", {})
|
||||||
|
for k in ("trading_range", "phases", "events", "volume_profile"):
|
||||||
|
assert k in doc.get("wyckoff_keys", [])
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
"""ECR-002:TF_DF 全量 __init__ 冒烟(CODE_REVIEW ECR-001 Finding 5)。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
sys.path.insert(0, str(ROOT))
|
||||||
|
|
||||||
|
from tests.generate_golden import make_ohlcv # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def test_tf_df_full_init_smoke():
|
||||||
|
from chanlun import TF_DF
|
||||||
|
|
||||||
|
df = make_ohlcv(400)
|
||||||
|
# interval=1:不重采样,走完整 init_TF_DF 流水线
|
||||||
|
tf = TF_DF(df, interval=1, timeframe="5m")
|
||||||
|
assert tf is not None
|
||||||
|
assert len(getattr(tf, "klu_list", []) or []) > 0
|
||||||
|
assert hasattr(tf, "bi_list")
|
||||||
|
assert hasattr(tf, "seg_list")
|
||||||
|
assert getattr(tf, "chanmacd", None) is not None
|
||||||
@@ -0,0 +1,386 @@
|
|||||||
|
"""威科夫引擎单测:合成震荡箱 + Spring/SOS + VP POC(ECR-004 收紧)。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
sys.path.insert(0, str(ROOT))
|
||||||
|
|
||||||
|
from chanlun.analysis.wyckoff import analyze_wyckoff # noqa: E402
|
||||||
|
from chanlun.analysis.wyckoff.range import ( # noqa: E402
|
||||||
|
detect_trading_range,
|
||||||
|
detect_trading_ranges,
|
||||||
|
_overlap_ratio,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _box_df(n_box: int = 60, spring: bool = True, sos: bool = True) -> pd.DataFrame:
|
||||||
|
"""构造明显箱体:40~60,前 20 根下跌趋势,可选假破与上破。"""
|
||||||
|
rng = np.random.default_rng(7)
|
||||||
|
rows = []
|
||||||
|
t0 = pd.Timestamp("2024-06-01", tz="UTC")
|
||||||
|
price = 50.0
|
||||||
|
# 进入箱体前下跌
|
||||||
|
for i in range(20):
|
||||||
|
price -= 0.3 + rng.random() * 0.1
|
||||||
|
o, c = price + 0.2, price
|
||||||
|
h, l = max(o, c) + 0.15, min(o, c) - 0.15
|
||||||
|
rows.append((t0 + pd.Timedelta(minutes=5 * i), o, h, l, c, 100 + rng.random() * 20))
|
||||||
|
# 箱体 40-60
|
||||||
|
lo, hi = 40.0, 60.0
|
||||||
|
for i in range(n_box):
|
||||||
|
c = lo + (hi - lo) * (0.3 + 0.4 * rng.random())
|
||||||
|
o = c + rng.normal(0, 0.5)
|
||||||
|
h = min(hi + 0.5, max(o, c) + abs(rng.normal(0.5, 0.2)))
|
||||||
|
l = max(lo - 0.5, min(o, c) - abs(rng.normal(0.5, 0.2)))
|
||||||
|
if i % 7 == 0:
|
||||||
|
h = hi - 0.1
|
||||||
|
if i % 7 == 3:
|
||||||
|
l = lo + 0.1
|
||||||
|
rows.append(
|
||||||
|
(
|
||||||
|
t0 + pd.Timedelta(minutes=5 * (20 + i)),
|
||||||
|
o,
|
||||||
|
h,
|
||||||
|
l,
|
||||||
|
c,
|
||||||
|
80 + rng.random() * 40,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
base = 20 + n_box
|
||||||
|
if spring:
|
||||||
|
rows.append(
|
||||||
|
(
|
||||||
|
t0 + pd.Timedelta(minutes=5 * base),
|
||||||
|
42.0,
|
||||||
|
43.0,
|
||||||
|
37.0,
|
||||||
|
41.5,
|
||||||
|
90.0,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
base += 1
|
||||||
|
if sos:
|
||||||
|
rows.append(
|
||||||
|
(
|
||||||
|
t0 + pd.Timedelta(minutes=5 * base),
|
||||||
|
58.0,
|
||||||
|
66.0,
|
||||||
|
57.0,
|
||||||
|
64.0,
|
||||||
|
220.0,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
base += 1
|
||||||
|
rows.append(
|
||||||
|
(
|
||||||
|
t0 + pd.Timedelta(minutes=5 * base),
|
||||||
|
62.0,
|
||||||
|
63.0,
|
||||||
|
59.5,
|
||||||
|
61.0,
|
||||||
|
70.0,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return pd.DataFrame(rows, columns=["date", "open", "high", "low", "close", "volume"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_wyckoff_detects_range_and_events():
|
||||||
|
"""Test C:旧接口兼容 — 顶层字段仍在,且 cycles[0] 为 ACTIVE 镜像。"""
|
||||||
|
df = _box_df()
|
||||||
|
out = analyze_wyckoff(df, lookback=200)
|
||||||
|
assert out["trading_range"] is not None
|
||||||
|
tr = out["trading_range"]
|
||||||
|
assert 38.0 <= tr["low"] <= 42.0
|
||||||
|
assert 58.0 <= tr["high"] <= 62.0
|
||||||
|
# 起点不应落入前 20 根下跌段(允许少量 overlap)
|
||||||
|
box_start = df["date"].iloc[20]
|
||||||
|
assert tr["start_time"] is not None
|
||||||
|
start_ts = pd.Timestamp(tr["start_time"])
|
||||||
|
assert start_ts >= box_start - pd.Timedelta(minutes=5 * 8)
|
||||||
|
types = {e["type"] for e in out["events"]}
|
||||||
|
assert "Spring" in types
|
||||||
|
assert "SOS" in types
|
||||||
|
assert out["bias"] in ("accumulation", "distribution", "unknown")
|
||||||
|
assert len(out["phases"]) >= 3
|
||||||
|
keys = [(p["start_time"], p["end_time"]) for p in out["phases"]]
|
||||||
|
assert len(keys) == len(set(keys)), "phases must not share identical start/end"
|
||||||
|
# cycles 契约
|
||||||
|
assert len(out.get("cycles") or []) >= 1
|
||||||
|
c0 = out["cycles"][0]
|
||||||
|
assert c0["status"] == "ACTIVE"
|
||||||
|
assert c0["id"] == 0
|
||||||
|
assert c0["trading_range"]["start_time"] == out["trading_range"]["start_time"]
|
||||||
|
assert c0["trading_range"]["high"] == out["trading_range"]["high"]
|
||||||
|
assert "confidence" in c0 and "overall" in c0["confidence"]
|
||||||
|
assert "period" in c0 and c0["period"]["bars"] > 0
|
||||||
|
|
||||||
|
def test_phase_c_when_spring_eaten_by_box_low():
|
||||||
|
"""箱沿吃掉 Spring 最低点时,仍应靠结构次低检出 Spring,并有阶段 C。"""
|
||||||
|
rng = np.random.default_rng(1)
|
||||||
|
t0 = pd.Timestamp("2024-06-01", tz="UTC")
|
||||||
|
rows = []
|
||||||
|
box_lo, box_hi = 40.0, 60.0
|
||||||
|
for i in range(60):
|
||||||
|
c = box_lo + (box_hi - box_lo) * (0.3 + 0.4 * rng.random())
|
||||||
|
o = c
|
||||||
|
h = min(box_hi, max(o, c) + 1)
|
||||||
|
l = max(box_lo, min(o, c) - 1)
|
||||||
|
if i % 7 == 0:
|
||||||
|
h = box_hi - 0.2
|
||||||
|
if i % 7 == 3:
|
||||||
|
l = box_lo + 0.2
|
||||||
|
rows.append((t0 + pd.Timedelta(hours=4 * i), o, h, l, c, 100.0))
|
||||||
|
# 箱内假破:最低点 38,收回到 43
|
||||||
|
rows[45] = (rows[45][0], 42.0, 45.0, 38.0, 43.0, 80.0)
|
||||||
|
for j in range(3):
|
||||||
|
rows.append((t0 + pd.Timedelta(hours=4 * (60 + j)), 61.0, 63.0, 60.5, 62.0, 150.0))
|
||||||
|
df = pd.DataFrame(rows, columns=["date", "open", "high", "low", "close", "volume"])
|
||||||
|
# 模拟 4h:TR.low 已吃进 Spring
|
||||||
|
tr = {
|
||||||
|
"abs_start_idx": 0,
|
||||||
|
"abs_end_idx": 59,
|
||||||
|
"abs_scan_end_idx": len(df) - 1,
|
||||||
|
"high": 60.0,
|
||||||
|
"low": 38.0,
|
||||||
|
"mid": 49.0,
|
||||||
|
"tol": 1.0,
|
||||||
|
}
|
||||||
|
from chanlun.analysis.wyckoff.events import detect_bias_and_events, build_phases
|
||||||
|
|
||||||
|
bias, ev, _ = detect_bias_and_events(df, tr)
|
||||||
|
ph = build_phases(df, tr, bias, ev)
|
||||||
|
assert "Spring" in {e["type"] for e in ev}
|
||||||
|
assert "C" in {p["phase"] for p in ph}
|
||||||
|
assert bias == "accumulation"
|
||||||
|
|
||||||
|
|
||||||
|
def test_range_scoring_skips_pretrend():
|
||||||
|
df = _box_df(spring=False, sos=False)
|
||||||
|
tr = detect_trading_range(df, lookback=200)
|
||||||
|
assert tr is not None
|
||||||
|
assert tr["abs_start_idx"] >= 12 # 不应从 bar 0 吞掉整段下跌
|
||||||
|
|
||||||
|
|
||||||
|
def test_range_anchored_rejects_full_trend():
|
||||||
|
"""整段趋势+末端箱:硬锚数据起点应因过宽回落,仍能搜出末端箱。"""
|
||||||
|
rng = np.random.default_rng(0)
|
||||||
|
t0 = pd.Timestamp("2024-06-01", tz="UTC")
|
||||||
|
rows = []
|
||||||
|
price = 100.0
|
||||||
|
for i in range(200):
|
||||||
|
price += 0.4 + rng.random() * 0.2
|
||||||
|
o, c = price - 0.1, price
|
||||||
|
h, l = max(o, c) + 0.3, min(o, c) - 0.3
|
||||||
|
rows.append((t0 + pd.Timedelta(hours=i), o, h, l, c, 100.0))
|
||||||
|
lo, hi = price - 5, price + 5
|
||||||
|
for i in range(80):
|
||||||
|
c = lo + (hi - lo) * (0.3 + 0.4 * rng.random())
|
||||||
|
o = c + rng.normal(0, 0.3)
|
||||||
|
h = min(hi + 0.5, max(o, c) + 0.4)
|
||||||
|
l = max(lo - 0.5, min(o, c) - 0.4)
|
||||||
|
if i % 8 == 0:
|
||||||
|
h = hi - 0.1
|
||||||
|
if i % 8 == 3:
|
||||||
|
l = lo + 0.1
|
||||||
|
rows.append((t0 + pd.Timedelta(hours=200 + i), o, h, l, c, 90.0))
|
||||||
|
df = pd.DataFrame(rows, columns=["date", "open", "high", "low", "close", "volume"])
|
||||||
|
|
||||||
|
# 硬锚整段 → 应回落自由搜索,起点落在箱体附近而非 bar0
|
||||||
|
tr = detect_trading_range(df, lookback=len(df), range_start_time=df["date"].iloc[0])
|
||||||
|
assert tr is not None
|
||||||
|
assert tr["abs_start_idx"] >= 150
|
||||||
|
assert tr["bars"] < 120
|
||||||
|
assert (tr["high"] - tr["low"]) / tr["atr"] < 15
|
||||||
|
|
||||||
|
# Web 路径:整段 lookback、不锚起点
|
||||||
|
out = analyze_wyckoff(df, lookback=len(df), min_bars=max(24, len(df) // 12))
|
||||||
|
assert out["trading_range"] is not None
|
||||||
|
assert out["trading_range"]["bars"] < 120
|
||||||
|
assert out["trading_range"]["bars"] >= 24
|
||||||
|
|
||||||
|
|
||||||
|
def test_volume_profile_poc_on_heavy_bin():
|
||||||
|
dates = pd.date_range("2024-01-01", periods=40, freq="5min", tz="UTC")
|
||||||
|
rows = []
|
||||||
|
for i, d in enumerate(dates):
|
||||||
|
c = 50.0 + (i % 5) * 0.1
|
||||||
|
vol = 1000.0 if 49.8 <= c <= 50.2 else 10.0
|
||||||
|
rows.append((d, c, c + 0.2, c - 0.2, c, vol))
|
||||||
|
df = pd.DataFrame(rows, columns=["date", "open", "high", "low", "close", "volume"])
|
||||||
|
out = analyze_wyckoff(df, lookback=80, vp_bins=20)
|
||||||
|
vp = out["volume_profile"]
|
||||||
|
assert vp["poc"] is not None
|
||||||
|
assert vp["vah"] is not None and vp["val"] is not None
|
||||||
|
assert abs(vp["poc"] - 50.0) < 1.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_live_does_not_pollute_confirmed_events():
|
||||||
|
"""Live 形成中:confirmed.events 不含 candidate;live 可有 Spring candidate。"""
|
||||||
|
from chanlun.analysis.wyckoff.live import analyze_live_structure
|
||||||
|
|
||||||
|
rng = np.random.default_rng(11)
|
||||||
|
t0 = pd.Timestamp("2024-05-01", tz="UTC")
|
||||||
|
rows = []
|
||||||
|
lo, hi = 40.0, 60.0
|
||||||
|
for i in range(40):
|
||||||
|
c = lo + (hi - lo) * (0.35 + 0.3 * rng.random())
|
||||||
|
o = c
|
||||||
|
h = min(hi, max(o, c) + 0.8)
|
||||||
|
l = max(lo, min(o, c) - 0.8)
|
||||||
|
rows.append((t0 + pd.Timedelta(hours=i), o, h, l, c, 100.0))
|
||||||
|
# 正在测下沿:长下影,尚未形成 Confirmed Spring 所需的刺破+收回序列写进 events 引擎
|
||||||
|
rows.append((t0 + pd.Timedelta(hours=40), 42.0, 44.0, 39.5, 42.5, 70.0))
|
||||||
|
df = pd.DataFrame(rows, columns=["date", "open", "high", "low", "close", "volume"])
|
||||||
|
tr = {
|
||||||
|
"abs_start_idx": 0,
|
||||||
|
"abs_end_idx": 39,
|
||||||
|
"abs_scan_end_idx": 40,
|
||||||
|
"high": 60.0,
|
||||||
|
"low": 40.0,
|
||||||
|
"mid": 50.0,
|
||||||
|
"tol": 1.0,
|
||||||
|
"atr": 1.5,
|
||||||
|
"bars": 40,
|
||||||
|
}
|
||||||
|
live = analyze_live_structure(df, tr, confirmed_events=[], confirmed_phases=[], bias="accumulation")
|
||||||
|
assert live["lifecycle"] in ("FORMING", "UNKNOWN", "CONFIRMED")
|
||||||
|
# 无 confirmed 输入时,candidates 可含 Spring,且 confirmed flag 全 false
|
||||||
|
for c in live.get("event_candidates") or []:
|
||||||
|
assert c.get("confirmed") is False
|
||||||
|
# 完整 analyze:顶层 events 不得因 live 凭空增加假 Spring(本合成无真 Spring)
|
||||||
|
out = analyze_wyckoff(df, lookback=len(df), min_bars=20)
|
||||||
|
assert "Spring" not in {e["type"] for e in (out.get("events") or [])} or out["lifecycle"] == "CONFIRMED"
|
||||||
|
# live 与 confirmed 分离
|
||||||
|
c0 = (out.get("cycles") or [{}])[0]
|
||||||
|
if c0.get("live") and c0["live"].get("event_candidates"):
|
||||||
|
for c in c0["live"]["event_candidates"]:
|
||||||
|
assert c.get("confirmed") is False
|
||||||
|
confirmed_types = {e["type"] for e in (c0.get("confirmed") or {}).get("events") or []}
|
||||||
|
for c in c0["live"]["event_candidates"]:
|
||||||
|
# candidate 不应出现在 confirmed(同 type 且仅 candidate)
|
||||||
|
if c["type"] not in confirmed_types:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def test_confirmed_upgrade_and_execution_isolation():
|
||||||
|
"""有 Spring+SOS 确认 → lifecycle CONFIRMED;execution.source==confirmed。"""
|
||||||
|
from chanlun.analysis.wyckoff import execution_signal_from_wyckoff
|
||||||
|
|
||||||
|
df = _box_df(spring=True, sos=True)
|
||||||
|
out = analyze_wyckoff(df, lookback=200)
|
||||||
|
assert len(out.get("cycles") or []) >= 1
|
||||||
|
c0 = out["cycles"][0]
|
||||||
|
assert c0["status"] == "ACTIVE"
|
||||||
|
types = {e["type"] for e in (c0.get("confirmed") or {}).get("events") or out.get("events") or []}
|
||||||
|
assert "Spring" in types and "SOS" in types
|
||||||
|
assert c0.get("lifecycle") == "CONFIRMED"
|
||||||
|
# live 不得把已确认事件再标为 candidate
|
||||||
|
for c in (c0.get("live") or {}).get("event_candidates") or []:
|
||||||
|
assert c["type"] not in types
|
||||||
|
sig = execution_signal_from_wyckoff(out)
|
||||||
|
assert sig is not None
|
||||||
|
assert sig["source"] == "confirmed"
|
||||||
|
# 仅 live、无 confirmed 时不得给 execution
|
||||||
|
empty_live_only = {
|
||||||
|
"cycles": [{
|
||||||
|
"id": 0,
|
||||||
|
"lifecycle": "FORMING",
|
||||||
|
"confirmed": {"events": [], "phases": []},
|
||||||
|
"live": {"event_candidates": [{"type": "Spring", "confirmed": False}]},
|
||||||
|
}],
|
||||||
|
"events": [],
|
||||||
|
}
|
||||||
|
assert execution_signal_from_wyckoff(empty_live_only) is None
|
||||||
|
|
||||||
|
|
||||||
|
def _make_box_segment(t0, n, lo, hi, freq_hours, rng, base_i=0):
|
||||||
|
rows = []
|
||||||
|
for i in range(n):
|
||||||
|
c = lo + (hi - lo) * (0.3 + 0.4 * rng.random())
|
||||||
|
o = c + rng.normal(0, 0.2)
|
||||||
|
h = min(hi + 0.3, max(o, c) + 0.4)
|
||||||
|
l = max(lo - 0.3, min(o, c) - 0.4)
|
||||||
|
if i % 8 == 0:
|
||||||
|
h = hi - 0.1
|
||||||
|
if i % 8 == 3:
|
||||||
|
l = lo + 0.1
|
||||||
|
rows.append((t0 + pd.Timedelta(hours=freq_hours * (base_i + i)), o, h, l, c, 90.0))
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def test_multi_cycle_two_boxes_with_trend():
|
||||||
|
"""Test A:双箱 + 中间趋势;cycles[0] 更新、不重叠、顶层镜像 cycles[0]。"""
|
||||||
|
rng = np.random.default_rng(3)
|
||||||
|
t0 = pd.Timestamp("2024-01-01", tz="UTC")
|
||||||
|
rows = []
|
||||||
|
# 早箱 100-110
|
||||||
|
rows += _make_box_segment(t0, 50, 100.0, 110.0, 1, rng, 0)
|
||||||
|
# 中间上涨趋势
|
||||||
|
price = 110.0
|
||||||
|
for i in range(40):
|
||||||
|
price += 0.8 + rng.random() * 0.3
|
||||||
|
o, c = price - 0.2, price
|
||||||
|
h, l = max(o, c) + 0.3, min(o, c) - 0.3
|
||||||
|
rows.append((t0 + pd.Timedelta(hours=50 + i), o, h, l, c, 100.0))
|
||||||
|
# 近端箱
|
||||||
|
lo2, hi2 = price - 4, price + 4
|
||||||
|
rows += _make_box_segment(t0, 50, lo2, hi2, 1, rng, 90)
|
||||||
|
df = pd.DataFrame(rows, columns=["date", "open", "high", "low", "close", "volume"])
|
||||||
|
|
||||||
|
out = analyze_wyckoff(df, lookback=len(df), min_bars=24, max_cycles=8)
|
||||||
|
cycles = out.get("cycles") or []
|
||||||
|
assert len(cycles) >= 2
|
||||||
|
assert cycles[0]["status"] == "ACTIVE"
|
||||||
|
assert cycles[1]["status"] == "HISTORICAL"
|
||||||
|
# 时间倒序:C0.end > C1.end
|
||||||
|
e0 = pd.Timestamp(cycles[0]["period"]["end_time"])
|
||||||
|
e1 = pd.Timestamp(cycles[1]["period"]["end_time"])
|
||||||
|
assert e0 > e1
|
||||||
|
# 不重叠
|
||||||
|
a0 = cycles[0]["trading_range"]
|
||||||
|
# 用引擎内部 abs 不在 payload;用 period 时间近似
|
||||||
|
s0 = pd.Timestamp(cycles[0]["period"]["start_time"])
|
||||||
|
s1 = pd.Timestamp(cycles[1]["period"]["start_time"])
|
||||||
|
# C1 应完全在 C0 之前
|
||||||
|
assert e1 <= s0 or (e1 - s0).total_seconds() <= 3600
|
||||||
|
# 顶层 == cycles[0]
|
||||||
|
assert out["trading_range"]["start_time"] == cycles[0]["trading_range"]["start_time"]
|
||||||
|
assert out["trading_range"]["high"] == cycles[0]["trading_range"]["high"]
|
||||||
|
assert out["trading_range"]["low"] == cycles[0]["trading_range"]["low"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_multi_cycle_nested_box_no_overlap():
|
||||||
|
"""Test B:大箱套小箱不得产出 overlap_ratio>=0.2 的两段。"""
|
||||||
|
rng = np.random.default_rng(5)
|
||||||
|
t0 = pd.Timestamp("2024-03-01", tz="UTC")
|
||||||
|
# 大箱 80 根
|
||||||
|
rows = _make_box_segment(t0, 80, 40.0, 60.0, 1, rng, 0)
|
||||||
|
df = pd.DataFrame(rows, columns=["date", "open", "high", "low", "close", "volume"])
|
||||||
|
trs = detect_trading_ranges(df, lookback=len(df), min_bars=20, max_cycles=8)
|
||||||
|
# 任意两段 overlap < 0.2
|
||||||
|
for i in range(len(trs)):
|
||||||
|
for j in range(i + 1, len(trs)):
|
||||||
|
r = _overlap_ratio(
|
||||||
|
int(trs[i]["abs_start_idx"]),
|
||||||
|
int(trs[i]["abs_end_idx"]),
|
||||||
|
int(trs[j]["abs_start_idx"]),
|
||||||
|
int(trs[j]["abs_end_idx"]),
|
||||||
|
)
|
||||||
|
assert r < 0.2, f"overlap {r} between {i} and {j}"
|
||||||
|
|
||||||
|
out = analyze_wyckoff(df, lookback=len(df), min_bars=20, max_cycles=8)
|
||||||
|
cycles = out.get("cycles") or []
|
||||||
|
assert len(cycles) >= 1
|
||||||
|
assert cycles[0]["status"] == "ACTIVE"
|
||||||
|
# 若有两段,时间窗也不应高度重叠
|
||||||
|
if len(cycles) >= 2:
|
||||||
|
# period 不重叠:历史 end <= active start(允许 1h 容差)
|
||||||
|
assert pd.Timestamp(cycles[1]["period"]["end_time"]) <= pd.Timestamp(
|
||||||
|
cycles[0]["period"]["start_time"]
|
||||||
|
) + pd.Timedelta(hours=2)
|
||||||
@@ -5,6 +5,97 @@ from services import runtime as R
|
|||||||
|
|
||||||
bp = Blueprint("analyze", __name__)
|
bp = Blueprint("analyze", __name__)
|
||||||
|
|
||||||
|
_WYCKOFF_EMPTY = {
|
||||||
|
'trading_range': None,
|
||||||
|
'bias': 'unknown',
|
||||||
|
'phases': [],
|
||||||
|
'events': [],
|
||||||
|
'volume_profile': {'bins': [], 'poc': None, 'vah': None, 'val': None, 'bin_count': 0},
|
||||||
|
'volume_confirm': {'avg_volume': 0.0, 'event_checks': {}},
|
||||||
|
'cycles': [],
|
||||||
|
'live': None,
|
||||||
|
'lifecycle': 'UNKNOWN',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _localize_wyckoff_payload(w, client_tz):
|
||||||
|
"""把威科夫时间统一成客户端时区 ISO,便于与主图对齐。"""
|
||||||
|
if not w:
|
||||||
|
return w
|
||||||
|
|
||||||
|
def _loc_tr(tr):
|
||||||
|
if not tr:
|
||||||
|
return
|
||||||
|
tr['start_time'] = format_time_safely(tr.get('start_time'), client_tz) or tr.get('start_time')
|
||||||
|
tr['end_time'] = format_time_safely(tr.get('end_time'), client_tz) or tr.get('end_time')
|
||||||
|
|
||||||
|
def _loc_cycle(c):
|
||||||
|
if not c:
|
||||||
|
return
|
||||||
|
per = c.get('period') or {}
|
||||||
|
per['start_time'] = format_time_safely(per.get('start_time'), client_tz) or per.get('start_time')
|
||||||
|
per['end_time'] = format_time_safely(per.get('end_time'), client_tz) or per.get('end_time')
|
||||||
|
c['period'] = per
|
||||||
|
_loc_tr(c.get('trading_range'))
|
||||||
|
for ph in c.get('phases') or []:
|
||||||
|
ph['start_time'] = format_time_safely(ph.get('start_time'), client_tz) or ph.get('start_time')
|
||||||
|
ph['end_time'] = format_time_safely(ph.get('end_time'), client_tz) or ph.get('end_time')
|
||||||
|
for ev in c.get('events') or []:
|
||||||
|
ev['time'] = format_time_safely(ev.get('time'), client_tz) or ev.get('time')
|
||||||
|
|
||||||
|
_loc_tr(w.get('trading_range'))
|
||||||
|
for ph in w.get('phases') or []:
|
||||||
|
ph['start_time'] = format_time_safely(ph.get('start_time'), client_tz) or ph.get('start_time')
|
||||||
|
ph['end_time'] = format_time_safely(ph.get('end_time'), client_tz) or ph.get('end_time')
|
||||||
|
for ev in w.get('events') or []:
|
||||||
|
ev['time'] = format_time_safely(ev.get('time'), client_tz) or ev.get('time')
|
||||||
|
for c in w.get('cycles') or []:
|
||||||
|
_loc_cycle(c)
|
||||||
|
return w
|
||||||
|
|
||||||
|
|
||||||
|
def _compute_wyckoff_from_df(df, tf, vp_bins, client_tz=None, range_start_time=None, prefer_start_time=None):
|
||||||
|
"""直接用该周期已有 DataFrame(与缠论同一份)。
|
||||||
|
搜索窗口 = 整段数据;箱体在窗内评分选取(近优分取更长),
|
||||||
|
次/次次可用 prefer_start_time 对齐主箱起点。
|
||||||
|
"""
|
||||||
|
from chanlun.analysis.wyckoff import analyze_wyckoff
|
||||||
|
|
||||||
|
try:
|
||||||
|
if df is None or len(df) < 30:
|
||||||
|
empty = dict(_WYCKOFF_EMPTY)
|
||||||
|
empty['volume_profile'] = dict(_WYCKOFF_EMPTY['volume_profile'])
|
||||||
|
empty['volume_confirm'] = dict(_WYCKOFF_EMPTY['volume_confirm'])
|
||||||
|
empty['timeframe'] = tf
|
||||||
|
return empty
|
||||||
|
lookback = len(df)
|
||||||
|
min_bars = max(24, min(80, lookback // 12))
|
||||||
|
out = analyze_wyckoff(
|
||||||
|
df,
|
||||||
|
lookback=lookback,
|
||||||
|
vp_bins=vp_bins,
|
||||||
|
min_bars=min_bars,
|
||||||
|
range_start_time=range_start_time,
|
||||||
|
prefer_start_time=prefer_start_time,
|
||||||
|
)
|
||||||
|
out['timeframe'] = tf
|
||||||
|
out['lookback'] = lookback
|
||||||
|
out['min_bars'] = min_bars
|
||||||
|
if client_tz is not None:
|
||||||
|
_localize_wyckoff_payload(out, client_tz)
|
||||||
|
return out
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Wyckoff 分析出错 ({tf}): {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
empty = dict(_WYCKOFF_EMPTY)
|
||||||
|
empty['volume_profile'] = dict(_WYCKOFF_EMPTY['volume_profile'])
|
||||||
|
empty['volume_confirm'] = dict(_WYCKOFF_EMPTY['volume_confirm'])
|
||||||
|
empty['timeframe'] = tf
|
||||||
|
empty['error'] = str(e)
|
||||||
|
return empty
|
||||||
|
|
||||||
|
|
||||||
@bp.route('/api/analyze')
|
@bp.route('/api/analyze')
|
||||||
def analyze():
|
def analyze():
|
||||||
"""分析接口"""
|
"""分析接口"""
|
||||||
@@ -25,6 +116,9 @@ def analyze():
|
|||||||
# 获取分形元素时间周期与次次周期
|
# 获取分形元素时间周期与次次周期
|
||||||
element_timeframe = request.args.get('element_timeframe')
|
element_timeframe = request.args.get('element_timeframe')
|
||||||
sub_sub_timeframe = request.args.get('sub_sub_timeframe')
|
sub_sub_timeframe = request.args.get('sub_sub_timeframe')
|
||||||
|
# 供文末三周期威科夫复用(避免重复拉数)
|
||||||
|
element_df_for_wyckoff = None
|
||||||
|
sub_sub_df_for_wyckoff = None
|
||||||
|
|
||||||
# 获取是否只需要分形元素数据的参数
|
# 获取是否只需要分形元素数据的参数
|
||||||
elements_only_param = request.args.get('elements_only')
|
elements_only_param = request.args.get('elements_only')
|
||||||
@@ -249,6 +343,7 @@ def analyze():
|
|||||||
if element_df is not None and len(element_df) > 0:
|
if element_df is not None and len(element_df) > 0:
|
||||||
# 添加小周期技术指标(包括布林带)
|
# 添加小周期技术指标(包括布林带)
|
||||||
element_df = add_indicators(element_df)
|
element_df = add_indicators(element_df)
|
||||||
|
element_df_for_wyckoff = element_df
|
||||||
|
|
||||||
# 对小周期数据进行缠论分析
|
# 对小周期数据进行缠论分析
|
||||||
element_analysis = analyze_chan(element_df, symbol, element_timeframe)
|
element_analysis = analyze_chan(element_df, symbol, element_timeframe)
|
||||||
@@ -427,6 +522,7 @@ def analyze():
|
|||||||
sub_sub_df = get_kl_data(symbol, sub_sub_timeframe, start_time=start_time, end_time=end_time)
|
sub_sub_df = get_kl_data(symbol, sub_sub_timeframe, start_time=start_time, end_time=end_time)
|
||||||
if sub_sub_df is not None and len(sub_sub_df) > 0:
|
if sub_sub_df is not None and len(sub_sub_df) > 0:
|
||||||
sub_sub_df = add_indicators(sub_sub_df)
|
sub_sub_df = add_indicators(sub_sub_df)
|
||||||
|
sub_sub_df_for_wyckoff = sub_sub_df
|
||||||
sub_sub_analysis = analyze_chan(sub_sub_df, symbol, sub_sub_timeframe)
|
sub_sub_analysis = analyze_chan(sub_sub_df, symbol, sub_sub_timeframe)
|
||||||
result['sub_sub_timeframe'] = sub_sub_timeframe
|
result['sub_sub_timeframe'] = sub_sub_timeframe
|
||||||
result['sub_sub_kline_data'] = clean_dataframe_for_json(sub_sub_df).to_dict('records')
|
result['sub_sub_kline_data'] = clean_dataframe_for_json(sub_sub_df).to_dict('records')
|
||||||
@@ -656,5 +752,36 @@ def analyze():
|
|||||||
else:
|
else:
|
||||||
result['structure_zones'] = []
|
result['structure_zones'] = []
|
||||||
|
|
||||||
|
# 威科夫:主 / 次 / 次次各算一份(非 elements_only);前端开关只控制绘制
|
||||||
|
# include_wyckoff=0 可显式跳过;缺省与其它真值均计算
|
||||||
|
include_wyckoff_param = request.args.get('include_wyckoff', '1')
|
||||||
|
include_wyckoff = str(include_wyckoff_param).lower() not in ('0', 'false', 'no')
|
||||||
|
if include_wyckoff and not elements_only:
|
||||||
|
# 主周期先算;次/次次只同步 active=cycles[0] 的 start(WYCKOFF-MULTI-CYCLE-001)
|
||||||
|
wyckoff_bins = max(10, min(int(request.args.get('wyckoff_vp_bins', 24)), 24))
|
||||||
|
result['wyckoff'] = _compute_wyckoff_from_df(df, timeframe, wyckoff_bins, client_tz=None)
|
||||||
|
main_w = result.get('wyckoff') or {}
|
||||||
|
cycles = main_w.get('cycles') or []
|
||||||
|
# active 唯一来源 cycles[0];禁止 cycles[-1]
|
||||||
|
active = cycles[0] if cycles else None
|
||||||
|
prefer_start = None
|
||||||
|
if active:
|
||||||
|
prefer_start = ((active.get('trading_range') or {}).get('start_time')
|
||||||
|
or (active.get('period') or {}).get('start_time'))
|
||||||
|
elif main_w.get('trading_range'):
|
||||||
|
prefer_start = main_w['trading_range'].get('start_time')
|
||||||
|
if client_tz is not None:
|
||||||
|
_localize_wyckoff_payload(result['wyckoff'], client_tz)
|
||||||
|
if element_timeframe:
|
||||||
|
result['element_wyckoff'] = _compute_wyckoff_from_df(
|
||||||
|
element_df_for_wyckoff, element_timeframe, wyckoff_bins, client_tz,
|
||||||
|
prefer_start_time=prefer_start,
|
||||||
|
)
|
||||||
|
if sub_sub_timeframe:
|
||||||
|
result['sub_sub_wyckoff'] = _compute_wyckoff_from_df(
|
||||||
|
sub_sub_df_for_wyckoff, sub_sub_timeframe, wyckoff_bins, client_tz,
|
||||||
|
prefer_start_time=prefer_start,
|
||||||
|
)
|
||||||
|
|
||||||
return jsonify(result)
|
return jsonify(result)
|
||||||
|
|
||||||
|
|||||||
+7
-2
@@ -1,5 +1,6 @@
|
|||||||
"""页面路由。"""
|
"""页面路由。"""
|
||||||
from flask import Blueprint, render_template, send_from_directory
|
from flask import Blueprint, jsonify, render_template, request, send_from_directory
|
||||||
|
from config import DATA_SERVICE_URL, DATA_SERVICE_WS_URL
|
||||||
from services.runtime import * # noqa: F403
|
from services.runtime import * # noqa: F403
|
||||||
from services import runtime as R
|
from services import runtime as R
|
||||||
|
|
||||||
@@ -8,7 +9,11 @@ bp = Blueprint("pages", __name__)
|
|||||||
@bp.route('/chan_tv')
|
@bp.route('/chan_tv')
|
||||||
def chan_tv():
|
def chan_tv():
|
||||||
"""缠论 TradingView 高级图表页面"""
|
"""缠论 TradingView 高级图表页面"""
|
||||||
return render_template('chan_tv.html')
|
return render_template(
|
||||||
|
'chan_tv.html',
|
||||||
|
data_service_url=DATA_SERVICE_URL,
|
||||||
|
data_service_ws_url=DATA_SERVICE_WS_URL,
|
||||||
|
)
|
||||||
|
|
||||||
@bp.route('/charting_library/<path:filename>')
|
@bp.route('/charting_library/<path:filename>')
|
||||||
def serve_charting_library(filename):
|
def serve_charting_library(filename):
|
||||||
|
|||||||
@@ -7,6 +7,11 @@ DATA_SERVICE_URL = os.environ.get(
|
|||||||
"DATA_SERVICE_URL",
|
"DATA_SERVICE_URL",
|
||||||
os.environ.get("DATASVC_URL", "https://provider.jackyu66.com"),
|
os.environ.get("DATASVC_URL", "https://provider.jackyu66.com"),
|
||||||
)
|
)
|
||||||
|
# WebSocket 与 REST 可能不同域名(nginx 反代)
|
||||||
|
DATA_SERVICE_WS_URL = os.environ.get(
|
||||||
|
"DATA_SERVICE_WS_URL",
|
||||||
|
"wss://jackyu66.com/ws",
|
||||||
|
)
|
||||||
ASHARE_DP_URL = os.environ.get("ASHARE_DP_URL", "http://103.179.242.166:8000")
|
ASHARE_DP_URL = os.environ.get("ASHARE_DP_URL", "http://103.179.242.166:8000")
|
||||||
|
|
||||||
# HTTP 代理:未设置则不走代理;可设 HTTP_PROXY/HTTPS_PROXY 或 CHAN_HTTP_PROXY
|
# HTTP 代理:未设置则不走代理;可设 HTTP_PROXY/HTTPS_PROXY 或 CHAN_HTTP_PROXY
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,95 @@
|
|||||||
|
"""runtime 门面:保持 `from services.runtime import *` 与 `import services.runtime as R` 兼容。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
# ---- 历史兼容:旧 monolith 上 `from pytz import timezone` 等会随 import * 漏出 ----
|
||||||
|
import json # noqa: F401
|
||||||
|
import logging
|
||||||
|
import sys as _sys
|
||||||
|
import time # noqa: F401
|
||||||
|
from collections import OrderedDict # noqa: F401
|
||||||
|
from concurrent.futures import ThreadPoolExecutor, as_completed # noqa: F401
|
||||||
|
|
||||||
|
import numpy as np # noqa: F401
|
||||||
|
from pytz import timezone # noqa: F401
|
||||||
|
|
||||||
|
from chanlun.analysis.ChanZone import ( # noqa: F401
|
||||||
|
StructureZoneConfig,
|
||||||
|
analyze_structure_zones_from_serialized,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger("services.runtime")
|
||||||
|
|
||||||
|
from .state import ( # noqa: F401
|
||||||
|
TRADE_POINT_TYPE,
|
||||||
|
macd_fast_period,
|
||||||
|
macd_slow_period,
|
||||||
|
macd_signal_period,
|
||||||
|
exchange,
|
||||||
|
china_stock,
|
||||||
|
_zone_cache,
|
||||||
|
DEFAULT_TIMEFRAME_LABELS,
|
||||||
|
DEFAULT_SYMBOLS,
|
||||||
|
TIMEFRAMES,
|
||||||
|
SYMBOLS,
|
||||||
|
DATA_SERVICE_AVAILABLE,
|
||||||
|
SERVICE_METADATA_LAST_REFRESH,
|
||||||
|
)
|
||||||
|
from .timeframes import ( # noqa: F401
|
||||||
|
_zone_cache_ttl,
|
||||||
|
timeframe_to_minutes,
|
||||||
|
format_timeframe_label,
|
||||||
|
build_timeframe_labels,
|
||||||
|
compute_timeframe_defaults,
|
||||||
|
is_smaller_timeframe,
|
||||||
|
is_smaller_or_equal_timeframe,
|
||||||
|
)
|
||||||
|
from .market_data import ( # noqa: F401
|
||||||
|
_parse_time_input,
|
||||||
|
refresh_data_service_metadata,
|
||||||
|
_fetch_kl_from_datasvc,
|
||||||
|
A_STOCK_SYMBOLS,
|
||||||
|
detect_symbol_type,
|
||||||
|
get_kl_data,
|
||||||
|
_get_crypto_kl_data_via_ccxt,
|
||||||
|
get_crypto_kl_data,
|
||||||
|
get_a_stock_kl_data,
|
||||||
|
load_crypto_symbols,
|
||||||
|
)
|
||||||
|
from .indicators import ( # noqa: F401
|
||||||
|
add_indicators,
|
||||||
|
calculate_macd,
|
||||||
|
)
|
||||||
|
from .analyze import ( # noqa: F401
|
||||||
|
analyze_chan,
|
||||||
|
classify_trend_stage,
|
||||||
|
)
|
||||||
|
from .serialize import ( # noqa: F401
|
||||||
|
convert_direction,
|
||||||
|
format_time_safely,
|
||||||
|
serialize_chan_macd_data,
|
||||||
|
clean_dataframe_for_json,
|
||||||
|
get_uncompleted_seg_list,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 预取元信息(与拆分前模块加载行为一致)
|
||||||
|
refresh_data_service_metadata(force=True)
|
||||||
|
|
||||||
|
# 标量在 import 时会拷贝;刷新后写回本模块,供 `from services.runtime import *` 读到最新值
|
||||||
|
from . import state as _state
|
||||||
|
|
||||||
|
_mod = _sys.modules[__name__]
|
||||||
|
_mod.DATA_SERVICE_AVAILABLE = _state.DATA_SERVICE_AVAILABLE
|
||||||
|
_mod.SERVICE_METADATA_LAST_REFRESH = _state.SERVICE_METADATA_LAST_REFRESH
|
||||||
|
_mod.macd_fast_period = _state.macd_fast_period
|
||||||
|
_mod.macd_slow_period = _state.macd_slow_period
|
||||||
|
_mod.macd_signal_period = _state.macd_signal_period
|
||||||
|
|
||||||
|
|
||||||
|
def __getattr__(name: str):
|
||||||
|
if hasattr(_state, name):
|
||||||
|
return getattr(_state, name)
|
||||||
|
raise AttributeError(name)
|
||||||
|
|
||||||
|
|
||||||
|
def __dir__():
|
||||||
|
return sorted(set(globals()) | set(dir(_state)))
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import talib.abstract as ta
|
||||||
|
|
||||||
|
from chanlun import TF_DF
|
||||||
|
from chanlun.core.ChanEnum import Chan_KLC_FX, Chan_FX_TYPE
|
||||||
|
from chanlun.indicators.ChanMACD import ChanMACD
|
||||||
|
|
||||||
|
from .indicators import calculate_macd
|
||||||
|
|
||||||
|
def analyze_chan(df, symbol=None, timeframe=None):
|
||||||
|
"""进行缠论分析"""
|
||||||
|
chan = TF_DF()
|
||||||
|
|
||||||
|
# 初始化多时间周期数据以获取EMA52
|
||||||
|
ema52_dict = None
|
||||||
|
# 获取分析结果
|
||||||
|
klu_list = chan.get_kl_data(df)
|
||||||
|
klc_list = chan.get_klc_list(klu_list)
|
||||||
|
bi_list = chan.cal_bi_list(klc_list)
|
||||||
|
#for index in range(0, 10):
|
||||||
|
#print(bi_list[index].start_time, bi_list[index].start_klc.end_time, bi_list[index].dir)
|
||||||
|
seg_list = chan.get_seg_list(bi_list)
|
||||||
|
zs_list = chan.calculate_seg_zs(seg_list)
|
||||||
|
# 计算笔中枢(BI中枢)并拍平成列表
|
||||||
|
|
||||||
|
#bi_zs_list = chan.cal_bi_zs_list_pure(bi_list)
|
||||||
|
bi_zs_list = chan.cal_bi_zs(seg_list)
|
||||||
|
bsp_list = []
|
||||||
|
if len(bi_zs_list) > 0:
|
||||||
|
bsp_list = chan.find_all_bsp(bi_list, bi_zs_list)
|
||||||
|
#bsp_state_list = chan.get_bsp_state(df)
|
||||||
|
#for bsp in bsp_list:
|
||||||
|
#print(bsp.end_time, bsp.type, bsp.dir)
|
||||||
|
# 添加买卖点识别
|
||||||
|
for bi in bi_list:
|
||||||
|
bi.cal_macdhist()
|
||||||
|
for bi in bi_list:
|
||||||
|
bi.cal_macd_div()
|
||||||
|
#print(bi.start_time, bi.macd_hist, bi.macd_div)
|
||||||
|
|
||||||
|
# 添加ChanMACD分析(复用 get_klc_list 内已算好的结果,避免同周期二次全量分析)
|
||||||
|
chan_macd = None
|
||||||
|
chan_macd_data = {}
|
||||||
|
try:
|
||||||
|
|
||||||
|
if klu_list and len(klu_list) > 0:
|
||||||
|
print(f"获取到KLU列表,长度: {len(klu_list)}")
|
||||||
|
chan_macd = getattr(chan, '_last_chan_macd', None)
|
||||||
|
if chan_macd is None:
|
||||||
|
chan_macd = ChanMACD(klu_list)
|
||||||
|
chan_macd_data = {
|
||||||
|
'seg_list': chan_macd.seg_list,
|
||||||
|
'unittf_list': chan_macd.unittf_list,
|
||||||
|
'histset_list': chan_macd.histset_list,
|
||||||
|
'klu_list': chan_macd.klu_list,
|
||||||
|
'high_position_list': chan_macd.high_position_list,
|
||||||
|
'high_empty_list': chan_macd.high_empty_list,
|
||||||
|
'low_position_list': getattr(chan_macd, 'low_position_list', []),
|
||||||
|
'low_empty_list': getattr(chan_macd, 'low_empty_list', []),
|
||||||
|
'return_zero_list': chan_macd.return_zero_list,
|
||||||
|
'cross0_up_list': chan_macd.cross0_up_list,
|
||||||
|
'cross0_down_list': chan_macd.cross0_down_list
|
||||||
|
}
|
||||||
|
print(f"ChanMACD分析完成: seg={len(chan_macd.seg_list)}, unittf={len(chan_macd.unittf_list)}, histset={len(chan_macd.histset_list)}")
|
||||||
|
else:
|
||||||
|
print("未能获取KLU列表或列表为空")
|
||||||
|
chan_macd_data = {
|
||||||
|
'seg_list': [],
|
||||||
|
'unittf_list': [],
|
||||||
|
'histset_list': [],
|
||||||
|
'high_position_list': [],
|
||||||
|
'high_empty_list': [],
|
||||||
|
'return_zero_list': [],
|
||||||
|
'cross0_up_list': [],
|
||||||
|
'cross0_down_list': []
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
print(f"ChanMACD分析出错: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
chan_macd_data = {
|
||||||
|
'seg_list': [],
|
||||||
|
'unittf_list': [],
|
||||||
|
'histset_list': [],
|
||||||
|
'high_position_list': [],
|
||||||
|
'high_empty_list': [],
|
||||||
|
'low_position_list': [],
|
||||||
|
'low_empty_list': [],
|
||||||
|
'return_zero_list': [],
|
||||||
|
'cross0_up_list': [],
|
||||||
|
'cross0_down_list': []
|
||||||
|
}
|
||||||
|
|
||||||
|
# 提取K线分型信息
|
||||||
|
klc_fx_info = []
|
||||||
|
for klc in klc_list:
|
||||||
|
if hasattr(klc, 'klc_fx_type') and klc.klc_fx_type != Chan_KLC_FX.UNKNOWN:
|
||||||
|
try:
|
||||||
|
# 计算分型强度
|
||||||
|
fx_strength = 0
|
||||||
|
fx_strength_level = ""
|
||||||
|
is_strong_fx = False
|
||||||
|
|
||||||
|
# 统一使用cal_fx_strength函数
|
||||||
|
if hasattr(klc, 'cal_fx_strength'):
|
||||||
|
fx_strength = klc.cal_fx_strength(5)
|
||||||
|
|
||||||
|
# 尝试获取分型强度等级
|
||||||
|
if hasattr(klc, 'get_fx_strength_level'):
|
||||||
|
fx_strength_level = klc.get_fx_strength_level()
|
||||||
|
|
||||||
|
# 尝试判断是否为强分型
|
||||||
|
if hasattr(klc, 'is_strong_fx'):
|
||||||
|
is_strong_fx = klc.is_strong_fx()
|
||||||
|
|
||||||
|
# 如果分型强度小于1,设为0
|
||||||
|
if fx_strength < 1:
|
||||||
|
fx_strength = 0
|
||||||
|
|
||||||
|
# KLC 分型框(起止时间+高低价):
|
||||||
|
# 仅使用 cal_fx_box 通过 display 条件后生成的 klc.fx_box。
|
||||||
|
# 若无 fx_box,则前端不应绘制分型框。
|
||||||
|
fx_box = getattr(klc, 'fx_box', None)
|
||||||
|
box_start_time = getattr(fx_box, 'start_time', None) if fx_box else None
|
||||||
|
box_end_time = getattr(fx_box, 'end_time', None) if fx_box else None
|
||||||
|
box_high = getattr(fx_box, 'high', None) if fx_box else None
|
||||||
|
box_low = getattr(fx_box, 'low', None) if fx_box else None
|
||||||
|
|
||||||
|
if klc.bb_out:
|
||||||
|
klc_fx_info.append({
|
||||||
|
'time': klc.end_time,
|
||||||
|
'price': klc.low if klc.fx == Chan_FX_TYPE.BOTTOM else klc.high,
|
||||||
|
'fx_type': str(klc.klc_fx_type).replace("Chan_KLC_FX.", ""),
|
||||||
|
'is_bottom': klc.fx == Chan_FX_TYPE.BOTTOM,
|
||||||
|
'fx_strength': fx_strength, # 分型强度分数 (0-100)
|
||||||
|
'fx_strength_level': fx_strength_level, # 分型强度等级 (极强/强/中等/弱/极弱)
|
||||||
|
'is_strong_fx': is_strong_fx, # 是否为强分型
|
||||||
|
|
||||||
|
# 虚线分型框信息(给前端画框用)
|
||||||
|
'start_time': box_start_time,
|
||||||
|
'end_time': box_end_time,
|
||||||
|
'high': float(box_high) if box_high is not None else None,
|
||||||
|
'low': float(box_low) if box_low is not None else None,
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
# 如果出错,仍然添加基本信息,但分型强度为0
|
||||||
|
fx_box = getattr(klc, 'fx_box', None)
|
||||||
|
box_start_time = getattr(fx_box, 'start_time', None) if fx_box else None
|
||||||
|
box_end_time = getattr(fx_box, 'end_time', None) if fx_box else None
|
||||||
|
box_high = getattr(fx_box, 'high', None) if fx_box else None
|
||||||
|
box_low = getattr(fx_box, 'low', None) if fx_box else None
|
||||||
|
|
||||||
|
klc_fx_info.append({
|
||||||
|
'time': klc.end_time,
|
||||||
|
'price': klc.low if klc.fx == Chan_FX_TYPE.BOTTOM else klc.high,
|
||||||
|
'fx_type': str(klc.klc_fx_type).replace("Chan_KLC_FX.", ""),
|
||||||
|
'is_bottom': klc.fx == Chan_FX_TYPE.BOTTOM,
|
||||||
|
'fx_strength': 0,
|
||||||
|
'fx_strength_level': "",
|
||||||
|
'is_strong_fx': False,
|
||||||
|
|
||||||
|
# 虚线分型框信息(给前端画框用)
|
||||||
|
'start_time': box_start_time,
|
||||||
|
'end_time': box_end_time,
|
||||||
|
'high': float(box_high) if box_high is not None else None,
|
||||||
|
'low': float(box_low) if box_low is not None else None,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
return {
|
||||||
|
'klc_list': klc_list,
|
||||||
|
'klu_list': klu_list, # 添加KLU列表
|
||||||
|
'bi_list': bi_list,
|
||||||
|
'seg_list': seg_list,
|
||||||
|
'zs_list': zs_list,
|
||||||
|
'bi_zs_list': bi_zs_list, # 添加BI中枢列表
|
||||||
|
'bsp_list': bsp_list, # 添加买卖点列表
|
||||||
|
'klc_fx_info': klc_fx_info, # KLC分型信息
|
||||||
|
'chan_macd': chan_macd_data, # 添加ChanMACD分析数据
|
||||||
|
'ema52_dict': ema52_dict # 添加多时间周期EMA52数据
|
||||||
|
}
|
||||||
|
|
||||||
|
def classify_trend_stage(df):
|
||||||
|
"""根据 EMA 斜率与多空排列判断趋势方向与阶段
|
||||||
|
返回: direction in {"bull","bear","sideways"}, stage in {"early","mid","late"}, strength_score (0-100)
|
||||||
|
"""
|
||||||
|
if df is None or len(df) < 60:
|
||||||
|
return "sideways", "early", 0
|
||||||
|
|
||||||
|
# 使用 EMA5/10/24/52
|
||||||
|
closes = df['close'].values
|
||||||
|
ema5 = df['ema5'].values if 'ema5' in df else ta.EMA(df, timeperiod=5)
|
||||||
|
ema10 = df['ema10'].values if 'ema10' in df else ta.EMA(df, timeperiod=10)
|
||||||
|
ema24 = df['ema24'].values if 'ema24' in df else ta.EMA(df, timeperiod=24)
|
||||||
|
ema52 = df['ema52'].values if 'ema52' in df else ta.EMA(df, timeperiod=52)
|
||||||
|
|
||||||
|
# 最近N根用于斜率与排列判定
|
||||||
|
lookback = min(30, len(df) - 1)
|
||||||
|
if lookback <= 5:
|
||||||
|
return "sideways", "early", 0
|
||||||
|
|
||||||
|
# 简单斜率: 最近k根的线性变化率近似
|
||||||
|
def slope(arr, k=10):
|
||||||
|
k = min(k, len(arr) - 1)
|
||||||
|
if k < 2:
|
||||||
|
return 0.0
|
||||||
|
y = arr[-k:]
|
||||||
|
x = np.arange(k)
|
||||||
|
# 最小二乘拟合斜率
|
||||||
|
denom = np.dot(x - x.mean(), x - x.mean())
|
||||||
|
if denom == 0:
|
||||||
|
return 0.0
|
||||||
|
m = np.dot(y - y.mean(), x - x.mean()) / denom
|
||||||
|
return float(m)
|
||||||
|
|
||||||
|
k_slope = 12 # 斜率窗口
|
||||||
|
s5 = slope(ema5, k_slope)
|
||||||
|
s10 = slope(ema10, k_slope)
|
||||||
|
s24 = slope(ema24, k_slope)
|
||||||
|
s52 = slope(ema52, k_slope)
|
||||||
|
|
||||||
|
# 多空排列
|
||||||
|
last5, last10, last24, last52 = ema5[-1], ema10[-1], ema24[-1], ema52[-1]
|
||||||
|
bull_stack = last5 > last10 > last24 > last52
|
||||||
|
bear_stack = last5 < last10 < last24 < last52
|
||||||
|
|
||||||
|
# 波动性与动量增强: MACD 柱体最近均值
|
||||||
|
macdhist = df['macdhist'].values if 'macdhist' in df else calculate_macd(df)['histogram']
|
||||||
|
hist_recent = macdhist[-lookback:]
|
||||||
|
hist_power = float(np.mean(np.abs(hist_recent))) if len(hist_recent) else 0.0
|
||||||
|
|
||||||
|
# 方向
|
||||||
|
if bull_stack and s24 > 0 and s52 > 0:
|
||||||
|
direction = "bull"
|
||||||
|
elif bear_stack and s24 < 0 and s52 < 0:
|
||||||
|
direction = "bear"
|
||||||
|
else:
|
||||||
|
# 用价格相对 EMA52 辅助
|
||||||
|
if closes[-1] > last52 and (s24 + s52) > 0:
|
||||||
|
direction = "bull"
|
||||||
|
elif closes[-1] < last52 and (s24 + s52) < 0:
|
||||||
|
direction = "bear"
|
||||||
|
else:
|
||||||
|
direction = "sideways"
|
||||||
|
|
||||||
|
# 阶段: 依据(斜率大小、与EMA52距离、MACD柱体扩张/收敛)
|
||||||
|
dist52 = float((closes[-1] - last52) / last52) if last52 else 0.0
|
||||||
|
slope_score = max(0.0, (abs(s24) + abs(s52)) * 1000.0) # 归一化
|
||||||
|
dist_score = min(50.0, abs(dist52) * 200.0)
|
||||||
|
hist_score = min(30.0, hist_power * 10.0)
|
||||||
|
strength = float(min(100.0, slope_score + dist_score + hist_score))
|
||||||
|
|
||||||
|
# 简单阶段判定
|
||||||
|
if direction == "sideways":
|
||||||
|
stage = "early"
|
||||||
|
strength = min(strength, 30.0)
|
||||||
|
else:
|
||||||
|
# 查看最近 hist 是否在扩大或收敛
|
||||||
|
if len(hist_recent) >= 6:
|
||||||
|
recent_growth = np.mean(np.abs(hist_recent[-3:])) - np.mean(np.abs(hist_recent[-6:-3]))
|
||||||
|
else:
|
||||||
|
recent_growth = 0.0
|
||||||
|
|
||||||
|
if recent_growth > 0 and abs(dist52) < 0.05:
|
||||||
|
stage = "early"
|
||||||
|
elif recent_growth > 0 and abs(dist52) >= 0.05:
|
||||||
|
stage = "mid"
|
||||||
|
else:
|
||||||
|
stage = "late"
|
||||||
|
|
||||||
|
return direction, stage, strength
|
||||||
|
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import talib.abstract as ta
|
||||||
|
from . import state
|
||||||
|
|
||||||
|
def add_indicators(df):
|
||||||
|
macd = ta.MACD(df, fastperiod=state.macd_fast_period, slowperiod=state.macd_slow_period, signalperiod=state.macd_signal_period)
|
||||||
|
|
||||||
|
df['macd'] = macd['macd']
|
||||||
|
df['macdsignal'] = macd['macdsignal']
|
||||||
|
df['macdhist'] = macd['macdhist']
|
||||||
|
df['ma5'] = (ta.MA(df, timeperiod=5)).fillna(0)
|
||||||
|
df['ma10'] = (ta.MA(df, timeperiod=10)).fillna(0)
|
||||||
|
df['ma30'] = (ta.EMA(df, timeperiod=30)).fillna(0)
|
||||||
|
df['ma250'] = (ta.MA(df, timeperiod=250)).fillna(0)
|
||||||
|
# 新增 EMA 指标
|
||||||
|
df['ema5'] = (ta.EMA(df, timeperiod=5)).fillna(0)
|
||||||
|
df['ema10'] = (ta.EMA(df, timeperiod=10)).fillna(0)
|
||||||
|
df['ema24'] = (ta.EMA(df, timeperiod=24)).fillna(0)
|
||||||
|
df['ema52'] = (ta.EMA(df, timeperiod=52)).fillna(0)
|
||||||
|
df['ema26'] = (ta.EMA(df, timeperiod=26)).fillna(0)
|
||||||
|
df['ema13'] = (ta.EMA(df, timeperiod=13)).fillna(0)
|
||||||
|
df['ema7'] = (ta.EMA(df, timeperiod=7)).fillna(0)
|
||||||
|
df['ema104'] = (ta.EMA(df, timeperiod=104)).fillna(0)
|
||||||
|
df['ema156'] = (ta.EMA(df, timeperiod=156)).fillna(0)
|
||||||
|
df['ema208'] = (ta.EMA(df, timeperiod=208)).fillna(0)
|
||||||
|
# 常用SMA 24/52
|
||||||
|
try:
|
||||||
|
df['sma24'] = (ta.SMA(df, timeperiod=24)).fillna(0)
|
||||||
|
df['sma52'] = (ta.SMA(df, timeperiod=52)).fillna(0)
|
||||||
|
except Exception:
|
||||||
|
df['sma24'] = 0
|
||||||
|
df['sma52'] = 0
|
||||||
|
df['rsi'] = ta.RSI(df, timeperiod=14)
|
||||||
|
|
||||||
|
# 计算布林带 (当前周期 - 20周期,2标准差)
|
||||||
|
bb = ta.BBANDS(df, timeperiod=365, nbdevup=3.0, nbdevdn=3.0, matype=0)
|
||||||
|
df['bb_upper'] = bb['upperband'].fillna(0)
|
||||||
|
df['bb_middle'] = bb['middleband'].fillna(0)
|
||||||
|
df['bb_lower'] = bb['lowerband'].fillna(0)
|
||||||
|
bb30 = ta.BBANDS(df, timeperiod=41, nbdevup=2.3, nbdevdn=2.3, matype=0)
|
||||||
|
#bb30 = ta.BBANDS(df, timeperiod=20, nbdevup=2.0, nbdevdn=2.0, matype=0)
|
||||||
|
df['bbup30'] = bb30['upperband'].fillna(0)
|
||||||
|
df['bblow30'] = bb30['lowerband'].fillna(0)
|
||||||
|
bb302 = ta.BBANDS(df, timeperiod=41, nbdevup=2.0, nbdevdn=2.0, matype=0)
|
||||||
|
#bb302 = ta.BBANDS(df, timeperiod=20, nbdevup=2.0, nbdevdn=2.0, matype=0)
|
||||||
|
df['bbup302'] = bb302['upperband'].fillna(0)
|
||||||
|
df['bblow302'] = bb302['lowerband'].fillna(0)
|
||||||
|
# 计算次周期布林带 (14周期,2标准差)
|
||||||
|
bb_element = ta.BBANDS(df, timeperiod=14, nbdevup=2.0, nbdevdn=2.0, matype=0)
|
||||||
|
df['element_bb_upper'] = bb_element['upperband'].fillna(0)
|
||||||
|
df['element_bb_middle'] = bb_element['middleband'].fillna(0)
|
||||||
|
df['element_bb_lower'] = bb_element['lowerband'].fillna(0)
|
||||||
|
|
||||||
|
df['macd'] = df['macd'].fillna(0)
|
||||||
|
df['macdsignal'] = df['macdsignal'].fillna(0)
|
||||||
|
df['macdhist'] = df['macdhist'].fillna(0)
|
||||||
|
df['ma5'] = df['ma5'].fillna(0)
|
||||||
|
df['ma10'] = df['ma10'].fillna(0)
|
||||||
|
df['ma30'] = df['ma30'].fillna(0)
|
||||||
|
df['ma250'] = df['ma250'].fillna(0)
|
||||||
|
df['ema5'] = df['ema5'].fillna(0)
|
||||||
|
df['ema10'] = df['ema10'].fillna(0)
|
||||||
|
df['ema24'] = df['ema24'].fillna(0)
|
||||||
|
df['ema52'] = df['ema52'].fillna(0)
|
||||||
|
df['sma24'] = df['sma24'].fillna(0)
|
||||||
|
df['sma52'] = df['sma52'].fillna(0)
|
||||||
|
df['rsi'] = df['rsi'].fillna(0)
|
||||||
|
df['avg_volume'] = df['volume'].rolling(10).mean()
|
||||||
|
# 计算量比,避免产生Infinity值
|
||||||
|
df['volume_ratio'] = df['volume'] / df['avg_volume']
|
||||||
|
# 填充缺失值(前N根K线)
|
||||||
|
df['volume_ratio'] = df['volume_ratio'].fillna(1.0)
|
||||||
|
df['avg_volume'] = df['avg_volume'].fillna(0)
|
||||||
|
|
||||||
|
# 处理Infinity和-Infinity值
|
||||||
|
df['volume_ratio'] = df['volume_ratio'].replace([float('inf'), float('-inf')], 1.0)
|
||||||
|
|
||||||
|
# 计算ATR (Average True Range) - 14周期
|
||||||
|
df['atr'] = ta.ATR(df, timeperiod=14)
|
||||||
|
df['atr'] = df['atr'].fillna(0)
|
||||||
|
bb2633 = ta.BBANDS(df, timeperiod=26, nbdevup=3.0, nbdevdn=3.0, matype=0)
|
||||||
|
bbp2633 = (df['close'] - bb2633['lowerband']) / (bb2633['upperband'] - bb2633['lowerband'])
|
||||||
|
df['bb2633upper'] = bb2633['upperband'].fillna(0)
|
||||||
|
df['bb2633lower'] = bb2633['lowerband'].fillna(0)
|
||||||
|
df['bbp2633'] = bbp2633.fillna(0)
|
||||||
|
df['bb2633middle'] = bb2633['middleband'].fillna(0)
|
||||||
|
return df
|
||||||
|
|
||||||
|
def calculate_macd(df):
|
||||||
|
"""计算MACD指标"""
|
||||||
|
exp1 = df['close'].ewm(span=state.macd_fast_period, adjust=False).mean()
|
||||||
|
exp2 = df['close'].ewm(span=state.macd_slow_period, adjust=False).mean()
|
||||||
|
macd = exp1 - exp2
|
||||||
|
signal = macd.ewm(span=state.macd_signal_period, adjust=False).mean()
|
||||||
|
histogram = macd - signal
|
||||||
|
|
||||||
|
return {
|
||||||
|
'macd': macd.tolist(),
|
||||||
|
'signal': signal.tolist(),
|
||||||
|
'histogram': histogram.tolist()
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,321 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from config import DATA_SERVICE_URL
|
||||||
|
from . import state
|
||||||
|
from .state import DEFAULT_SYMBOLS, DEFAULT_TIMEFRAME_LABELS
|
||||||
|
from .timeframes import build_timeframe_labels
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
def _parse_time_input(value):
|
||||||
|
if value in (None, '', 0):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return int(float(value))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def refresh_data_service_metadata(force=False):
|
||||||
|
"""刷新数据服务提供的交易对与周期元信息。"""
|
||||||
|
now = time.time()
|
||||||
|
if not force and state.DATA_SERVICE_AVAILABLE and now - state.SERVICE_METADATA_LAST_REFRESH < 60:
|
||||||
|
return True
|
||||||
|
try:
|
||||||
|
resp = requests.get(f"{DATA_SERVICE_URL}/health", timeout=5)
|
||||||
|
resp.raise_for_status()
|
||||||
|
payload = resp.json()
|
||||||
|
service_symbols = payload.get("symbols") or payload.get("symbol_list") or []
|
||||||
|
base_timeframes = payload.get("timeframes") or payload.get("base_timeframes") or []
|
||||||
|
derived = payload.get("derived_timeframes") or []
|
||||||
|
service_timeframes = list(base_timeframes)
|
||||||
|
for tf in derived:
|
||||||
|
if tf not in service_timeframes:
|
||||||
|
service_timeframes.append(tf)
|
||||||
|
if service_symbols:
|
||||||
|
state.SYMBOLS[:] = service_symbols
|
||||||
|
if service_timeframes:
|
||||||
|
state.TIMEFRAMES.clear()
|
||||||
|
state.TIMEFRAMES.update(build_timeframe_labels(service_timeframes))
|
||||||
|
state.DATA_SERVICE_AVAILABLE = True
|
||||||
|
state.SERVICE_METADATA_LAST_REFRESH = now
|
||||||
|
return True
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("无法加载数据服务元信息: %s", exc)
|
||||||
|
if not state.DATA_SERVICE_AVAILABLE:
|
||||||
|
state.TIMEFRAMES.clear()
|
||||||
|
state.TIMEFRAMES.update(DEFAULT_TIMEFRAME_LABELS)
|
||||||
|
state.SYMBOLS[:] = DEFAULT_SYMBOLS
|
||||||
|
state.DATA_SERVICE_AVAILABLE = False
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_kl_from_datasvc(symbol, timeframe, start_ms=None, end_ms=None, limit=None):
|
||||||
|
params = {"symbol": symbol, "tf": timeframe}
|
||||||
|
if start_ms is not None:
|
||||||
|
params["start"] = int(start_ms)
|
||||||
|
if end_ms is not None:
|
||||||
|
params["end"] = int(end_ms)
|
||||||
|
if limit is not None:
|
||||||
|
params["limit"] = limit
|
||||||
|
resp = requests.get(f"{DATA_SERVICE_URL}/api/candles", params=params, timeout=10)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
if not data:
|
||||||
|
return None
|
||||||
|
df = pd.DataFrame(data)
|
||||||
|
if df.empty or "timestamp" not in df.columns:
|
||||||
|
return None
|
||||||
|
numeric_cols = ["open", "high", "low", "close", "volume"]
|
||||||
|
df["timestamp"] = pd.to_numeric(df["timestamp"], errors="coerce")
|
||||||
|
df = df.dropna(subset=["timestamp"])
|
||||||
|
df["timestamp"] = df["timestamp"].astype("int64")
|
||||||
|
for col in numeric_cols:
|
||||||
|
if col in df.columns:
|
||||||
|
df[col] = pd.to_numeric(df[col], errors="coerce")
|
||||||
|
df = df.dropna(subset=numeric_cols)
|
||||||
|
df = df.sort_values("timestamp")
|
||||||
|
if limit and len(df) > limit:
|
||||||
|
df = df.tail(limit)
|
||||||
|
df = df.reset_index(drop=True)
|
||||||
|
df["date"] = pd.to_datetime(df["timestamp"], unit='ms', utc=True).dt.tz_convert('Asia/Shanghai')
|
||||||
|
return df
|
||||||
|
|
||||||
|
|
||||||
|
# 模块加载时尝试预取一次元信息,但失败不阻塞后续流程
|
||||||
|
refresh_data_service_metadata(force=True)
|
||||||
|
|
||||||
|
# A股热门股票
|
||||||
|
# 模板中 A 股下拉仅放默认一项;用户切换到「A股」时由前端请求 /api/a_stocks 填充全市场(约 5500+)
|
||||||
|
A_STOCK_SYMBOLS = [{'symbol': '000001', 'name': '平安银行'}]
|
||||||
|
|
||||||
|
def detect_symbol_type(symbol):
|
||||||
|
"""检测交易对类型:crypto 或 a_stock"""
|
||||||
|
if '/' in symbol and 'USDT' in symbol:
|
||||||
|
return 'crypto'
|
||||||
|
elif len(symbol) == 6 and symbol.isdigit():
|
||||||
|
return 'a_stock'
|
||||||
|
else:
|
||||||
|
return 'unknown'
|
||||||
|
|
||||||
|
def get_kl_data(symbol, timeframe, limit=100000, start_time=None, end_time=None):
|
||||||
|
"""获取K线数据,支持加密货币和A股"""
|
||||||
|
symbol_type = detect_symbol_type(symbol)
|
||||||
|
|
||||||
|
if symbol_type == 'crypto':
|
||||||
|
return get_crypto_kl_data(symbol, timeframe, limit, start_time, end_time)
|
||||||
|
elif symbol_type == 'a_stock':
|
||||||
|
return get_a_stock_kl_data(symbol, timeframe, limit, start_time, end_time)
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _get_crypto_kl_data_via_ccxt(symbol, timeframe, limit=100000, start_time=None, end_time=None):
|
||||||
|
"""获取加密货币K线数据,支持分页加载确保获取指定时间范围内的所有数据"""
|
||||||
|
try:
|
||||||
|
# 初始化参数
|
||||||
|
since = None
|
||||||
|
if start_time:
|
||||||
|
try:
|
||||||
|
since = int(start_time)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 结束时间处理
|
||||||
|
until = None
|
||||||
|
if end_time:
|
||||||
|
try:
|
||||||
|
until = int(end_time)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 根据时间周期调整每次请求的数据量
|
||||||
|
batch_size = 1000 # 默认批次大小
|
||||||
|
if timeframe in ['1m', '3m', '5m']:
|
||||||
|
batch_size = 1000 # 分钟级数据减少批次大小
|
||||||
|
elif timeframe in ['15m', '30m', '1h']:
|
||||||
|
batch_size = 1000
|
||||||
|
else:
|
||||||
|
batch_size = 1500 # 日线及以上可以获取更多
|
||||||
|
batch_size = 1500 # 默认批次大小
|
||||||
|
# 初始化存储所有K线数据的列表
|
||||||
|
all_ohlcv = []
|
||||||
|
|
||||||
|
# 初始化当前查询的开始时间
|
||||||
|
current_since = since
|
||||||
|
|
||||||
|
# 添加请求计数和最大限制
|
||||||
|
request_count = 0
|
||||||
|
max_requests = 300 # 最大请求次数,防止无限循环
|
||||||
|
|
||||||
|
# 分页加载数据
|
||||||
|
while request_count < max_requests:
|
||||||
|
request_count += 1
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 获取当前页的数据
|
||||||
|
ohlcv = state.exchange.fetch_ohlcv(symbol, timeframe, since=current_since, limit=batch_size)
|
||||||
|
|
||||||
|
# 如果没有获取到数据,结束循环
|
||||||
|
if not ohlcv or len(ohlcv) == 0:
|
||||||
|
break
|
||||||
|
|
||||||
|
# 将获取到的数据添加到总列表中
|
||||||
|
all_ohlcv.extend(ohlcv)
|
||||||
|
|
||||||
|
# 获取最后一条数据的时间戳
|
||||||
|
last_timestamp = ohlcv[-1][0]
|
||||||
|
|
||||||
|
# 如果已达到结束时间,结束循环
|
||||||
|
if until and last_timestamp >= until:
|
||||||
|
break
|
||||||
|
|
||||||
|
# 如果获取的数据条数小于限制数,说明已经获取完所有数据
|
||||||
|
if len(ohlcv) < batch_size:
|
||||||
|
break
|
||||||
|
|
||||||
|
# 更新下一页的开始时间(加1毫秒避免重复)
|
||||||
|
current_since = last_timestamp + 1
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
# 如果单个批次失败,继续尝试下一个批次
|
||||||
|
if current_since:
|
||||||
|
# 尝试增加时间跳过可能的问题时间点
|
||||||
|
current_since += 60000 # 跳过1分钟
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
|
||||||
|
# 防止API请求过于频繁
|
||||||
|
time.sleep(0.3) # 减少到0.3秒提高效率
|
||||||
|
|
||||||
|
# 数据为空的情况
|
||||||
|
if not all_ohlcv or len(all_ohlcv) == 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 转换为DataFrame
|
||||||
|
df = pd.DataFrame(all_ohlcv, columns=['timestamp', 'open', 'high', 'low', 'close', 'volume'])
|
||||||
|
df['date'] = pd.to_datetime(df['timestamp'], unit='ms').dt.tz_localize('UTC').dt.tz_convert('Asia/Shanghai')
|
||||||
|
|
||||||
|
# 在客户端进行结束时间过滤
|
||||||
|
if until:
|
||||||
|
df = df[df['timestamp'] <= until]
|
||||||
|
|
||||||
|
# 去除重复数据
|
||||||
|
df = df.drop_duplicates(subset=['timestamp'])
|
||||||
|
|
||||||
|
# 按时间排序
|
||||||
|
df = df.sort_values('timestamp')
|
||||||
|
|
||||||
|
# 限制数据条数的逻辑 - 优先考虑时间范围
|
||||||
|
if start_time and end_time:
|
||||||
|
# 如果指定了明确的时间范围,返回该时间范围内的所有数据
|
||||||
|
if len(df) > 100000: # 防止数据量过大,设置一个合理的上限
|
||||||
|
df = df.tail(100000).reset_index(drop=True)
|
||||||
|
elif limit and len(df) > limit:
|
||||||
|
# 如果没有指定明确时间范围,使用默认的limit限制
|
||||||
|
df = df.tail(limit).reset_index(drop=True)
|
||||||
|
|
||||||
|
# 如果过滤后没有数据,返回None
|
||||||
|
if len(df) == 0:
|
||||||
|
return None
|
||||||
|
return df
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_crypto_kl_data(symbol, timeframe, limit=100000, start_time=None, end_time=None):
|
||||||
|
"""优先通过本地数据服务获取加密货币K线,失败时回退至交易所API。"""
|
||||||
|
start_ms = _parse_time_input(start_time)
|
||||||
|
end_ms = _parse_time_input(end_time)
|
||||||
|
|
||||||
|
refresh_data_service_metadata()
|
||||||
|
if state.DATA_SERVICE_AVAILABLE:
|
||||||
|
try:
|
||||||
|
df = _fetch_kl_from_datasvc(
|
||||||
|
symbol=symbol,
|
||||||
|
timeframe=timeframe,
|
||||||
|
start_ms=start_ms,
|
||||||
|
end_ms=end_ms,
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
if df is not None and not df.empty:
|
||||||
|
return df
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("数据服务请求失败,准备回退至交易所 API:%s", exc)
|
||||||
|
|
||||||
|
return _get_crypto_kl_data_via_ccxt(symbol, timeframe, limit, start_time, end_time)
|
||||||
|
|
||||||
|
|
||||||
|
def get_a_stock_kl_data(symbol, timeframe, limit=100000, start_time=None, end_time=None):
|
||||||
|
"""获取A股K线数据"""
|
||||||
|
try:
|
||||||
|
# 处理时间戳参数转换为日期字符串
|
||||||
|
start_date = None
|
||||||
|
end_date = None
|
||||||
|
|
||||||
|
if start_time:
|
||||||
|
try:
|
||||||
|
# 尝试解析时间戳(毫秒)
|
||||||
|
start_timestamp = int(start_time)
|
||||||
|
start_date = datetime.fromtimestamp(start_timestamp / 1000).strftime('%Y-%m-%d')
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
# 如果不是时间戳,尝试解析datetime-local格式 (YYYY-MM-DDTHH:MM)
|
||||||
|
try:
|
||||||
|
if 'T' in str(start_time):
|
||||||
|
# datetime-local格式:2025-05-19T06:07
|
||||||
|
start_date = str(start_time).split('T')[0] # 只取日期部分
|
||||||
|
else:
|
||||||
|
start_date = str(start_time)
|
||||||
|
except:
|
||||||
|
start_date = start_time
|
||||||
|
|
||||||
|
if end_time:
|
||||||
|
try:
|
||||||
|
# 尝试解析时间戳(毫秒)
|
||||||
|
end_timestamp = int(end_time)
|
||||||
|
end_date = datetime.fromtimestamp(end_timestamp / 1000).strftime('%Y-%m-%d')
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
# 如果不是时间戳,尝试解析datetime-local格式
|
||||||
|
try:
|
||||||
|
if 'T' in str(end_time):
|
||||||
|
# datetime-local格式:2025-05-26T06:07
|
||||||
|
end_date = str(end_time).split('T')[0] # 只取日期部分
|
||||||
|
else:
|
||||||
|
end_date = str(end_time)
|
||||||
|
except:
|
||||||
|
end_date = end_time
|
||||||
|
|
||||||
|
# 如果用户指定了时间范围,优先获取该范围内的所有数据
|
||||||
|
actual_limit = limit
|
||||||
|
if start_date and end_date:
|
||||||
|
actual_limit = None # 不限制数据条数,获取完整时间范围数据
|
||||||
|
|
||||||
|
# 调用A股数据获取器
|
||||||
|
df = state.china_stock.get_kl_data(symbol, timeframe, start_date, end_date, actual_limit)
|
||||||
|
|
||||||
|
if df is None:
|
||||||
|
return None
|
||||||
|
return df
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def load_crypto_symbols(limit=200):
|
||||||
|
"""加载常见USDT永续合约交易对,返回列表"""
|
||||||
|
refresh_data_service_metadata()
|
||||||
|
if state.SYMBOLS:
|
||||||
|
return state.SYMBOLS[:limit]
|
||||||
|
try:
|
||||||
|
markets = state.exchange.load_markets()
|
||||||
|
symbols = [s for s in markets.keys() if '/USDT' in s and ':USDT' in s]
|
||||||
|
return symbols[:limit]
|
||||||
|
except Exception:
|
||||||
|
return DEFAULT_SYMBOLS[:limit]
|
||||||
|
|
||||||
@@ -0,0 +1,300 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
from chanlun.core.ChanEnum import Chan_BI_DIR, Chan_SEG_DIR, Chan_MACDSEG_DIR, Chan_MACDHISTSET_DIR
|
||||||
|
|
||||||
|
# 辅助函数,转换缠论方向枚举为整数
|
||||||
|
def convert_direction(direction):
|
||||||
|
"""转换方向枚举为数字"""
|
||||||
|
if direction == Chan_BI_DIR.UP or direction == Chan_SEG_DIR.UP:
|
||||||
|
return 1
|
||||||
|
elif direction == Chan_BI_DIR.DOWN or direction == Chan_SEG_DIR.DOWN:
|
||||||
|
return -1
|
||||||
|
else:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def format_time_safely(time_obj, client_tz):
|
||||||
|
"""安全地格式化时间对象,处理字符串和datetime两种情况"""
|
||||||
|
if time_obj is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if isinstance(time_obj, str):
|
||||||
|
# 尝试将字符串解析为datetime
|
||||||
|
try:
|
||||||
|
from dateutil import parser
|
||||||
|
time_obj = parser.parse(time_obj)
|
||||||
|
return time_obj.astimezone(client_tz).isoformat()
|
||||||
|
except:
|
||||||
|
return time_obj
|
||||||
|
else:
|
||||||
|
# 已经是datetime对象
|
||||||
|
return time_obj.astimezone(client_tz).isoformat()
|
||||||
|
|
||||||
|
def serialize_chan_macd_data(chan_macd_data, client_tz):
|
||||||
|
"""序列化ChanMACD数据为JSON可序列化格式"""
|
||||||
|
serialized_data = {
|
||||||
|
'seg_list': [],
|
||||||
|
'unittf_list': [],
|
||||||
|
'histset_list': [],
|
||||||
|
# 状态标记数据
|
||||||
|
'high_position_list': [],
|
||||||
|
'high_empty_list': [],
|
||||||
|
'low_position_list': [],
|
||||||
|
'low_empty_list': [],
|
||||||
|
'return_zero_list': [],
|
||||||
|
'cross0_up_list': [],
|
||||||
|
'cross0_down_list': [],
|
||||||
|
# 新增:输出KLU的继续背驰/分离背驰标志
|
||||||
|
'klu_list': []
|
||||||
|
}
|
||||||
|
|
||||||
|
# 序列化seg_list
|
||||||
|
for seg in chan_macd_data.get('seg_list', []):
|
||||||
|
try:
|
||||||
|
seg_data = {
|
||||||
|
'start_time': format_time_safely(seg.start_time, client_tz),
|
||||||
|
'end_time': format_time_safely(seg.end_time, client_tz) if seg.end_time else None,
|
||||||
|
'seg_dir': 'ABOVE' if seg.seg_dir == Chan_MACDSEG_DIR.ABOVE else 'UNDER',
|
||||||
|
'klu_count': len(seg.klu_list) if hasattr(seg, 'klu_list') else 0,
|
||||||
|
'unittf_count': len(seg.unittf_list) if hasattr(seg, 'unittf_list') else 0,
|
||||||
|
'histset_count': len(seg.hist_set) if hasattr(seg, 'hist_set') else 0
|
||||||
|
}
|
||||||
|
serialized_data['seg_list'].append(seg_data)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"序列化seg出错: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 序列化unittf_list(兼容新结构与枚举类型)
|
||||||
|
for unittf in chan_macd_data.get('unittf_list', []):
|
||||||
|
try:
|
||||||
|
dir_value = getattr(unittf, 'uinttf_dir', None)
|
||||||
|
dir_name = getattr(dir_value, 'name', dir_value if isinstance(dir_value, str) else None)
|
||||||
|
start_t = getattr(unittf, 'start_type', None)
|
||||||
|
start_type = getattr(start_t, 'name', start_t)
|
||||||
|
end_t = getattr(unittf, 'end_type', None)
|
||||||
|
end_type = getattr(end_t, 'name', end_t)
|
||||||
|
peak_abs = getattr(unittf, 'peak_abs', None)
|
||||||
|
if peak_abs is None:
|
||||||
|
peak_abs = getattr(unittf, 'peak_hist', None)
|
||||||
|
length = getattr(unittf, 'length', None)
|
||||||
|
if length is None:
|
||||||
|
length = len(unittf.klu_list) if hasattr(unittf, 'klu_list') else None
|
||||||
|
|
||||||
|
unittf_data = {
|
||||||
|
'start_time': format_time_safely(getattr(unittf, 'start_time', None), client_tz),
|
||||||
|
'end_time': format_time_safely(getattr(unittf, 'end_time', None), client_tz) if getattr(unittf, 'end_time', None) else None,
|
||||||
|
'dir': dir_name, # 'ABOVE' | 'UNDER' | None
|
||||||
|
'start_type': start_type, # e.g. 'START' | 'CROSS0' | 'NEAR0_UP' | 'NEAR0_DOWN'
|
||||||
|
'end_type': end_type,
|
||||||
|
'invalid': getattr(unittf, 'invalid', False),
|
||||||
|
'peak_abs': peak_abs,
|
||||||
|
'length': length,
|
||||||
|
'klu_count': len(unittf.klu_list) if hasattr(unittf, 'klu_list') else 0,
|
||||||
|
'histset_count': len(unittf.histset_list) if hasattr(unittf, 'histset_list') else 0
|
||||||
|
}
|
||||||
|
serialized_data['unittf_list'].append(unittf_data)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"序列化unittf出错: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 序列化histset_list
|
||||||
|
for histset in chan_macd_data.get('histset_list', []):
|
||||||
|
try:
|
||||||
|
histset_data = {
|
||||||
|
'start_time': format_time_safely(getattr(histset, 'start_time', None), client_tz),
|
||||||
|
'end_time': format_time_safely(getattr(histset, 'end_time', None), client_tz),
|
||||||
|
'histset_dir': 'ABOVE' if histset.histset_dir == Chan_MACDHISTSET_DIR.ABOVE else 'UNDER',
|
||||||
|
'klu_count': len(histset.klu_list) if hasattr(histset, 'klu_list') else 0
|
||||||
|
}
|
||||||
|
serialized_data['histset_list'].append(histset_data)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"序列化histset出错: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 序列化状态标记数据
|
||||||
|
# 序列化高位列表
|
||||||
|
for high_pos in chan_macd_data.get('high_position_list', []):
|
||||||
|
try:
|
||||||
|
high_pos_data = {
|
||||||
|
'time': format_time_safely(high_pos['time'], client_tz),
|
||||||
|
'end_time': format_time_safely(high_pos.get('end_time'), client_tz) if high_pos.get('end_time') else None,
|
||||||
|
'type': high_pos.get('type', 'start'),
|
||||||
|
'macd': high_pos.get('macd'),
|
||||||
|
'signal': high_pos.get('signal'),
|
||||||
|
'macdhist': high_pos.get('macdhist'),
|
||||||
|
'end_macd': high_pos.get('end_macd'),
|
||||||
|
'end_signal': high_pos.get('end_signal'),
|
||||||
|
'end_macdhist': high_pos.get('end_macdhist')
|
||||||
|
}
|
||||||
|
serialized_data['high_position_list'].append(high_pos_data)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"序列化high_position出错: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 序列化高位空列表
|
||||||
|
for high_empty in chan_macd_data.get('high_empty_list', []):
|
||||||
|
try:
|
||||||
|
high_empty_data = {
|
||||||
|
'time': format_time_safely(high_empty['time'], client_tz),
|
||||||
|
'end_time': format_time_safely(high_empty.get('end_time'), client_tz) if high_empty.get('end_time') else None,
|
||||||
|
'type': high_empty.get('type', 'start'),
|
||||||
|
'macd': high_empty.get('macd'),
|
||||||
|
'signal': high_empty.get('signal'),
|
||||||
|
'macdhist': high_empty.get('macdhist'),
|
||||||
|
'end_macd': high_empty.get('end_macd'),
|
||||||
|
'end_signal': high_empty.get('end_signal'),
|
||||||
|
'end_macdhist': high_empty.get('end_macdhist')
|
||||||
|
}
|
||||||
|
serialized_data['high_empty_list'].append(high_empty_data)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"序列化high_empty出错: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 序列化低位与低位空
|
||||||
|
for low_pos in chan_macd_data.get('low_position_list', []):
|
||||||
|
try:
|
||||||
|
low_pos_data = {
|
||||||
|
'time': format_time_safely(low_pos['time'], client_tz),
|
||||||
|
'end_time': format_time_safely(low_pos.get('end_time'), client_tz) if low_pos.get('end_time') else None,
|
||||||
|
'type': low_pos.get('type', 'start'),
|
||||||
|
'macd': low_pos.get('macd'),
|
||||||
|
'signal': low_pos.get('signal'),
|
||||||
|
'macdhist': low_pos.get('macdhist'),
|
||||||
|
'end_macd': low_pos.get('end_macd'),
|
||||||
|
'end_signal': low_pos.get('end_signal'),
|
||||||
|
'end_macdhist': low_pos.get('end_macdhist')
|
||||||
|
}
|
||||||
|
serialized_data['low_position_list'].append(low_pos_data)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"序列化low_position出错: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
for low_empty in chan_macd_data.get('low_empty_list', []):
|
||||||
|
try:
|
||||||
|
low_empty_data = {
|
||||||
|
'time': format_time_safely(low_empty['time'], client_tz),
|
||||||
|
'end_time': format_time_safely(low_empty.get('end_time'), client_tz) if low_empty.get('end_time') else None,
|
||||||
|
'type': low_empty.get('type', 'start'),
|
||||||
|
'macd': low_empty.get('macd'),
|
||||||
|
'signal': low_empty.get('signal'),
|
||||||
|
'macdhist': low_empty.get('macdhist'),
|
||||||
|
'end_macd': low_empty.get('end_macd'),
|
||||||
|
'end_signal': low_empty.get('end_signal'),
|
||||||
|
'end_macdhist': low_empty.get('end_macdhist')
|
||||||
|
}
|
||||||
|
serialized_data['low_empty_list'].append(low_empty_data)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"序列化low_empty出错: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 序列化归零轴列表
|
||||||
|
for return_zero in chan_macd_data.get('return_zero_list', []):
|
||||||
|
try:
|
||||||
|
return_zero_data = {
|
||||||
|
'time': format_time_safely(return_zero['time'], client_tz),
|
||||||
|
'end_time': format_time_safely(return_zero.get('end_time'), client_tz) if return_zero.get('end_time') else None,
|
||||||
|
'type': return_zero.get('type', 'start'),
|
||||||
|
'macd': return_zero.get('macd'),
|
||||||
|
'signal': return_zero.get('signal'),
|
||||||
|
'macdhist': return_zero.get('macdhist'),
|
||||||
|
'end_macd': return_zero.get('end_macd'),
|
||||||
|
'end_signal': return_zero.get('end_signal'),
|
||||||
|
'end_macdhist': return_zero.get('end_macdhist')
|
||||||
|
}
|
||||||
|
serialized_data['return_zero_list'].append(return_zero_data)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"序列化return_zero出错: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 序列化穿越零轴列表
|
||||||
|
for cross0_up in chan_macd_data.get('cross0_up_list', []):
|
||||||
|
try:
|
||||||
|
cross0_up_data = {
|
||||||
|
'time': format_time_safely(cross0_up['time'], client_tz),
|
||||||
|
'type': cross0_up.get('type', 'start'),
|
||||||
|
'macd': cross0_up.get('macd'),
|
||||||
|
'signal': cross0_up.get('signal'),
|
||||||
|
'macdhist': cross0_up.get('macdhist')
|
||||||
|
}
|
||||||
|
serialized_data['cross0_up_list'].append(cross0_up_data)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"序列化cross0_up出错: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
for cross0_down in chan_macd_data.get('cross0_down_list', []):
|
||||||
|
try:
|
||||||
|
cross0_down_data = {
|
||||||
|
'time': format_time_safely(cross0_down['time'], client_tz),
|
||||||
|
'type': cross0_down.get('type', 'start'),
|
||||||
|
'macd': cross0_down.get('macd'),
|
||||||
|
'signal': cross0_down.get('signal'),
|
||||||
|
'macdhist': cross0_down.get('macdhist')
|
||||||
|
}
|
||||||
|
serialized_data['cross0_down_list'].append(cross0_down_data)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"序列化cross0_down出错: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 序列化 KLU 列表(仅导出需要的时间与背驰标志)
|
||||||
|
for klu in chan_macd_data.get('klu_list', []):
|
||||||
|
try:
|
||||||
|
serialized_data['klu_list'].append({
|
||||||
|
'time': format_time_safely(getattr(klu, 'time', None), client_tz),
|
||||||
|
'continue_div': bool(getattr(klu, 'continue_div', False)),
|
||||||
|
'separate_div': int(getattr(klu, 'separate_div', 0)) if getattr(klu, 'separate_div', 0) is not None else 0,
|
||||||
|
'near0_return': int(getattr(klu, 'near0_return', 0)) if getattr(klu, 'near0_return', 0) is not None else 0
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
print(f"序列化klu出错: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
return serialized_data
|
||||||
|
|
||||||
|
def clean_dataframe_for_json(df):
|
||||||
|
"""清理DataFrame数据用于JSON序列化"""
|
||||||
|
# 创建副本避免修改原始数据
|
||||||
|
clean_df = df.copy()
|
||||||
|
|
||||||
|
# 替换NaN值为None
|
||||||
|
clean_df = clean_df.where(pd.notnull(clean_df), None)
|
||||||
|
|
||||||
|
return clean_df
|
||||||
|
|
||||||
|
def get_uncompleted_seg_list(seg_list, client_tz):
|
||||||
|
"""获取未完成线段列表,正确处理倒数第二个和最后一个未完成线段"""
|
||||||
|
uncompleted_segs = [seg for seg in seg_list if not seg.is_sure]
|
||||||
|
|
||||||
|
if len(uncompleted_segs) == 0:
|
||||||
|
return []
|
||||||
|
|
||||||
|
result = []
|
||||||
|
|
||||||
|
for i, seg in enumerate(uncompleted_segs):
|
||||||
|
is_last = (i == len(uncompleted_segs) - 1) # 是否为最后一个未完成线段
|
||||||
|
|
||||||
|
seg_data = {
|
||||||
|
'start_time': seg.start_bi.start_klc.end_time if isinstance(seg.start_bi.start_klc.end_time, str) else seg.start_bi.start_klc.end_time.astimezone(client_tz).isoformat(),
|
||||||
|
'sure_time': format_time_safely(seg.sure_time, client_tz) if seg.sure_time else None,
|
||||||
|
'start_price': seg.start_bi.start_klc.low if convert_direction(seg.dir) == 1 else seg.start_bi.start_klc.high,
|
||||||
|
'direction': convert_direction(seg.dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
if is_last:
|
||||||
|
# 最后一个未完成线段:没有结束时间和价格
|
||||||
|
seg_data['end_time'] = None
|
||||||
|
seg_data['end_price'] = None
|
||||||
|
else:
|
||||||
|
# 倒数第二个及之前的未完成线段:使用实际的结束时间和价格
|
||||||
|
if seg.end_bi and seg.end_bi.end_klc:
|
||||||
|
seg_data['end_time'] = seg.end_bi.end_klc.end_time if isinstance(seg.end_bi.end_klc.end_time, str) else seg.end_bi.end_klc.end_time.astimezone(client_tz).isoformat()
|
||||||
|
seg_data['end_price'] = seg.end_bi.end_klc.high if convert_direction(seg.dir) == 1 else seg.end_bi.end_klc.low
|
||||||
|
else:
|
||||||
|
# 如果没有结束笔,设为None
|
||||||
|
seg_data['end_time'] = None
|
||||||
|
seg_data['end_price'] = None
|
||||||
|
|
||||||
|
result.append(seg_data)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
from collections import OrderedDict
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import ccxt
|
||||||
|
|
||||||
|
_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
if _ROOT not in sys.path:
|
||||||
|
sys.path.append(_ROOT)
|
||||||
|
|
||||||
|
from config import MACD_FAST, MACD_SLOW, MACD_SIGNAL, ccxt_proxies
|
||||||
|
from services.cn_stock import ChinaStockData
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
class TRADE_POINT_TYPE:
|
||||||
|
BUY1 = 1 # 一类买点
|
||||||
|
BUY2 = 2 # 二类买点
|
||||||
|
BUY3 = 3 # 三类买点
|
||||||
|
SELL1 = -1 # 一类卖点
|
||||||
|
SELL2 = -2 # 二类卖点
|
||||||
|
SELL3 = -3 # 三类卖点
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# mutable runtime state
|
||||||
|
macd_fast_period = MACD_FAST
|
||||||
|
macd_slow_period = MACD_SLOW
|
||||||
|
macd_signal_period = MACD_SIGNAL
|
||||||
|
|
||||||
|
_proxies = ccxt_proxies()
|
||||||
|
_exchange_kwargs = {"enableRateLimit": True}
|
||||||
|
if _proxies:
|
||||||
|
_exchange_kwargs["proxies"] = _proxies
|
||||||
|
exchange = ccxt.binance(_exchange_kwargs)
|
||||||
|
|
||||||
|
china_stock = ChinaStockData()
|
||||||
|
_zone_cache = {}
|
||||||
|
|
||||||
|
DEFAULT_TIMEFRAME_LABELS = OrderedDict([
|
||||||
|
("1m", "1分钟"),
|
||||||
|
("3m", "3分钟"),
|
||||||
|
("5m", "5分钟"),
|
||||||
|
("15m", "15分钟"),
|
||||||
|
("30m", "30分钟"),
|
||||||
|
("1h", "1小时"),
|
||||||
|
("2h", "2小时"),
|
||||||
|
("4h", "4小时"),
|
||||||
|
("6h", "6小时"),
|
||||||
|
("8h", "8小时"),
|
||||||
|
("12h", "12小时"),
|
||||||
|
("1d", "日线"),
|
||||||
|
("3d", "3日线"),
|
||||||
|
("1w", "周线"),
|
||||||
|
("1M", "月线"),
|
||||||
|
])
|
||||||
|
|
||||||
|
DEFAULT_SYMBOLS = [
|
||||||
|
'SOL/USDT:USDT', 'BTC/USDT:USDT', 'ETH/USDT:USDT', 'BNB/USDT:USDT', 'XRP/USDT:USDT', 'WIF/USDT:USDT',
|
||||||
|
'ADA/USDT:USDT', 'DOGE/USDT:USDT', 'AVAX/USDT:USDT', 'DOT/USDT:USDT', 'MATIC/USDT:USDT'
|
||||||
|
]
|
||||||
|
|
||||||
|
TIMEFRAMES = DEFAULT_TIMEFRAME_LABELS.copy()
|
||||||
|
SYMBOLS = DEFAULT_SYMBOLS.copy()
|
||||||
|
DATA_SERVICE_AVAILABLE = False
|
||||||
|
SERVICE_METADATA_LAST_REFRESH = 0
|
||||||
|
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections import OrderedDict
|
||||||
|
from .state import DEFAULT_TIMEFRAME_LABELS
|
||||||
|
|
||||||
|
def _zone_cache_ttl(tf_name: str) -> int:
|
||||||
|
"""根据时间周期返回缓存过期时间(秒)"""
|
||||||
|
minutes = timeframe_to_minutes(tf_name) or 5
|
||||||
|
if minutes <= 5:
|
||||||
|
return 120 # 5m及以下: 2分钟
|
||||||
|
elif minutes <= 15:
|
||||||
|
return 300 # 15m: 5分钟
|
||||||
|
elif minutes <= 60:
|
||||||
|
return 600 # 1h: 10分钟
|
||||||
|
else:
|
||||||
|
return 1800 # 4h+: 30分钟
|
||||||
|
|
||||||
|
|
||||||
|
def timeframe_to_minutes(tf: str):
|
||||||
|
"""将时间周期转换为分钟数,用于排序。"""
|
||||||
|
if not tf:
|
||||||
|
return None
|
||||||
|
unit = tf[-1]
|
||||||
|
try:
|
||||||
|
value = int(tf[:-1])
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return None
|
||||||
|
multiplier = {
|
||||||
|
'm': 1,
|
||||||
|
'h': 60,
|
||||||
|
'd': 1440,
|
||||||
|
'w': 10080,
|
||||||
|
'M': 43200, # 30天近似
|
||||||
|
}.get(unit)
|
||||||
|
if multiplier is None:
|
||||||
|
return None
|
||||||
|
return value * multiplier
|
||||||
|
|
||||||
|
|
||||||
|
def format_timeframe_label(tf: str) -> str:
|
||||||
|
"""将时间周期转换为可读标签。"""
|
||||||
|
if not tf:
|
||||||
|
return tf
|
||||||
|
unit = tf[-1]
|
||||||
|
try:
|
||||||
|
value = int(tf[:-1])
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return tf
|
||||||
|
if unit == 'm':
|
||||||
|
return f"{value}分钟"
|
||||||
|
if unit == 'h':
|
||||||
|
return f"{value}小时"
|
||||||
|
if unit == 'd':
|
||||||
|
return "日线" if value == 1 else f"{value}日线"
|
||||||
|
if unit == 'w':
|
||||||
|
return "周线" if value == 1 else f"{value}周线"
|
||||||
|
if unit == 'M':
|
||||||
|
return "月线" if value == 1 else f"{value}月线"
|
||||||
|
return tf
|
||||||
|
|
||||||
|
|
||||||
|
def build_timeframe_labels(timeframes):
|
||||||
|
ordered = sorted(
|
||||||
|
timeframes,
|
||||||
|
key=lambda tf: timeframe_to_minutes(tf) if timeframe_to_minutes(tf) is not None else float('inf'),
|
||||||
|
)
|
||||||
|
labels = OrderedDict()
|
||||||
|
for tf in ordered:
|
||||||
|
labels[tf] = format_timeframe_label(tf)
|
||||||
|
return labels
|
||||||
|
|
||||||
|
|
||||||
|
def _adjacent_smaller(timeframe_keys, ceiling_tf):
|
||||||
|
"""取排序列表中严格小于 ceiling 的相邻周期。"""
|
||||||
|
if not timeframe_keys:
|
||||||
|
return ceiling_tf
|
||||||
|
try:
|
||||||
|
idx = timeframe_keys.index(ceiling_tf)
|
||||||
|
return timeframe_keys[idx - 1] if idx > 0 else timeframe_keys[0]
|
||||||
|
except ValueError:
|
||||||
|
return timeframe_keys[0]
|
||||||
|
|
||||||
|
|
||||||
|
def _prefer_smaller(candidates, labels_ordered, ceiling_tf, timeframe_keys):
|
||||||
|
"""从候选中选第一个存在且严格小于 ceiling 的周期,否则回退相邻更小。"""
|
||||||
|
ceil_m = timeframe_to_minutes(ceiling_tf)
|
||||||
|
for tf in candidates:
|
||||||
|
m = timeframe_to_minutes(tf)
|
||||||
|
if tf in labels_ordered and m is not None and ceil_m is not None and m < ceil_m:
|
||||||
|
return tf
|
||||||
|
return _adjacent_smaller(timeframe_keys, ceiling_tf)
|
||||||
|
|
||||||
|
|
||||||
|
def compute_timeframe_defaults(labels_ordered):
|
||||||
|
"""
|
||||||
|
根据已排序的「周期 → 中文标签」映射,计算主 / 次 / 次次周期默认值。
|
||||||
|
默认偏好:主 4h、次 2h、次次 1h(威科夫与结构在小时级更可读)。
|
||||||
|
labels_ordered: OrderedDict 或按插入顺序排列的 dict。
|
||||||
|
"""
|
||||||
|
if not labels_ordered:
|
||||||
|
labels_ordered = DEFAULT_TIMEFRAME_LABELS.copy()
|
||||||
|
timeframe_keys = list(labels_ordered.keys())
|
||||||
|
preferred_main = next((tf for tf in ['4h', '2h', '1h'] if tf in labels_ordered), None)
|
||||||
|
default_main = preferred_main or (timeframe_keys[0] if timeframe_keys else '1m')
|
||||||
|
if default_main not in labels_ordered and timeframe_keys:
|
||||||
|
default_main = timeframe_keys[0]
|
||||||
|
|
||||||
|
default_element = _prefer_smaller(['2h', '1h'], labels_ordered, default_main, timeframe_keys)
|
||||||
|
default_sub_sub = _prefer_smaller(['1h'], labels_ordered, default_element, timeframe_keys)
|
||||||
|
|
||||||
|
return default_main, default_element, default_sub_sub, timeframe_keys
|
||||||
|
|
||||||
|
def is_smaller_timeframe(tf1, tf2):
|
||||||
|
"""判断时间周期tf1是否小于tf2"""
|
||||||
|
tf1_value = timeframe_to_minutes(tf1)
|
||||||
|
tf2_value = timeframe_to_minutes(tf2)
|
||||||
|
if tf1_value is None or tf2_value is None:
|
||||||
|
return False
|
||||||
|
return tf1_value < tf2_value
|
||||||
|
|
||||||
|
def is_smaller_or_equal_timeframe(tf1, tf2):
|
||||||
|
"""判断时间周期tf1是否小于等于tf2"""
|
||||||
|
tf1_value = timeframe_to_minutes(tf1)
|
||||||
|
tf2_value = timeframe_to_minutes(tf2)
|
||||||
|
if tf1_value is None or tf2_value is None:
|
||||||
|
return False
|
||||||
|
return tf1_value <= tf2_value
|
||||||
|
|
||||||
@@ -981,19 +981,41 @@ function findBiCenters(biList) {
|
|||||||
var lows = biList_for_zs.map(function(bi) { return Math.min(bi.p0, bi.p1) })
|
var lows = biList_for_zs.map(function(bi) { return Math.min(bi.p0, bi.p1) })
|
||||||
gg = Math.max.apply(null, highs)
|
gg = Math.max.apply(null, highs)
|
||||||
dd = Math.min.apply(null, lows)
|
dd = Math.min.apply(null, lows)
|
||||||
endBiIdx = startIdx + addedAfterLeave.length
|
endBiIdx = startIdx + 2 + addedAfterLeave.length
|
||||||
|
}
|
||||||
|
|
||||||
|
var lastBiInCenter = biList_for_zs[biList_for_zs.length - 1]
|
||||||
|
// 是否已离开中枢:之后出现完全在 ZG 之上或 ZD 之下的确认笔 → 中枢完成
|
||||||
|
var zsSure = false
|
||||||
|
var lastInListIdx = -1
|
||||||
|
for (var li = 0; li < biList.length; li++) {
|
||||||
|
if (biList[li] === lastBiInCenter || (biList[li].t0 === lastBiInCenter.t0 && biList[li].t1 === lastBiInCenter.t1)) {
|
||||||
|
lastInListIdx = li
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (lastInListIdx < 0) lastInListIdx = endBiIdx
|
||||||
|
for (var j = lastInListIdx + 1; j < biList.length; j++) {
|
||||||
|
var leaveBi = biList[j]
|
||||||
|
if (!leaveBi.sure) break
|
||||||
|
var lbh = Math.max(leaveBi.p0, leaveBi.p1)
|
||||||
|
var lbl = Math.min(leaveBi.p0, leaveBi.p1)
|
||||||
|
if (lbl > zg || lbh < zd) {
|
||||||
|
zsSure = true
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var zs = {
|
var zs = {
|
||||||
t0: bi1.t0,
|
t0: bi1.t0,
|
||||||
t1: biList_for_zs[biList_for_zs.length - 1].t1,
|
t1: lastBiInCenter.t1,
|
||||||
high: zg, low: zd,
|
high: zg, low: zd,
|
||||||
zg: zg, zd: zd,
|
zg: zg, zd: zd,
|
||||||
gg: gg, dd: dd,
|
gg: gg, dd: dd,
|
||||||
is_sure: biList_for_zs[biList_for_zs.length - 1].sure,
|
is_sure: zsSure,
|
||||||
bi_count: biList_for_zs.length,
|
bi_count: biList_for_zs.length,
|
||||||
bi_list: biList_for_zs, // 中枢内的笔列表(按序)
|
bi_list: biList_for_zs,
|
||||||
start_bi_idx: startIdx, // 中枢首笔在总列表中的索引
|
start_bi_idx: startIdx,
|
||||||
dir: zsDir,
|
dir: zsDir,
|
||||||
pre: lastZs,
|
pre: lastZs,
|
||||||
next: null,
|
next: null,
|
||||||
@@ -1008,28 +1030,6 @@ function findBiCenters(biList) {
|
|||||||
startIdx = startIdx + 4 + (addedAfterLeave.length > 0 ? addedAfterLeave.length : 0)
|
startIdx = startIdx + 4 + (addedAfterLeave.length > 0 ? addedAfterLeave.length : 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 末中枢确认
|
|
||||||
if (lastZs && !lastZs.is_sure) {
|
|
||||||
var lastBiInZs = lastZs.bi_count > 0 ? biList_for_zs[biList_for_zs.length - 1] : null
|
|
||||||
if (lastBiInZs) {
|
|
||||||
var hasLeave = false
|
|
||||||
var lastBiIdx = biList.indexOf(lastBiInZs)
|
|
||||||
if (lastBiIdx >= 0) {
|
|
||||||
for (var i = lastBiIdx + 1; i < biList.length; i++) {
|
|
||||||
var bi = biList[i]
|
|
||||||
if (bi.sure) {
|
|
||||||
var bh = Math.max(bi.p0, bi.p1), bl = Math.min(bi.p0, bi.p1)
|
|
||||||
var leave = (bl > lastZs.zg && bh > lastZs.zg) || (bh < lastZs.zd && bl < lastZs.zd)
|
|
||||||
if (leave) { hasLeave = true; break }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (hasLeave && lastBiInZs.sure) {
|
|
||||||
lastZs.t1 = lastBiInZs.t1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return zsList
|
return zsList
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1119,16 +1119,37 @@ function findSegCenters(segs) {
|
|||||||
endSegIdx = startIdx + 2 + addedSegs.length
|
endSegIdx = startIdx + 2 + addedSegs.length
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var lastSegInCenter = segList_for_zs[segList_for_zs.length - 1]
|
||||||
|
var zsSure = false
|
||||||
|
var lastSegListIdx = -1
|
||||||
|
for (var lsi = 0; lsi < segs.length; lsi++) {
|
||||||
|
if (segs[lsi] === lastSegInCenter || (segs[lsi].t0 === lastSegInCenter.t0 && segs[lsi].t1 === lastSegInCenter.t1)) {
|
||||||
|
lastSegListIdx = lsi
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (lastSegListIdx < 0) lastSegListIdx = endSegIdx
|
||||||
|
for (var sj = lastSegListIdx + 1; sj < segs.length; sj++) {
|
||||||
|
var leaveSeg = segs[sj]
|
||||||
|
if (!leaveSeg.sure) break
|
||||||
|
var lsh = Math.max(leaveSeg.p0, leaveSeg.p1)
|
||||||
|
var lsl = Math.min(leaveSeg.p0, leaveSeg.p1)
|
||||||
|
if (lsl > zg || lsh < zd) {
|
||||||
|
zsSure = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var zs = {
|
var zs = {
|
||||||
t0: s1.t0,
|
t0: s1.t0,
|
||||||
t1: segs[endSegIdx].t1,
|
t1: lastSegInCenter.t1,
|
||||||
high: zg, low: zd,
|
high: zg, low: zd,
|
||||||
zg: zg, zd: zd,
|
zg: zg, zd: zd,
|
||||||
gg: gg, dd: dd,
|
gg: gg, dd: dd,
|
||||||
is_sure: segs[endSegIdx].sure,
|
is_sure: zsSure,
|
||||||
seg_count: segList_for_zs.length,
|
seg_count: segList_for_zs.length,
|
||||||
seg_list: segList_for_zs, // 中枢内的段列表(按序)
|
seg_list: segList_for_zs,
|
||||||
start_seg_idx: startIdx, // 中枢首段在总列表中的索引
|
start_seg_idx: startIdx,
|
||||||
dir: zsDir,
|
dir: zsDir,
|
||||||
pre: lastZs,
|
pre: lastZs,
|
||||||
next: null,
|
next: null,
|
||||||
|
|||||||
@@ -121,6 +121,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 中枢填充:区间内每根 bar 写入 top/bottom
|
// 中枢填充:区间内每根 bar 写入 top/bottom
|
||||||
|
// 未完成中枢:右边界拉到最新 K(与主站 uncompleted_zs 一致)
|
||||||
function fillZs(t0, t1, high, low, topField, botField) {
|
function fillZs(t0, t1, high, low, topField, botField) {
|
||||||
var lo = lowerBound(sortedBarTimes, t0)
|
var lo = lowerBound(sortedBarTimes, t0)
|
||||||
var hi = upperBound(sortedBarTimes, t1)
|
var hi = upperBound(sortedBarTimes, t1)
|
||||||
@@ -131,14 +132,30 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var lastBarT = sortedBarTimes.length ? sortedBarTimes[sortedBarTimes.length - 1] : null
|
||||||
|
|
||||||
if (slice.zs) {
|
if (slice.zs) {
|
||||||
slice.zs.forEach(function (z) {
|
slice.zs.forEach(function (z) {
|
||||||
fillZs(z.t0, z.t1, z.high || z.zg, z.low || z.zd, 'zs_top', 'zs_bottom')
|
var t1 = z.t1
|
||||||
|
var sure = z.is_sure !== false && z.is_sure !== 0
|
||||||
|
if (!sure && lastBarT != null) t1 = Math.max(t1 || 0, lastBarT)
|
||||||
|
if (sure) {
|
||||||
|
fillZs(z.t0, t1, z.high || z.zg, z.low || z.zd, 'zs_top', 'zs_bottom')
|
||||||
|
} else {
|
||||||
|
fillZs(z.t0, t1, z.high || z.zg, z.low || z.zd, 'zs_pending_top', 'zs_pending_bottom')
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (slice.segzs) {
|
if (slice.segzs) {
|
||||||
slice.segzs.forEach(function (z) {
|
slice.segzs.forEach(function (z) {
|
||||||
fillZs(z.t0, z.t1, z.high || z.zg, z.low || z.zd, 'segzs_top', 'segzs_bottom')
|
var t1 = z.t1
|
||||||
|
var sure = z.is_sure !== false && z.is_sure !== 0
|
||||||
|
if (!sure && lastBarT != null) t1 = Math.max(t1 || 0, lastBarT)
|
||||||
|
if (sure) {
|
||||||
|
fillZs(z.t0, t1, z.high || z.zg, z.low || z.zd, 'segzs_top', 'segzs_bottom')
|
||||||
|
} else {
|
||||||
|
fillZs(z.t0, t1, z.high || z.zg, z.low || z.zd, 'segzs_pending_top', 'segzs_pending_bottom')
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -233,6 +250,10 @@
|
|||||||
{ id: 'zs_bottom', type: 'line' },
|
{ id: 'zs_bottom', type: 'line' },
|
||||||
{ id: 'segzs_top', type: 'line' },
|
{ id: 'segzs_top', type: 'line' },
|
||||||
{ id: 'segzs_bottom', type: 'line' },
|
{ id: 'segzs_bottom', type: 'line' },
|
||||||
|
{ id: 'zs_pending_top', type: 'line' },
|
||||||
|
{ id: 'zs_pending_bottom', type: 'line' },
|
||||||
|
{ id: 'segzs_pending_top', type: 'line' },
|
||||||
|
{ id: 'segzs_pending_bottom', type: 'line' },
|
||||||
]
|
]
|
||||||
|
|
||||||
BSP_SUBTYPES.forEach(function (t) {
|
BSP_SUBTYPES.forEach(function (t) {
|
||||||
@@ -279,6 +300,22 @@
|
|||||||
linestyle: 0, linewidth: 0, plottype: 0, trackPrice: false,
|
linestyle: 0, linewidth: 0, plottype: 0, trackPrice: false,
|
||||||
transparency: 100, visible: false, color: '#ef6c00', display: 0,
|
transparency: 100, visible: false, color: '#ef6c00', display: 0,
|
||||||
}),
|
}),
|
||||||
|
zs_pending_top: mergeStyle('zs_pending_top', {
|
||||||
|
linestyle: 0, linewidth: 0, plottype: 0, trackPrice: false,
|
||||||
|
transparency: 100, visible: false, color: '#f1c40f', display: 0,
|
||||||
|
}),
|
||||||
|
zs_pending_bottom: mergeStyle('zs_pending_bottom', {
|
||||||
|
linestyle: 0, linewidth: 0, plottype: 0, trackPrice: false,
|
||||||
|
transparency: 100, visible: false, color: '#f1c40f', display: 0,
|
||||||
|
}),
|
||||||
|
segzs_pending_top: mergeStyle('segzs_pending_top', {
|
||||||
|
linestyle: 0, linewidth: 0, plottype: 0, trackPrice: false,
|
||||||
|
transparency: 100, visible: false, color: '#9b59b6', display: 0,
|
||||||
|
}),
|
||||||
|
segzs_pending_bottom: mergeStyle('segzs_pending_bottom', {
|
||||||
|
linestyle: 0, linewidth: 0, plottype: 0, trackPrice: false,
|
||||||
|
transparency: 100, visible: false, color: '#9b59b6', display: 0,
|
||||||
|
}),
|
||||||
}
|
}
|
||||||
|
|
||||||
// BSP 样式
|
// BSP 样式
|
||||||
@@ -311,6 +348,10 @@
|
|||||||
zs_bottom: { title: '中枢下沿', histogramBase: 0, isHidden: true },
|
zs_bottom: { title: '中枢下沿', histogramBase: 0, isHidden: true },
|
||||||
segzs_top: { title: '段中枢上沿', histogramBase: 0, isHidden: true },
|
segzs_top: { title: '段中枢上沿', histogramBase: 0, isHidden: true },
|
||||||
segzs_bottom: { title: '段中枢下沿', histogramBase: 0, isHidden: true },
|
segzs_bottom: { title: '段中枢下沿', histogramBase: 0, isHidden: true },
|
||||||
|
zs_pending_top: { title: '未完成中枢上沿', histogramBase: 0, isHidden: true },
|
||||||
|
zs_pending_bottom: { title: '未完成中枢下沿', histogramBase: 0, isHidden: true },
|
||||||
|
segzs_pending_top: { title: '未完成段中枢上沿', histogramBase: 0, isHidden: true },
|
||||||
|
segzs_pending_bottom: { title: '未完成段中枢下沿', histogramBase: 0, isHidden: true },
|
||||||
}
|
}
|
||||||
|
|
||||||
BSP_SUBTYPES.forEach(function (t) {
|
BSP_SUBTYPES.forEach(function (t) {
|
||||||
@@ -358,7 +399,7 @@
|
|||||||
name: '缠论',
|
name: '缠论',
|
||||||
metainfo: {
|
metainfo: {
|
||||||
_metainfoVersion: 53,
|
_metainfoVersion: 53,
|
||||||
id: 'Chan@tv-basicstudies-5',
|
id: 'Chan@tv-basicstudies-6',
|
||||||
scriptIdPart: '',
|
scriptIdPart: '',
|
||||||
description: 'Chan 缠论',
|
description: 'Chan 缠论',
|
||||||
shortDescription: '缠论',
|
shortDescription: '缠论',
|
||||||
@@ -373,12 +414,18 @@
|
|||||||
title: '中枢', isHidden: false },
|
title: '中枢', isHidden: false },
|
||||||
{ id: 'segzs_fill', objAId: 'segzs_top', objBId: 'segzs_bottom', type: 'plot_plot',
|
{ id: 'segzs_fill', objAId: 'segzs_top', objBId: 'segzs_bottom', type: 'plot_plot',
|
||||||
title: '段中枢', isHidden: false },
|
title: '段中枢', isHidden: false },
|
||||||
|
{ id: 'zs_pending_fill', objAId: 'zs_pending_top', objBId: 'zs_pending_bottom', type: 'plot_plot',
|
||||||
|
title: '未完成中枢', isHidden: false },
|
||||||
|
{ id: 'segzs_pending_fill', objAId: 'segzs_pending_top', objBId: 'segzs_pending_bottom', type: 'plot_plot',
|
||||||
|
title: '未完成段中枢', isHidden: false },
|
||||||
],
|
],
|
||||||
defaults: {
|
defaults: {
|
||||||
styles: styles,
|
styles: styles,
|
||||||
filledAreasStyle: {
|
filledAreasStyle: {
|
||||||
zs_fill: mergeFill('zs_fill', { color: '#f1d96a', visible: true, transparency: 75 }),
|
zs_fill: mergeFill('zs_fill', { color: '#f1d96a', visible: true, transparency: 75 }),
|
||||||
segzs_fill: mergeFill('segzs_fill', { color: '#6361f7', visible: true, transparency: 75 }),
|
segzs_fill: mergeFill('segzs_fill', { color: '#6361f7', visible: true, transparency: 75 }),
|
||||||
|
zs_pending_fill: mergeFill('zs_pending_fill', { color: '#f1c40f', visible: true, transparency: 55 }),
|
||||||
|
segzs_pending_fill: mergeFill('segzs_pending_fill', { color: '#9b59b6', visible: true, transparency: 55 }),
|
||||||
},
|
},
|
||||||
precision: 2,
|
precision: 2,
|
||||||
inputs: { epoch: 0 },
|
inputs: { epoch: 0 },
|
||||||
@@ -394,8 +441,8 @@
|
|||||||
self._context = ctx
|
self._context = ctx
|
||||||
}
|
}
|
||||||
this.main = function (context) {
|
this.main = function (context) {
|
||||||
// 32 个 plot: 8 结构 + 24 BSP
|
// 36 个 plot: 12 结构 + 24 BSP
|
||||||
var NANS = new Array(32).fill(NaN)
|
var NANS = new Array(36).fill(NaN)
|
||||||
// v31: sniffing pass 时 context.symbol.time 为 NaN
|
// v31: sniffing pass 时 context.symbol.time 为 NaN
|
||||||
var t = context.symbol.time
|
var t = context.symbol.time
|
||||||
if (isNaN(t)) return NANS
|
if (isNaN(t)) return NANS
|
||||||
@@ -415,6 +462,10 @@
|
|||||||
e.zs_bottom != null ? e.zs_bottom : NaN,
|
e.zs_bottom != null ? e.zs_bottom : NaN,
|
||||||
e.segzs_top != null ? e.segzs_top : NaN,
|
e.segzs_top != null ? e.segzs_top : NaN,
|
||||||
e.segzs_bottom != null ? e.segzs_bottom : NaN,
|
e.segzs_bottom != null ? e.segzs_bottom : NaN,
|
||||||
|
e.zs_pending_top != null ? e.zs_pending_top : NaN,
|
||||||
|
e.zs_pending_bottom != null ? e.zs_pending_bottom : NaN,
|
||||||
|
e.segzs_pending_top != null ? e.segzs_pending_top : NaN,
|
||||||
|
e.segzs_pending_bottom != null ? e.segzs_pending_bottom : NaN,
|
||||||
]
|
]
|
||||||
|
|
||||||
BSP_SUBTYPES.forEach(function (sub) {
|
BSP_SUBTYPES.forEach(function (sub) {
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
/* chart_format.js — split from chart.js */
|
/* chart_format.js — split from chart.js */
|
||||||
/* chart.js */
|
/* chart.js */
|
||||||
function updateChartDisplay() {
|
function updateChartDisplay() {
|
||||||
|
if (typeof renderWyckoffCycleSummary === 'function') {
|
||||||
|
renderWyckoffCycleSummary();
|
||||||
|
}
|
||||||
if (currentData) {
|
if (currentData) {
|
||||||
// 检测K线周期是否切换
|
// 检测K线周期是否切换
|
||||||
const curPeriod = $('#subSubPeriodKline').is(':checked') ? 'subsub' :
|
const curPeriod = $('#subSubPeriodKline').is(':checked') ? 'subsub' :
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user