Author SHA1 Message Date
jackyu66git e0fa9ac375 fix: pipeline MACD 参数统一为标准 12/26/9(与 web/交易所一致) 2026-09-12 02:15:21 +08:00
jackyu66gitandCursor 6c627f009a docs(ECR-008): record implementation commit in TRACEABILITY
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 15:09:48 +08:00
jackyu66gitandCursor dbb6202325 feat(ECR-008): 拆分主站 chart_tv.js 为多模块薄门面
行为冻结物理拆分;保留 initTradingView/dispose 对外 API;无打包器。node --check 全绿。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 15:09:48 +08:00
jackyu66gitandCursor efad2bb333 docs(ECR-007): archive LOOP-RUN-005 and sync STATE
关门收尾:归档 loop/gate 产物至 docs/runs,同步 CURRENT/MEMORY/PROFILE,并忽略工作目录 .gates/loop。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 15:02:54 +08:00
jackyu66gitandCursor 2964d6f230 docs(ECR-007): mark LOOP-RUN-005 DONE after Final Approval
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 03:20:39 +08:00
jackyu66gitandCursor 7991a6b2bf docs(ECR-007): record implementation commit in TRACEABILITY
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 03:14:19 +08:00
jackyu66gitandCursor 276481e02c feat(ECR-007): Wyckoff Live Structure with Confirmed/Live isolation
Add live.py lifecycle and event candidates; assemble confirmed vs live
in engine; Summary partition; execution_signal source=confirmed only.
Keep strategies untouched; do not lower Confirmed thresholds for Live.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 03:14:19 +08:00
61 changed files with 7710 additions and 4847 deletions
+4
View File
@@ -40,3 +40,7 @@ feature_meta
.DS_Store
data_provider/._config.json
.gstack/
# ESS gate / engineering-loop working dirs(归档进 docs/runs/
.gates/
loop/
+3 -2
View File
@@ -1,6 +1,7 @@
"""威科夫分析(启发式):交易区间 / 阶段 / 事件 / Volume Profile。"""
"""威科夫分析(启发式):交易区间 / 阶段 / 事件 / Volume Profile / Live"""
from __future__ import annotations
from .engine import analyze_wyckoff
from .live import execution_signal_from_wyckoff
__all__ = ["analyze_wyckoff"]
__all__ = ["analyze_wyckoff", "execution_signal_from_wyckoff"]
+145 -29
View File
@@ -1,12 +1,18 @@
"""威科夫分析入口"""
"""威科夫分析入口Cycle → Phase → Event → VP + LiveMULTI-CYCLE / LIVE-STRUCTURE)。
range.py 只产 TradingRangeConfirmed 走 events.pyLive 走 live.py。
cycles[0]=ACTIVE;禁止 cycles[-1] 取 active。
Execution 只消费 Confirmed(见 live.execution_signal_from_wyckoff)。
"""
from __future__ import annotations
from typing import Any, Dict, Optional
from typing import Any, Dict, List, Optional
import pandas as pd
from .events import build_phases, detect_bias_and_events
from .range import detect_trading_range
from .live import analyze_live_structure
from .range import detect_trading_ranges
from .volume_profile import compute_volume_profile
@@ -21,31 +27,55 @@ def _fmt_time(v) -> Optional[str]:
return str(v)
def analyze_wyckoff(df: pd.DataFrame, lookback: int = 120, vp_bins: int = 50) -> Dict[str, Any]:
"""
对主周期 OHLCV DataFrame 做威科夫启发式分析。
需要列: open, high, low, close, volume;建议有 date 或 timestamp。
"""
empty = {
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,
}
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
tr = detect_trading_range(work, lookback=lookback)
if tr is None:
return empty
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(
@@ -54,27 +84,113 @@ def analyze_wyckoff(df: pd.DataFrame, lookback: int = 120, vp_bins: int = 50) ->
int(tr["abs_end_idx"]),
bin_count=vp_bins,
)
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(tr.get("active", True)),
"bars": int(tr.get("bars", 0)),
}
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"),
}
+157 -35
View File
@@ -1,7 +1,7 @@
"""威科夫阶段与事件(启发式)。"""
from __future__ import annotations
from typing import Any, Dict, List, Tuple
from typing import Any, Dict, List, Optional, Tuple
import numpy as np
import pandas as pd
@@ -29,6 +29,9 @@ def detect_bias_and_events(
) -> 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"])
@@ -38,6 +41,24 @@ def detect_bias_and_events(
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))
@@ -57,8 +78,8 @@ def detect_bias_and_events(
avg_v = _avg_vol(df, i)
ratio = vol / avg_v if avg_v else 0.0
# Spring: pierce below low then close back above low
if spring is None and low < lo - tol * 0.5 and close >= lo - tol * 0.2:
# 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",
@@ -70,8 +91,8 @@ def detect_bias_and_events(
"idx": i,
}
# UTAD: pierce above high then close back below
if utad is None and high > hi + tol * 0.5 and close <= hi + tol * 0.2:
# 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",
@@ -154,11 +175,15 @@ def detect_bias_and_events(
}
break
# 冲突清理:已判定吸筹且有 SOS 时,丢弃更早的 UTAD(避免阶段/图面误导)
# 派发且有 SOW 时,丢弃更晚才合理的 Spring 假信号同理在偏置后再滤
keep = []
for ev in (spring, sos, lps, utad, sod, lpsy):
if ev:
events.append({k: v for k, v in ev.items() if k != "idx"})
if not ev:
continue
keep.append(ev)
# bias
# 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))):
@@ -174,6 +199,16 @@ def detect_bias_and_events(
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,
@@ -189,59 +224,146 @@ def build_phases(
events: List[Dict[str, Any]],
min_bars: int = 3,
) -> List[Dict[str, Any]]:
"""按时间切分 A–E 粗阶段;保证非重叠且每段至少 min_bars 根(空间不足则截断尾部阶段)。"""
"""
按威科夫事件锚点切分 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)
event_idx = {}
for ev in events:
t = ev.get("time")
for i in range(s, min(len(df), e + 20)):
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:
event_idx[ev["type"]] = i
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
a_end = s + max(min_bars, (e - s) // 5)
c_anchor = event_idx.get("Spring") or event_idx.get("UTAD") or (s + (e - s) // 2)
d_anchor = event_idx.get("SOS") or event_idx.get("SOW") or e
def _lab(phase: str) -> str:
if bias == "distribution":
m = {"A": "A停止上涨", "B": "B筑顶", "C": "C测试", "D": "D派发", "E": "E下跌"}
else:
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)
# 理想切点(随后再强制非重叠 + 最小跨度)
raw = [
("A", s, a_end),
("B", a_end, c_anchor),
("C", c_anchor, d_anchor),
("D", d_anchor, min(n_last, d_anchor + max(min_bars, (e - s) // 6))),
("E", min(n_last, d_anchor + max(min_bars, (e - s) // 6)), min(n_last, max(e, d_anchor + max(min_bars * 2, 8)))),
]
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(_b, a + min_span))
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 < min_span:
# 尾部空间不足:并入上一段终点并停止新增
if phases:
phases[-1]["end_time"] = _bar_time(df, n_last)
break
if b < a:
continue
if phases and phases[-1].get("_a") == a and phases[-1].get("_b") == b:
continue
phases.append(
{
"phase": phase,
"label": _lab(phase),
"start_time": _bar_time(df, a),
"end_time": _bar_time(df, b),
"_a": a,
"_b": b,
}
)
cursor = b
for p in phases:
p.pop("_a", None)
p.pop("_b", None)
return phases
+258
View File
@@ -0,0 +1,258 @@
"""威科夫 Live / Developing 层(WYCKOFF-LIVE-STRUCTURE-001)。
独立于 Confirmed Engine:不修改 events 确认条件,不写入 confirmed.events。
Execution 不得消费本模块输出。
"""
from __future__ import annotations
from typing import Any, Dict, List, Optional, Set
import numpy as np
import pandas as pd
def _avg_vol(df: pd.DataFrame, i: int, win: int = 20) -> float:
a = max(0, i - win + 1)
v = df["volume"].astype(float).iloc[a : i + 1]
m = float(v.mean()) if len(v) else 0.0
return m if m > 0 else 1.0
def _empty_live() -> Dict[str, Any]:
return {
"lifecycle": "UNKNOWN",
"range_formation": None,
"phase_candidate": None,
"event_candidates": [],
"next_expected": None,
"confidence": {
"cycle": 0.0,
"phase": 0.0,
"event": 0.0,
"structure": 0.0,
"volume": 0.0,
"overall": 0.0,
},
"note": "",
}
def analyze_live_structure(
df: pd.DataFrame,
tr: Optional[Dict[str, Any]],
confirmed_events: Optional[List[Dict[str, Any]]] = None,
confirmed_phases: Optional[List[Dict[str, Any]]] = None,
bias: str = "unknown",
) -> Dict[str, Any]:
"""
基于当前 TradingRange 与已确认事件,推演 Live candidates。
confirmed_* 只读,用于避免重复提示已确认事件,不修改之。
"""
out = _empty_live()
if df is None or len(df) < 20 or tr is None:
out["note"] = "insufficient structure"
return out
confirmed_events = confirmed_events or []
confirmed_phases = confirmed_phases or []
confirmed_types: Set[str] = {str(e.get("type")) for e in confirmed_events if e.get("type")}
s = int(tr["abs_start_idx"])
e = int(tr["abs_end_idx"])
scan_end = int(tr.get("abs_scan_end_idx", len(df) - 1))
scan_end = min(len(df) - 1, max(scan_end, e))
hi = float(tr["high"])
lo = float(tr["low"])
mid = float(tr["mid"])
tol = float(tr.get("tol") or (hi - lo) * 0.05)
atr = float(tr.get("atr") or max((hi - lo) * 0.2, 1e-9))
seg = df.iloc[s : e + 1]
if len(seg) < 8:
out["note"] = "range too short"
return out
# —— Range Formation(横盘 / 波动收敛)——
closes = seg["close"].astype(float)
highs = seg["high"].astype(float)
lows = seg["low"].astype(float)
vols = seg["volume"].astype(float) if "volume" in seg.columns else pd.Series([1.0] * len(seg))
half = max(4, len(seg) // 2)
vol_early = float(np.std(closes.iloc[:half])) if half > 1 else 0.0
vol_late = float(np.std(closes.iloc[-half:])) if half > 1 else 0.0
width = hi - lo
width_atr = width / atr if atr > 0 else 99.0
converging = vol_early > 1e-12 and vol_late < vol_early * 0.85
range_ok = 1.2 <= width_atr <= 10.0 and len(seg) >= 16
structure_score = 0.35
if range_ok:
structure_score += 0.25
if converging:
structure_score += 0.2
if width_atr <= 6.0:
structure_score += 0.1
structure_score = float(min(0.95, structure_score))
out["range_formation"] = {
"potential_trading_range": bool(range_ok),
"converging": bool(converging),
"width_atr": round(width_atr, 3),
"bars": int(len(seg)),
}
# —— 最近 K 形态(Phase C / Event candidates)——
i = scan_end
row = df.iloc[i]
o = float(row["open"])
h = float(row["high"])
l = float(row["low"])
c = float(row["close"])
rng = max(h - l, 1e-9)
lower_wick = min(o, c) - l
upper_wick = h - max(o, c)
avg_v = _avg_vol(df, i)
vol = float(row["volume"]) if "volume" in df.columns else avg_v
vol_ratio = vol / avg_v if avg_v else 1.0
volume_score = float(np.clip(1.1 - abs(vol_ratio - 1.0) * 0.35, 0.2, 0.95))
phase_candidate = None
phase_conf = 0.0
# Phase C:测低 + 下影 + 缩量(吸筹语境)
near_lo = l <= lo + tol * 1.2
test_low = l < mid and lower_wick >= rng * 0.35
vol_contract = vol_ratio <= 1.05
if bias != "distribution" and near_lo and test_low and vol_contract:
phase_candidate = "C"
phase_conf = 0.55 + (0.1 if lower_wick >= rng * 0.5 else 0) + (0.08 if vol_ratio < 0.9 else 0)
# Phase D 候选:价格在箱上半、有上破意图但未确认 SOS
elif c >= mid and (h >= hi - tol or c > hi - tol * 0.5):
phase_candidate = "D"
phase_conf = 0.5 + (0.1 if c > mid else 0)
elif c < mid and (l <= lo + tol):
phase_candidate = "B"
phase_conf = 0.45
# 已有 confirmed phase 时,candidate 取「下一阶段」提示,不覆盖事实
confirmed_phase_set = {str(p.get("phase")) for p in confirmed_phases}
if "E" in confirmed_phase_set:
phase_candidate = phase_candidate or "E"
phase_conf = max(phase_conf, 0.7)
elif "D" in confirmed_phase_set and phase_candidate is None:
phase_candidate = "D"
phase_conf = max(phase_conf, 0.65)
out["phase_candidate"] = phase_candidate
phase_conf = float(min(0.92, phase_conf))
# —— Event candidates(仅 Spring / SOS / LPS / UTAD)——
candidates: List[Dict[str, Any]] = []
def _add(typ: str, conf: float, note: str) -> None:
if typ in confirmed_types:
return # 已确认则不再作为 candidate
candidates.append(
{
"type": typ,
"confidence": round(float(min(0.9, conf)), 3),
"confirmed": False,
"note": note,
}
)
# Spring candidate:刺破或贴近下沿,收盘收回,但未达 Confirmed 规则(或不在 confirmed
pierce_lo = l < lo - tol * 0.15
close_back = c >= lo - tol * 0.5
if pierce_lo and close_back:
_add("Spring", 0.5 + (0.12 if vol_ratio <= 1.2 else 0) + (0.08 if close_back else 0), "假破下沿收回(未确认)")
elif l <= lo + tol * 0.35 and close_back and lower_wick >= rng * 0.4:
_add("Spring", 0.45 + (0.1 if vol_contract else 0), "测下沿长下影(未确认)")
# UTAD candidate
pierce_hi = h > hi + tol * 0.15
close_back_dn = c <= hi + tol * 0.5
if pierce_hi and close_back_dn:
_add("UTAD", 0.5 + (0.1 if vol_ratio >= 0.9 else 0), "假破上沿跌回(未确认)")
# SOS candidate:接近/轻破上沿,量能一般,未确认
if c > hi - tol * 0.4 or h >= hi:
sos_conf = 0.48 + (0.12 if c > hi else 0) + (0.1 if vol_ratio >= 1.05 else 0)
_add("SOS", sos_conf, "上破/逼近箱顶(未确认)")
# LPS candidate:站上 mid/上沿带后回踩
if c >= mid and l >= mid - tol * 1.5 and l > lo + (hi - lo) * 0.25:
_add("LPS", 0.46 + (0.1 if vol_ratio <= 1.0 else 0), "箱内上沿带回踩(未确认)")
candidates.sort(key=lambda x: x["confidence"], reverse=True)
out["event_candidates"] = candidates[:4]
event_score = float(candidates[0]["confidence"]) if candidates else 0.25
# next_expected(简规则)
next_exp = None
if "Spring" in confirmed_types and "SOS" not in confirmed_types:
next_exp = "SOS"
elif "SOS" in confirmed_types and "LPS" not in confirmed_types:
next_exp = "LPS"
elif "UTAD" in confirmed_types and "SOW" not in confirmed_types:
next_exp = "SOW"
elif any(c["type"] == "Spring" for c in candidates):
next_exp = "Test"
elif any(c["type"] == "SOS" for c in candidates):
next_exp = "LPS"
out["next_expected"] = next_exp
# —— lifecycle ——
key_confirmed = confirmed_types & {"Spring", "SOS", "UTAD", "SOW", "LPS", "LPSY"}
if key_confirmed:
lifecycle = "CONFIRMED"
elif range_ok or phase_candidate or candidates:
lifecycle = "FORMING"
else:
lifecycle = "UNKNOWN"
out["lifecycle"] = lifecycle
cycle_c = structure_score
overall = 0.35 * cycle_c + 0.25 * phase_conf + 0.25 * event_score + 0.15 * volume_score
out["confidence"] = {
"cycle": round(cycle_c, 3),
"phase": round(phase_conf, 3),
"event": round(event_score, 3),
"structure": round(structure_score, 3),
"volume": round(volume_score, 3),
"overall": round(float(overall), 3),
}
parts = []
if out["range_formation"]["potential_trading_range"]:
parts.append("Potential TR")
if phase_candidate:
parts.append(f"Phase {phase_candidate} candidate")
if candidates:
parts.append(f"{candidates[0]['type']} candidate")
out["note"] = "; ".join(parts) if parts else "observing"
return out
def execution_signal_from_wyckoff(payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""
Execution 边界:只允许 Confirmed。
返回 source='confirmed' 的信号描述;Live-only 时返回 None。
"""
if not payload:
return None
cycles = payload.get("cycles") or []
active = cycles[0] if cycles else None
events = []
if active and isinstance(active.get("confirmed"), dict):
events = list(active["confirmed"].get("events") or [])
if not events:
# 兼容旧顶层 events(均为 confirmed 镜像)
events = list(payload.get("events") or [])
if not events:
return None
last = events[-1]
return {
"source": "confirmed",
"type": last.get("type"),
"time": last.get("time"),
"lifecycle": (active or {}).get("lifecycle") or "CONFIRMED",
}
+371 -57
View File
@@ -1,11 +1,18 @@
"""交易区间检测:ATR 容差下按评分选取近期震荡箱。"""
"""交易区间检测:仅负责 TradingRange(起止/高低/结构分)。
WYCKOFF-MULTI-CYCLE-001Phase/Event/VP 不得进入本模块。
过滤顺序固定:detect → quality → trend → overlap(<0.2) → accept → mask。
"""
from __future__ import annotations
from typing import Any, Dict, Optional
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)
@@ -23,6 +30,15 @@ def _atr(df: pd.DataFrame, period: int = 14) -> pd.Series:
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,
@@ -31,27 +47,161 @@ def _score_segment(
width: float,
atr: float,
) -> float:
"""触边密度 + 箱内比例 − 相对宽度;弱奖励长度以免只追最长"""
touch_density = (near_hi + near_lo) / float(max(length, 1))
"""结构质量分(非 Phase/Event"""
touch = min(near_hi, 6) + min(near_lo, 6)
width_pen = (width / atr) if atr > 0 else width
return touch_density * 50.0 + float(inside) * 30.0 - width_pen * 3.0 + min(length / 40.0, 2.0)
return float(touch) * 4.0 + float(inside) * 25.0 - width_pen * 3.0 + min(length / 40.0, 2.0)
def detect_trading_range(
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,
lookback: int = 120,
start_i: int,
end_i: int,
hi: float,
lo: float,
tol: float,
last_atr: float,
score: float,
n: int,
window_offset: int = 0,
) -> Dict[str, Any]:
"""组装 TradingRange(仅结构字段)。"""
mid = (hi + lo) / 2.0
last_c = float(work["close"].iloc[min(end_i, len(work) - 1)])
price_in_box = (lo - tol * 1.5) <= last_c <= (hi + tol * 1.5)
bars = int(end_i - start_i + 1)
# 结构置信:归一化 score(启发式)
range_conf = float(np.clip(score / 55.0, 0.05, 0.99))
best = {
"start_idx": int(start_i),
"end_idx": int(end_i),
"high": float(hi),
"low": float(lo),
"mid": float(mid),
"active": bool(price_in_box),
"atr": float(last_atr),
"tol": float(tol),
"bars": bars,
"score": float(score),
"quality": float(score),
"range_confidence": range_conf,
}
def _ts(row) -> Any:
col = _time_col(work)
if col and pd.notna(row[col]):
return row[col]
return None
best["start_time"] = _ts(work.iloc[best["start_idx"]])
best["end_time"] = _ts(work.iloc[best["end_idx"]])
# window_offsetslice 相对父 DataFrame 的起点;勿用 len(df)-len(work)
offset = int(window_offset)
best["abs_start_idx"] = offset + best["start_idx"]
best["abs_end_idx"] = offset + best["end_idx"]
best["abs_scan_end_idx"] = offset + n - 1
return best
def _overlap_ratio(a0: int, a1: int, b0: int, b1: int) -> float:
"""两闭区间重叠长度 / 较短区间长度。"""
lo = max(a0, b0)
hi = min(a1, b1)
if hi < lo:
return 0.0
overlap = hi - lo + 1
shorter = min(a1 - a0 + 1, b1 - b0 + 1)
if shorter <= 0:
return 0.0
return float(overlap) / float(shorter)
def _passes_quality(tr: Dict[str, Any], min_bars: int) -> bool:
if tr is None:
return False
if int(tr.get("bars") or 0) < max(8, min_bars // 2):
return False
if float(tr.get("score") or 0) < 12.0:
return False
hi = float(tr["high"])
lo = float(tr["low"])
atr = float(tr.get("atr") or 0) or 1.0
if (hi - lo) / atr > 12.0:
return False
return True
def _passes_trend_filter(work: pd.DataFrame, tr: Dict[str, Any]) -> bool:
"""趋势污染:定向位移过大则非震荡箱。"""
s = int(tr["start_idx"])
e = int(tr["end_idx"])
seg = work.iloc[s : e + 1]
if len(seg) < 8:
return False
c0 = float(seg["close"].iloc[0])
c1 = float(seg["close"].iloc[-1])
atr = float(tr.get("atr") or 0) or 1.0
drift = abs(c1 - c0) / atr
# 相对箱宽:漂移占箱宽过大 → 趋势
width = max(float(tr["high"]) - float(tr["low"]), atr)
drift_frac = abs(c1 - c0) / width
if drift > 6.0 and drift_frac > 0.55:
return False
return True
def _detect_in_window(
df: pd.DataFrame,
win_start: int,
win_end: int,
min_bars: int = 24,
atr_mult: float = 1.2,
tail_reserve: int = 12,
prefer_start_time: Any = None,
range_start_time: Any = None,
) -> Optional[Dict[str, Any]]:
"""
最近 lookback 根内寻找高低点波动受控的连续段作为交易区间
尾部预留 tail_reserve 根用于事件(Spring/SOS),不参与箱体边界计算
在硬门槛之上按评分取最优段(非仅最长窗口)。
df[win_start:win_end+1] 内检测单个 TradingRange
只返回箱体结构,不含 Phase/Event/VP
"""
if df is None or len(df) < min_bars + 5:
if df is None or win_end < win_start:
return None
work = df.tail(lookback).reset_index(drop=True)
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
@@ -68,61 +218,225 @@ def detect_trading_range(
if not np.isfinite(last_atr) or last_atr <= 0:
last_atr = float(core["close"].iloc[-1]) * 0.01
best = None
best_score = float("-inf")
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)
for length in range(min(cn, lookback), min_bars - 1, -4):
seg = core.iloc[-length:]
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())
width = hi - lo
if width <= 0 or width > last_atr * atr_mult * 3.5:
continue
tol = last_atr * atr_mult * 0.35
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:
continue
return
inside = float(((seg["close"] >= lo - tol) & (seg["close"] <= hi + tol)).mean())
if inside < 0.75:
continue
score = _score_segment(length, near_hi, near_lo, inside, width, last_atr)
if score <= best_score:
continue
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
end_i = cn - 1
mid = (hi + lo) / 2.0
last_c = float(work["close"].iloc[-1])
active = (lo - tol * 1.5) <= last_c <= (hi + tol * 1.5)
best_score = score
best = {
"start_idx": int(start_i),
"end_idx": int(end_i),
"high": hi,
"low": lo,
"mid": mid,
"active": bool(active),
"atr": last_atr,
"tol": tol,
"bars": int(length),
"score": float(score),
}
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 best is None:
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
def _ts(row) -> Any:
if "date" in work.columns and pd.notna(row["date"]):
return row["date"]
if "timestamp" in work.columns:
return row["timestamp"]
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)
best["start_time"] = _ts(work.iloc[best["start_idx"]])
# 区间时间结束取 core 末,事件可落在其后
best["end_time"] = _ts(work.iloc[best["end_idx"]])
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)
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
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
+2 -2
View File
@@ -55,8 +55,8 @@ class IndicatorsBuilderMixin:
return None
def add_indicators(self, df):
fast = 26
slow = 52
fast = 12
slow = 26
period = 9
macd = ta.MACD(df, fastperiod=fast, slowperiod=slow, signalperiod=period)
bb365 = ta.BBANDS(df, timeperiod=365, nbdevup=3.0, nbdevdn=3.0, matype=0)
+8 -3
View File
@@ -22,18 +22,23 @@
- ECR-002 Reviewed:拆 `web/services/runtime/`、加深 analyze 契约
- ECR-003 Reviewed:主站威科夫叠层(`chanlun/analysis/wyckoff/` + `include_wyckoff`)→ `081a57a`
- ECR-004 ReviewedTR 评分硬化 + VP 少系列 + 阶段/门闩/单测(无币种参数)
- ECR-007 Final Approval / `276481e`Wyckoff Live Structure`live.py`);Confirmed ≠ Liveexecution 仅 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);勿借机改缠论算法
- 交易 L2+ → RISK_REVIEW + EXPLive 须 Human
- 威科夫为独立叠层(ECR-003/007);勿借机改缠论算法
- Live candidate **不得**进入 execution交易 L2+ → RISK_REVIEW + EXPLive 须 Human
## 已知债务
- `chart_tv.js` 单体巨大 → 后续可选 ECR
- 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-001FROZEN)。
## 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.
+15
View File
@@ -1,5 +1,19 @@
# CHANGELOG
## Unreleased — 2026-08-07
### ECR-008L3Reviewed
- 主站 `chart_tv.js` 拆为 lifecycle / shell / indicators / chan / overlays / finalize + 薄门面
- 行为冻结;`initTradingView` / `disposeTradingViewCharts` 对外不变;无 Vite/TS
### ECR-007L2LOOP-RUN-005
- Wyckoff **Live Structure**`live.py` + engine 组装 `lifecycle` / `confirmed` / `live`
- Event candidatesSpring/SOS/LPS/UTAD+ 可解释 confidenceSummary Confirmed/Live 分区
- `execution_signal_from_wyckoff` **仅** `source=confirmed`Live-only → None
- **No** Confirmed 门槛降低;**No** strategies / 自动交易
## Unreleased — 2026-08-06
### ECR-004L2Reviewed
@@ -7,6 +21,7 @@
- 威科夫 TR 评分选段(防吞前置趋势);阶段非重叠最小跨度
- 主站 VP Top-8 + bins≤24;填充线减负
- `elements_only` 时不跑威科夫;收紧单测(无币种独立参数)
- **后续**:威科夫随主 `/api/analyze` 默认一并返回;前端开关只控制绘制(不再勾选才加载)
### ECR-003L2Reviewed
+25
View File
@@ -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,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
+61
View File
@@ -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,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,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.
+27
View File
@@ -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 → EngineerECR-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。
+27
View File
@@ -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
+29
View File
@@ -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。
+2 -1
View File
@@ -34,10 +34,11 @@ Trading System(缠论分析引擎 + 可视化 Web;Freqtrade 策略目录独
## Active anchors
- ECR: ECR-002/003/004 Reviewed(威科夫 + 硬化
- ECR: ECR-002/003/004 ReviewedECR-007 Final Approval(待合入 `dev`);ECR-008 Reviewedchart_tv 拆分
- EXP: N/A
- TRACEABILITY: `docs/TRACEABILITY.md`
- Memory: `docs/AGENT_MEMORY.md`
- Loop archive: `docs/runs/LOOP-RUN-005/`
## Pointers
+7 -3
View File
@@ -1,11 +1,11 @@
# STATE
**owner:** idle
**active_ecr:** noneECR-004 Reviewed待本批提交合入
**active_ecr:** noneECR-008 ReviewedECR-007 待合入 `dev`
**phase:** post-review
**system_version:** v1.0.0
**strategy_version:** unchanged
**updated:** 2026-08-06
**updated:** 2026-08-07
## Recent
@@ -16,8 +16,12 @@
| 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
- ECR-004**Approve**14 passed);无币种独立参数
- ECR-007**FINAL_APPROVAL** · 已 push;开 PRhttps://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
+10
View File
@@ -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/
+7
View File
@@ -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
+15
View File
@@ -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.
+33
View File
@@ -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
+34
View File
@@ -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_OK2026-08-07
```
## Manual smoke checklist
| Case | Result |
|------|--------|
| 符号导出:`disposeTradingViewCharts` / `initTradingView` / 各 `chartTv*` | PASS(全局函数存在于对应文件) |
| 语法 | PASS |
| 浏览器:首屏 / 自动刷新 dispose / 威科夫 / 三周期元素 | 待 Human 硬刷新 `?v=20260807f` 目测 |
## Design Compliance
PASS — 无打包器;行为冻结搬移;API/strategies 未改。
+17
View File
@@ -44,3 +44,20 @@
| ECR-004 | TR 评分选最优段 | ENG-004 | `wyckoff/range.py` | `test_wyckoff` / `test_range_scoring_skips_pretrend` |
| ECR-004 | VP/填充少 series | ENG-004 | `chart_tv.js` Top-8 + 填充 3bins≤24 | 人工 + ENG |
| ECR-004 | 阶段最小长度 + elements_only 门闩 | ENG-004 | `events.py` + `analyze.py` | 契约 `elements_only` |
## 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 |
+72
View File
@@ -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 candidatesSpring / 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 面板分区
- 测例
+76
View File
@@ -0,0 +1,76 @@
# WYCKOFF-LIVE-VALIDATION-001
**Status:** DRAFT(待确认执行后 FROZEN
**Depends on:** WYCKOFF-LIVE-STRUCTURE-001(已 FROZEN
**Goal:** 验证 Live 是否有预测价值,而非继续加事件规则
## 不做
- 不新增 BC / AR / ST / UT / UTADv1 已够)
- 不降低 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 文案升级(独立小项)
+62
View File
@@ -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"
}
+18
View File
@@ -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.
+49
View File
@@ -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
+77
View File
@@ -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
+8 -4
View File
@@ -14,11 +14,11 @@
"uncompleted_bi_list",
"uncompleted_seg_list",
"uncompleted_zs_list",
"wyckoff",
"zs_list"
],
"optional_when": {
"include_structure_zones": ["structure_zones"],
"include_wyckoff": ["wyckoff"]
"include_structure_zones": ["structure_zones"]
},
"wyckoff_keys": [
"trading_range",
@@ -26,6 +26,10 @@
"phases",
"events",
"volume_profile",
"volume_confirm"
]
"volume_confirm",
"cycles",
"live",
"lifecycle"
],
"notes": "wyckoff 默认返回;cycles[0]=ACTIVEphases/events=Confirmedlive=DevelopingWYCKOFF-LIVE-STRUCTURE-001);Execution 仅 Confirmed;见 docs/notes/"
}
+2 -1
View File
@@ -148,11 +148,11 @@ def analyze_contract_keys() -> dict:
"macd",
"chan_macd",
"klc_trend",
"wyckoff",
]
),
"optional_when": {
"include_structure_zones": ["structure_zones"],
"include_wyckoff": ["wyckoff"],
},
"wyckoff_keys": [
"trading_range",
@@ -162,6 +162,7 @@ def analyze_contract_keys() -> dict:
"volume_profile",
"volume_confirm",
],
"notes": "wyckoff 随主周期 analyze 默认返回;有次/次次周期时另附 element_wyckoff / sub_sub_wyckoffinclude_wyckoff=0 可跳过;elements_only 时不返回",
}
+2 -2
View File
@@ -43,9 +43,9 @@ def test_analyze_contract_keys_file():
)
)
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"):
for k in ("kline_data", "bi_list", "seg_list", "zs_list", "bsp_list", "wyckoff"):
assert k in keys
if isinstance(doc, dict):
assert "include_wyckoff" in doc.get("optional_when", {})
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", [])
+259 -1
View File
@@ -11,7 +11,11 @@ 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 detect_trading_range # 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:
@@ -87,6 +91,7 @@ def _box_df(n_box: int = 60, spring: bool = True, sos: bool = True) -> pd.DataFr
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
@@ -105,6 +110,55 @@ def test_wyckoff_detects_range_and_events():
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"])
# 模拟 4hTR.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)
@@ -113,6 +167,44 @@ def test_range_scoring_skips_pretrend():
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 = []
@@ -126,3 +218,169 @@ def test_volume_profile_poc_on_heavy_bin():
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 不含 candidatelive 可有 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 CONFIRMEDexecution.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)
+124 -25
View File
@@ -5,6 +5,97 @@ from services import runtime as R
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')
def analyze():
"""分析接口"""
@@ -25,6 +116,9 @@ def analyze():
# 获取分形元素时间周期与次次周期
element_timeframe = request.args.get('element_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')
@@ -249,6 +343,7 @@ def analyze():
if element_df is not None and len(element_df) > 0:
# 添加小周期技术指标(包括布林带)
element_df = add_indicators(element_df)
element_df_for_wyckoff = element_df
# 对小周期数据进行缠论分析
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)
if sub_sub_df is not None and len(sub_sub_df) > 0:
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)
result['sub_sub_timeframe'] = sub_sub_timeframe
result['sub_sub_kline_data'] = clean_dataframe_for_json(sub_sub_df).to_dict('records')
@@ -656,33 +752,36 @@ def analyze():
else:
result['structure_zones'] = []
# 威科夫分析 —— 按需:include_wyckoff=1,且须有主周期分析(非 elements_only
include_wyckoff_param = request.args.get('include_wyckoff', '')
include_wyckoff = str(include_wyckoff_param).lower() in ('1', 'true', 'yes')
# 威科夫:主 / 次 / 次次各算一份(非 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:
try:
from chanlun.analysis.wyckoff import analyze_wyckoff
wyckoff_lookback = int(request.args.get('wyckoff_lookback', 120))
# ECR-004:默认/上限 24 binsA+C
wyckoff_bins = int(request.args.get('wyckoff_vp_bins', 24))
result['wyckoff'] = analyze_wyckoff(
df,
lookback=max(40, min(wyckoff_lookback, 500)),
vp_bins=max(10, min(wyckoff_bins, 24)),
# 主周期先算;次/次次只同步 active=cycles[0] 的 startWYCKOFF-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,
)
except Exception as e:
print(f"Wyckoff 分析出错: {e}")
import traceback
traceback.print_exc()
result['wyckoff'] = {
'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': {}},
'error': str(e),
}
return jsonify(result)
+1 -1
View File
@@ -1,5 +1,5 @@
"""页面路由。"""
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 import runtime as R
+25 -18
View File
@@ -70,36 +70,43 @@ def build_timeframe_labels(timeframes):
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 ['5m', '15m', '1h'] if tf in labels_ordered), None)
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]
if timeframe_keys:
try:
idx = timeframe_keys.index(default_main)
default_element = timeframe_keys[idx - 1] if idx > 0 else timeframe_keys[0]
except ValueError:
default_element = timeframe_keys[0]
else:
default_element = default_main
if timeframe_keys:
try:
idx_el = timeframe_keys.index(default_element)
default_sub_sub = timeframe_keys[idx_el - 1] if idx_el > 0 else timeframe_keys[0]
except ValueError:
default_sub_sub = timeframe_keys[0]
else:
default_sub_sub = default_element
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
+3
View File
@@ -1,6 +1,9 @@
/* chart_format.js — split from chart.js */
/* chart.js */
function updateChartDisplay() {
if (typeof renderWyckoffCycleSummary === 'function') {
renderWyckoffCycleSummary();
}
if (currentData) {
// 检测K线周期是否切换
const curPeriod = $('#subSubPeriodKline').is(':checked') ? 'subsub' :
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+228
View File
@@ -0,0 +1,228 @@
/* chart_tv_finalize.js — time sync / bindSync / view restore / tooltip */
function chartTvFinalize(ctx) {
var symbol = ctx.symbol;
var timeframe = ctx.timeframe;
var symbolConfig = ctx.symbolConfig;
var useSubSubPeriod = ctx.useSubSubPeriod;
var useElementPeriod = ctx.useElementPeriod;
var klinePeriodLabel = ctx.klinePeriodLabel;
var candles = ctx.candles;
var klineDataSource = ctx.klineDataSource;
var container = ctx.container;
var showMacd = ctx.showMacd;
var showOriginalKline = ctx.showOriginalKline;
var mainChartContainer = ctx.mainChartContainer;
var volumeChartContainer = ctx.volumeChartContainer;
var atrChartContainer = ctx.atrChartContainer;
var macdChartContainer = ctx.macdChartContainer;
var chanMacdChartContainer = ctx.chanMacdChartContainer;
var mainChart = ctx.mainChart;
var volumeChart = ctx.volumeChart;
var atrChart = ctx.atrChart;
var macdChart = ctx.macdChart;
var chanMacdChart = ctx.chanMacdChart;
var createChartOptions = ctx.createChartOptions;
// 同步所有图表的时间轴配置
const syncTimeScaleSettings = () => {
// 获取主图表的时间轴设置
const mainTimeScale = mainChart.timeScale();
const baseOptions = {
timeVisible: true,
secondsVisible: false,
borderColor: '#ddd',
barSpacing: symbolConfig.type === 'a_stock' ? 6 : 10,
rightOffset: 12,
lockVisibleTimeRangeOnResize: true,
// 关键:确保所有图表边缘行为完全一致
fixLeftEdge: false,
fixRightEdge: false,
// 确保时间刻度行为一致
ticksVisible: true,
minimumHeight: 0,
};
console.log('🔧 同步时间轴设置:', baseOptions);
// 应用相同的设置到所有图表
mainChart.timeScale().applyOptions(baseOptions);
volumeChart.timeScale().applyOptions(baseOptions);
atrChart.timeScale().applyOptions(baseOptions);
if (showMacd && macdChart) {
macdChart.timeScale().applyOptions(baseOptions);
}
};
// 首先同步时间轴设置
syncTimeScaleSettings();
// 仅在没有待恢复视图时,设置默认可见范围
const totalBars = candles ? candles.length : 0;
const visibleBarsCount = 200;
const hasPendingRestoreView = !!window._pendingRestoreView;
if (!hasPendingRestoreView) {
// 显示最近 200 根K线而非全部挤压(避免K线过多时重叠)
if (totalBars > visibleBarsCount) {
const rangeFrom = totalBars - visibleBarsCount;
const rangeTo = totalBars + 12;
mainChart.timeScale().setVisibleLogicalRange({ from: rangeFrom, to: rangeTo });
} else {
mainChart.timeScale().fitContent();
}
}
// 立即同步其他图表到主图表的范围
setTimeout(() => {
const logRange = mainChart.timeScale().getVisibleLogicalRange();
if (logRange) {
console.log('🔧 同步可见范围:', logRange);
volumeChart.timeScale().setVisibleLogicalRange(logRange);
atrChart.timeScale().setVisibleLogicalRange(logRange);
if (showMacd && macdChart) {
macdChart.timeScale().setVisibleLogicalRange(logRange);
}
if (showMacd && chanMacdChart) {
chanMacdChart.timeScale().setVisibleLogicalRange(logRange);
}
console.log('🔧 时间轴同步完成');
}
}, 50);
// 保存图表对象
tvWidget.mainChart = mainChart;
tvWidget.volumeChart = volumeChart;
tvWidget.atrChart = atrChart;
tvWidget.macdChart = macdChart;
tvWidget.chanMacdChart = chanMacdChart;
tvWidget.state.isInitialized = true;
// 注册窗口卸载时释放资源,避免GPU内存泄漏
window.onbeforeunload = function() {
try {
if (tvWidget && tvWidget.state && tvWidget.state.isInitialized) {
if (tvWidget.mainChart && typeof tvWidget.mainChart.remove === 'function') tvWidget.mainChart.remove();
if (tvWidget.volumeChart && typeof tvWidget.volumeChart.remove === 'function') tvWidget.volumeChart.remove();
if (tvWidget.macdChart && typeof tvWidget.macdChart.remove === 'function') tvWidget.macdChart.remove();
if (tvWidget.chanMacdChart && typeof tvWidget.chanMacdChart.remove === 'function') tvWidget.chanMacdChart.remove();
if (tvWidget.atrChart && typeof tvWidget.atrChart.remove === 'function') tvWidget.atrChart.remove();
}
} catch (e) {}
};
// 初始化默认均线/布林带配置(仅在首次初始化时)
if (!hasInitializedDefaultMAs && movingAverages.length === 0) {
console.log('初始化默认均线与布林带指标');
if (typeof maIdCounter !== 'number' || !Number.isFinite(maIdCounter)) {
maIdCounter = 0;
}
if (typeof bbIdCounter !== 'number' || !Number.isFinite(bbIdCounter)) {
bbIdCounter = 0;
}
const defaultMAs = [
{ type: 'EMA', length: 13, color: '#800080', name: 'EMA13', visible: true }, // 紫色
{ type: 'EMA', length: 26, color: '#FF8C00', name: 'EMA26', visible: true }, // 橙色
{ type: 'EMA', length: 52, color: '#000000', name: 'EMA52', visible: false }, // 黑色
{ type: 'EMA', length: 104, color: '#1E90FF', name: 'EMA104', visible: false }, // 蓝色
{ type: 'EMA', length: 156, color: '#F700FF', name: 'EMA156', visible: false } // 粉色
];
defaultMAs.forEach(ma => {
const config = {
id: ++maIdCounter,
type: ma.type,
length: ma.length,
source: 'close',
smoothType: 'none',
smoothLength: 3,
lineWidth: 1, // 1px线宽
lineStyle: 0, // 实线
color: ma.color,
visible: ma.visible
};
movingAverages.push(config);
console.log(`添加默认${ma.name}:`, ma.color);
});
if (bollingerBands.length === 0) {
const defaultBB = {
id: ++bbIdCounter,
type: 'Bollinger Bands',
length: 20,
upperMultiplier: 2,
lowerMultiplier: 2,
source: 'close',
lineWidth: 1,
lineStyle: 0,
upperColor: '#ff6b6b',
middleColor: '#ffffff',
lowerColor: '#ff6b6b',
visible: false
};
bollingerBands.push(defaultBB);
console.log('添加默认布林带: BB(20, 2, 2)');
}
console.log('默认指标配置完成,当前均线数量', movingAverages.length, '布林带数量', bollingerBands.length);
hasInitializedDefaultMAs = true;
}
// 添加均线到图表
addMovingAveragesToChart(candles);
// 添加布林带到图表
addBollingerBandsToChart(candles);
// 更新技术指标面板显示
updateIndicatorPanel();
// 绑定同步事件
bindSyncEvents(mainChartContainer, volumeChartContainer, atrChartContainer, macdChartContainer, chanMacdChartContainer, mainChart, volumeChart, atrChart, macdChart, chanMacdChart, showMacd);
// 最终确保所有图表时间轴对齐(同时恢复刷新前保存的缩放/位置)
setTimeout(() => {
const allCharts = [mainChart, volumeChart, atrChart];
if (showMacd && macdChart) allCharts.push(macdChart);
if (showMacd && chanMacdChart) allCharts.push(chanMacdChart);
// 检查是否有待恢复的视图(缩放 + 位置)
const pending = window._pendingRestoreView;
window._pendingRestoreView = null;
if (pending) {
// 恢复刷新前的缩放和位置(优先可见范围/逻辑范围,最后回退到滚动位置)
console.log('📌 恢复图表视图:', JSON.stringify(pending));
restoreChartViewState(allCharts, pending);
} else {
// 无保存视图,正常同步主图到子图
const visibleRange = mainChart.timeScale().getVisibleRange();
if (visibleRange) {
console.log('🔧 最终同步可见范围:', visibleRange);
[volumeChart, atrChart].concat(
showMacd && macdChart ? [macdChart] : [],
showMacd && chanMacdChart ? [chanMacdChart] : []
).forEach(c => {
try { c.timeScale().setVisibleRange(visibleRange); } catch(e) {}
});
}
}
console.log('🔧 最终时间轴对齐完成');
}, 150);
// 只有在时间输入框都为空时才设置图表默认时间范围
if (!$('#start_time').val() && !$('#end_time').val()) {
setDefaultTimeRange();
}
// 添加买卖点提示
// 初始化 tooltip 与 U 显示状态
window.showUOnMain = $('#toggleUOnMain').is(':checked');
window.showUOnElement = $('#toggleUOnElement').is(':checked');
setupTooltip(mainChart, [], [], mainChartContainer, volumeChartContainer, atrChartContainer, macdChartContainer, chanMacdChartContainer, volumeChart, atrChart, macdChart, chanMacdChart, showMacd);
// 更新EMA52显示
if (currentData) {
updateEMA52Display(currentData);
}
}
+564
View File
@@ -0,0 +1,564 @@
/* chart_tv_indicators.js — volume / ATR / ChanMACD */
function chartTvRenderIndicators(ctx) {
var symbol = ctx.symbol;
var timeframe = ctx.timeframe;
var symbolConfig = ctx.symbolConfig;
var useSubSubPeriod = ctx.useSubSubPeriod;
var useElementPeriod = ctx.useElementPeriod;
var klinePeriodLabel = ctx.klinePeriodLabel;
var candles = ctx.candles;
var klineDataSource = ctx.klineDataSource;
var container = ctx.container;
var showMacd = ctx.showMacd;
var showOriginalKline = ctx.showOriginalKline;
var mainChartContainer = ctx.mainChartContainer;
var volumeChartContainer = ctx.volumeChartContainer;
var atrChartContainer = ctx.atrChartContainer;
var macdChartContainer = ctx.macdChartContainer;
var chanMacdChartContainer = ctx.chanMacdChartContainer;
var mainChart = ctx.mainChart;
var volumeChart = ctx.volumeChart;
var atrChart = ctx.atrChart;
var macdChart = ctx.macdChart;
var chanMacdChart = ctx.chanMacdChart;
var createChartOptions = ctx.createChartOptions;
// 转换成交量数据 - 与K线周期一致
let volumes = [];
const volumeDataSource = useSubSubPeriod ? (currentData.sub_sub_kline_data || []) : (useElementPeriod ? currentData.element_kline_data : currentData.kline_data);
console.log('成交量数据源选择:', klinePeriodLabel);
console.log('成交量数据长度:', volumeDataSource.length);
if (volumeDataSource && Array.isArray(volumeDataSource)) {
volumes = volumeDataSource.map(kline => {
// 使用与K线和MACD完全相同的时间戳计算方式
const timestamp = Math.floor(new Date(kline.date).getTime() / 1000);
return {
time: timestamp,
value: parseFloat(kline.volume),
color: parseFloat(kline.close) >= parseFloat(kline.open) ? 'rgba(40, 167, 69, 0.5)' : 'rgba(220, 53, 69, 0.5)',
};
});
console.log('处理后的成交量数据点数:', volumes.length);
}
// 添加成交量图表
const volumeSeries = volumeChart.addHistogramSeries({
color: '#26a69a',
priceFormat: {
type: 'volume',
},
title: '成交量',
});
volumeSeries.setData(volumes);
tvWidget.series.volumeSeries = volumeSeries;
// 添加ATR图表
const atrLineSeries = atrChart.addLineSeries({
color: '#FF9800',
lineWidth: 2,
title: 'ATR',
lastValueVisible: false,
priceLineVisible: false,
});
// 准备ATR数据
const atrData = [];
const atrKlineDataSource = useSubSubPeriod ? (currentData.sub_sub_kline_data || []) : (useElementPeriod ? currentData.element_kline_data : currentData.kline_data);
const atrDataSource = useSubSubPeriod ? (currentData.sub_sub_atr || currentData.atr) : (useElementPeriod ? (currentData.element_atr || currentData.atr) : currentData.atr);
console.log('ATR数据源选择:', klinePeriodLabel);
console.log('ATR数据长度:', atrDataSource ? atrDataSource.length : 0);
console.log('K线数据长度:', atrKlineDataSource ? atrKlineDataSource.length : 0);
if (atrDataSource && Array.isArray(atrDataSource) && atrKlineDataSource && Array.isArray(atrKlineDataSource)) {
// 关键修复:为每个K线时间点都创建ATR数据点,包括没有ATR值的前期数据
for (let i = 0; i < atrKlineDataSource.length; i++) {
const kline = atrKlineDataSource[i];
const timestamp = Math.floor(new Date(kline.date).getTime() / 1000);
// 为每个时间点都添加数据以保持时间轴对齐,但ATR为0时不显示
if (atrDataSource[i] !== undefined) {
if (atrDataSource[i] > 0) {
// ATR有效值,正常显示
atrData.push({
time: timestamp,
value: atrDataSource[i]
});
} else {
// ATR为0,添加时间点但不显示线条(使用undefined作为value
atrData.push({
time: timestamp,
value: undefined
});
}
}
}
console.log('处理后的ATR数据点数:', atrData.length);
console.log('ATR数据样本:', atrData.slice(0, 5));
}
console.log('处理后的ATR数据点数:', atrData.length);
atrLineSeries.setData(atrData);
tvWidget.series.atrLineSeries = atrLineSeries;
// 旧 MACD 图已移除,不再绘制(保留占位但彻底禁用)
if (FEATURES.legacyMacd && showMacd && currentData.macd && currentData.kline_data && Array.isArray(currentData.kline_data)) {
// 创建MACD线
const macdLineSeries = macdChart.addLineSeries({
color: '#2962FF',
lineWidth: 1,
title: 'MACD',
lastValueVisible: false, // 禁用最后值标签,防止遮挡
priceLineVisible: false, // 禁用价格线
});
// 创建信号线
const signalLineSeries = macdChart.addLineSeries({
color: '#FF6B6B',
lineWidth: 1,
title: 'Signal',
lastValueVisible: false, // 禁用最后值标签,防止遮挡
priceLineVisible: false, // 禁用价格线
});
// 创建直方图
const histogramSeries = macdChart.addHistogramSeries({
color: '#26a69a',
title: 'Histogram',
priceFormat: {
type: 'price',
precision: 4,
},
});
// 提取MACD数据 - 使用和K线数据相同的时间处理逻辑
const macdData = [];
const signalData = [];
const histogramData = [];
// 使用与K线数据相同的数据源来确保时间对齐
const klineDataSource = useSubSubPeriod ? (currentData.sub_sub_kline_data || []) : (useElementPeriod ? currentData.element_kline_data : currentData.kline_data);
const macdDataSource = useElementPeriod ?
(currentData.element_macd || currentData.macd) : // 如果有次周期MACD数据则使用,否则使用主周期
currentData.macd; // 主周期使用主周期MACD数据
console.log('MACD数据源选择:', useElementPeriod ? '次周期' : '主周期');
console.log('K线数据长度:', klineDataSource.length);
console.log('MACD数据:', macdDataSource);
for (let i = 0; i < klineDataSource.length; i++) {
const kline = klineDataSource[i];
// 使用与K线完全相同的时间戳计算方式
const timestamp = Math.floor(new Date(kline.date).getTime() / 1000);
if (macdDataSource && macdDataSource.macd && macdDataSource.macd[i] !== undefined) {
macdData.push({
time: timestamp,
value: macdDataSource.macd[i]
});
signalData.push({
time: timestamp,
value: macdDataSource.signal[i]
});
// 设置直方图颜色
const histValue = macdDataSource.histogram[i];
histogramData.push({
time: timestamp,
value: histValue,
color: histValue >= 0 ? 'rgba(40, 167, 69, 0.5)' : 'rgba(220, 53, 69, 0.5)'
});
}
}
console.log('处理后的MACD数据点数:', macdData.length);
macdLineSeries.setData(macdData);
signalLineSeries.setData(signalData);
histogramSeries.setData(histogramData);
tvWidget.series.macdLineSeries = macdLineSeries;
tvWidget.series.signalLineSeries = signalLineSeries;
tvWidget.series.histogramSeries = histogramSeries;
}
// 添加ChanMACD图表
console.log('ChanMACD图表创建条件检查:', {
showMacd: showMacd,
chanMacdChart: !!chanMacdChart,
hasMacd: !!currentData.macd,
hasKlineData: !!currentData.kline_data,
isArray: Array.isArray(currentData.kline_data)
});
// 在创建 ChanMACD 前,确保一次性同步 U 显示开关到全局(默认不显示)
if (typeof window.showUOnMain === 'undefined') {
window.showUOnMain = $('#toggleUOnMain').is(':checked');
}
if (typeof window.showUOnElement === 'undefined') {
window.showUOnElement = $('#toggleUOnElement').is(':checked');
}
if (typeof window.showUOnSubSub === 'undefined') {
window.showUOnSubSub = $('#toggleUOnSubSub').is(':checked');
}
if (showMacd && chanMacdChart && ((useSubSubPeriod && currentData.sub_sub_macd) || (useElementPeriod && currentData.element_macd) || currentData.macd) && (useSubSubPeriod ? (currentData.sub_sub_kline_data || []) : (useElementPeriod ? currentData.element_kline_data : currentData.kline_data))) {
console.log('✅ 开始创建 ChanMACD 系列');
// 创建ChanMACD线系列
const chanMacdLineSeries = chanMacdChart.addLineSeries({
color: '#2962FF',
lineWidth: 1,
title: 'ChanMACD',
lastValueVisible: false,
priceLineVisible: false,
});
// 创建ChanMACD信号线系列
const chanMacdSignalSeries = chanMacdChart.addLineSeries({
color: '#FF6B6B',
lineWidth: 1,
title: 'ChanSignal',
lastValueVisible: false,
priceLineVisible: false,
});
// 创建ChanMACD柱状图系列
const chanMacdHistSeries = chanMacdChart.addHistogramSeries({
color: '#26a69a',
title: 'ChanHistogram',
priceFormat: {
type: 'price',
precision: 4,
},
});
// 设置ChanMACD图表的字体大小
chanMacdChart.applyOptions({
layout: {
fontSize: 10, // 设置更小的字体大小
},
rightPriceScale: {
fontSize: 10, // 设置右侧价格轴的字体大小
},
timeScale: {
fontSize: 10, // 设置时间轴的字体大小
},
});
// 使用与主图一致的数据源(小周期开启时使用小周期MACD与K线)
const klineDataSource = useSubSubPeriod ? (currentData.sub_sub_kline_data || []) : (useElementPeriod ? currentData.element_kline_data : currentData.kline_data);
const macdDataSource = useSubSubPeriod ? (currentData.sub_sub_macd || currentData.macd) : (useElementPeriod ? (currentData.element_macd || currentData.macd) : currentData.macd);
// 准备ChanMACD数据
const chanMacdData = [];
const chanSignalData = [];
const chanHistData = [];
console.log('ChanMACD数据源检查:', {
klineDataSourceLength: klineDataSource.length,
macdDataSource: !!macdDataSource,
macdLength: macdDataSource ? macdDataSource.macd.length : 0
});
console.log('ChanMACD数据源检查:', {
klineDataSourceLength: klineDataSource.length,
macdDataSource: !!macdDataSource,
macdLength: macdDataSource ? macdDataSource.macd.length : 0
});
for (let i = 0; i < klineDataSource.length; i++) {
const kline = klineDataSource[i];
if (kline && kline.date &&
i < macdDataSource.macd.length &&
macdDataSource.macd[i] !== null && macdDataSource.macd[i] !== undefined) {
// 使用与K线完全相同的时间戳计算方式
const timestamp = Math.floor(new Date(kline.date).getTime() / 1000);
chanMacdData.push({
time: timestamp,
value: macdDataSource.macd[i]
});
chanSignalData.push({
time: timestamp,
value: macdDataSource.signal[i]
});
chanHistData.push({
time: timestamp,
value: macdDataSource.histogram[i],
color: macdDataSource.histogram[i] >= 0 ? 'rgba(40, 167, 69, 0.5)' : 'rgba(220, 53, 69, 0.5)'
});
}
}
console.log('ChanMACD数据处理完成:', {
chanMacdDataLength: chanMacdData.length,
chanSignalDataLength: chanSignalData.length,
chanHistDataLength: chanHistData.length,
sampleData: chanMacdData.length > 0 ? chanMacdData[0] : null
});
// 设置ChanMACD数据
console.log('ChanMACD数据长度:', chanMacdData.length, chanSignalData.length, chanHistData.length);
if (chanMacdData.length > 0) {
chanMacdLineSeries.setData(chanMacdData);
chanMacdSignalSeries.setData(chanSignalData);
chanMacdHistSeries.setData(chanHistData);
console.log('✅ ChanMACD数据设置成功');
} else {
console.warn('⚠️ ChanMACD数据为空,无法设置数据');
}
// 保存到tvWidget
tvWidget.series.chanMacdLineSeries = chanMacdLineSeries;
tvWidget.series.chanMacdSignalSeries = chanMacdSignalSeries;
tvWidget.series.chanMacdHistSeries = chanMacdHistSeries;
console.log('✅ ChanMACD图表系列已保存到tvWidget');
// 添加ChanMACD分析标注
// 根据主/次周期开关与各自的"显示U"独立控制
const cm = useSubSubPeriod ? (currentData.sub_sub_chan_macd || currentData.chan_macd) : (useElementPeriod ? (currentData.element_chan_macd || currentData.chan_macd) : currentData.chan_macd);
// 默认不显示,必须用户勾选对应复选框
const allowU = useSubSubPeriod ? !!window.showUOnSubSub : (useElementPeriod ? !!window.showUOnElement : !!window.showUOnMain);
if (cm && allowU) {
console.log('添加ChanMACD分析标注:', {
segListLength: cm.seg_list ? cm.seg_list.length : 0,
unittfListLength: cm.unittf_list ? cm.unittf_list.length : 0,
histsetListLength: cm.histset_list ? cm.histset_list.length : 0
});
// 详细检查段数据
if (cm.seg_list && cm.seg_list.length > 0) {
console.log('段数据详情:', cm.seg_list.slice(0, 3)); // 显示前3个段
} else {
console.log('⚠️ 段数据为空或不存在');
}
addAllChanMacdMarkers(
cm.seg_list || [],
cm.unittf_list || [],
cm.histset_list || [],
{
high_position_list: cm.high_position_list || [],
high_empty_list: cm.high_empty_list || [],
low_position_list: cm.low_position_list || [],
low_empty_list: cm.low_empty_list || [],
return_zero_list: cm.return_zero_list || [],
cross0_up_list: cm.cross0_up_list || [],
cross0_down_list: cm.cross0_down_list || []
}
);
// 同时从主/次周期的 klu_list 提取 SD/CD 标记,分别使用不同样式
try {
const mainCm = currentData.chan_macd || {};
const elementCm = currentData.element_chan_macd || {};
const mainMarkers = [];
const elementMarkers = [];
// 基于时间构建 MACD 值映射,便于按时间快速获取对应的 MACD 值
const buildMacdTimeMap = (macdObj, klineArr) => {
const map = new Map();
if (!macdObj || !klineArr || !Array.isArray(klineArr)) return map;
for (let i = 0; i < klineArr.length; i++) {
const k = klineArr[i];
if (!k || !k.date) continue;
const t = Math.floor(new Date(k.date).getTime() / 1000);
const val = (macdObj.macd && macdObj.macd[i] !== undefined && macdObj.macd[i] !== null) ? macdObj.macd[i] : null;
map.set(t, val);
}
return map;
};
const mainMacdMap = buildMacdTimeMap(currentData.macd, currentData.kline_data);
const elementMacdMap = buildMacdTimeMap(
(currentData.element_macd || currentData.macd),
(currentData.element_kline_data || currentData.kline_data)
);
// 主周期 U 标记(蓝/橙,与原样式一致)
if (window.showUOnMain && Array.isArray(mainCm.klu_list)) {
mainCm.klu_list.forEach((item) => {
if (!item || !item.time) return;
const ts = Math.floor(new Date(item.time).getTime() / 1000);
if (isNaN(ts)) return;
if (Number(item.separate_div) > 0) {
const macdVal = mainMacdMap.get(ts);
const posSd = (macdVal > 0) ? 'aboveBar' : (macdVal < 0) ? 'belowBar' : 'aboveBar';
mainMarkers.push({ time: ts, position: posSd, color: '#03a9f4', shape: 'arrowUp', text: `SD${Number(item.separate_div)}`, size: 0.6 });
}
if (item.continue_div === true) {
const macdVal = mainMacdMap.get(ts);
const posCd = (macdVal > 0) ? 'aboveBar' : (macdVal < 0) ? 'belowBar' : 'belowBar';
mainMarkers.push({ time: ts, position: posCd, color: '#ff9800', shape: 'arrowDown', text: 'CD', size: 0.6 });
}
if (item.near0_return && Number(item.near0_return) > 0) {
mainMarkers.push({ time: ts, position: 'belowBar', color: '#8bc34a', shape: 'circle', text: `${Number(item.near0_return)}`, size: 0.6 });
}
});
}
// 次周期 U 标记(使用不同配色以区分)
if (window.showUOnElement && Array.isArray(elementCm.klu_list)) {
elementCm.klu_list.forEach((item) => {
if (!item || !item.time) return;
const ts = Math.floor(new Date(item.time).getTime() / 1000);
if (isNaN(ts)) return;
if (Number(item.separate_div) > 0) {
const macdVal = elementMacdMap.get(ts);
const posSd = (macdVal > 0) ? 'aboveBar' : (macdVal < 0) ? 'belowBar' : 'aboveBar';
elementMarkers.push({ time: ts, position: posSd, color: '#9c27b0', shape: 'arrowUp', text: `SD${Number(item.separate_div)}`, size: 0.6 });
}
if (item.continue_div === true) {
const macdVal = elementMacdMap.get(ts);
const posCd = (macdVal > 0) ? 'aboveBar' : (macdVal < 0) ? 'belowBar' : 'belowBar';
elementMarkers.push({ time: ts, position: posCd, color: '#4caf50', shape: 'arrowDown', text: 'CD', size: 0.6 });
}
if (item.near0_return && Number(item.near0_return) > 0) {
elementMarkers.push({ time: ts, position: 'belowBar', color: '#009688', shape: 'circle', text: `${Number(item.near0_return)}`, size: 0.6 });
}
});
}
const subSubCm = currentData.sub_sub_chan_macd || {};
const subSubMarkers = [];
const subSubMacdMap = buildMacdTimeMap(currentData.macd, currentData.kline_data);
if (window.showUOnSubSub && Array.isArray(subSubCm.klu_list)) {
subSubCm.klu_list.forEach((item) => {
if (!item || !item.time) return;
const ts = Math.floor(new Date(item.time).getTime() / 1000);
if (isNaN(ts)) return;
if (Number(item.separate_div) > 0) {
const macdVal = subSubMacdMap.get(ts);
const posSd = (macdVal > 0) ? 'aboveBar' : (macdVal < 0) ? 'belowBar' : 'aboveBar';
subSubMarkers.push({ time: ts, position: posSd, color: '#00897b', shape: 'arrowUp', text: `SD${Number(item.separate_div)}`, size: 0.6 });
}
if (item.continue_div === true) {
const macdVal = subSubMacdMap.get(ts);
const posCd = (macdVal > 0) ? 'aboveBar' : (macdVal < 0) ? 'belowBar' : 'belowBar';
subSubMarkers.push({ time: ts, position: posCd, color: '#26a69a', shape: 'arrowDown', text: 'CD', size: 0.6 });
}
if (item.near0_return && Number(item.near0_return) > 0) {
subSubMarkers.push({ time: ts, position: 'belowBar', color: '#00695c', shape: 'circle', text: `${Number(item.near0_return)}`, size: 0.6 });
}
});
}
window.kluDivMarkersSubSub = subSubMarkers;
// 保存到全局,供主图合并标记使用
window.kluDivMarkersMain = mainMarkers;
window.kluDivMarkersElement = elementMarkers;
} catch (e) {
console.warn('处理 KLU 背驰标记出错:', e);
window.kluDivMarkersMain = [];
window.kluDivMarkersElement = [];
window.kluDivMarkersSubSub = [];
}
} else {
console.log('⚠️ 没有ChanMACD分析数据');
// 无数据时清空本次的 KLU 背驰标记
window.kluDivMarkersMain = [];
window.kluDivMarkersElement = [];
window.kluDivMarkersSubSub = [];
}
} else {
console.log('⚠️ ChanMACD图表创建条件不满足');
}
// 独立于当前显示周期:计算主/次周期 SD/CD 标记(用于主图合并显示)
try {
const mainCmAll = currentData.chan_macd || {};
const elementCmAll = currentData.element_chan_macd || {};
const mainMarkersAll = [];
const elementMarkersAll = [];
// 构建 MACD 时间映射,用于依据 MACD 正负决定 SD/CD 的显示上下位置
const buildMacdTimeMapAll = (macdObj, klineArr) => {
const map = new Map();
if (!macdObj || !klineArr || !Array.isArray(klineArr)) return map;
for (let i = 0; i < klineArr.length; i++) {
const k = klineArr[i];
if (!k || !k.date) continue;
const t = Math.floor(new Date(k.date).getTime() / 1000);
const val = (macdObj.macd && macdObj.macd[i] !== undefined && macdObj.macd[i] !== null) ? macdObj.macd[i] : null;
map.set(t, val);
}
return map;
};
const mainMacdMapAll = buildMacdTimeMapAll(currentData.macd, currentData.kline_data);
const elementMacdMapAll = buildMacdTimeMapAll(
(currentData.element_macd || currentData.macd),
(currentData.element_kline_data || currentData.kline_data)
);
if ((typeof window.showUOnMain === 'undefined' ? false : window.showUOnMain) && Array.isArray(mainCmAll.klu_list)) {
mainCmAll.klu_list.forEach((item) => {
if (!item || !item.time) return;
const ts = Math.floor(new Date(item.time).getTime() / 1000);
if (isNaN(ts)) return;
if (Number(item.separate_div) > 0) {
const macdVal = mainMacdMapAll.get(ts);
const posSd = (macdVal > 0) ? 'aboveBar' : (macdVal < 0) ? 'belowBar' : 'aboveBar';
mainMarkersAll.push({ time: ts, position: posSd, color: '#03a9f4', shape: 'arrowUp', text: `SD${Number(item.separate_div)}`, size: 0.6 });
}
if (item.continue_div === true) {
const macdVal = mainMacdMapAll.get(ts);
const posCd = (macdVal > 0) ? 'aboveBar' : (macdVal < 0) ? 'belowBar' : 'belowBar';
mainMarkersAll.push({ time: ts, position: posCd, color: '#ff9800', shape: 'arrowDown', text: 'CD', size: 0.6 });
}
if (item.near0_return && Number(item.near0_return) > 0) {
mainMarkersAll.push({ time: ts, position: 'belowBar', color: '#8bc34a', shape: 'circle', text: `${Number(item.near0_return)}`, size: 0.6 });
}
});
}
if ((typeof window.showUOnElement === 'undefined' ? false : window.showUOnElement) && Array.isArray(elementCmAll.klu_list)) {
elementCmAll.klu_list.forEach((item) => {
if (!item || !item.time) return;
const ts = Math.floor(new Date(item.time).getTime() / 1000);
if (isNaN(ts)) return;
if (Number(item.separate_div) > 0) {
const macdVal = elementMacdMapAll.get(ts);
const posSd = (macdVal > 0) ? 'aboveBar' : (macdVal < 0) ? 'belowBar' : 'aboveBar';
elementMarkersAll.push({ time: ts, position: posSd, color: '#9c27b0', shape: 'arrowUp', text: `SD${Number(item.separate_div)}`, size: 0.6 });
}
if (item.continue_div === true) {
const macdVal = elementMacdMapAll.get(ts);
const posCd = (macdVal > 0) ? 'aboveBar' : (macdVal < 0) ? 'belowBar' : 'belowBar';
elementMarkersAll.push({ time: ts, position: posCd, color: '#4caf50', shape: 'arrowDown', text: 'CD', size: 0.6 });
}
if (item.near0_return && Number(item.near0_return) > 0) {
elementMarkersAll.push({ time: ts, position: 'belowBar', color: '#009688', shape: 'circle', text: `${Number(item.near0_return)}`, size: 0.6 });
}
});
}
window.kluDivMarkersMain = mainMarkersAll;
window.kluDivMarkersElement = elementMarkersAll;
const subSubCmAll = currentData.sub_sub_chan_macd || {};
const subSubMarkersAll = [];
if (window.showUOnSubSub && Array.isArray(subSubCmAll.klu_list)) {
subSubCmAll.klu_list.forEach((item) => {
if (!item || !item.time) return;
const ts = Math.floor(new Date(item.time).getTime() / 1000);
if (isNaN(ts)) return;
if (Number(item.separate_div) > 0) {
subSubMarkersAll.push({ time: ts, position: 'aboveBar', color: '#00897b', shape: 'arrowUp', text: `SD${Number(item.separate_div)}`, size: 0.6 });
}
if (item.continue_div === true) {
subSubMarkersAll.push({ time: ts, position: 'belowBar', color: '#26a69a', shape: 'arrowDown', text: 'CD', size: 0.6 });
}
if (item.near0_return && Number(item.near0_return) > 0) {
subSubMarkersAll.push({ time: ts, position: 'belowBar', color: '#00695c', shape: 'circle', text: `${Number(item.near0_return)}`, size: 0.6 });
}
});
}
window.kluDivMarkersSubSub = subSubMarkersAll;
} catch (e) {
console.warn('独立计算 KLU 背驰标记出错:', e);
window.kluDivMarkersMain = [];
window.kluDivMarkersElement = [];
window.kluDivMarkersSubSub = [];
}
}
+54
View File
@@ -0,0 +1,54 @@
/* chart_tv_lifecycle.js — dispose Lightweight Charts / DOM / listeners */
/** 释放 Lightweight Charts 实例、DOM 与全局事件,避免自动刷新内存泄漏 */
function disposeTradingViewCharts() {
try {
if (window._tvInitCleanups && Array.isArray(window._tvInitCleanups)) {
window._tvInitCleanups.forEach(function (fn) { try { fn(); } catch (e) {} });
}
window._tvInitCleanups = [];
if (window._bindSyncCleanups && Array.isArray(window._bindSyncCleanups)) {
window._bindSyncCleanups.forEach(function (fn) { try { fn(); } catch (e) {} });
}
window._bindSyncCleanups = [];
if (window._tooltipCleanups && Array.isArray(window._tooltipCleanups)) {
window._tooltipCleanups.forEach(function (fn) { try { fn(); } catch (e) {} });
}
window._tooltipCleanups = [];
document.querySelectorAll(
'.volume-crosshair-line, .atr-crosshair-line, .macd-crosshair-line, .chanmacd-crosshair-line'
).forEach(function (el) { try { el.remove(); } catch (e) {} });
if (typeof clearEMA52Series === 'function') {
try { clearEMA52Series(); } catch (e) {}
}
if (tvWidget) {
['mainChart', 'volumeChart', 'macdChart', 'chanMacdChart', 'atrChart'].forEach(function (key) {
try {
if (tvWidget[key] && typeof tvWidget[key].remove === 'function') {
tvWidget[key].remove();
}
} catch (e) {}
tvWidget[key] = null;
});
if (tvWidget.state) {
tvWidget.state.isInitialized = false;
}
}
var chartRoot = document.getElementById('tradingview_chart');
if (chartRoot) {
// 重建前救出 Cycle Summary,避免 innerHTML 清空时被销毁
var summaryEl = document.getElementById('wyckoffCycleSummary');
var chartHost = chartRoot.parentElement;
if (summaryEl && chartRoot.contains(summaryEl) && chartHost) {
chartHost.appendChild(summaryEl);
}
chartRoot.innerHTML = '';
}
} catch (e) {
console.warn('disposeTradingViewCharts 失败(可忽略):', e);
}
}
File diff suppressed because it is too large Load Diff
+519
View File
@@ -0,0 +1,519 @@
/* chart_tv_shell.js — containers, charts, main price series */
function chartTvBuildShell(ctx) {
var symbol = ctx.symbol;
var timeframe = ctx.timeframe;
// 获取当前交易对的配置
const symbolConfig = getSymbolConfig(symbol);
console.log('交易对配置:', symbolConfig);
// 检查数据是否存在
if (!currentData || !currentData.kline_data) {
console.error('数据加载失败或不存在');
return;
}
// 检查使用哪一档K线数据:次次周期 / 小周期 / 主周期
const useSubSubPeriod = $('#subSubPeriodKline').is(':checked') &&
currentData.sub_sub_kline_data &&
Array.isArray(currentData.sub_sub_kline_data);
const useElementPeriod = $('#elementPeriodKline').is(':checked') &&
currentData.element_kline_data &&
Array.isArray(currentData.element_kline_data);
// 输出K线周期选择状态
const klinePeriodLabel = useSubSubPeriod ? '次次周期' : (useElementPeriod ? '小周期' : '主周期');
console.log('K线周期选择:', klinePeriodLabel);
console.log('当前选择时区:', $('#timezone').val());
console.log('交易对类型:', symbolConfig.type);
let candles = [];
const klineDataSource = useSubSubPeriod ? (currentData.sub_sub_kline_data || []) : (useElementPeriod ? (currentData.element_kline_data || []) : (currentData.kline_data || []));
if (useSubSubPeriod || useElementPeriod) {
if (!klineDataSource.length) {
console.error(useSubSubPeriod ? '次次周期K线数据不存在或为空' : '小周期K线数据不存在或为空', klineDataSource);
return;
}
candles = klineDataSource.map((kline) => {
const date = new Date(kline.date);
const timestamp = date.getTime() / 1000;
return {
time: timestamp,
open: parseFloat(kline.open),
high: parseFloat(kline.high),
low: parseFloat(kline.low),
close: parseFloat(kline.close),
};
});
} else {
if (!currentData.kline_data || !Array.isArray(currentData.kline_data)) {
console.error('主周期K线数据不存在或不是数组:', currentData.kline_data);
return;
}
candles = currentData.kline_data.map((kline) => {
const date = new Date(kline.date);
const timestamp = date.getTime() / 1000;
return {
time: timestamp,
open: parseFloat(kline.open),
high: parseFloat(kline.high),
low: parseFloat(kline.low),
close: parseFloat(kline.close),
};
});
}
// 根据交易对类型过滤数据(仅用于显示优化)
if (symbolConfig.type === 'a_stock' && timeframe.includes('m')) {
// 对于A股分钟级数据,过滤非交易时间
const originalLength = candles.length;
candles = filterTradingHours(candles, symbolConfig);
console.log(`A股数据过滤: ${originalLength} -> ${candles.length} 条记录`);
}
// 重置图表对象(容器已在 disposeTradingViewCharts 清空)
tvWidget = {
mainChart: null,
volumeChart: null,
macdChart: null,
series: {
candleSeries: null,
lineSeries: null,
volumeSeries: null,
macdLineSeries: null,
signalLineSeries: null,
histogramSeries: null,
mainBiSeries: [],
mainUncompletedBiSeries: [],
mainSegSeries: [],
mainUncompletedSegSeries: [],
mainZsSeries: [],
mainUncompletedZsSeries: [],
elementBiSeries: [],
elementUncompletedBiSeries: [],
elementSegSeries: [],
elementUncompletedSegSeries: [],
elementZsSeries: [],
elementUncompletedZsSeries: [],
subSubBiSeries: [],
subSubUncompletedBiSeries: [],
subSubSegSeries: [],
subSubUncompletedSegSeries: [],
subSubZsSeries: [],
subSubUncompletedZsSeries: [],
tradePointSeries: [],
mainBollingerSeries: [],
elementBollingerSeries: [],
maSeries: [], // 添加均线系列数组
bbSeries: [], // 添加布林带系列数组
ema52Series: [] // 添加EMA52系列数组
},
state: {
isInitialized: false,
visibleRange: null,
logicalRange: null
}
};
// 设置父容器样式
const container = document.getElementById('tradingview_chart');
container.style.position = 'relative';
container.style.width = '100%';
container.style.height = '100%';
// 是否显示MACD
const showMacd = $('#showMacd').is(':checked');
const showOriginalKline = $('#showOriginalKline').is(':checked');
// 创建主图容器
const mainChartContainer = document.createElement('div');
mainChartContainer.style.width = '100%';
mainChartContainer.style.position = 'absolute';
mainChartContainer.style.top = '0';
mainChartContainer.style.left = '0';
mainChartContainer.style.right = '0';
// 创建成交量副图容器
const volumeChartContainer = document.createElement('div');
volumeChartContainer.style.width = '100%';
volumeChartContainer.style.position = 'absolute';
volumeChartContainer.style.left = '0';
volumeChartContainer.style.right = '0';
volumeChartContainer.style.borderTop = '1px solid #e0e0e0';
// 添加ATR图表容器
const atrChartContainer = document.createElement('div');
atrChartContainer.style.width = '100%';
atrChartContainer.style.position = 'absolute';
atrChartContainer.style.left = '0';
atrChartContainer.style.right = '0';
atrChartContainer.style.borderTop = '1px solid #e0e0e0';
// 如果需要显示MACD,创建MACD容器
let macdChartContainer = null;
let chanMacdChartContainer = null;
if (showMacd) {
// 仅显示新的 ChanMACD 图:让其占用原 MACD+ChanMACD 的整体高度
// 新布局:主图(40%) → ChanMACD(30%) → 成交量(17.5%) → ATR(12.5%)
mainChartContainer.style.height = '40%';
// 隐藏旧 MACD 容器(不创建)
// 创建 ChanMACD 容器占据原 MACD+ChanMACD 高度(30%
chanMacdChartContainer = document.createElement('div');
chanMacdChartContainer.style.width = '100%';
chanMacdChartContainer.style.height = '30%';
chanMacdChartContainer.style.position = 'absolute';
chanMacdChartContainer.style.top = '40%';
chanMacdChartContainer.style.left = '0';
chanMacdChartContainer.style.right = '0';
chanMacdChartContainer.style.borderTop = '1px solid #e0e0e0';
chanMacdChartContainer.style.zIndex = '10';
// 水印:便于区分是新的 ChanMACD 子图
const chanMacdWatermark = document.createElement('div');
chanMacdWatermark.textContent = 'ChanMACD';
chanMacdWatermark.style.position = 'absolute';
chanMacdWatermark.style.top = '4px';
chanMacdWatermark.style.left = '8px';
chanMacdWatermark.style.fontSize = '11px';
chanMacdWatermark.style.color = '#888';
chanMacdWatermark.style.pointerEvents = 'none';
chanMacdChartContainer.appendChild(chanMacdWatermark);
// 成交量位于 ChanMACD 之下
volumeChartContainer.style.top = '70%';
volumeChartContainer.style.height = '17.5%';
// ATR 位于最底部
atrChartContainer.style.top = '87.5%';
atrChartContainer.style.height = '12.5%';
} else {
// 不显示MACD时的高度 - 主图、成交量图和ATR图分配
mainChartContainer.style.height = '55%'; // 主图占55%
volumeChartContainer.style.top = '55%';
volumeChartContainer.style.height = '22.5%'; // 成交量图占22.5%
atrChartContainer.style.top = '77.5%'; // ATR图从77.5%位置开始
atrChartContainer.style.height = '22.5%'; // ATR图占22.5%
}
container.appendChild(mainChartContainer);
container.appendChild(volumeChartContainer);
container.appendChild(atrChartContainer);
if (showMacd) {
// 只追加新的 ChanMACD 容器
container.appendChild(chanMacdChartContainer);
}
// 防止同步过程中的无限循环(实际同步由 bindSyncEvents 负责)
// 创建统一的图表选项
const createChartOptions = (showTimeScale = true, chartType = 'main') => {
// 根据图表类型确定高度
let chartHeight;
if (chartType === 'main') {
chartHeight = mainChartContainer.clientHeight;
} else if (chartType === 'volume') {
chartHeight = volumeChartContainer.clientHeight;
} else if (chartType === 'atr') {
chartHeight = atrChartContainer.clientHeight;
} else if (chartType === 'macd') {
chartHeight = macdChartContainer ? macdChartContainer.clientHeight : 0;
} else if (chartType === 'chanmacd') {
chartHeight = chanMacdChartContainer ? chanMacdChartContainer.clientHeight : 0;
} else {
chartHeight = mainChartContainer.clientHeight;
}
const baseOptions = {
width: mainChartContainer.clientWidth,
height: chartHeight,
layout: {
background: { color: '#ffffff' },
textColor: '#333',
},
grid: {
vertLines: { color: '#f0f0f0' },
horzLines: { color: '#f0f0f0' },
},
crosshair: {
mode: LightweightCharts.CrosshairMode.Normal,
// 添加十字线工具提示本地化配置
horzLine: {
labelVisible: true,
},
vertLine: {
labelVisible: true,
// 自定义时间格式化
labelFormatter: (time) => {
const selectedTimezone = $('#timezone').val();
try {
const date = new Date(time * 1000);
if (symbolConfig.type === 'a_stock') {
// A股使用中国时区格式
return date.toLocaleString('zh-CN', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
} else {
return date.toLocaleString('zh-CN', {
timeZone: selectedTimezone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
}
} catch (e) {
console.error('十字线时间格式化错误:', e);
return new Date(time * 1000).toLocaleString();
}
},
},
},
rightPriceScale: {
borderColor: '#ddd',
scaleMargins: {
top: 0.1,
bottom: 0.1,
},
// 为标签留出更多空间,防止遮挡
minimumWidth: 80,
},
// 添加左边距配置
leftPriceScale: {
visible: false,
},
// 添加本地化选项,确保所有时间显示都使用选定的时区
localization: {
timeFormatter: (time) => {
const selectedTimezone = $('#timezone').val();
try {
const date = new Date(time * 1000);
if (symbolConfig.type === 'a_stock') {
// A股使用中国时区格式
return date.toLocaleString('zh-CN', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
} else {
return date.toLocaleString('zh-CN', {
timeZone: selectedTimezone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
}
} catch (e) {
console.error('全局时间格式化错误:', e);
return new Date(time * 1000).toLocaleString();
}
}
},
timeScale: {
timeVisible: true,
secondsVisible: false,
visible: showTimeScale,
borderColor: '#ddd',
barSpacing: symbolConfig.type === 'a_stock' ? 6 : 10,
// 确保所有图表使用相同的边距设置
rightOffset: 12,
// 移除可能影响拖动的固定边缘设置
// fixLeftEdge: true,
// fixRightEdge: true,
lockVisibleTimeRangeOnResize: true,
tickMarkFormatter: (time) => {
const selectedTimezone = symbolConfig.type === 'a_stock' ? 'Asia/Shanghai' : $('#timezone').val();
try {
// 使用完整的配置确保时区正确应用
const date = new Date(time * 1000);
console.log('格式化时间:', time, '转换为:', date.toISOString(), '时区:', selectedTimezone);
return date.toLocaleString('zh-CN', {
timeZone: selectedTimezone,
month: 'numeric',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
} catch (e) {
console.error('时间格式化错误:', e);
// 如果时区格式化失败,返回简单格式
return new Date(time * 1000).toLocaleString();
}
},
},
};
// 根据交易对类型调整配置
return adjustChartForSymbolType(baseOptions, symbolConfig);
};
// 创建主图表
const mainChart = LightweightCharts.createChart(mainChartContainer, createChartOptions(true, 'main'));
// Cycle Summary 挂到主图左下角(相对 K 线主图 pane,而非整图底边)
(function mountWyckoffCycleSummary() {
var summaryEl = document.getElementById('wyckoffCycleSummary');
if (!summaryEl) {
summaryEl = document.createElement('div');
summaryEl.id = 'wyckoffCycleSummary';
summaryEl.className = 'wyckoff-cycle-summary';
summaryEl.setAttribute('aria-live', 'polite');
}
mainChartContainer.appendChild(summaryEl);
if (typeof renderWyckoffCycleSummary === 'function') {
try { renderWyckoffCycleSummary(); } catch (e) {}
}
})();
// 创建成交量图表 - 只显示底部的时间轴
const volumeChart = LightweightCharts.createChart(volumeChartContainer, createChartOptions(false, 'volume'));
// 创建ATR图表
const atrChart = LightweightCharts.createChart(atrChartContainer, createChartOptions(false, 'atr'));
// 创建MACD图表(如果需要):仅创建新的 ChanMACD 图
let macdChart = null;
let chanMacdChart = null;
if (showMacd) {
chanMacdChart = LightweightCharts.createChart(chanMacdChartContainer, createChartOptions(false, 'chanmacd'));
}
// 创建主价格系列并设置数据(支持多种图表类型)
(function(){
const klineType = ($('#klineType').val() || 'candlestick');
// 先清空旧的主系列引用
tvWidget.series.candleSeries = null;
tvWidget.series.lineSeries = null;
tvWidget.series.barSeries = null;
tvWidget.series.areaSeries = null;
tvWidget.series.baselineSeries = null;
tvWidget.series.renkoSeries = null;
tvWidget.series.heikinSeries = null;
if (klineType === 'candlestick') {
const series = mainChart.addCandlestickSeries({
upColor: '#28a745',
downColor: '#dc3545',
borderVisible: false,
wickUpColor: '#28a745',
wickDownColor: '#dc3545',
});
series.setData(candles);
tvWidget.series.candleSeries = series;
} else if (klineType === 'renko') {
const series = mainChart.addCandlestickSeries({
upColor: '#28a745',
downColor: '#dc3545',
borderVisible: false,
wickUpColor: '#28a745',
wickDownColor: '#dc3545',
});
const bricks = buildRenkoFromCandles(candles);
series.setData(bricks);
tvWidget.series.renkoSeries = series;
} else if (klineType === 'heikin') {
const series = mainChart.addCandlestickSeries({
upColor: '#28a745',
downColor: '#dc3545',
borderVisible: false,
wickUpColor: '#28a745',
wickDownColor: '#dc3545',
});
const hk = buildHeikinFromCandles(candles);
series.setData(hk);
tvWidget.series.heikinSeries = series;
} else if (klineType === 'bar') {
const series = mainChart.addBarSeries({
upColor: '#28a745',
downColor: '#dc3545',
thinBars: false
});
series.setData(candles);
tvWidget.series.barSeries = series;
} else if (klineType === 'line') {
const series = mainChart.addLineSeries({
color: '#2962FF',
lineWidth: 2,
crosshairMarkerVisible: true,
lastValueVisible: true,
priceLineVisible: true,
});
const lineData = candles.map(c => ({ time: c.time, value: c.close }));
series.setData(lineData);
tvWidget.series.lineSeries = series;
} else if (klineType === 'area') {
const series = mainChart.addAreaSeries({
topColor: 'rgba(41, 98, 255, 0.4)',
bottomColor: 'rgba(41, 98, 255, 0.0)',
lineColor: '#2962FF',
lineWidth: 2,
});
const areaData = candles.map(c => ({ time: c.time, value: c.close }));
series.setData(areaData);
tvWidget.series.areaSeries = series;
} else if (klineType === 'baseline') {
const series = mainChart.addBaselineSeries({
baseValue: { type: 'price', price: candles.length ? candles[candles.length - 1].close : 0 },
topLineColor: '#26a69a',
bottomLineColor: '#ef5350',
topFillColor1: 'rgba(38, 166, 154, 0.28)',
topFillColor2: 'rgba(38, 166, 154, 0.05)',
bottomFillColor1: 'rgba(239, 83, 80, 0.28)',
bottomFillColor2: 'rgba(239, 83, 80, 0.05)'
});
const baseData = candles.map(c => ({ time: c.time, value: c.close }));
series.setData(baseData);
tvWidget.series.baselineSeries = series;
} else if (klineType === 'klc') {
// KLC显示模式 - 使用蜡烛线显示KLC数据
const series = mainChart.addCandlestickSeries({
upColor: '#28a745',
downColor: '#dc3545',
borderVisible: false,
wickUpColor: '#28a745',
wickDownColor: '#dc3545',
});
// 使用KLC数据创建蜡烛图
const klcCandles = buildKLCFromAnalysis(currentData);
series.setData(klcCandles);
tvWidget.series.klcSeries = series;
}
})();
ctx.symbolConfig = symbolConfig;
ctx.useSubSubPeriod = useSubSubPeriod;
ctx.useElementPeriod = useElementPeriod;
ctx.klinePeriodLabel = klinePeriodLabel;
ctx.candles = candles;
ctx.klineDataSource = klineDataSource;
ctx.container = container;
ctx.showMacd = showMacd;
ctx.showOriginalKline = showOriginalKline;
ctx.mainChartContainer = mainChartContainer;
ctx.volumeChartContainer = volumeChartContainer;
ctx.atrChartContainer = atrChartContainer;
ctx.macdChartContainer = macdChartContainer;
ctx.chanMacdChartContainer = chanMacdChartContainer;
ctx.mainChart = mainChart;
ctx.volumeChart = volumeChart;
ctx.atrChart = atrChart;
ctx.macdChart = macdChart;
ctx.chanMacdChart = chanMacdChart;
ctx.createChartOptions = createChartOptions;
}
+6 -3
View File
@@ -13,7 +13,7 @@ function updateChart(options) {
symbol = $('#astockSymbol').val() || '000001';
}
const timeframe = $('#timeframe').val() || window.DEFAULT_MAIN_TIMEFRAME || '5m';
const timeframe = $('#timeframe').val() || window.DEFAULT_MAIN_TIMEFRAME || '4h';
const timezone = $('#timezone').val() || 'Asia/Shanghai';
const elementTimeframe = $('#elementTimeframe').val() || window.DEFAULT_ELEMENT_TIMEFRAME || '1m';
const subSubTimeframe = $('#subSubTimeframe').val() || '';
@@ -62,8 +62,8 @@ function updateChart(options) {
end_time: endTimeMs,
elements_only: false,
zone_kl_lines: parseInt($('#zoneKlLines').val()) || 1000,
include_structure_zones: $('#showMainStructureZone').is(':checked') ? 1 : 0,
include_wyckoff: $('#showWyckoff').is(':checked') ? 1 : 0
include_structure_zones: $('#showMainStructureZone').is(':checked') ? 1 : 0
// 威科夫随主分析一并返回;开关仅控制绘制,不再传 include_wyckoff
},
success: function(data) {
// 隐藏加载图标
@@ -81,6 +81,9 @@ function updateChart(options) {
delete currentData.original_macd;
}
currentData = data;
if (typeof renderWyckoffCycleSummary === 'function') {
renderWyckoffCycleSummary();
}
refreshChart(data, {
incremental: options.incremental !== undefined
+8 -17
View File
@@ -93,25 +93,16 @@ $(document).on('change', '#showMainStructureZone', function() {
}
});
// 威科夫主开关:勾选才请求;子项仅本地重绘
function syncWyckoffSubControls() {
const on = $('#showWyckoff').is(':checked');
$('#showWyckoffRange, #showWyckoffPhases, #showWyckoffEvents, #showWyckoffVP').prop('disabled', !on);
}
$(document).on('change', '#showWyckoff', function() {
const on = $('#showWyckoff').is(':checked');
syncWyckoffSubControls();
console.log('威科夫切换为:', on);
if (on) {
updateChart();
} else {
// 区间/阶段/时间/VP:与缠论笔开关一样,本地重绘
$(document).on(
'change',
'#showMainWrRange, #showMainWrPhases, #showMainWrEvents, #showMainWrVP,' +
'#showElementWrRange, #showElementWrPhases, #showElementWrEvents, #showElementWrVP,' +
'#showSubSubWrRange, #showSubSubWrPhases, #showSubSubWrEvents, #showSubSubWrVP',
function() {
updateChartDisplay();
}
});
$(document).on('change', '#showWyckoffRange, #showWyckoffPhases, #showWyckoffEvents, #showWyckoffVP', function() {
updateChartDisplay();
});
$(function() { syncWyckoffSubControls(); });
);
// 添加趋势显示复选框变更事件(主/元素),变更后刷新主图
$('#showMainTrend').change(function() {
+252 -3
View File
@@ -1,4 +1,252 @@
/* ui.js */
/** Trading OS 可消费的威科夫 Cycle 摘要(Confirmed + Live 分区;cycles[0]=ACTIVE */
function buildWyckoffCycleSummaryPayload(w, tf) {
if (!w) return null;
const cycles = (w.cycles && w.cycles.length)
? w.cycles
: (w.trading_range ? [{
id: 0, status: 'ACTIVE', role: 'latest', lifecycle: w.lifecycle || 'UNKNOWN',
trading_range: w.trading_range, bias: w.bias,
phases: w.phases || [], events: w.events || [],
confirmed: { phases: w.phases || [], events: w.events || [] },
live: w.live || null,
confidence: { overall: null },
period: {
start_time: w.trading_range.start_time,
end_time: w.trading_range.end_time,
bars: w.trading_range.bars
}
}] : []);
if (!cycles.length) return null;
const active = cycles[0]; // 禁止 cycles[-1]
const confirmed = active.confirmed || {
phases: active.phases || w.phases || [],
events: active.events || w.events || []
};
const live = active.live || w.live || null;
const cPhases = confirmed.phases || [];
const cEvents = confirmed.events || [];
const lastPhase = cPhases.length ? cPhases[cPhases.length - 1] : null;
const lastEvent = cEvents.length ? cEvents[cEvents.length - 1] : null;
const tr = active.trading_range || {};
const prev = cycles.length > 1 ? cycles[1] : null;
const biasLabel = ({
accumulation: 'Accumulation',
distribution: 'Distribution',
unknown: 'Unknown'
})[active.bias] || (active.bias || 'Unknown');
const liveCand = (live && live.event_candidates && live.event_candidates[0]) || null;
const liveConf = live && live.confidence ? live.confidence.overall : null;
return {
symbol: (typeof currentData !== 'undefined' && currentData && currentData.symbol) || $('#symbol').val() || '',
timeframe: (tf || w.timeframe || $('#timeframe').val() || '').toString().toUpperCase(),
active: {
cycle_id: active.id != null ? active.id : 0,
status: active.status || 'ACTIVE',
lifecycle: active.lifecycle || (live && live.lifecycle) || 'UNKNOWN',
structure: biasLabel,
phase_confirmed: lastPhase ? String(lastPhase.phase || '') : null,
event_confirmed: lastEvent ? String(lastEvent.type || '') : null,
phase_candidate: live ? live.phase_candidate : null,
event_candidate: liveCand ? liveCand.type : null,
event_candidate_confidence: liveCand ? liveCand.confidence : null,
next_expected: live ? live.next_expected : null,
range: {
low: tr.low,
high: tr.high,
start_time: (active.period && active.period.start_time) || tr.start_time,
end_time: (active.period && active.period.end_time) || tr.end_time,
bars: (active.period && active.period.bars) != null ? active.period.bars : tr.bars
},
confidence_confirmed: (active.confidence && active.confidence.overall != null)
? active.confidence.overall
: null,
confidence_live: liveConf
},
confirmed_history: cycles.slice(1, 4).map(function(c) {
const evs = ((c.confirmed && c.confirmed.events) || c.events || [])
.map(function(e) { return e.type; }).filter(Boolean);
return {
cycle_id: c.id,
structure: ({
accumulation: 'Accumulation',
distribution: 'Distribution',
unknown: 'Unknown'
})[c.bias] || c.bias,
events: evs,
lifecycle: c.lifecycle || 'COMPLETED'
};
}),
live: live,
cycle_count: cycles.length
};
}
function _wrLayerTogglesOn(prefix) {
// prefix: Main | Element | SubSub
return $('#show' + prefix + 'WrRange').is(':checked')
|| $('#show' + prefix + 'WrPhases').is(':checked')
|| $('#show' + prefix + 'WrEvents').is(':checked')
|| $('#show' + prefix + 'WrVP').is(':checked');
}
/** 面板展示用中文(机器可读 payload 仍保留英文原值) */
function _wcsLifecycleZh(v) {
return ({
UNKNOWN: '未知',
FORMING: '形成中',
CONFIRMED: '已确认',
COMPLETED: '已完成',
ACTIVE: '当前'
})[v] || v || '未知';
}
function _wcsStructureZh(v) {
if (!v) return '—';
const key = String(v).toLowerCase();
return ({
accumulation: '吸筹',
distribution: '派发',
unknown: '未知'
})[key] || ({
Accumulation: '吸筹',
Distribution: '派发',
Unknown: '未知'
})[v] || v;
}
function _wcsEventZh(v) {
if (v == null || v === '') return '—';
return ({
Spring: '弹簧',
UTAD: '上升后派发',
SOS: '强势信号',
SOW: '弱势信号',
LPS: '最后支撑',
LPSY: '最后供应',
Test: '回测',
PSY: '初步供应',
BC: '买气高潮',
AR: '自动回落',
ST: '二次测试',
SC: '卖气高潮'
})[v] || v;
}
function _htmlWyckoffSummaryBlock(payload, blockClass) {
if (!payload || !payload.active) return '';
const a = payload.active;
const fmtPx = function(v) {
if (v == null || isNaN(Number(v))) return '—';
const n = Number(v);
return n >= 1000 ? n.toFixed(1) : n.toFixed(4);
};
const pct = function(v) {
if (v == null || isNaN(Number(v))) return '—';
return Math.round(Number(v) * 100) + '%';
};
let html = '<div class="wcs-block ' + (blockClass || '') + '">';
html += '<div class="wcs-title">' + (payload.symbol || '') + ' '
+ (payload.timeframe || '') + '</div>';
html += '<div><span class="wcs-badge">当前 C' + a.cycle_id + '</span> '
+ '<span class="wcs-badge" style="background:#fff8c5;color:#9a6700;">'
+ _wcsLifecycleZh(a.lifecycle) + '</span></div>';
html += '<div class="wcs-active">';
html += '<div class="wcs-row"><span class="wcs-k">结构</span><span class="wcs-v">'
+ _wcsStructureZh(a.structure) + '</span></div>';
html += '<div class="wcs-row"><span class="wcs-k">阶段</span><span class="wcs-v">'
+ (a.phase_candidate
? ('阶段 ' + a.phase_candidate + '(候选)')
: (a.phase_confirmed ? ('阶段 ' + a.phase_confirmed) : '—'))
+ '</span></div>';
html += '<div class="wcs-row"><span class="wcs-k">事件</span><span class="wcs-v">'
+ (a.event_candidate
? (_wcsEventZh(a.event_candidate) + '(候选)')
: _wcsEventZh(a.event_confirmed))
+ '</span></div>';
if (a.event_confirmed && a.event_candidate) {
html += '<div class="wcs-row"><span class="wcs-k">已确认</span><span class="wcs-v">'
+ _wcsEventZh(a.event_confirmed) + '</span></div>';
}
html += '<div class="wcs-row"><span class="wcs-k">区间</span><span class="wcs-v">'
+ fmtPx(a.range && a.range.low) + ' ' + fmtPx(a.range && a.range.high) + '</span></div>';
html += '<div class="wcs-row"><span class="wcs-k">置信度</span><span class="wcs-v">'
+ pct(a.confidence_live != null ? a.confidence_live : a.confidence_confirmed) + '</span></div>';
if (a.next_expected) {
html += '<div class="wcs-row"><span class="wcs-k">下一步</span><span class="wcs-v">'
+ _wcsEventZh(a.next_expected) + '</span></div>';
}
html += '</div>';
if (payload.confirmed_history && payload.confirmed_history.length) {
html += '<div class="wcs-prev"><div style="margin-bottom:2px;">已确认历史</div>';
payload.confirmed_history.forEach(function(h) {
const ev = (h.events && h.events.length)
? h.events.map(_wcsEventZh).join('、')
: '—';
html += '<div>C' + h.cycle_id + ' ' + _wcsStructureZh(h.structure) + ' · ' + ev + '</div>';
});
html += '</div>';
}
html += '</div>';
return html;
}
function renderWyckoffCycleSummary() {
const $el = $('#wyckoffCycleSummary');
if (!$el.length) return;
if (!currentData) {
$el.hide().empty();
window.wyckoffCycleSummary = null;
return;
}
const layers = [];
if (_wrLayerTogglesOn('Main') && currentData.wyckoff) {
layers.push({
key: 'main',
cls: 'wcs-main',
payload: buildWyckoffCycleSummaryPayload(
currentData.wyckoff,
currentData.timeframe || currentData.wyckoff.timeframe || $('#timeframe').val()
)
});
}
if (_wrLayerTogglesOn('Element') && currentData.element_wyckoff) {
layers.push({
key: 'element',
cls: 'wcs-element',
payload: buildWyckoffCycleSummaryPayload(
currentData.element_wyckoff,
currentData.element_timeframe || currentData.element_wyckoff.timeframe || $('#elementTimeframe').val()
)
});
}
if (_wrLayerTogglesOn('SubSub') && currentData.sub_sub_wyckoff) {
layers.push({
key: 'sub_sub',
cls: 'wcs-subsub',
payload: buildWyckoffCycleSummaryPayload(
currentData.sub_sub_wyckoff,
currentData.sub_sub_timeframe || currentData.sub_sub_wyckoff.timeframe || $('#subSubTimeframe').val()
)
});
}
const valid = layers.filter(function(L) { return L.payload && L.payload.active; });
if (!valid.length) {
$el.hide().empty();
window.wyckoffCycleSummary = null;
return;
}
const bag = {};
let html = '';
valid.forEach(function(L) {
bag[L.key] = L.payload;
html += _htmlWyckoffSummaryBlock(L.payload, L.cls);
});
window.wyckoffCycleSummary = bag;
$el.html(html).show();
}
function loadSymbols() {
$.get('/api/symbols', function(data) {
if (Array.isArray(data)) {
@@ -24,14 +272,15 @@ function loadSymbols() {
});
}
// 设置默认时间范围
// 设置默认时间范围(需覆盖威科夫 lookback;1 天在 4h/1h 上几乎检不出区间)
function setDefaultTimeRange() {
const now = new Date();
const oneDayAgo = new Date(now.getTime() - (24 * 60 * 60 * 1000));
const daysBack = 14;
const start = new Date(now.getTime() - (daysBack * 24 * 60 * 60 * 1000));
// 格式化为datetime-local输入框所需的格式 YYYY-MM-DDThh:mm
$('#end_time').val(formatDatetimeLocal(now));
$('#start_time').val(formatDatetimeLocal(oneDayAgo));
$('#start_time').val(formatDatetimeLocal(start));
}
// 格式化日期为datetime-local输入框格式
function formatDatetimeLocal(date) {
+137 -27
View File
@@ -96,6 +96,81 @@
position: relative;
z-index: 1; /* 确保图表在数据面板之上 */
}
/* 挂在主图容器内:左下角 = K线主图左下,而非整图(含副图)底边 */
.wyckoff-cycle-summary {
position: absolute;
left: 8px;
bottom: 28px; /* 略抬高,避开主图时间轴 */
top: auto;
right: auto;
z-index: 1100;
min-width: 200px;
max-width: 300px;
max-height: calc(100% - 36px);
overflow-y: auto;
padding: 8px 10px;
background: rgba(255, 255, 255, 0.94);
border: 1px solid #d0d7de;
border-radius: 6px;
box-shadow: 0 2px 10px rgba(0,0,0,0.08);
font-size: 12px;
line-height: 1.45;
color: #24292f;
display: none;
pointer-events: auto;
}
.wyckoff-cycle-summary .wcs-block {
padding: 6px 0;
}
.wyckoff-cycle-summary .wcs-block + .wcs-block {
border-top: 1px solid #eaeef2;
margin-top: 6px;
padding-top: 8px;
}
.wyckoff-cycle-summary .wcs-block.wcs-main { border-left: 3px solid #3498db; padding-left: 8px; }
.wyckoff-cycle-summary .wcs-block.wcs-element { border-left: 3px solid #e67e22; padding-left: 8px; }
.wyckoff-cycle-summary .wcs-block.wcs-subsub { border-left: 3px solid #27ae60; padding-left: 8px; }
.wyckoff-cycle-summary .wcs-title {
font-weight: 650;
font-size: 13px;
margin-bottom: 6px;
letter-spacing: 0.02em;
}
.wyckoff-cycle-summary .wcs-row {
display: flex;
justify-content: space-between;
gap: 8px;
margin: 2px 0;
}
.wyckoff-cycle-summary .wcs-k {
color: #656d76;
flex-shrink: 0;
}
.wyckoff-cycle-summary .wcs-v {
text-align: right;
font-variant-numeric: tabular-nums;
}
.wyckoff-cycle-summary .wcs-active {
margin-top: 2px;
padding: 6px 0 4px;
border-top: 1px solid #eaeef2;
}
.wyckoff-cycle-summary .wcs-prev {
margin-top: 6px;
padding-top: 6px;
border-top: 1px dashed #eaeef2;
color: #656d76;
font-size: 11px;
}
.wyckoff-cycle-summary .wcs-badge {
display: inline-block;
padding: 1px 6px;
border-radius: 3px;
background: #ddf4ff;
color: #0969da;
font-weight: 600;
font-size: 11px;
}
.chart-options {
position: absolute;
top: 10px;
@@ -972,26 +1047,6 @@
<label class="form-check-label" for="showMainStructureZone">结构区</label>
</div>
<input type="number" id="zoneKlLines" class="form-control form-control-sm" value="1000" min="100" max="5000" step="100" style="width:80px;" title="结构区K线数量">
<div class="form-check form-check-inline me-1 ms-2">
<input class="form-check-input" type="checkbox" id="showWyckoff">
<label class="form-check-label" for="showWyckoff">威科夫</label>
</div>
<div class="form-check form-check-inline me-1">
<input class="form-check-input" type="checkbox" id="showWyckoffRange" checked disabled>
<label class="form-check-label" for="showWyckoffRange">区间</label>
</div>
<div class="form-check form-check-inline me-1">
<input class="form-check-input" type="checkbox" id="showWyckoffPhases" checked disabled>
<label class="form-check-label" for="showWyckoffPhases">阶段</label>
</div>
<div class="form-check form-check-inline me-1">
<input class="form-check-input" type="checkbox" id="showWyckoffEvents" checked disabled>
<label class="form-check-label" for="showWyckoffEvents">事件</label>
</div>
<div class="form-check form-check-inline me-1">
<input class="form-check-input" type="checkbox" id="showWyckoffVP" checked disabled>
<label class="form-check-label" for="showWyckoffVP">VP</label>
</div>
<span id="nextRefreshTime" class="text-muted" style="display:none;font-size:0.85rem;"></span>
<div id="refreshLoadingSpinner" class="loading-spinner ms-2" style="display:none;"></div>
</div>
@@ -1037,6 +1092,22 @@
<input class="form-check-input" type="checkbox" id="showMainBsp">
<label class="form-check-label" for="showMainBsp">买卖点</label>
</div>
<div class="form-check form-check-inline ms-2">
<input class="form-check-input" type="checkbox" id="showMainWrRange">
<label class="form-check-label" for="showMainWrRange">区间</label>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" id="showMainWrPhases">
<label class="form-check-label" for="showMainWrPhases">阶段</label>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" id="showMainWrEvents">
<label class="form-check-label" for="showMainWrEvents">时间</label>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" id="showMainWrVP">
<label class="form-check-label" for="showMainWrVP">VP</label>
</div>
</div>
<div class="d-flex align-items-center mt-1">
<label class="form-label me-0 mb-0">次周期:</label>
@@ -1079,6 +1150,22 @@
<input class="form-check-input" type="checkbox" id="showElementBsp">
<label class="form-check-label" for="showElementBsp">买卖点</label>
</div>
<div class="form-check form-check-inline ms-2">
<input class="form-check-input" type="checkbox" id="showElementWrRange">
<label class="form-check-label" for="showElementWrRange">区间</label>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" id="showElementWrPhases">
<label class="form-check-label" for="showElementWrPhases">阶段</label>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" id="showElementWrEvents">
<label class="form-check-label" for="showElementWrEvents">时间</label>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" id="showElementWrVP">
<label class="form-check-label" for="showElementWrVP">VP</label>
</div>
</div>
<div class="d-flex align-items-center mt-1">
<label class="form-label me-0 mb-0">次次周期:</label>
@@ -1121,6 +1208,22 @@
<input class="form-check-input" type="checkbox" id="showSubSubBsp">
<label class="form-check-label" for="showSubSubBsp">买卖点</label>
</div>
<div class="form-check form-check-inline ms-2">
<input class="form-check-input" type="checkbox" id="showSubSubWrRange">
<label class="form-check-label" for="showSubSubWrRange">区间</label>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" id="showSubSubWrPhases">
<label class="form-check-label" for="showSubSubWrPhases">阶段</label>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" id="showSubSubWrEvents">
<label class="form-check-label" for="showSubSubWrEvents">时间</label>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" id="showSubSubWrVP">
<label class="form-check-label" for="showSubSubWrVP">VP</label>
</div>
</div>
</div>
</div>
@@ -1130,6 +1233,7 @@
<div class="chart-container">
<div id="tradingview_chart"></div>
<div id="wyckoffCycleSummary" class="wyckoff-cycle-summary" aria-live="polite"></div>
<!-- 技术指标下拉菜单 -->
<div class="indicator-dropdown dropdown">
<button class="add-indicator-btn dropdown-toggle" type="button" id="indicatorDropdown" data-bs-toggle="dropdown" aria-expanded="false">
@@ -1282,13 +1386,19 @@
<script defer src="{{ url_for('static', filename='js/app/api_client.js') }}"></script>
<script defer src="{{ url_for('static', filename='js/app/state.js') }}"></script>
<script defer src="{{ url_for('static', filename='js/app/trend.js') }}"></script>
<script defer src="{{ url_for('static', filename='js/app/macd_ui.js') }}"></script>
<script defer src="{{ url_for('static', filename='js/app/chart_format.js') }}"></script>
<script defer src="{{ url_for('static', filename='js/app/chart_view.js') }}?v=20260806a"></script>
<script defer src="{{ url_for('static', filename='js/app/chart_tv.js') }}?v=20260806a"></script>
<script defer src="{{ url_for('static', filename='js/app/chart_sync.js') }}?v=20260806a"></script>
<script defer src="{{ url_for('static', filename='js/app/chart_tables.js') }}"></script>
<script defer src="{{ url_for('static', filename='js/app/ui.js') }}?v=20260806a"></script>
<script defer src="{{ url_for('static', filename='js/app/macd_ui.js') }}?v=20260807f"></script>
<script defer src="{{ url_for('static', filename='js/app/chart_format.js') }}?v=20260807f"></script>
<script defer src="{{ url_for('static', filename='js/app/chart_view.js') }}?v=20260807f"></script>
<script defer src="{{ url_for('static', filename='js/app/chart_tv_lifecycle.js') }}?v=20260807f"></script>
<script defer src="{{ url_for('static', filename='js/app/chart_tv_shell.js') }}?v=20260807f"></script>
<script defer src="{{ url_for('static', filename='js/app/chart_tv_indicators.js') }}?v=20260807f"></script>
<script defer src="{{ url_for('static', filename='js/app/chart_tv_chan.js') }}?v=20260807f"></script>
<script defer src="{{ url_for('static', filename='js/app/chart_tv_overlays.js') }}?v=20260807f"></script>
<script defer src="{{ url_for('static', filename='js/app/chart_tv_finalize.js') }}?v=20260807f"></script>
<script defer src="{{ url_for('static', filename='js/app/chart_tv.js') }}?v=20260807f"></script>
<script defer src="{{ url_for('static', filename='js/app/chart_sync.js') }}?v=20260807f"></script>
<script defer src="{{ url_for('static', filename='js/app/chart_tables.js') }}?v=20260807f"></script>
<script defer src="{{ url_for('static', filename='js/app/ui.js') }}?v=20260807f"></script>
<script defer src="{{ url_for('static', filename='js/app/overlays.js') }}"></script>
<script defer src="{{ url_for('static', filename='js/app/main.js') }}"></script>
+41 -10
View File
@@ -127,11 +127,13 @@ def test_analyze_http_contract_with_mocked_kl():
assert payload is not None and "error" not in payload
missing = [k for k in CONTRACT_KEYS if k not in payload]
assert not missing, f"missing contract keys: {missing}"
assert "wyckoff" not in payload
assert "wyckoff" in payload
for k in WYCKOFF_KEYS:
assert k in payload["wyckoff"], f"missing wyckoff key: {k}"
def test_analyze_http_wyckoff_opt_in():
"""include_wyckoff=1响应含 wyckoff 约定键;默认不返回"""
def test_analyze_http_wyckoff_can_opt_out():
"""include_wyckoff=0可显式跳过威科夫"""
from app import app
from services.runtime import add_indicators
@@ -148,19 +150,49 @@ def test_analyze_http_wyckoff_opt_in():
"symbol": "BTC/USDT:USDT",
"timeframe": "5m",
"timezone": "Asia/Shanghai",
"include_wyckoff": 1,
"include_wyckoff": 0,
},
)
assert resp.status_code == 200, resp.data[:500]
payload = resp.get_json()
assert payload is not None and "wyckoff" in payload
w = payload["wyckoff"]
for k in WYCKOFF_KEYS:
assert k in w, f"missing wyckoff key: {k}"
assert payload is not None and "wyckoff" not in payload
def test_analyze_http_wyckoff_for_three_timeframes():
"""主/次/次次均返回各自 wyckoff 载荷。"""
from app import app
from services.runtime import add_indicators
df = add_indicators(make_ohlcv(300))
df = df.copy()
if "timestamp" not in df.columns:
df["timestamp"] = (pd.to_datetime(df["date"]).astype("int64") // 10**6).astype("int64")
with patch("api.analyze.get_kl_data", return_value=df):
client = app.test_client()
resp = client.get(
"/api/analyze",
query_string={
"symbol": "BTC/USDT:USDT",
"timeframe": "4h",
"element_timeframe": "2h",
"sub_sub_timeframe": "1h",
"timezone": "Asia/Shanghai",
},
)
assert resp.status_code == 200, resp.data[:500]
payload = resp.get_json()
assert payload is not None and "error" not in payload
assert "wyckoff" in payload
assert "element_wyckoff" in payload
assert "sub_sub_wyckoff" in payload
for key in ("wyckoff", "element_wyckoff", "sub_sub_wyckoff"):
for k in WYCKOFF_KEYS:
assert k in payload[key], f"missing {k} in {key}"
def test_analyze_http_wyckoff_skipped_when_elements_only():
"""elements_only=true 时即使 include_wyckoff=1 也不返回 wyckoff。"""
"""elements_only=true 时不返回 wyckoff。"""
from app import app
from services.runtime import add_indicators
@@ -179,7 +211,6 @@ def test_analyze_http_wyckoff_skipped_when_elements_only():
"element_timeframe": "1m",
"timezone": "Asia/Shanghai",
"elements_only": "true",
"include_wyckoff": 1,
},
)
assert resp.status_code == 200, resp.data[:500]