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 .DS_Store
data_provider/._config.json data_provider/._config.json
.gstack/ .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 __future__ import annotations
from .engine import analyze_wyckoff 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 __future__ import annotations
from typing import Any, Dict, Optional from typing import Any, Dict, List, Optional
import pandas as pd import pandas as pd
from .events import build_phases, detect_bias_and_events 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 from .volume_profile import compute_volume_profile
@@ -21,31 +27,55 @@ def _fmt_time(v) -> Optional[str]:
return str(v) return str(v)
def analyze_wyckoff(df: pd.DataFrame, lookback: int = 120, vp_bins: int = 50) -> Dict[str, Any]: def _empty(vp_bins: int) -> Dict[str, Any]:
""" return {
对主周期 OHLCV DataFrame 做威科夫启发式分析。 "cycles": [],
需要列: open, high, low, close, volume;建议有 date 或 timestamp。
"""
empty = {
"trading_range": None, "trading_range": None,
"bias": "unknown", "bias": "unknown",
"phases": [], "phases": [],
"events": [], "events": [],
"volume_profile": {"bins": [], "poc": None, "vah": None, "val": None, "bin_count": vp_bins}, "volume_profile": {"bins": [], "poc": None, "vah": None, "val": None, "bin_count": vp_bins},
"volume_confirm": {"avg_volume": 0.0, "event_checks": {}}, "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) bias, events, volume_confirm = detect_bias_and_events(work, tr)
phases = build_phases(work, tr, bias, events) phases = build_phases(work, tr, bias, events)
vp = compute_volume_profile( 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"]), int(tr["abs_end_idx"]),
bin_count=vp_bins, 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: for ev in events:
ev["time"] = _fmt_time(ev.get("time")) ev["time"] = _fmt_time(ev.get("time"))
for ph in phases: for ph in phases:
ph["start_time"] = _fmt_time(ph.get("start_time")) ph["start_time"] = _fmt_time(ph.get("start_time"))
ph["end_time"] = _fmt_time(ph.get("end_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 { 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, "trading_range": trading_range,
"bias": bias, "bias": bias,
# 兼容旧读法:顶层 phases/events = confirmed
"phases": phases, "phases": phases,
"events": events, "events": events,
"confirmed": {
"phases": phases,
"events": events,
"volume_confirm": volume_confirm,
},
"live": live,
"volume_profile": vp, "volume_profile": vp,
"volume_confirm": volume_confirm, "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 __future__ import annotations
from typing import Any, Dict, List, Tuple from typing import Any, Dict, List, Optional, Tuple
import numpy as np import numpy as np
import pandas as pd import pandas as pd
@@ -29,6 +29,9 @@ def detect_bias_and_events(
) -> Tuple[str, List[Dict[str, Any]], Dict[str, Any]]: ) -> Tuple[str, List[Dict[str, Any]], Dict[str, Any]]:
""" """
返回 bias、events、volume_confirm。 返回 bias、events、volume_confirm。
Spring/UTAD 相对「结构高低」判定:取区间内次低/次高(剔除单根极值),
避免箱体把假破低点吃进 lo 后永远刺不破、从而无 C 阶段。
""" """
hi = float(tr["high"]) hi = float(tr["high"])
lo = float(tr["low"]) lo = float(tr["low"])
@@ -38,6 +41,24 @@ def detect_bias_and_events(
e = int(tr["abs_end_idx"]) e = int(tr["abs_end_idx"])
events: List[Dict[str, Any]] = [] 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 # 扫描区间内及之后(含 tail_reserve
scan_end = int(tr.get("abs_scan_end_idx", min(len(df) - 1, e + 15))) 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)) 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) avg_v = _avg_vol(df, i)
ratio = vol / avg_v if avg_v else 0.0 ratio = vol / avg_v if avg_v else 0.0
# Spring: pierce below low then close back above low # Spring: pierce below structural support then close back
if spring is None and low < lo - tol * 0.5 and close >= lo - tol * 0.2: 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) 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 = { spring = {
"type": "Spring", "type": "Spring",
@@ -70,8 +91,8 @@ def detect_bias_and_events(
"idx": i, "idx": i,
} }
# UTAD: pierce above high then close back below # UTAD: pierce above structural resistance then close back
if utad is None and high > hi + tol * 0.5 and close <= hi + tol * 0.2: if utad is None and high > event_hi + tol * 0.35 and close <= event_hi + tol * 0.35:
vol_ok = ratio >= 0.8 vol_ok = ratio >= 0.8
utad = { utad = {
"type": "UTAD", "type": "UTAD",
@@ -154,11 +175,15 @@ def detect_bias_and_events(
} }
break break
# 冲突清理:已判定吸筹且有 SOS 时,丢弃更早的 UTAD(避免阶段/图面误导)
# 派发且有 SOW 时,丢弃更晚才合理的 Spring 假信号同理在偏置后再滤
keep = []
for ev in (spring, sos, lps, utad, sod, lpsy): for ev in (spring, sos, lps, utad, sod, lpsy):
if ev: if not ev:
events.append({k: v for k, v in ev.items() if k != "idx"}) continue
keep.append(ev)
# bias # bias(先算)
last_c = float(df["close"].iloc[-1]) last_c = float(df["close"].iloc[-1])
bias = "unknown" bias = "unknown"
if sos and (not sod or int(sos.get("idx", 0)) >= int(sod.get("idx", 0))): 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: else:
bias = "distribution" 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 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 = { volume_confirm = {
"avg_volume": avg_volume, "avg_volume": avg_volume,
@@ -189,59 +224,146 @@ def build_phases(
events: List[Dict[str, Any]], events: List[Dict[str, Any]],
min_bars: int = 3, min_bars: int = 3,
) -> List[Dict[str, Any]]: ) -> 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"]) s = int(tr["abs_start_idx"])
e = int(tr["abs_end_idx"]) e = int(tr["abs_end_idx"])
hi = float(tr["high"])
lo = float(tr["low"])
n_last = len(df) - 1 n_last = len(df) - 1
min_span = max(2, min_bars - 1) min_span = max(2, min_bars - 1)
range_len = max(1, e - s)
event_idx = {} def _match_idx(t) -> Optional[int]:
for ev in events: if t is None:
t = ev.get("time") return None
for i in range(s, min(len(df), e + 20)): lo = max(0, s - 2)
hi = min(len(df), e + 40)
for i in range(lo, hi):
if _bar_time(df, i) == t: 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 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: def _lab(phase: str) -> str:
if bias == "distribution": 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离开"} 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) return m.get(phase, phase)
# 理想切点(随后再强制非重叠 + 最小跨度) a_end = s + max(min_bars, range_len // 5)
raw = [
("A", s, a_end), c_start = c_end = None
("B", a_end, c_anchor), if c_ev is not None:
("C", c_anchor, d_anchor), c_start = max(s, int(c_ev) - 1)
("D", d_anchor, min(n_last, d_anchor + max(min_bars, (e - s) // 6))), c_end = min(n_last, int(c_ev) + 1)
("E", min(n_last, d_anchor + max(min_bars, (e - s) // 6)), min(n_last, max(e, d_anchor + max(min_bars * 2, 8)))),
] 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]] = [] phases: List[Dict[str, Any]] = []
cursor = s cursor = s
for phase, _a, _b in raw: for phase, _a, _b in raw:
if cursor >= n_last: if cursor >= n_last:
break break
a = max(int(_a), cursor) 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)) b = int(np.clip(b, a, n_last))
if b - a < min_span: if b < a:
# 尾部空间不足:并入上一段终点并停止新增 continue
if phases: if phases and phases[-1].get("_a") == a and phases[-1].get("_b") == b:
phases[-1]["end_time"] = _bar_time(df, n_last) continue
break
phases.append( phases.append(
{ {
"phase": phase, "phase": phase,
"label": _lab(phase), "label": _lab(phase),
"start_time": _bar_time(df, a), "start_time": _bar_time(df, a),
"end_time": _bar_time(df, b), "end_time": _bar_time(df, b),
"_a": a,
"_b": b,
} }
) )
cursor = b cursor = b
for p in phases:
p.pop("_a", None)
p.pop("_b", None)
return phases 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",
}
+373 -59
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 __future__ import annotations
from typing import Any, Dict, Optional from typing import Any, Dict, List, Optional, Tuple
import numpy as np import numpy as np
import pandas as pd import pandas as pd
MAX_CYCLES = 8
OVERLAP_RATIO_MAX = 0.2
def _atr(df: pd.DataFrame, period: int = 14) -> pd.Series: def _atr(df: pd.DataFrame, period: int = 14) -> pd.Series:
high = df["high"].astype(float) 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() 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( def _score_segment(
length: int, length: int,
near_hi: int, near_hi: int,
@@ -31,27 +47,161 @@ def _score_segment(
width: float, width: float,
atr: float, atr: float,
) -> float: ) -> float:
"""触边密度 + 箱内比例 − 相对宽度;弱奖励长度以免只追最长""" """结构质量分(非 Phase/Event"""
touch_density = (near_hi + near_lo) / float(max(length, 1)) touch = min(near_hi, 6) + min(near_lo, 6)
width_pen = (width / atr) if atr > 0 else width 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, 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, min_bars: int = 24,
atr_mult: float = 1.2, atr_mult: float = 1.2,
tail_reserve: int = 12, tail_reserve: int = 12,
prefer_start_time: Any = None,
range_start_time: Any = None,
) -> Optional[Dict[str, Any]]: ) -> Optional[Dict[str, Any]]:
""" """
最近 lookback 根内寻找高低点波动受控的连续段作为交易区间 df[win_start:win_end+1] 内检测单个 TradingRange
尾部预留 tail_reserve 根用于事件(Spring/SOS),不参与箱体边界计算 只返回箱体结构,不含 Phase/Event/VP
在硬门槛之上按评分取最优段(非仅最长窗口)。
""" """
if df is None or len(df) < min_bars + 5: if df is None or win_end < win_start:
return None 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) n = len(work)
reserve = min(tail_reserve, max(0, n - min_bars - 2)) reserve = min(tail_reserve, max(0, n - min_bars - 2))
core_end = n - reserve if reserve > 0 else n 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: if not np.isfinite(last_atr) or last_atr <= 0:
last_atr = float(core["close"].iloc[-1]) * 0.01 last_atr = float(core["close"].iloc[-1]) * 0.01
best = None eff_atr_mult = float(atr_mult)
best_score = float("-inf") if lookback >= 280:
cn = len(core) eff_atr_mult = atr_mult * 1.7
for length in range(min(cn, lookback), min_bars - 1, -4): elif lookback >= 160:
seg = core.iloc[-length:] 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()) hi = float(seg["high"].max())
lo = float(seg["low"].min()) lo = float(seg["low"].min())
width = hi - lo rw = _robust_width(seg)
if width <= 0 or width > last_atr * atr_mult * 3.5: if 0 < rw <= max_width * 1.15:
continue near_hi = int((seg["high"] >= hi - tol).sum())
tol = last_atr * atr_mult * 0.35 near_lo = int((seg["low"] <= lo + tol).sum())
inside = float(((seg["close"] >= lo - tol) & (seg["close"] <= hi + tol)).mean())
if near_hi >= 2 and near_lo >= 2 and inside >= 0.70:
score = _score_segment(len(seg), near_hi, near_lo, inside, rw, last_atr)
return _pack_range(
work, df, start_i, core_end - 1, hi, lo, tol, last_atr, score, n,
window_offset=win_start,
)
eff_min_bars = max(8, int(min_bars))
cn = len(core)
max_bars = min(cn, max(eff_min_bars * 2, min(96, max(eff_min_bars + 8, int(cn * 0.5)))))
cands: List[Tuple[float, int, int, int, float, float, float]] = []
def _try_seg(start_i: int, end_i: int, prefer_boost: float = 0.0) -> None:
if end_i - start_i + 1 < eff_min_bars:
return
if start_i < 0 or end_i >= cn or start_i > end_i:
return
seg = work.iloc[start_i : end_i + 1]
hi = float(seg["high"].max())
lo = float(seg["low"].min())
rw = _robust_width(seg)
if rw <= 0 or rw > max_width:
return
raw_w = hi - lo
if raw_w > max_width * 1.35:
return
near_hi = int((seg["high"] >= hi - tol).sum()) near_hi = int((seg["high"] >= hi - tol).sum())
near_lo = int((seg["low"] <= lo + tol).sum()) near_lo = int((seg["low"] <= lo + tol).sum())
if near_hi < 2 or near_lo < 2: if near_hi < 2 or near_lo < 2:
continue return
inside = float(((seg["close"] >= lo - tol) & (seg["close"] <= hi + tol)).mean()) inside = float(((seg["close"] >= lo - tol) & (seg["close"] <= hi + tol)).mean())
if inside < 0.75: if inside < 0.72:
continue return
score = _score_segment(length, near_hi, near_lo, inside, width, last_atr) length = end_i - start_i + 1
if score <= best_score: score = _score_segment(length, near_hi, near_lo, inside, rw, last_atr) + prefer_boost
continue 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 start_i = cn - length
boost = 0.0
if prefer_i is not None:
dist = abs(start_i - int(prefer_i))
if dist <= 6:
boost = 10.0
elif dist <= 14:
boost = 4.0
elif start_i > int(prefer_i) + 16:
boost = -10.0
_try_seg(start_i, cn - 1, boost)
if prefer_i is not None:
pi = int(prefer_i)
if 0 <= pi < cn:
align_max = min(cn, max(max_bars, int(cn * 0.65)))
alen = cn - pi
if eff_min_bars <= alen <= align_max:
_try_seg(pi, cn - 1, prefer_boost=18.0)
elif alen > align_max:
start_i = max(0, cn - align_max)
if start_i > pi:
start_i = pi
end_i = min(cn - 1, pi + align_max - 1)
else:
end_i = cn - 1 end_i = cn - 1
mid = (hi + lo) / 2.0 _try_seg(start_i, end_i, prefer_boost=12.0)
last_c = float(work["close"].iloc[-1])
active = (lo - tol * 1.5) <= last_c <= (hi + tol * 1.5) if not cands:
best_score = score return None
best = {
"start_idx": int(start_i), cands.sort(key=lambda x: x[0], reverse=True)
"end_idx": int(end_i), best_score = cands[0][0]
"high": hi, band = max(4.0, abs(best_score) * 0.10)
"low": lo, near = [c for c in cands if c[0] >= best_score - band]
"mid": mid, chosen = max(near, key=lambda x: (x[1], x[0]))
"active": bool(active), score, _length, start_i, end_i, hi, lo, _rw = chosen
"atr": last_atr, return _pack_range(work, df, start_i, end_i, hi, lo, tol, last_atr, score, n, window_offset=win_start)
"tol": tol,
"bars": int(length),
"score": float(score), 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
if best is None: # abs_* 目前相对 work;若 df 比 work 长需加 offset
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
best["start_time"] = _ts(work.iloc[best["start_idx"]])
# 区间时间结束取 core 末,事件可落在其后
best["end_time"] = _ts(work.iloc[best["end_idx"]])
offset = len(df) - len(work) offset = len(df) - len(work)
best["abs_start_idx"] = offset + best["start_idx"] if offset:
best["abs_end_idx"] = offset + best["end_idx"] for tr in accepted:
best["abs_scan_end_idx"] = offset + n - 1 tr["abs_start_idx"] = int(tr["abs_start_idx"]) + offset
return best 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 return None
def add_indicators(self, df): def add_indicators(self, df):
fast = 26 fast = 12
slow = 52 slow = 26
period = 9 period = 9
macd = ta.MACD(df, fastperiod=fast, slowperiod=slow, signalperiod=period) macd = ta.MACD(df, fastperiod=fast, slowperiod=slow, signalperiod=period)
bb365 = ta.BBANDS(df, timeperiod=365, nbdevup=3.0, nbdevdn=3.0, matype=0) bb365 = ta.BBANDS(df, timeperiod=365, nbdevup=3.0, nbdevdn=3.0, matype=0)
+8 -3
View File
@@ -22,18 +22,23 @@
- ECR-002 Reviewed:拆 `web/services/runtime/`、加深 analyze 契约 - ECR-002 Reviewed:拆 `web/services/runtime/`、加深 analyze 契约
- ECR-003 Reviewed:主站威科夫叠层(`chanlun/analysis/wyckoff/` + `include_wyckoff`)→ `081a57a` - ECR-003 Reviewed:主站威科夫叠层(`chanlun/analysis/wyckoff/` + `include_wyckoff`)→ `081a57a`
- ECR-004 ReviewedTR 评分硬化 + VP 少系列 + 阶段/门闩/单测(无币种参数) - 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` 字段可增不可删 - `/api/analyze` 字段可增不可删
- 无 ADR 不改笔/段/中枢/买卖点语义 - 无 ADR 不改笔/段/中枢/买卖点语义
- 威科夫为独立叠层(ECR-003);勿借机改缠论算法 - 威科夫为独立叠层(ECR-003/007);勿借机改缠论算法
- 交易 L2+ → RISK_REVIEW + EXPLive 须 Human - Live candidate **不得**进入 execution交易 L2+ → RISK_REVIEW + EXPLive 须 Human
## 已知债务 ## 已知债务
- `chart_tv.js` 单体巨大 → 后续可选 ECR
- analyze 契约已加深(mock HTTP + wyckoff opt-in);可再加固定 JSON 快照文件 - analyze 契约已加深(mock HTTP + wyckoff opt-in);可再加固定 JSON 快照文件
- 内存泄漏尚无自动化 heap/监听断言 - 内存泄漏尚无自动化 heap/监听断言
- `macd_config` POST 写本地 global 的历史 quirks(未改) - `macd_config` POST 写本地 global 的历史 quirks(未改)
- 威科夫启发式参数未做 UI 调参 - 威科夫启发式参数未做 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 # 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 ## Unreleased — 2026-08-06
### ECR-004L2Reviewed ### ECR-004L2Reviewed
@@ -7,6 +21,7 @@
- 威科夫 TR 评分选段(防吞前置趋势);阶段非重叠最小跨度 - 威科夫 TR 评分选段(防吞前置趋势);阶段非重叠最小跨度
- 主站 VP Top-8 + bins≤24;填充线减负 - 主站 VP Top-8 + bins≤24;填充线减负
- `elements_only` 时不跑威科夫;收紧单测(无币种独立参数) - `elements_only` 时不跑威科夫;收紧单测(无币种独立参数)
- **后续**:威科夫随主 `/api/analyze` 默认一并返回;前端开关只控制绘制(不再勾选才加载)
### ECR-003L2Reviewed ### 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 ## 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 - EXP: N/A
- TRACEABILITY: `docs/TRACEABILITY.md` - TRACEABILITY: `docs/TRACEABILITY.md`
- Memory: `docs/AGENT_MEMORY.md` - Memory: `docs/AGENT_MEMORY.md`
- Loop archive: `docs/runs/LOOP-RUN-005/`
## Pointers ## Pointers
+7 -3
View File
@@ -1,11 +1,11 @@
# STATE # STATE
**owner:** idle **owner:** idle
**active_ecr:** noneECR-004 Reviewed待本批提交合入 **active_ecr:** noneECR-008 ReviewedECR-007 待合入 `dev`
**phase:** post-review **phase:** post-review
**system_version:** v1.0.0 **system_version:** v1.0.0
**strategy_version:** unchanged **strategy_version:** unchanged
**updated:** 2026-08-06 **updated:** 2026-08-07
## Recent ## Recent
@@ -16,8 +16,12 @@
| ECR-002 | L3 | Done (Reviewed) | runtime 包拆分 | | ECR-002 | L3 | Done (Reviewed) | runtime 包拆分 |
| ECR-003 | L2 | Done (Reviewed) | `081a57a` 主站威科夫 | | ECR-003 | L2 | Done (Reviewed) | `081a57a` 主站威科夫 |
| ECR-004 | L2 | Done (Reviewed) | 威科夫硬化 / VP 减负 | | ECR-004 | L2 | Done (Reviewed) | 威科夫硬化 / VP 减负 |
| ECR-007 | L2 | Done (Final Approval) | Live Structure · `276481e` · 待 Gitea PR → `dev` |
| ECR-008 | L3 | Done (Reviewed) | chart_tv 拆分 · 本分支 |
## Notes ## Notes
- 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 - 未请求新 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 | 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 | 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-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_bi_list",
"uncompleted_seg_list", "uncompleted_seg_list",
"uncompleted_zs_list", "uncompleted_zs_list",
"wyckoff",
"zs_list" "zs_list"
], ],
"optional_when": { "optional_when": {
"include_structure_zones": ["structure_zones"], "include_structure_zones": ["structure_zones"]
"include_wyckoff": ["wyckoff"]
}, },
"wyckoff_keys": [ "wyckoff_keys": [
"trading_range", "trading_range",
@@ -26,6 +26,10 @@
"phases", "phases",
"events", "events",
"volume_profile", "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", "macd",
"chan_macd", "chan_macd",
"klc_trend", "klc_trend",
"wyckoff",
] ]
), ),
"optional_when": { "optional_when": {
"include_structure_zones": ["structure_zones"], "include_structure_zones": ["structure_zones"],
"include_wyckoff": ["wyckoff"],
}, },
"wyckoff_keys": [ "wyckoff_keys": [
"trading_range", "trading_range",
@@ -162,6 +162,7 @@ def analyze_contract_keys() -> dict:
"volume_profile", "volume_profile",
"volume_confirm", "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 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 assert k in keys
if isinstance(doc, dict): 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"): for k in ("trading_range", "phases", "events", "volume_profile"):
assert k in doc.get("wyckoff_keys", []) 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)) sys.path.insert(0, str(ROOT))
from chanlun.analysis.wyckoff import analyze_wyckoff # noqa: E402 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: 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(): def test_wyckoff_detects_range_and_events():
"""Test C:旧接口兼容 — 顶层字段仍在,且 cycles[0] 为 ACTIVE 镜像。"""
df = _box_df() df = _box_df()
out = analyze_wyckoff(df, lookback=200) out = analyze_wyckoff(df, lookback=200)
assert out["trading_range"] is not None assert out["trading_range"] is not None
@@ -105,6 +110,55 @@ def test_wyckoff_detects_range_and_events():
assert len(out["phases"]) >= 3 assert len(out["phases"]) >= 3
keys = [(p["start_time"], p["end_time"]) for p in out["phases"]] 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" 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(): def test_range_scoring_skips_pretrend():
df = _box_df(spring=False, sos=False) 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 吞掉整段下跌 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(): def test_volume_profile_poc_on_heavy_bin():
dates = pd.date_range("2024-01-01", periods=40, freq="5min", tz="UTC") dates = pd.date_range("2024-01-01", periods=40, freq="5min", tz="UTC")
rows = [] rows = []
@@ -126,3 +218,169 @@ def test_volume_profile_poc_on_heavy_bin():
assert vp["poc"] is not None assert vp["poc"] is not None
assert vp["vah"] is not None and vp["val"] is not None assert vp["vah"] is not None and vp["val"] is not None
assert abs(vp["poc"] - 50.0) < 1.0 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__) bp = Blueprint("analyze", __name__)
_WYCKOFF_EMPTY = {
'trading_range': None,
'bias': 'unknown',
'phases': [],
'events': [],
'volume_profile': {'bins': [], 'poc': None, 'vah': None, 'val': None, 'bin_count': 0},
'volume_confirm': {'avg_volume': 0.0, 'event_checks': {}},
'cycles': [],
'live': None,
'lifecycle': 'UNKNOWN',
}
def _localize_wyckoff_payload(w, client_tz):
"""把威科夫时间统一成客户端时区 ISO,便于与主图对齐。"""
if not w:
return w
def _loc_tr(tr):
if not tr:
return
tr['start_time'] = format_time_safely(tr.get('start_time'), client_tz) or tr.get('start_time')
tr['end_time'] = format_time_safely(tr.get('end_time'), client_tz) or tr.get('end_time')
def _loc_cycle(c):
if not c:
return
per = c.get('period') or {}
per['start_time'] = format_time_safely(per.get('start_time'), client_tz) or per.get('start_time')
per['end_time'] = format_time_safely(per.get('end_time'), client_tz) or per.get('end_time')
c['period'] = per
_loc_tr(c.get('trading_range'))
for ph in c.get('phases') or []:
ph['start_time'] = format_time_safely(ph.get('start_time'), client_tz) or ph.get('start_time')
ph['end_time'] = format_time_safely(ph.get('end_time'), client_tz) or ph.get('end_time')
for ev in c.get('events') or []:
ev['time'] = format_time_safely(ev.get('time'), client_tz) or ev.get('time')
_loc_tr(w.get('trading_range'))
for ph in w.get('phases') or []:
ph['start_time'] = format_time_safely(ph.get('start_time'), client_tz) or ph.get('start_time')
ph['end_time'] = format_time_safely(ph.get('end_time'), client_tz) or ph.get('end_time')
for ev in w.get('events') or []:
ev['time'] = format_time_safely(ev.get('time'), client_tz) or ev.get('time')
for c in w.get('cycles') or []:
_loc_cycle(c)
return w
def _compute_wyckoff_from_df(df, tf, vp_bins, client_tz=None, range_start_time=None, prefer_start_time=None):
"""直接用该周期已有 DataFrame(与缠论同一份)。
搜索窗口 = 整段数据;箱体在窗内评分选取(近优分取更长),
次/次次可用 prefer_start_time 对齐主箱起点。
"""
from chanlun.analysis.wyckoff import analyze_wyckoff
try:
if df is None or len(df) < 30:
empty = dict(_WYCKOFF_EMPTY)
empty['volume_profile'] = dict(_WYCKOFF_EMPTY['volume_profile'])
empty['volume_confirm'] = dict(_WYCKOFF_EMPTY['volume_confirm'])
empty['timeframe'] = tf
return empty
lookback = len(df)
min_bars = max(24, min(80, lookback // 12))
out = analyze_wyckoff(
df,
lookback=lookback,
vp_bins=vp_bins,
min_bars=min_bars,
range_start_time=range_start_time,
prefer_start_time=prefer_start_time,
)
out['timeframe'] = tf
out['lookback'] = lookback
out['min_bars'] = min_bars
if client_tz is not None:
_localize_wyckoff_payload(out, client_tz)
return out
except Exception as e:
print(f"Wyckoff 分析出错 ({tf}): {e}")
import traceback
traceback.print_exc()
empty = dict(_WYCKOFF_EMPTY)
empty['volume_profile'] = dict(_WYCKOFF_EMPTY['volume_profile'])
empty['volume_confirm'] = dict(_WYCKOFF_EMPTY['volume_confirm'])
empty['timeframe'] = tf
empty['error'] = str(e)
return empty
@bp.route('/api/analyze') @bp.route('/api/analyze')
def analyze(): def analyze():
"""分析接口""" """分析接口"""
@@ -25,6 +116,9 @@ def analyze():
# 获取分形元素时间周期与次次周期 # 获取分形元素时间周期与次次周期
element_timeframe = request.args.get('element_timeframe') element_timeframe = request.args.get('element_timeframe')
sub_sub_timeframe = request.args.get('sub_sub_timeframe') sub_sub_timeframe = request.args.get('sub_sub_timeframe')
# 供文末三周期威科夫复用(避免重复拉数)
element_df_for_wyckoff = None
sub_sub_df_for_wyckoff = None
# 获取是否只需要分形元素数据的参数 # 获取是否只需要分形元素数据的参数
elements_only_param = request.args.get('elements_only') elements_only_param = request.args.get('elements_only')
@@ -249,6 +343,7 @@ def analyze():
if element_df is not None and len(element_df) > 0: if element_df is not None and len(element_df) > 0:
# 添加小周期技术指标(包括布林带) # 添加小周期技术指标(包括布林带)
element_df = add_indicators(element_df) element_df = add_indicators(element_df)
element_df_for_wyckoff = element_df
# 对小周期数据进行缠论分析 # 对小周期数据进行缠论分析
element_analysis = analyze_chan(element_df, symbol, element_timeframe) element_analysis = analyze_chan(element_df, symbol, element_timeframe)
@@ -427,6 +522,7 @@ def analyze():
sub_sub_df = get_kl_data(symbol, sub_sub_timeframe, start_time=start_time, end_time=end_time) sub_sub_df = get_kl_data(symbol, sub_sub_timeframe, start_time=start_time, end_time=end_time)
if sub_sub_df is not None and len(sub_sub_df) > 0: if sub_sub_df is not None and len(sub_sub_df) > 0:
sub_sub_df = add_indicators(sub_sub_df) sub_sub_df = add_indicators(sub_sub_df)
sub_sub_df_for_wyckoff = sub_sub_df
sub_sub_analysis = analyze_chan(sub_sub_df, symbol, sub_sub_timeframe) sub_sub_analysis = analyze_chan(sub_sub_df, symbol, sub_sub_timeframe)
result['sub_sub_timeframe'] = sub_sub_timeframe result['sub_sub_timeframe'] = sub_sub_timeframe
result['sub_sub_kline_data'] = clean_dataframe_for_json(sub_sub_df).to_dict('records') result['sub_sub_kline_data'] = clean_dataframe_for_json(sub_sub_df).to_dict('records')
@@ -656,33 +752,36 @@ def analyze():
else: else:
result['structure_zones'] = [] result['structure_zones'] = []
# 威科夫分析 —— 按需:include_wyckoff=1,且须有主周期分析(非 elements_only # 威科夫:主 / 次 / 次次各算一份(非 elements_only;前端开关只控制绘制
include_wyckoff_param = request.args.get('include_wyckoff', '') # include_wyckoff=0 可显式跳过;缺省与其它真值均计算
include_wyckoff = str(include_wyckoff_param).lower() in ('1', 'true', 'yes') 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: if include_wyckoff and not elements_only:
try: # 主周期先算;次/次次只同步 active=cycles[0] 的 startWYCKOFF-MULTI-CYCLE-001
from chanlun.analysis.wyckoff import analyze_wyckoff wyckoff_bins = max(10, min(int(request.args.get('wyckoff_vp_bins', 24)), 24))
wyckoff_lookback = int(request.args.get('wyckoff_lookback', 120)) result['wyckoff'] = _compute_wyckoff_from_df(df, timeframe, wyckoff_bins, client_tz=None)
# ECR-004:默认/上限 24 binsA+C main_w = result.get('wyckoff') or {}
wyckoff_bins = int(request.args.get('wyckoff_vp_bins', 24)) cycles = main_w.get('cycles') or []
result['wyckoff'] = analyze_wyckoff( # active 唯一来源 cycles[0];禁止 cycles[-1]
df, active = cycles[0] if cycles else None
lookback=max(40, min(wyckoff_lookback, 500)), prefer_start = None
vp_bins=max(10, min(wyckoff_bins, 24)), 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) 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 config import DATA_SERVICE_URL, DATA_SERVICE_WS_URL
from services.runtime import * # noqa: F403 from services.runtime import * # noqa: F403
from services import runtime as R from services import runtime as R
+25 -18
View File
@@ -70,36 +70,43 @@ def build_timeframe_labels(timeframes):
return labels 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): def compute_timeframe_defaults(labels_ordered):
""" """
根据已排序的周期 中文标签映射计算主 / / 次次周期默认值 根据已排序的周期 中文标签映射计算主 / / 次次周期默认值
默认偏好 4h 2h次次 1h威科夫与结构在小时级更可读
labels_ordered: OrderedDict 或按插入顺序排列的 dict labels_ordered: OrderedDict 或按插入顺序排列的 dict
""" """
if not labels_ordered: if not labels_ordered:
labels_ordered = DEFAULT_TIMEFRAME_LABELS.copy() labels_ordered = DEFAULT_TIMEFRAME_LABELS.copy()
timeframe_keys = list(labels_ordered.keys()) 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') default_main = preferred_main or (timeframe_keys[0] if timeframe_keys else '1m')
if default_main not in labels_ordered and timeframe_keys: if default_main not in labels_ordered and timeframe_keys:
default_main = timeframe_keys[0] default_main = timeframe_keys[0]
if timeframe_keys: default_element = _prefer_smaller(['2h', '1h'], labels_ordered, default_main, timeframe_keys)
try: default_sub_sub = _prefer_smaller(['1h'], labels_ordered, default_element, timeframe_keys)
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
return default_main, default_element, default_sub_sub, 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_format.js — split from chart.js */
/* chart.js */ /* chart.js */
function updateChartDisplay() { function updateChartDisplay() {
if (typeof renderWyckoffCycleSummary === 'function') {
renderWyckoffCycleSummary();
}
if (currentData) { if (currentData) {
// 检测K线周期是否切换 // 检测K线周期是否切换
const curPeriod = $('#subSubPeriodKline').is(':checked') ? 'subsub' : const curPeriod = $('#subSubPeriodKline').is(':checked') ? 'subsub' :
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'; 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 timezone = $('#timezone').val() || 'Asia/Shanghai';
const elementTimeframe = $('#elementTimeframe').val() || window.DEFAULT_ELEMENT_TIMEFRAME || '1m'; const elementTimeframe = $('#elementTimeframe').val() || window.DEFAULT_ELEMENT_TIMEFRAME || '1m';
const subSubTimeframe = $('#subSubTimeframe').val() || ''; const subSubTimeframe = $('#subSubTimeframe').val() || '';
@@ -62,8 +62,8 @@ function updateChart(options) {
end_time: endTimeMs, end_time: endTimeMs,
elements_only: false, elements_only: false,
zone_kl_lines: parseInt($('#zoneKlLines').val()) || 1000, zone_kl_lines: parseInt($('#zoneKlLines').val()) || 1000,
include_structure_zones: $('#showMainStructureZone').is(':checked') ? 1 : 0, include_structure_zones: $('#showMainStructureZone').is(':checked') ? 1 : 0
include_wyckoff: $('#showWyckoff').is(':checked') ? 1 : 0 // 威科夫随主分析一并返回;开关仅控制绘制,不再传 include_wyckoff
}, },
success: function(data) { success: function(data) {
// 隐藏加载图标 // 隐藏加载图标
@@ -81,6 +81,9 @@ function updateChart(options) {
delete currentData.original_macd; delete currentData.original_macd;
} }
currentData = data; currentData = data;
if (typeof renderWyckoffCycleSummary === 'function') {
renderWyckoffCycleSummary();
}
refreshChart(data, { refreshChart(data, {
incremental: options.incremental !== undefined incremental: options.incremental !== undefined
+8 -17
View File
@@ -93,25 +93,16 @@ $(document).on('change', '#showMainStructureZone', function() {
} }
}); });
// 威科夫主开关:勾选才请求;子项仅本地重绘 // 区间/阶段/时间/VP:与缠论笔开关一样,本地重绘
function syncWyckoffSubControls() { $(document).on(
const on = $('#showWyckoff').is(':checked'); 'change',
$('#showWyckoffRange, #showWyckoffPhases, #showWyckoffEvents, #showWyckoffVP').prop('disabled', !on); '#showMainWrRange, #showMainWrPhases, #showMainWrEvents, #showMainWrVP,' +
} '#showElementWrRange, #showElementWrPhases, #showElementWrEvents, #showElementWrVP,' +
$(document).on('change', '#showWyckoff', function() { '#showSubSubWrRange, #showSubSubWrPhases, #showSubSubWrEvents, #showSubSubWrVP',
const on = $('#showWyckoff').is(':checked'); function() {
syncWyckoffSubControls();
console.log('威科夫切换为:', on);
if (on) {
updateChart();
} else {
updateChartDisplay(); updateChartDisplay();
} }
}); );
$(document).on('change', '#showWyckoffRange, #showWyckoffPhases, #showWyckoffEvents, #showWyckoffVP', function() {
updateChartDisplay();
});
$(function() { syncWyckoffSubControls(); });
// 添加趋势显示复选框变更事件(主/元素),变更后刷新主图 // 添加趋势显示复选框变更事件(主/元素),变更后刷新主图
$('#showMainTrend').change(function() { $('#showMainTrend').change(function() {
+252 -3
View File
@@ -1,4 +1,252 @@
/* ui.js */ /* 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() { function loadSymbols() {
$.get('/api/symbols', function(data) { $.get('/api/symbols', function(data) {
if (Array.isArray(data)) { if (Array.isArray(data)) {
@@ -24,14 +272,15 @@ function loadSymbols() {
}); });
} }
// 设置默认时间范围 // 设置默认时间范围(需覆盖威科夫 lookback;1 天在 4h/1h 上几乎检不出区间)
function setDefaultTimeRange() { function setDefaultTimeRange() {
const now = new Date(); 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 // 格式化为datetime-local输入框所需的格式 YYYY-MM-DDThh:mm
$('#end_time').val(formatDatetimeLocal(now)); $('#end_time').val(formatDatetimeLocal(now));
$('#start_time').val(formatDatetimeLocal(oneDayAgo)); $('#start_time').val(formatDatetimeLocal(start));
} }
// 格式化日期为datetime-local输入框格式 // 格式化日期为datetime-local输入框格式
function formatDatetimeLocal(date) { function formatDatetimeLocal(date) {
+137 -27
View File
@@ -96,6 +96,81 @@
position: relative; position: relative;
z-index: 1; /* 确保图表在数据面板之上 */ 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 { .chart-options {
position: absolute; position: absolute;
top: 10px; top: 10px;
@@ -972,26 +1047,6 @@
<label class="form-check-label" for="showMainStructureZone">结构区</label> <label class="form-check-label" for="showMainStructureZone">结构区</label>
</div> </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线数量"> <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> <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 id="refreshLoadingSpinner" class="loading-spinner ms-2" style="display:none;"></div>
</div> </div>
@@ -1037,6 +1092,22 @@
<input class="form-check-input" type="checkbox" id="showMainBsp"> <input class="form-check-input" type="checkbox" id="showMainBsp">
<label class="form-check-label" for="showMainBsp">买卖点</label> <label class="form-check-label" for="showMainBsp">买卖点</label>
</div> </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>
<div class="d-flex align-items-center mt-1"> <div class="d-flex align-items-center mt-1">
<label class="form-label me-0 mb-0">次周期:</label> <label class="form-label me-0 mb-0">次周期:</label>
@@ -1079,6 +1150,22 @@
<input class="form-check-input" type="checkbox" id="showElementBsp"> <input class="form-check-input" type="checkbox" id="showElementBsp">
<label class="form-check-label" for="showElementBsp">买卖点</label> <label class="form-check-label" for="showElementBsp">买卖点</label>
</div> </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>
<div class="d-flex align-items-center mt-1"> <div class="d-flex align-items-center mt-1">
<label class="form-label me-0 mb-0">次次周期:</label> <label class="form-label me-0 mb-0">次次周期:</label>
@@ -1121,6 +1208,22 @@
<input class="form-check-input" type="checkbox" id="showSubSubBsp"> <input class="form-check-input" type="checkbox" id="showSubSubBsp">
<label class="form-check-label" for="showSubSubBsp">买卖点</label> <label class="form-check-label" for="showSubSubBsp">买卖点</label>
</div> </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> </div>
</div> </div>
@@ -1130,6 +1233,7 @@
<div class="chart-container"> <div class="chart-container">
<div id="tradingview_chart"></div> <div id="tradingview_chart"></div>
<div id="wyckoffCycleSummary" class="wyckoff-cycle-summary" aria-live="polite"></div>
<!-- 技术指标下拉菜单 --> <!-- 技术指标下拉菜单 -->
<div class="indicator-dropdown dropdown"> <div class="indicator-dropdown dropdown">
<button class="add-indicator-btn dropdown-toggle" type="button" id="indicatorDropdown" data-bs-toggle="dropdown" aria-expanded="false"> <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/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/state.js') }}"></script>
<script defer src="{{ url_for('static', filename='js/app/trend.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/macd_ui.js') }}?v=20260807f"></script>
<script defer src="{{ url_for('static', filename='js/app/chart_format.js') }}"></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=20260806a"></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.js') }}?v=20260806a"></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_sync.js') }}?v=20260806a"></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_tables.js') }}"></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/ui.js') }}?v=20260806a"></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/overlays.js') }}"></script>
<script defer src="{{ url_for('static', filename='js/app/main.js') }}"></script> <script defer src="{{ url_for('static', filename='js/app/main.js') }}"></script>
+40 -9
View File
@@ -127,11 +127,13 @@ def test_analyze_http_contract_with_mocked_kl():
assert payload is not None and "error" not in payload assert payload is not None and "error" not in payload
missing = [k for k in CONTRACT_KEYS if k not in payload] missing = [k for k in CONTRACT_KEYS if k not in payload]
assert not missing, f"missing contract keys: {missing}" 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(): def test_analyze_http_wyckoff_can_opt_out():
"""include_wyckoff=1响应含 wyckoff 约定键;默认不返回""" """include_wyckoff=0可显式跳过威科夫"""
from app import app from app import app
from services.runtime import add_indicators from services.runtime import add_indicators
@@ -148,19 +150,49 @@ def test_analyze_http_wyckoff_opt_in():
"symbol": "BTC/USDT:USDT", "symbol": "BTC/USDT:USDT",
"timeframe": "5m", "timeframe": "5m",
"timezone": "Asia/Shanghai", "timezone": "Asia/Shanghai",
"include_wyckoff": 1, "include_wyckoff": 0,
}, },
) )
assert resp.status_code == 200, resp.data[:500] assert resp.status_code == 200, resp.data[:500]
payload = resp.get_json() payload = resp.get_json()
assert payload is not None and "wyckoff" in payload assert payload is not None and "wyckoff" not in payload
w = payload["wyckoff"]
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: for k in WYCKOFF_KEYS:
assert k in w, f"missing wyckoff key: {k}" assert k in payload[key], f"missing {k} in {key}"
def test_analyze_http_wyckoff_skipped_when_elements_only(): 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 app import app
from services.runtime import add_indicators from services.runtime import add_indicators
@@ -179,7 +211,6 @@ def test_analyze_http_wyckoff_skipped_when_elements_only():
"element_timeframe": "1m", "element_timeframe": "1m",
"timezone": "Asia/Shanghai", "timezone": "Asia/Shanghai",
"elements_only": "true", "elements_only": "true",
"include_wyckoff": 1,
}, },
) )
assert resp.status_code == 200, resp.data[:500] assert resp.status_code == 200, resp.data[:500]