Compare commits
13
Commits
d4fbe9d905
...
web
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
97aa61705d | ||
|
|
340676bfbd | ||
|
|
9cf625c413 | ||
|
|
18a7f485e6 | ||
|
|
0f6eb92a1f | ||
|
|
9880e236a5 | ||
|
|
ec08de098e | ||
|
|
6c627f009a | ||
|
|
dbb6202325 | ||
|
|
efad2bb333 | ||
|
|
2964d6f230 | ||
|
|
7991a6b2bf | ||
|
|
276481e02c |
@@ -40,3 +40,10 @@ 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/
|
||||||
|
|
||||||
|
# Crypto Wyckoff Screener local cache
|
||||||
|
data/crypto_wyckoff/
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
"""威科夫分析入口。"""
|
"""威科夫分析入口:Cycle → Phase → Event → VP + Live(MULTI-CYCLE / LIVE-STRUCTURE)。
|
||||||
|
|
||||||
|
range.py 只产 TradingRange;Confirmed 走 events.py;Live 走 live.py。
|
||||||
|
cycles[0]=ACTIVE;禁止 cycles[-1] 取 active。
|
||||||
|
Execution 只消费 Confirmed(见 live.execution_signal_from_wyckoff)。
|
||||||
|
"""
|
||||||
from __future__ import annotations
|
from __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"),
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
}
|
||||||
@@ -1,11 +1,18 @@
|
|||||||
"""交易区间检测:ATR 容差下按评分选取近期震荡箱。"""
|
"""交易区间检测:仅负责 TradingRange(起止/高低/结构分)。
|
||||||
|
|
||||||
|
WYCKOFF-MULTI-CYCLE-001:Phase/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_offset:slice 相对父 DataFrame 的起点;勿用 len(df)-len(work)
|
||||||
|
offset = int(window_offset)
|
||||||
|
best["abs_start_idx"] = offset + best["start_idx"]
|
||||||
|
best["abs_end_idx"] = offset + best["end_idx"]
|
||||||
|
best["abs_scan_end_idx"] = offset + n - 1
|
||||||
|
return best
|
||||||
|
|
||||||
|
|
||||||
|
def _overlap_ratio(a0: int, a1: int, b0: int, b1: int) -> float:
|
||||||
|
"""两闭区间重叠长度 / 较短区间长度。"""
|
||||||
|
lo = max(a0, b0)
|
||||||
|
hi = min(a1, b1)
|
||||||
|
if hi < lo:
|
||||||
|
return 0.0
|
||||||
|
overlap = hi - lo + 1
|
||||||
|
shorter = min(a1 - a0 + 1, b1 - b0 + 1)
|
||||||
|
if shorter <= 0:
|
||||||
|
return 0.0
|
||||||
|
return float(overlap) / float(shorter)
|
||||||
|
|
||||||
|
|
||||||
|
def _passes_quality(tr: Dict[str, Any], min_bars: int) -> bool:
|
||||||
|
if tr is None:
|
||||||
|
return False
|
||||||
|
if int(tr.get("bars") or 0) < max(8, min_bars // 2):
|
||||||
|
return False
|
||||||
|
if float(tr.get("score") or 0) < 12.0:
|
||||||
|
return False
|
||||||
|
hi = float(tr["high"])
|
||||||
|
lo = float(tr["low"])
|
||||||
|
atr = float(tr.get("atr") or 0) or 1.0
|
||||||
|
if (hi - lo) / atr > 12.0:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _passes_trend_filter(work: pd.DataFrame, tr: Dict[str, Any]) -> bool:
|
||||||
|
"""趋势污染:定向位移过大则非震荡箱。"""
|
||||||
|
s = int(tr["start_idx"])
|
||||||
|
e = int(tr["end_idx"])
|
||||||
|
seg = work.iloc[s : e + 1]
|
||||||
|
if len(seg) < 8:
|
||||||
|
return False
|
||||||
|
c0 = float(seg["close"].iloc[0])
|
||||||
|
c1 = float(seg["close"].iloc[-1])
|
||||||
|
atr = float(tr.get("atr") or 0) or 1.0
|
||||||
|
drift = abs(c1 - c0) / atr
|
||||||
|
# 相对箱宽:漂移占箱宽过大 → 趋势
|
||||||
|
width = max(float(tr["high"]) - float(tr["low"]), atr)
|
||||||
|
drift_frac = abs(c1 - c0) / width
|
||||||
|
if drift > 6.0 and drift_frac > 0.55:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _detect_in_window(
|
||||||
|
df: pd.DataFrame,
|
||||||
|
win_start: int,
|
||||||
|
win_end: int,
|
||||||
min_bars: int = 24,
|
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:
|
||||||
|
eff_atr_mult = atr_mult * 1.7
|
||||||
|
elif lookback >= 160:
|
||||||
|
eff_atr_mult = atr_mult * 1.3
|
||||||
|
width_factor = 3.8 + min(2.2, max(0.0, (lookback - 80) / 100.0))
|
||||||
|
max_width = last_atr * eff_atr_mult * width_factor
|
||||||
|
tol = last_atr * eff_atr_mult * 0.35
|
||||||
|
|
||||||
|
prefer_i = None
|
||||||
|
if prefer_start_time is not None:
|
||||||
|
prefer_i = _bar_index_at_or_after(work, prefer_start_time)
|
||||||
|
|
||||||
|
if range_start_time is not None:
|
||||||
|
start_i = _bar_index_at_or_after(work, range_start_time)
|
||||||
|
if start_i is not None and start_i <= core_end - 8:
|
||||||
|
seg = work.iloc[start_i:core_end]
|
||||||
|
hi = float(seg["high"].max())
|
||||||
|
lo = float(seg["low"].min())
|
||||||
|
rw = _robust_width(seg)
|
||||||
|
if 0 < rw <= max_width * 1.15:
|
||||||
|
near_hi = int((seg["high"] >= hi - tol).sum())
|
||||||
|
near_lo = int((seg["low"] <= lo + tol).sum())
|
||||||
|
inside = float(((seg["close"] >= lo - tol) & (seg["close"] <= hi + tol)).mean())
|
||||||
|
if near_hi >= 2 and near_lo >= 2 and inside >= 0.70:
|
||||||
|
score = _score_segment(len(seg), near_hi, near_lo, inside, rw, last_atr)
|
||||||
|
return _pack_range(
|
||||||
|
work, df, start_i, core_end - 1, hi, lo, tol, last_atr, score, n,
|
||||||
|
window_offset=win_start,
|
||||||
|
)
|
||||||
|
|
||||||
|
eff_min_bars = max(8, int(min_bars))
|
||||||
cn = len(core)
|
cn = len(core)
|
||||||
for length in range(min(cn, lookback), min_bars - 1, -4):
|
max_bars = min(cn, max(eff_min_bars * 2, min(96, max(eff_min_bars + 8, int(cn * 0.5)))))
|
||||||
seg = core.iloc[-length:]
|
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())
|
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 rw <= 0 or rw > max_width:
|
||||||
continue
|
return
|
||||||
tol = last_atr * atr_mult * 0.35
|
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
|
||||||
end_i = cn - 1
|
boost = 0.0
|
||||||
mid = (hi + lo) / 2.0
|
if prefer_i is not None:
|
||||||
last_c = float(work["close"].iloc[-1])
|
dist = abs(start_i - int(prefer_i))
|
||||||
active = (lo - tol * 1.5) <= last_c <= (hi + tol * 1.5)
|
if dist <= 6:
|
||||||
best_score = score
|
boost = 10.0
|
||||||
best = {
|
elif dist <= 14:
|
||||||
"start_idx": int(start_i),
|
boost = 4.0
|
||||||
"end_idx": int(end_i),
|
elif start_i > int(prefer_i) + 16:
|
||||||
"high": hi,
|
boost = -10.0
|
||||||
"low": lo,
|
_try_seg(start_i, cn - 1, boost)
|
||||||
"mid": mid,
|
|
||||||
"active": bool(active),
|
|
||||||
"atr": last_atr,
|
|
||||||
"tol": tol,
|
|
||||||
"bars": int(length),
|
|
||||||
"score": float(score),
|
|
||||||
}
|
|
||||||
|
|
||||||
if best is None:
|
if prefer_i is not None:
|
||||||
|
pi = int(prefer_i)
|
||||||
|
if 0 <= pi < cn:
|
||||||
|
align_max = min(cn, max(max_bars, int(cn * 0.65)))
|
||||||
|
alen = cn - pi
|
||||||
|
if eff_min_bars <= alen <= align_max:
|
||||||
|
_try_seg(pi, cn - 1, prefer_boost=18.0)
|
||||||
|
elif alen > align_max:
|
||||||
|
start_i = max(0, cn - align_max)
|
||||||
|
if start_i > pi:
|
||||||
|
start_i = pi
|
||||||
|
end_i = min(cn - 1, pi + align_max - 1)
|
||||||
|
else:
|
||||||
|
end_i = cn - 1
|
||||||
|
_try_seg(start_i, end_i, prefer_boost=12.0)
|
||||||
|
|
||||||
|
if not cands:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _ts(row) -> Any:
|
cands.sort(key=lambda x: x[0], reverse=True)
|
||||||
if "date" in work.columns and pd.notna(row["date"]):
|
best_score = cands[0][0]
|
||||||
return row["date"]
|
band = max(4.0, abs(best_score) * 0.10)
|
||||||
if "timestamp" in work.columns:
|
near = [c for c in cands if c[0] >= best_score - band]
|
||||||
return row["timestamp"]
|
chosen = max(near, key=lambda x: (x[1], x[0]))
|
||||||
return None
|
score, _length, start_i, end_i, hi, lo, _rw = chosen
|
||||||
|
return _pack_range(work, df, start_i, end_i, hi, lo, tol, last_atr, score, n, window_offset=win_start)
|
||||||
|
|
||||||
best["start_time"] = _ts(work.iloc[best["start_idx"]])
|
|
||||||
# 区间时间结束取 core 末,事件可落在其后
|
def detect_trading_ranges(
|
||||||
best["end_time"] = _ts(work.iloc[best["end_idx"]])
|
df: pd.DataFrame,
|
||||||
|
lookback: Optional[int] = None,
|
||||||
|
min_bars: int = 24,
|
||||||
|
atr_mult: float = 1.2,
|
||||||
|
tail_reserve: int = 12,
|
||||||
|
max_cycles: int = MAX_CYCLES,
|
||||||
|
prefer_start_time: Any = None,
|
||||||
|
range_start_time: Any = None,
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
倒序切多段 TradingRange(近→远)。
|
||||||
|
过滤顺序:detect → quality → trend → overlap → accept → mask。
|
||||||
|
返回列表已按时间倒序,调用方将 [0] 标为 ACTIVE。
|
||||||
|
"""
|
||||||
|
if df is None or len(df) < min_bars + 5:
|
||||||
|
return []
|
||||||
|
lb = int(lookback) if lookback is not None else len(df)
|
||||||
|
work = df.tail(lb).reset_index(drop=True)
|
||||||
|
n = len(work)
|
||||||
|
occupied: List[Dict[str, Any]] = []
|
||||||
|
accepted: List[Dict[str, Any]] = []
|
||||||
|
|
||||||
|
# 搜索右端从 n-1 往左收缩;每接受一段后右端移到该段 start 之前
|
||||||
|
search_end = n - 1
|
||||||
|
prefer = prefer_start_time
|
||||||
|
hard_start = range_start_time
|
||||||
|
|
||||||
|
while len(accepted) < max(1, int(max_cycles)) and search_end >= min_bars + 4:
|
||||||
|
# 在剩余历史内从右往左试多个右边界,避免历史箱必须贴住 search_end
|
||||||
|
# (否则中间趋势会挡住更早的真实箱)
|
||||||
|
cand = None
|
||||||
|
step = max(4, min(12, (search_end - min_bars) // 10 or 4))
|
||||||
|
for end_try in range(search_end, min_bars + 4, -step):
|
||||||
|
trial = _detect_in_window(
|
||||||
|
work,
|
||||||
|
0,
|
||||||
|
end_try,
|
||||||
|
min_bars=min_bars,
|
||||||
|
atr_mult=atr_mult,
|
||||||
|
tail_reserve=tail_reserve,
|
||||||
|
prefer_start_time=prefer if len(accepted) == 0 and end_try == search_end else None,
|
||||||
|
range_start_time=hard_start if len(accepted) == 0 and end_try == search_end else None,
|
||||||
|
)
|
||||||
|
# 1) detect
|
||||||
|
if trial is None:
|
||||||
|
continue
|
||||||
|
# 2) quality
|
||||||
|
if not _passes_quality(trial, min_bars):
|
||||||
|
continue
|
||||||
|
# 3) trend contamination
|
||||||
|
if not _passes_trend_filter(work, trial):
|
||||||
|
continue
|
||||||
|
# 4) overlap with accepted
|
||||||
|
a0, a1 = int(trial["abs_start_idx"]), int(trial["abs_end_idx"])
|
||||||
|
overlap_bad = False
|
||||||
|
for occ in occupied:
|
||||||
|
ratio = _overlap_ratio(a0, a1, int(occ["start"]), int(occ["end"]))
|
||||||
|
if ratio >= OVERLAP_RATIO_MAX:
|
||||||
|
overlap_bad = True
|
||||||
|
break
|
||||||
|
if overlap_bad:
|
||||||
|
continue
|
||||||
|
# 取最靠右的合格箱(倒序第一段)
|
||||||
|
cand = trial
|
||||||
|
break
|
||||||
|
|
||||||
|
if cand is None:
|
||||||
|
break
|
||||||
|
|
||||||
|
# 5) accept
|
||||||
|
accepted.append(cand)
|
||||||
|
a0, a1 = int(cand["abs_start_idx"]), int(cand["abs_end_idx"])
|
||||||
|
# 6) mask
|
||||||
|
occupied.append(
|
||||||
|
{
|
||||||
|
"start": a0,
|
||||||
|
"end": max(a1, int(cand.get("abs_scan_end_idx", a1))),
|
||||||
|
"quality": float(cand.get("quality") or 0),
|
||||||
|
"high": float(cand["high"]),
|
||||||
|
"low": float(cand["low"]),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
# 下一轮只在更早窗口搜
|
||||||
|
search_end = int(cand["abs_start_idx"]) - 1
|
||||||
|
hard_start = None
|
||||||
|
prefer = None
|
||||||
|
|
||||||
|
# abs_* 目前相对 work;若 df 比 work 长需加 offset
|
||||||
offset = len(df) - len(work)
|
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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
"""crypto_wyckoff — multi-TF screener for crypto (ported from A_Share_DP Architecture v1.0)."""
|
||||||
|
|
||||||
|
from crypto_wyckoff.version import ARCHITECTURE_VERSION, WYCKOFF_ENGINE_VERSION
|
||||||
|
|
||||||
|
__all__ = ["WYCKOFF_ENGINE_VERSION", "ARCHITECTURE_VERSION"]
|
||||||
@@ -0,0 +1,342 @@
|
|||||||
|
"""Walk-forward Wyckoff phase/event annotations for chart overlay."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from crypto_wyckoff.domain_models import OHLCVFrame, WyckoffCycle, WyckoffEvent, WyckoffPhase
|
||||||
|
from crypto_wyckoff.cycle import CycleEngine
|
||||||
|
from crypto_wyckoff.event import EventEngine
|
||||||
|
from crypto_wyckoff.features import FeatureEngine
|
||||||
|
from crypto_wyckoff.phase import PhaseEngine
|
||||||
|
|
||||||
|
_MIN_BARS = {"1d": 40, "1w": 26, "1M": 18}
|
||||||
|
|
||||||
|
_NOTABLE_EVENTS = {
|
||||||
|
WyckoffEvent.PS.value,
|
||||||
|
WyckoffEvent.SC.value,
|
||||||
|
WyckoffEvent.AR.value,
|
||||||
|
WyckoffEvent.ST.value,
|
||||||
|
WyckoffEvent.SPRING.value,
|
||||||
|
WyckoffEvent.TEST.value,
|
||||||
|
WyckoffEvent.SOS.value,
|
||||||
|
WyckoffEvent.LPS.value,
|
||||||
|
WyckoffEvent.JUMP.value,
|
||||||
|
WyckoffEvent.BACKUP.value,
|
||||||
|
WyckoffEvent.BC.value,
|
||||||
|
WyckoffEvent.UTAD.value,
|
||||||
|
WyckoffEvent.SOW.value,
|
||||||
|
WyckoffEvent.LPSY.value,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _slice_frame(frame: OHLCVFrame, end_idx: int) -> OHLCVFrame:
|
||||||
|
n = end_idx + 1
|
||||||
|
return OHLCVFrame(
|
||||||
|
ts_code=frame.ts_code,
|
||||||
|
timeframe=frame.timeframe,
|
||||||
|
trade_dates=frame.trade_dates[:n],
|
||||||
|
open=frame.open[:n],
|
||||||
|
high=frame.high[:n],
|
||||||
|
low=frame.low[:n],
|
||||||
|
close=frame.close[:n],
|
||||||
|
volume=frame.volume[:n],
|
||||||
|
amount=frame.amount[:n] if frame.amount else [],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _compress_phases(points: list[tuple[str, str]]) -> list[dict]:
|
||||||
|
"""points: [(date_iso, phase), ...] → segments."""
|
||||||
|
if not points:
|
||||||
|
return []
|
||||||
|
segs: list[dict] = []
|
||||||
|
start, phase = points[0]
|
||||||
|
prev = start
|
||||||
|
for d, p in points[1:]:
|
||||||
|
if p != phase:
|
||||||
|
segs.append({"start": start, "end": prev, "phase": phase})
|
||||||
|
start, phase = d, p
|
||||||
|
prev = d
|
||||||
|
segs.append({"start": start, "end": prev, "phase": phase})
|
||||||
|
return segs
|
||||||
|
|
||||||
|
|
||||||
|
def annotate_frame(
|
||||||
|
frame: OHLCVFrame,
|
||||||
|
step: int | None = None,
|
||||||
|
*,
|
||||||
|
role: str | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""Pure annotation: phase bands + event markers + latest levels.
|
||||||
|
|
||||||
|
``role`` is the D/W/M rule alias (1d/1w/1M). Defaults to frame.timeframe.
|
||||||
|
``step`` defaults by role to keep interactive charts snappy.
|
||||||
|
"""
|
||||||
|
tf = role or frame.timeframe
|
||||||
|
min_bars = _MIN_BARS.get(tf, 30)
|
||||||
|
if step is None:
|
||||||
|
step = {"1d": 2, "1w": 1, "1M": 1}.get(tf, 2)
|
||||||
|
|
||||||
|
empty = {
|
||||||
|
"phases": [],
|
||||||
|
"events": [],
|
||||||
|
"levels": {},
|
||||||
|
"bars": len(frame),
|
||||||
|
"timeframe": tf,
|
||||||
|
}
|
||||||
|
if frame.empty or len(frame) < min_bars:
|
||||||
|
return empty
|
||||||
|
|
||||||
|
feat_eng = FeatureEngine()
|
||||||
|
cycle_eng = CycleEngine()
|
||||||
|
phase_eng = PhaseEngine()
|
||||||
|
event_eng = EventEngine()
|
||||||
|
|
||||||
|
phase_points: list[tuple[str, str]] = []
|
||||||
|
events: list[dict] = []
|
||||||
|
last_event: str | None = None
|
||||||
|
levels: dict = {}
|
||||||
|
|
||||||
|
# Ensure last bar is always evaluated
|
||||||
|
indices = list(range(min_bars - 1, len(frame), step))
|
||||||
|
if indices[-1] != len(frame) - 1:
|
||||||
|
indices.append(len(frame) - 1)
|
||||||
|
|
||||||
|
for i in indices:
|
||||||
|
sub = _slice_frame(frame, i)
|
||||||
|
f = feat_eng.run(sub, tf)
|
||||||
|
c = cycle_eng.run(f, tf)
|
||||||
|
p = phase_eng.run(c, f, tf)
|
||||||
|
e = event_eng.run(c, p, f, tf)
|
||||||
|
|
||||||
|
d = str(frame.trade_dates[i])[:10]
|
||||||
|
phase = p.payload.get("phase") or WyckoffPhase.NONE.value
|
||||||
|
phase_points.append((d, phase))
|
||||||
|
|
||||||
|
cur = e.payload.get("current_event") or WyckoffEvent.NONE.value
|
||||||
|
if cur in _NOTABLE_EVENTS and cur != last_event:
|
||||||
|
events.append({
|
||||||
|
"date": d,
|
||||||
|
"event": cur,
|
||||||
|
"price": float(frame.close[i]),
|
||||||
|
"low": float(frame.low[i]),
|
||||||
|
"high": float(frame.high[i]),
|
||||||
|
})
|
||||||
|
last_event = cur
|
||||||
|
elif cur == WyckoffEvent.NONE.value:
|
||||||
|
last_event = None
|
||||||
|
|
||||||
|
if i == len(frame) - 1 and not f.payload.get("insufficient"):
|
||||||
|
levels = {
|
||||||
|
k: f.payload.get(k)
|
||||||
|
for k in (
|
||||||
|
"range_high", "range_low", "ma20", "ma60",
|
||||||
|
"swing_high", "swing_low", "close",
|
||||||
|
)
|
||||||
|
if f.payload.get(k) is not None
|
||||||
|
}
|
||||||
|
levels["phase"] = phase
|
||||||
|
levels["cycle"] = c.payload.get("cycle")
|
||||||
|
levels["current_event"] = cur
|
||||||
|
|
||||||
|
return {
|
||||||
|
"phases": _compress_phases(phase_points),
|
||||||
|
"events": events,
|
||||||
|
"levels": levels,
|
||||||
|
"bars": len(frame),
|
||||||
|
"timeframe": tf,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
_RANGE_CYCLES = {
|
||||||
|
WyckoffCycle.ACCUMULATION.value,
|
||||||
|
WyckoffCycle.RE_ACCUMULATION.value,
|
||||||
|
WyckoffCycle.DISTRIBUTION.value,
|
||||||
|
WyckoffCycle.RE_DISTRIBUTION.value,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _build_range_zones(
|
||||||
|
price_frame: OHLCVFrame,
|
||||||
|
cycle_segs: list[dict],
|
||||||
|
levels: dict | None = None,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Build price boxes (high/low × date span) for accum/distrib ranges."""
|
||||||
|
if price_frame.empty:
|
||||||
|
return []
|
||||||
|
dates = [str(d)[:10] for d in price_frame.trade_dates]
|
||||||
|
highs = price_frame.high
|
||||||
|
lows = price_frame.low
|
||||||
|
zones: list[dict] = []
|
||||||
|
|
||||||
|
for seg in cycle_segs or []:
|
||||||
|
cy = seg.get("cycle")
|
||||||
|
if cy not in _RANGE_CYCLES:
|
||||||
|
continue
|
||||||
|
start, end = seg["start"], seg["end"]
|
||||||
|
idxs = [i for i, d in enumerate(dates) if start <= d <= end]
|
||||||
|
if not idxs:
|
||||||
|
# weekly bar date may sit between daily bars — take nearest window
|
||||||
|
i0 = next((i for i, d in enumerate(dates) if d >= start), None)
|
||||||
|
if i0 is None:
|
||||||
|
continue
|
||||||
|
i1 = next((i for i, d in enumerate(dates) if d > end), len(dates)) - 1
|
||||||
|
idxs = list(range(i0, max(i0, i1) + 1))
|
||||||
|
if not idxs:
|
||||||
|
continue
|
||||||
|
# pad short weekly hits to at least ~1 week of dailies for visibility
|
||||||
|
if len(idxs) < 5 and idxs[-1] + 1 < len(dates):
|
||||||
|
extra = min(5 - len(idxs), len(dates) - 1 - idxs[-1])
|
||||||
|
idxs = list(range(idxs[0], idxs[-1] + 1 + max(0, extra)))
|
||||||
|
hi = max(highs[i] for i in idxs)
|
||||||
|
lo = min(lows[i] for i in idxs)
|
||||||
|
if hi <= lo:
|
||||||
|
continue
|
||||||
|
zones.append({
|
||||||
|
"kind": cy,
|
||||||
|
"start": dates[idxs[0]],
|
||||||
|
"end": dates[idxs[-1]],
|
||||||
|
"high": float(hi),
|
||||||
|
"low": float(lo),
|
||||||
|
"current": False,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Always expose the latest trading-range box from feature snapshot
|
||||||
|
levels = levels or {}
|
||||||
|
rh, rl = levels.get("range_high"), levels.get("range_low")
|
||||||
|
if rh is not None and rl is not None and float(rh) > float(rl):
|
||||||
|
look = min(60, len(dates))
|
||||||
|
cy = levels.get("cycle") or "Unknown"
|
||||||
|
if cy not in _RANGE_CYCLES:
|
||||||
|
# Phase B/C in a range → treat as accumulation-style TR for display
|
||||||
|
ph = levels.get("phase") or ""
|
||||||
|
if ph in ("A", "B", "C"):
|
||||||
|
cy = WyckoffCycle.ACCUMULATION.value
|
||||||
|
elif ph in ("D", "E") and float(levels.get("close") or 0) < float(rh):
|
||||||
|
cy = WyckoffCycle.ACCUMULATION.value
|
||||||
|
else:
|
||||||
|
cy = "Range"
|
||||||
|
zones.append({
|
||||||
|
"kind": cy,
|
||||||
|
"start": dates[-look],
|
||||||
|
"end": dates[-1],
|
||||||
|
"high": float(rh),
|
||||||
|
"low": float(rl),
|
||||||
|
"current": True,
|
||||||
|
})
|
||||||
|
|
||||||
|
return zones
|
||||||
|
|
||||||
|
|
||||||
|
def annotate_symbol(
|
||||||
|
ts_code: str,
|
||||||
|
freq: str,
|
||||||
|
end_date: date | None = None,
|
||||||
|
lookback: int = 180,
|
||||||
|
*,
|
||||||
|
combo_id: str | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""IO + annotate for one symbol (used by API).
|
||||||
|
|
||||||
|
For the combo *low* chart, phase bands come from **mid** structure,
|
||||||
|
while event markers / levels come from the low TF.
|
||||||
|
"""
|
||||||
|
from crypto_wyckoff.combos import ROLE_HIGH, ROLE_LOW, ROLE_MID, get_combo
|
||||||
|
from crypto_wyckoff.io import load_frame
|
||||||
|
|
||||||
|
combo = get_combo(combo_id)
|
||||||
|
allowed = {combo["low"], combo["mid"], combo["high"]}
|
||||||
|
if freq not in allowed:
|
||||||
|
raise ValueError(f"freq {freq} not in combo {combo['id']} ({combo['label']})")
|
||||||
|
empty = {
|
||||||
|
"ts_code": ts_code,
|
||||||
|
"freq": freq,
|
||||||
|
"phases": [],
|
||||||
|
"events": [],
|
||||||
|
"levels": {},
|
||||||
|
"zones": [],
|
||||||
|
"bars": 0,
|
||||||
|
"phase_source": freq,
|
||||||
|
"cycles": [],
|
||||||
|
"combo_id": combo["id"],
|
||||||
|
}
|
||||||
|
_ = end_date
|
||||||
|
|
||||||
|
if freq == combo["low"]:
|
||||||
|
low = load_frame(ts_code, combo["low"], lookback)
|
||||||
|
mid = load_frame(ts_code, combo["mid"], max(60, lookback // 3))
|
||||||
|
if low is None:
|
||||||
|
return empty
|
||||||
|
d_ann = annotate_frame(low, role=ROLE_LOW)
|
||||||
|
w_ann = annotate_frame(mid, role=ROLE_MID) if mid is not None else {"phases": []}
|
||||||
|
cycles = _cycle_segments(mid, role=ROLE_MID) if mid is not None else []
|
||||||
|
levels = d_ann.get("levels") or {}
|
||||||
|
if cycles:
|
||||||
|
levels = {**levels, "cycle": cycles[-1].get("cycle") or levels.get("cycle")}
|
||||||
|
for p in reversed(w_ann.get("phases") or []):
|
||||||
|
if p.get("phase") not in (None, "None"):
|
||||||
|
levels = {**levels, "phase": p["phase"]}
|
||||||
|
break
|
||||||
|
return {
|
||||||
|
"ts_code": ts_code,
|
||||||
|
"freq": freq,
|
||||||
|
"end_date": low.trade_dates[-1].isoformat() if low.trade_dates else None,
|
||||||
|
"phases": w_ann.get("phases") or [],
|
||||||
|
"events": d_ann.get("events") or [],
|
||||||
|
"levels": d_ann.get("levels") or {},
|
||||||
|
"zones": _build_range_zones(low, cycles, levels),
|
||||||
|
"bars": d_ann.get("bars", 0),
|
||||||
|
"phase_source": combo["mid"],
|
||||||
|
"cycles": cycles,
|
||||||
|
"combo_id": combo["id"],
|
||||||
|
}
|
||||||
|
|
||||||
|
role = ROLE_MID if freq == combo["mid"] else ROLE_HIGH
|
||||||
|
frame = load_frame(ts_code, freq, lookback)
|
||||||
|
if frame is None:
|
||||||
|
return empty
|
||||||
|
out = annotate_frame(frame, role=role)
|
||||||
|
out["ts_code"] = ts_code
|
||||||
|
out["freq"] = freq
|
||||||
|
out["end_date"] = frame.trade_dates[-1].isoformat() if frame.trade_dates else None
|
||||||
|
out["phase_source"] = freq
|
||||||
|
out["cycles"] = _cycle_segments(frame, role=ROLE_HIGH if role == ROLE_HIGH else ROLE_MID)
|
||||||
|
out["zones"] = _build_range_zones(frame, out["cycles"], out.get("levels") or {})
|
||||||
|
out["combo_id"] = combo["id"]
|
||||||
|
if role == ROLE_HIGH:
|
||||||
|
if not any(p.get("phase") not in (None, "None") for p in out["phases"]):
|
||||||
|
out["phases"] = [
|
||||||
|
{"start": c["start"], "end": c["end"], "phase": c["cycle"]}
|
||||||
|
for c in out["cycles"]
|
||||||
|
if c.get("cycle") and c["cycle"] != "Unknown"
|
||||||
|
]
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _cycle_segments(
|
||||||
|
frame: OHLCVFrame,
|
||||||
|
step: int | None = None,
|
||||||
|
*,
|
||||||
|
role: str | None = None,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Walk-forward cycle labels compressed to segments."""
|
||||||
|
tf = role or frame.timeframe
|
||||||
|
min_bars = _MIN_BARS.get(tf, 30)
|
||||||
|
if step is None:
|
||||||
|
step = {"1d": 3, "1w": 1, "1M": 1}.get(tf, 2)
|
||||||
|
if frame.empty or len(frame) < min_bars:
|
||||||
|
return []
|
||||||
|
|
||||||
|
feat_eng = FeatureEngine()
|
||||||
|
cycle_eng = CycleEngine()
|
||||||
|
points: list[tuple[str, str]] = []
|
||||||
|
indices = list(range(min_bars - 1, len(frame), step))
|
||||||
|
if indices[-1] != len(frame) - 1:
|
||||||
|
indices.append(len(frame) - 1)
|
||||||
|
for i in indices:
|
||||||
|
sub = _slice_frame(frame, i)
|
||||||
|
f = feat_eng.run(sub, tf)
|
||||||
|
c = cycle_eng.run(f, tf)
|
||||||
|
points.append((str(frame.trade_dates[i])[:10], c.payload.get("cycle") or "Unknown"))
|
||||||
|
segs = _compress_phases(points)
|
||||||
|
return [{"start": s["start"], "end": s["end"], "cycle": s["phase"]} for s in segs]
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
"""Multi-timeframe combo presets for Crypto Wyckoff Screener.
|
||||||
|
|
||||||
|
Roles (engine rule aliases stay D/W/M):
|
||||||
|
high → Cycle (rules as 1M)
|
||||||
|
mid → Phase (rules as 1w)
|
||||||
|
low → Event (rules as 1d)
|
||||||
|
|
||||||
|
Actual bar TFs come from the combo (e.g. 8h/4h/1h).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import threading
|
||||||
|
from copy import deepcopy
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from crypto_wyckoff.io import DATA_DIR, ensure_dirs
|
||||||
|
|
||||||
|
ROLE_LOW = "1d"
|
||||||
|
ROLE_MID = "1w"
|
||||||
|
ROLE_HIGH = "1M"
|
||||||
|
|
||||||
|
# Minutes for ordering / validation (provider labels)
|
||||||
|
_TF_MINUTES: dict[str, int] = {
|
||||||
|
"1m": 1, "2m": 2, "3m": 3, "4m": 4, "5m": 5,
|
||||||
|
"10m": 10, "15m": 15, "20m": 20, "25m": 25, "30m": 30, "45m": 45,
|
||||||
|
"1h": 60, "2h": 120, "3h": 180, "4h": 240, "5h": 300,
|
||||||
|
"6h": 360, "7h": 420, "8h": 480, "9h": 540, "10h": 600,
|
||||||
|
"11h": 660, "12h": 720, "16h": 960, "20h": 1200,
|
||||||
|
"1d": 1440, "2d": 2880, "3d": 4320, "4d": 5760, "5d": 7200, "6d": 8640,
|
||||||
|
"1w": 10080, "2w": 20160, "3w": 30240,
|
||||||
|
"1M": 43200,
|
||||||
|
}
|
||||||
|
|
||||||
|
# TFs we allow in custom combos (provider-backed + local 1M)
|
||||||
|
ALLOWED_TFS: tuple[str, ...] = (
|
||||||
|
"1h", "2h", "3h", "4h", "6h", "8h", "12h",
|
||||||
|
"1d", "2d", "3d", "1w", "1M",
|
||||||
|
)
|
||||||
|
|
||||||
|
BUILTIN: list[dict[str, Any]] = [
|
||||||
|
{
|
||||||
|
"id": "h8_4_1",
|
||||||
|
"label": "8h / 4h / 1h",
|
||||||
|
"high": "8h",
|
||||||
|
"mid": "4h",
|
||||||
|
"low": "1h",
|
||||||
|
"builtin": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "d_w_m",
|
||||||
|
"label": "1d / 1w / 1M",
|
||||||
|
"high": "1M",
|
||||||
|
"mid": "1w",
|
||||||
|
"low": "1d",
|
||||||
|
"builtin": True,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
_COMBOS_FILE = DATA_DIR / "combos.json"
|
||||||
|
_lock = threading.Lock()
|
||||||
|
_cache: list[dict[str, Any]] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def tf_minutes(tf: str) -> int | None:
|
||||||
|
if tf in _TF_MINUTES:
|
||||||
|
return _TF_MINUTES[tf]
|
||||||
|
# tolerate provider typo "10" → skip
|
||||||
|
m = re.fullmatch(r"(\d+)([mhdwM])", tf)
|
||||||
|
if not m:
|
||||||
|
return None
|
||||||
|
n, u = int(m.group(1)), m.group(2)
|
||||||
|
mult = {"m": 1, "h": 60, "d": 1440, "w": 10080, "M": 43200}[u]
|
||||||
|
return n * mult
|
||||||
|
|
||||||
|
|
||||||
|
def combo_id_for(high: str, mid: str, low: str) -> str:
|
||||||
|
def _tok(t: str) -> str:
|
||||||
|
return t.replace("/", "_")
|
||||||
|
|
||||||
|
return f"{_tok(high)}_{_tok(mid)}_{_tok(low)}"
|
||||||
|
|
||||||
|
|
||||||
|
def validate_combo(high: str, mid: str, low: str) -> str | None:
|
||||||
|
"""Return error message or None if ok."""
|
||||||
|
for tf in (high, mid, low):
|
||||||
|
if tf not in ALLOWED_TFS:
|
||||||
|
return f"不支持的周期: {tf}"
|
||||||
|
if len({high, mid, low}) < 3:
|
||||||
|
return "高/中/低周期必须互不相同"
|
||||||
|
hm, mm, lm = tf_minutes(high), tf_minutes(mid), tf_minutes(low)
|
||||||
|
if hm is None or mm is None or lm is None:
|
||||||
|
return "无法解析周期长度"
|
||||||
|
if not (hm > mm > lm):
|
||||||
|
return "须满足 高 > 中 > 低(例如 8h > 4h > 1h)"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize(row: dict[str, Any]) -> dict[str, Any] | None:
|
||||||
|
high, mid, low = row.get("high"), row.get("mid"), row.get("low")
|
||||||
|
if not high or not mid or not low:
|
||||||
|
return None
|
||||||
|
err = validate_combo(str(high), str(mid), str(low))
|
||||||
|
if err:
|
||||||
|
return None
|
||||||
|
cid = str(row.get("id") or combo_id_for(high, mid, low))
|
||||||
|
label = str(row.get("label") or f"{high} / {mid} / {low}")
|
||||||
|
return {
|
||||||
|
"id": cid,
|
||||||
|
"label": label,
|
||||||
|
"high": str(high),
|
||||||
|
"mid": str(mid),
|
||||||
|
"low": str(low),
|
||||||
|
"builtin": bool(row.get("builtin", False)),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _load_raw() -> list[dict[str, Any]]:
|
||||||
|
ensure_dirs()
|
||||||
|
if not _COMBOS_FILE.exists():
|
||||||
|
return deepcopy(BUILTIN)
|
||||||
|
try:
|
||||||
|
data = json.loads(_COMBOS_FILE.read_text(encoding="utf-8"))
|
||||||
|
items = data.get("combos") if isinstance(data, dict) else data
|
||||||
|
if not isinstance(items, list):
|
||||||
|
return deepcopy(BUILTIN)
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
return deepcopy(BUILTIN)
|
||||||
|
|
||||||
|
out: list[dict[str, Any]] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for b in BUILTIN:
|
||||||
|
out.append(deepcopy(b))
|
||||||
|
seen.add(b["id"])
|
||||||
|
for row in items:
|
||||||
|
if not isinstance(row, dict):
|
||||||
|
continue
|
||||||
|
norm = _normalize(row)
|
||||||
|
if not norm or norm["id"] in seen:
|
||||||
|
continue
|
||||||
|
if norm["id"] in {b["id"] for b in BUILTIN}:
|
||||||
|
continue
|
||||||
|
norm["builtin"] = False
|
||||||
|
out.append(norm)
|
||||||
|
seen.add(norm["id"])
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _save(combos: list[dict[str, Any]]) -> None:
|
||||||
|
ensure_dirs()
|
||||||
|
custom = [c for c in combos if not c.get("builtin")]
|
||||||
|
payload = {"combos": custom}
|
||||||
|
tmp = _COMBOS_FILE.with_suffix(".tmp")
|
||||||
|
tmp.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||||
|
tmp.replace(_COMBOS_FILE)
|
||||||
|
|
||||||
|
|
||||||
|
def list_combos() -> list[dict[str, Any]]:
|
||||||
|
global _cache
|
||||||
|
with _lock:
|
||||||
|
if _cache is None:
|
||||||
|
_cache = _load_raw()
|
||||||
|
return deepcopy(_cache)
|
||||||
|
|
||||||
|
|
||||||
|
def get_combo(combo_id: str | None) -> dict[str, Any]:
|
||||||
|
combos = list_combos()
|
||||||
|
if combo_id:
|
||||||
|
for c in combos:
|
||||||
|
if c["id"] == combo_id:
|
||||||
|
return deepcopy(c)
|
||||||
|
return deepcopy(combos[0])
|
||||||
|
|
||||||
|
|
||||||
|
def add_combo(high: str, mid: str, low: str, label: str | None = None) -> dict[str, Any]:
|
||||||
|
err = validate_combo(high, mid, low)
|
||||||
|
if err:
|
||||||
|
raise ValueError(err)
|
||||||
|
cid = combo_id_for(high, mid, low)
|
||||||
|
row = {
|
||||||
|
"id": cid,
|
||||||
|
"label": label or f"{high} / {mid} / {low}",
|
||||||
|
"high": high,
|
||||||
|
"mid": mid,
|
||||||
|
"low": low,
|
||||||
|
"builtin": False,
|
||||||
|
}
|
||||||
|
with _lock:
|
||||||
|
combos = _load_raw()
|
||||||
|
for c in combos:
|
||||||
|
if c["id"] == cid or (c["high"], c["mid"], c["low"]) == (high, mid, low):
|
||||||
|
_cache = combos
|
||||||
|
return deepcopy(c)
|
||||||
|
combos.append(row)
|
||||||
|
_save(combos)
|
||||||
|
_cache = combos
|
||||||
|
return deepcopy(row)
|
||||||
|
|
||||||
|
|
||||||
|
def delete_combo(combo_id: str) -> bool:
|
||||||
|
with _lock:
|
||||||
|
combos = _load_raw()
|
||||||
|
kept: list[dict[str, Any]] = []
|
||||||
|
removed = False
|
||||||
|
for c in combos:
|
||||||
|
if c["id"] == combo_id:
|
||||||
|
if c.get("builtin"):
|
||||||
|
raise ValueError("内置组合不可删除")
|
||||||
|
removed = True
|
||||||
|
continue
|
||||||
|
kept.append(c)
|
||||||
|
if removed:
|
||||||
|
_save(kept)
|
||||||
|
_cache = kept
|
||||||
|
return removed
|
||||||
|
|
||||||
|
|
||||||
|
def all_tfs_for_combos(combos: list[dict[str, Any]] | None = None) -> list[str]:
|
||||||
|
"""Unique TFs needed by active combos (stable order)."""
|
||||||
|
rows = combos if combos is not None else list_combos()
|
||||||
|
seen: list[str] = []
|
||||||
|
for c in rows:
|
||||||
|
for k in ("low", "mid", "high"):
|
||||||
|
tf = c[k]
|
||||||
|
if tf not in seen:
|
||||||
|
seen.append(tf)
|
||||||
|
return seen
|
||||||
|
|
||||||
|
|
||||||
|
def lookback_for(tf: str) -> int:
|
||||||
|
defaults = {
|
||||||
|
"1h": 500,
|
||||||
|
"2h": 400,
|
||||||
|
"3h": 350,
|
||||||
|
"4h": 300,
|
||||||
|
"6h": 280,
|
||||||
|
"8h": 250,
|
||||||
|
"12h": 220,
|
||||||
|
"1d": 250,
|
||||||
|
"2d": 200,
|
||||||
|
"3d": 180,
|
||||||
|
"1w": 104,
|
||||||
|
"1M": 60,
|
||||||
|
}
|
||||||
|
return defaults.get(tf, 200)
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
"""Cycle Engine — monthly/weekly macro cycle via Rule Registry."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from crypto_wyckoff.domain_models import EngineResult, WyckoffCycle
|
||||||
|
from crypto_wyckoff.rules.base import RuleHit
|
||||||
|
from crypto_wyckoff.rules.registry import rule_registry
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_range_conflict(hits: list[RuleHit], features: dict) -> list[RuleHit]:
|
||||||
|
"""Accumulation vs Distribution overlap → mutually exclusive by MA120 position."""
|
||||||
|
accum = [h for h in hits if h.cycle == WyckoffCycle.ACCUMULATION.value]
|
||||||
|
dist = [h for h in hits if h.cycle == WyckoffCycle.DISTRIBUTION.value]
|
||||||
|
if not (accum and dist):
|
||||||
|
return hits
|
||||||
|
|
||||||
|
close = float(features.get("close") or 0)
|
||||||
|
ma120 = float(features.get("ma120") or close) or close
|
||||||
|
others = [
|
||||||
|
h for h in hits
|
||||||
|
if h.cycle not in (WyckoffCycle.ACCUMULATION.value, WyckoffCycle.DISTRIBUTION.value)
|
||||||
|
]
|
||||||
|
# Below MA120 → accumulation; above → distribution; equal band uses relative position
|
||||||
|
if close < ma120 * 0.995:
|
||||||
|
return others + accum
|
||||||
|
if close > ma120 * 1.005:
|
||||||
|
return others + dist
|
||||||
|
# Tight band: keep higher confidence only
|
||||||
|
best_a = max(accum, key=lambda h: h.confidence)
|
||||||
|
best_d = max(dist, key=lambda h: h.confidence)
|
||||||
|
return others + ([best_a] if best_a.confidence >= best_d.confidence else [best_d])
|
||||||
|
|
||||||
|
|
||||||
|
class CycleEngine:
|
||||||
|
name = "Cycle"
|
||||||
|
version = "1.0.0"
|
||||||
|
|
||||||
|
def run(self, feature: EngineResult, timeframe: str) -> EngineResult:
|
||||||
|
features = feature.payload
|
||||||
|
if features.get("insufficient"):
|
||||||
|
return EngineResult(
|
||||||
|
name=self.name,
|
||||||
|
version=self.version,
|
||||||
|
confidence=15.0,
|
||||||
|
score=40.0,
|
||||||
|
reasons=[f"{timeframe} 数据不足,Cycle=Unknown"],
|
||||||
|
warnings=["insufficient_features"],
|
||||||
|
payload={
|
||||||
|
"cycle": WyckoffCycle.UNKNOWN.value,
|
||||||
|
"timeframe": timeframe,
|
||||||
|
"trend_score": 40.0,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
context = {"features": features, "timeframe": timeframe}
|
||||||
|
hits: list[RuleHit] = []
|
||||||
|
for rule in rule_registry.by_category("cycle", timeframe):
|
||||||
|
hit = rule.evaluate(context)
|
||||||
|
if hit and hit.cycle:
|
||||||
|
hits.append(hit)
|
||||||
|
|
||||||
|
hits = _resolve_range_conflict(hits, features)
|
||||||
|
|
||||||
|
if not hits:
|
||||||
|
return EngineResult(
|
||||||
|
name=self.name,
|
||||||
|
version=self.version,
|
||||||
|
confidence=30.0,
|
||||||
|
score=40.0,
|
||||||
|
reasons=["无匹配周期规则,标记 Unknown"],
|
||||||
|
payload={
|
||||||
|
"cycle": WyckoffCycle.UNKNOWN.value,
|
||||||
|
"timeframe": timeframe,
|
||||||
|
"trend_score": 40.0,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
best = max(hits, key=lambda h: h.confidence)
|
||||||
|
trend_score = best.score
|
||||||
|
if best.cycle == WyckoffCycle.MARKUP.value:
|
||||||
|
trend_score = max(trend_score, 75.0)
|
||||||
|
elif best.cycle == WyckoffCycle.ACCUMULATION.value:
|
||||||
|
trend_score = max(60.0, trend_score * 0.9)
|
||||||
|
elif best.cycle == WyckoffCycle.DISTRIBUTION.value:
|
||||||
|
trend_score = min(45.0, 100 - trend_score * 0.5)
|
||||||
|
elif best.cycle == WyckoffCycle.MARKDOWN.value:
|
||||||
|
trend_score = min(30.0, 100 - trend_score)
|
||||||
|
|
||||||
|
return EngineResult(
|
||||||
|
name=self.name,
|
||||||
|
version=self.version,
|
||||||
|
confidence=best.confidence,
|
||||||
|
score=trend_score,
|
||||||
|
reasons=best.reasons,
|
||||||
|
metrics=best.metrics,
|
||||||
|
payload={
|
||||||
|
"cycle": best.cycle,
|
||||||
|
"timeframe": timeframe,
|
||||||
|
"rule_id": best.rule_id,
|
||||||
|
"trend_score": trend_score,
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
"""Decision Engine — multi-timeframe fusion and tradability (Architecture v1.0)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from crypto_wyckoff.domain_models import (
|
||||||
|
DecisionSignal,
|
||||||
|
EngineResult,
|
||||||
|
RiskLevel,
|
||||||
|
WyckoffCycle,
|
||||||
|
WyckoffEvent,
|
||||||
|
WyckoffPhase,
|
||||||
|
)
|
||||||
|
|
||||||
|
BULL_CYCLES = {
|
||||||
|
WyckoffCycle.ACCUMULATION.value,
|
||||||
|
WyckoffCycle.RE_ACCUMULATION.value,
|
||||||
|
WyckoffCycle.MARKUP.value,
|
||||||
|
}
|
||||||
|
BEAR_CYCLES = {
|
||||||
|
WyckoffCycle.DISTRIBUTION.value,
|
||||||
|
WyckoffCycle.RE_DISTRIBUTION.value,
|
||||||
|
WyckoffCycle.MARKDOWN.value,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class DecisionEngine:
|
||||||
|
name = "Decision"
|
||||||
|
version = "1.0.0"
|
||||||
|
|
||||||
|
def run(
|
||||||
|
self,
|
||||||
|
monthly_cycle: EngineResult,
|
||||||
|
weekly_cycle: EngineResult,
|
||||||
|
weekly_phase: EngineResult,
|
||||||
|
weekly_event: EngineResult,
|
||||||
|
daily_event: EngineResult,
|
||||||
|
daily_signal: EngineResult,
|
||||||
|
) -> EngineResult:
|
||||||
|
m_cycle = monthly_cycle.payload.get("cycle", WyckoffCycle.UNKNOWN.value)
|
||||||
|
w_cycle = weekly_cycle.payload.get("cycle", WyckoffCycle.UNKNOWN.value)
|
||||||
|
w_phase = weekly_phase.payload.get("phase", WyckoffPhase.NONE.value)
|
||||||
|
w_event = weekly_event.payload.get("current_event", WyckoffEvent.NONE.value)
|
||||||
|
d_event = daily_event.payload.get("current_event", WyckoffEvent.NONE.value)
|
||||||
|
|
||||||
|
trend_score = float(monthly_cycle.payload.get("trend_score", monthly_cycle.score))
|
||||||
|
structure_score = float(weekly_phase.payload.get("structure_score", weekly_phase.score))
|
||||||
|
entry_score = float(daily_event.payload.get("entry_score", daily_event.score))
|
||||||
|
|
||||||
|
overall_score = 0.30 * trend_score + 0.30 * structure_score + 0.40 * entry_score
|
||||||
|
|
||||||
|
reasons: list[str] = []
|
||||||
|
warnings: list[str] = []
|
||||||
|
alignment = 50.0
|
||||||
|
|
||||||
|
m_bull = m_cycle in BULL_CYCLES
|
||||||
|
m_bear = m_cycle in BEAR_CYCLES
|
||||||
|
w_bull = w_cycle in BULL_CYCLES
|
||||||
|
d_bullish_event = d_event in {
|
||||||
|
WyckoffEvent.SPRING.value,
|
||||||
|
WyckoffEvent.TEST.value,
|
||||||
|
WyckoffEvent.SOS.value,
|
||||||
|
WyckoffEvent.LPS.value,
|
||||||
|
WyckoffEvent.JUMP.value,
|
||||||
|
WyckoffEvent.BACKUP.value,
|
||||||
|
}
|
||||||
|
d_bearish_event = d_event in {
|
||||||
|
WyckoffEvent.UTAD.value,
|
||||||
|
WyckoffEvent.SOW.value,
|
||||||
|
WyckoffEvent.LPSY.value,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Alignment scoring
|
||||||
|
if m_bull and w_bull and d_bullish_event:
|
||||||
|
alignment = 92.0
|
||||||
|
reasons.append("✓ 月/周多头结构与日线多头事件一致")
|
||||||
|
elif m_bull and d_bullish_event:
|
||||||
|
alignment = 78.0
|
||||||
|
reasons.append("✓ 月线支持,日线有入场事件")
|
||||||
|
if not w_bull:
|
||||||
|
warnings.append("周线结构未完全确认")
|
||||||
|
alignment -= 8
|
||||||
|
elif m_bear and d_bullish_event:
|
||||||
|
alignment = 35.0
|
||||||
|
reasons.append("✗ 月线派发/下跌,日线弹簧可能只是反弹")
|
||||||
|
elif m_bear and d_bearish_event:
|
||||||
|
alignment = 85.0
|
||||||
|
reasons.append("✓ 空头多周期一致")
|
||||||
|
else:
|
||||||
|
alignment = 55.0
|
||||||
|
reasons.append("○ 多周期部分一致,需观察")
|
||||||
|
|
||||||
|
if w_phase in (WyckoffPhase.D.value, WyckoffPhase.E.value) and m_bull:
|
||||||
|
alignment = min(98.0, alignment + 6)
|
||||||
|
reasons.append(f"✓ 周线阶段 {w_phase} 结构成熟({w_event})")
|
||||||
|
active = daily_event.payload.get("active_events") or daily_event.payload.get("recent_events") or []
|
||||||
|
if d_event == WyckoffEvent.SPRING.value and len(active) >= 3:
|
||||||
|
alignment = min(98.0, alignment + 4)
|
||||||
|
reasons.append("✓ 日线多重事件同时确认")
|
||||||
|
|
||||||
|
# Decision signal — hard gate on monthly bear + daily spring
|
||||||
|
decision = DecisionSignal.WATCH.value
|
||||||
|
risk = RiskLevel.MEDIUM.value
|
||||||
|
|
||||||
|
if m_bear and d_event == WyckoffEvent.SPRING.value:
|
||||||
|
decision = DecisionSignal.WATCH.value
|
||||||
|
risk = RiskLevel.HIGH.value
|
||||||
|
overall_score = min(overall_score, 55.0)
|
||||||
|
reasons.append("→ 决策:观察(月线不支持,禁止追日线弹簧)")
|
||||||
|
elif m_bear and d_bullish_event:
|
||||||
|
decision = DecisionSignal.AVOID.value
|
||||||
|
risk = RiskLevel.HIGH.value
|
||||||
|
overall_score = min(overall_score, 48.0)
|
||||||
|
reasons.append("→ 决策:回避(逆大周期多头事件)")
|
||||||
|
elif (
|
||||||
|
m_bull
|
||||||
|
and w_phase in (WyckoffPhase.D.value, WyckoffPhase.E.value, WyckoffPhase.C.value)
|
||||||
|
and d_event in (WyckoffEvent.SPRING.value, WyckoffEvent.LPS.value, WyckoffEvent.SOS.value)
|
||||||
|
and alignment >= 85
|
||||||
|
and overall_score >= 80
|
||||||
|
):
|
||||||
|
decision = DecisionSignal.STRONG_BUY.value
|
||||||
|
risk = RiskLevel.LOW.value
|
||||||
|
reasons.append("→ 决策:强烈买入(三级共振)")
|
||||||
|
elif m_bull and d_bullish_event and overall_score >= 68 and alignment >= 70:
|
||||||
|
decision = DecisionSignal.BUY.value
|
||||||
|
risk = RiskLevel.LOW.value if alignment >= 80 else RiskLevel.MEDIUM.value
|
||||||
|
reasons.append("→ 决策:买入")
|
||||||
|
elif m_bear and d_bearish_event and overall_score >= 65:
|
||||||
|
decision = DecisionSignal.SELL.value
|
||||||
|
risk = RiskLevel.MEDIUM.value
|
||||||
|
reasons.append("→ 决策:卖出")
|
||||||
|
else:
|
||||||
|
decision = DecisionSignal.WATCH.value
|
||||||
|
reasons.append("→ 决策:观察")
|
||||||
|
|
||||||
|
# Stars from score + alignment
|
||||||
|
combo = 0.6 * overall_score + 0.4 * alignment
|
||||||
|
if combo >= 90:
|
||||||
|
stars = 5
|
||||||
|
elif combo >= 80:
|
||||||
|
stars = 4
|
||||||
|
elif combo >= 65:
|
||||||
|
stars = 3
|
||||||
|
elif combo >= 50:
|
||||||
|
stars = 2
|
||||||
|
else:
|
||||||
|
stars = 1
|
||||||
|
|
||||||
|
overall_confidence = (
|
||||||
|
0.25 * monthly_cycle.confidence
|
||||||
|
+ 0.25 * weekly_phase.confidence
|
||||||
|
+ 0.25 * daily_event.confidence
|
||||||
|
+ 0.25 * daily_signal.confidence
|
||||||
|
)
|
||||||
|
# Weak event pulls overall down
|
||||||
|
if daily_event.confidence < 60:
|
||||||
|
overall_confidence = min(overall_confidence, daily_event.confidence + 15)
|
||||||
|
|
||||||
|
return EngineResult(
|
||||||
|
name=self.name,
|
||||||
|
version=self.version,
|
||||||
|
confidence=overall_confidence,
|
||||||
|
score=overall_score,
|
||||||
|
reasons=reasons,
|
||||||
|
warnings=warnings,
|
||||||
|
metrics={
|
||||||
|
"trend_score": trend_score,
|
||||||
|
"structure_score": structure_score,
|
||||||
|
"entry_score": entry_score,
|
||||||
|
"alignment": alignment,
|
||||||
|
"stars": stars,
|
||||||
|
},
|
||||||
|
payload={
|
||||||
|
"decision_signal": decision,
|
||||||
|
"alignment": alignment,
|
||||||
|
"stars": stars,
|
||||||
|
"risk": risk,
|
||||||
|
"overall_score": overall_score,
|
||||||
|
"overall_confidence": overall_confidence,
|
||||||
|
"trend_score": trend_score,
|
||||||
|
"structure_score": structure_score,
|
||||||
|
"entry_score": entry_score,
|
||||||
|
"m_cycle": m_cycle,
|
||||||
|
"w_cycle": w_cycle,
|
||||||
|
"w_phase": w_phase,
|
||||||
|
"w_event": w_event,
|
||||||
|
"d_event": d_event,
|
||||||
|
# Facts preserved — never overwritten
|
||||||
|
"facts": {
|
||||||
|
"monthly": {"cycle": m_cycle},
|
||||||
|
"weekly": {"cycle": w_cycle, "phase": w_phase, "event": w_event},
|
||||||
|
"daily": {"event": d_event},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
"""Wyckoff Screener domain models — Architecture v1.0 frozen contracts."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import date, datetime
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
|
||||||
|
class WyckoffCycle(str, Enum):
|
||||||
|
ACCUMULATION = "Accumulation"
|
||||||
|
RE_ACCUMULATION = "ReAccumulation"
|
||||||
|
MARKUP = "Markup"
|
||||||
|
DISTRIBUTION = "Distribution"
|
||||||
|
RE_DISTRIBUTION = "ReDistribution"
|
||||||
|
MARKDOWN = "Markdown"
|
||||||
|
UNKNOWN = "Unknown"
|
||||||
|
|
||||||
|
|
||||||
|
class WyckoffPhase(str, Enum):
|
||||||
|
A = "A"
|
||||||
|
B = "B"
|
||||||
|
C = "C"
|
||||||
|
D = "D"
|
||||||
|
E = "E"
|
||||||
|
NONE = "None"
|
||||||
|
|
||||||
|
|
||||||
|
class WyckoffEvent(str, Enum):
|
||||||
|
PS = "PS"
|
||||||
|
SC = "SC"
|
||||||
|
AR = "AR"
|
||||||
|
ST = "ST"
|
||||||
|
SPRING = "Spring"
|
||||||
|
TEST = "Test"
|
||||||
|
SOS = "SOS"
|
||||||
|
LPS = "LPS"
|
||||||
|
JUMP = "Jump"
|
||||||
|
BACKUP = "Backup"
|
||||||
|
BC = "BC"
|
||||||
|
UTAD = "UTAD"
|
||||||
|
SOW = "SOW"
|
||||||
|
LPSY = "LPSY"
|
||||||
|
NONE = "None"
|
||||||
|
|
||||||
|
|
||||||
|
class DecisionSignal(str, Enum):
|
||||||
|
STRONG_BUY = "StrongBuy"
|
||||||
|
BUY = "Buy"
|
||||||
|
WATCH = "Watch"
|
||||||
|
AVOID = "Avoid"
|
||||||
|
SELL = "Sell"
|
||||||
|
|
||||||
|
|
||||||
|
class RiskLevel(str, Enum):
|
||||||
|
LOW = "Low"
|
||||||
|
MEDIUM = "Medium"
|
||||||
|
HIGH = "High"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class EngineResult:
|
||||||
|
"""Unified result envelope for every Wyckoff engine (v1.0 contract)."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
version: str = "1.0.0"
|
||||||
|
confidence: float = 0.0
|
||||||
|
score: float = 0.0
|
||||||
|
reasons: list[str] = field(default_factory=list)
|
||||||
|
warnings: list[str] = field(default_factory=list)
|
||||||
|
metrics: dict[str, Any] = field(default_factory=dict)
|
||||||
|
payload: dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"name": self.name,
|
||||||
|
"version": self.version,
|
||||||
|
"confidence": self.confidence,
|
||||||
|
"score": self.score,
|
||||||
|
"reasons": self.reasons,
|
||||||
|
"warnings": self.warnings,
|
||||||
|
"metrics": self.metrics,
|
||||||
|
"payload": self.payload,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class OHLCVFrame:
|
||||||
|
"""In-memory OHLCV for one symbol one timeframe. Engines never touch DB."""
|
||||||
|
|
||||||
|
ts_code: str
|
||||||
|
timeframe: str # "1d" | "1w" | "1M"
|
||||||
|
trade_dates: list[date]
|
||||||
|
open: list[float]
|
||||||
|
high: list[float]
|
||||||
|
low: list[float]
|
||||||
|
close: list[float]
|
||||||
|
volume: list[float]
|
||||||
|
amount: list[float] = field(default_factory=list)
|
||||||
|
|
||||||
|
def __len__(self) -> int:
|
||||||
|
return len(self.close)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def empty(self) -> bool:
|
||||||
|
return len(self.close) == 0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class WyckoffScanRow:
|
||||||
|
"""Persisted scan row for wyckoff_scan table."""
|
||||||
|
|
||||||
|
trade_date: date
|
||||||
|
ts_code: str
|
||||||
|
name: str = ""
|
||||||
|
industry: str = ""
|
||||||
|
engine_version: str = "v1.0.0"
|
||||||
|
combo_id: str = "d_w_m"
|
||||||
|
|
||||||
|
m_cycle: str = WyckoffCycle.UNKNOWN.value
|
||||||
|
cycle_confidence: float = 0.0
|
||||||
|
trend_score: float = 0.0
|
||||||
|
|
||||||
|
w_cycle: str = WyckoffCycle.UNKNOWN.value
|
||||||
|
w_phase: str = WyckoffPhase.NONE.value
|
||||||
|
w_current_event: str = WyckoffEvent.NONE.value
|
||||||
|
w_recent_events_json: str = "[]"
|
||||||
|
phase_confidence: float = 0.0
|
||||||
|
structure_score: float = 0.0
|
||||||
|
|
||||||
|
d_current_event: str = WyckoffEvent.NONE.value
|
||||||
|
d_recent_events_json: str = "[]"
|
||||||
|
event_confidence: float = 0.0
|
||||||
|
entry_score: float = 0.0
|
||||||
|
|
||||||
|
entry: Optional[float] = None
|
||||||
|
stop: Optional[float] = None
|
||||||
|
target1: Optional[float] = None
|
||||||
|
target2: Optional[float] = None
|
||||||
|
rr: Optional[float] = None
|
||||||
|
|
||||||
|
alignment: float = 0.0
|
||||||
|
stars: int = 1
|
||||||
|
decision_signal: str = DecisionSignal.WATCH.value
|
||||||
|
signal_confidence: float = 0.0
|
||||||
|
overall_confidence: float = 0.0
|
||||||
|
overall_score: float = 0.0
|
||||||
|
risk: str = RiskLevel.MEDIUM.value
|
||||||
|
reasons_json: str = "[]"
|
||||||
|
|
||||||
|
feature_snapshot_json: str = "{}"
|
||||||
|
markers_json: str = "[]"
|
||||||
|
scanned_at: datetime = field(default_factory=datetime.now)
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
"""Event Engine — active concurrent events via Rule Registry.
|
||||||
|
|
||||||
|
Note: `active_events` are rules that fire on the latest bar snapshot,
|
||||||
|
NOT a historical SC→AR→ST timeline. Do not present as chronological chain.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from crypto_wyckoff.domain_models import EngineResult, WyckoffEvent
|
||||||
|
from crypto_wyckoff.rules.registry import rule_registry
|
||||||
|
|
||||||
|
# Display order only (not temporal history)
|
||||||
|
_DISPLAY_ORDER = [
|
||||||
|
WyckoffEvent.PS.value,
|
||||||
|
WyckoffEvent.SC.value,
|
||||||
|
WyckoffEvent.AR.value,
|
||||||
|
WyckoffEvent.ST.value,
|
||||||
|
WyckoffEvent.SPRING.value,
|
||||||
|
WyckoffEvent.TEST.value,
|
||||||
|
WyckoffEvent.SOS.value,
|
||||||
|
WyckoffEvent.LPS.value,
|
||||||
|
WyckoffEvent.JUMP.value,
|
||||||
|
WyckoffEvent.BACKUP.value,
|
||||||
|
WyckoffEvent.BC.value,
|
||||||
|
WyckoffEvent.UTAD.value,
|
||||||
|
WyckoffEvent.SOW.value,
|
||||||
|
WyckoffEvent.LPSY.value,
|
||||||
|
]
|
||||||
|
|
||||||
|
# Dominant event: highest confidence wins; ties broken by this priority
|
||||||
|
_DOMINANCE_PRIORITY = [
|
||||||
|
WyckoffEvent.SOS.value,
|
||||||
|
WyckoffEvent.LPS.value,
|
||||||
|
WyckoffEvent.UTAD.value,
|
||||||
|
WyckoffEvent.SPRING.value,
|
||||||
|
WyckoffEvent.JUMP.value,
|
||||||
|
WyckoffEvent.BACKUP.value,
|
||||||
|
WyckoffEvent.TEST.value,
|
||||||
|
WyckoffEvent.SC.value,
|
||||||
|
WyckoffEvent.SOW.value,
|
||||||
|
WyckoffEvent.AR.value,
|
||||||
|
WyckoffEvent.ST.value,
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class EventEngine:
|
||||||
|
name = "Event"
|
||||||
|
version = "1.0.0"
|
||||||
|
|
||||||
|
def run(
|
||||||
|
self,
|
||||||
|
cycle: EngineResult,
|
||||||
|
phase: EngineResult,
|
||||||
|
feature: EngineResult,
|
||||||
|
timeframe: str,
|
||||||
|
) -> EngineResult:
|
||||||
|
if feature.payload.get("insufficient"):
|
||||||
|
return EngineResult(
|
||||||
|
name=self.name,
|
||||||
|
version=self.version,
|
||||||
|
confidence=20.0,
|
||||||
|
score=30.0,
|
||||||
|
reasons=["特征不足,跳过事件识别"],
|
||||||
|
warnings=["insufficient_features"],
|
||||||
|
payload={
|
||||||
|
"current_event": WyckoffEvent.NONE.value,
|
||||||
|
"active_events": [],
|
||||||
|
"recent_events": [], # alias for DB/API compat; same as active_events
|
||||||
|
"timeframe": timeframe,
|
||||||
|
"entry_score": 30.0,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
context = {
|
||||||
|
"features": feature.payload,
|
||||||
|
"cycle": cycle.payload,
|
||||||
|
"phase": phase.payload,
|
||||||
|
"timeframe": timeframe,
|
||||||
|
}
|
||||||
|
hits = []
|
||||||
|
for rule in rule_registry.by_category("event", timeframe):
|
||||||
|
hit = rule.evaluate(context)
|
||||||
|
if hit and hit.event:
|
||||||
|
hits.append(hit)
|
||||||
|
|
||||||
|
if not hits:
|
||||||
|
return EngineResult(
|
||||||
|
name=self.name,
|
||||||
|
version=self.version,
|
||||||
|
confidence=35.0,
|
||||||
|
score=40.0,
|
||||||
|
reasons=["无显著事件"],
|
||||||
|
payload={
|
||||||
|
"current_event": WyckoffEvent.NONE.value,
|
||||||
|
"active_events": [],
|
||||||
|
"recent_events": [],
|
||||||
|
"timeframe": timeframe,
|
||||||
|
"entry_score": 40.0,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
by_event: dict[str, float] = {}
|
||||||
|
reasons: list[str] = []
|
||||||
|
metrics: dict = {}
|
||||||
|
for h in hits:
|
||||||
|
prev = by_event.get(h.event, -1.0)
|
||||||
|
if h.confidence >= prev:
|
||||||
|
by_event[h.event] = h.confidence
|
||||||
|
reasons.extend(h.reasons)
|
||||||
|
metrics.update(h.metrics)
|
||||||
|
|
||||||
|
active = [e for e in _DISPLAY_ORDER if e in by_event]
|
||||||
|
for e in by_event:
|
||||||
|
if e not in active:
|
||||||
|
active.append(e)
|
||||||
|
|
||||||
|
# Dominant = max confidence; tie-break by dominance priority index
|
||||||
|
def _dom_key(ev: str) -> tuple:
|
||||||
|
conf = by_event[ev]
|
||||||
|
try:
|
||||||
|
prio = _DOMINANCE_PRIORITY.index(ev)
|
||||||
|
except ValueError:
|
||||||
|
prio = 99
|
||||||
|
return (conf, -prio)
|
||||||
|
|
||||||
|
current = max(by_event.keys(), key=_dom_key)
|
||||||
|
event_conf = by_event[current]
|
||||||
|
co_bonus = min(12.0, max(0, len(active) - 1) * 3)
|
||||||
|
entry_score = min(98.0, event_conf + co_bonus)
|
||||||
|
if current == WyckoffEvent.SPRING.value and WyckoffEvent.TEST.value in by_event:
|
||||||
|
entry_score = min(98.0, entry_score + 5)
|
||||||
|
|
||||||
|
return EngineResult(
|
||||||
|
name=self.name,
|
||||||
|
version=self.version,
|
||||||
|
confidence=event_conf,
|
||||||
|
score=entry_score,
|
||||||
|
reasons=list(dict.fromkeys(reasons))[:8],
|
||||||
|
warnings=["active_events_are_concurrent_not_timeline"],
|
||||||
|
metrics=metrics,
|
||||||
|
payload={
|
||||||
|
"current_event": current,
|
||||||
|
"active_events": active,
|
||||||
|
"recent_events": active, # persisted column name; semantic = active
|
||||||
|
"event_scores": by_event,
|
||||||
|
"timeframe": timeframe,
|
||||||
|
"entry_score": entry_score,
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
"""Feature Engine — pure function over OHLCVFrame → EngineResult(FeatureSnapshot)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from crypto_wyckoff.domain_models import EngineResult, OHLCVFrame
|
||||||
|
|
||||||
|
|
||||||
|
def _sma(arr: np.ndarray, n: int) -> float:
|
||||||
|
if len(arr) < n:
|
||||||
|
return float(arr[-1]) if len(arr) else 0.0
|
||||||
|
return float(np.mean(arr[-n:]))
|
||||||
|
|
||||||
|
|
||||||
|
def _atr(high: np.ndarray, low: np.ndarray, close: np.ndarray, n: int = 14) -> float:
|
||||||
|
if len(close) < 2:
|
||||||
|
return 0.0
|
||||||
|
prev_close = close[:-1]
|
||||||
|
tr = np.maximum(high[1:] - low[1:], np.maximum(np.abs(high[1:] - prev_close), np.abs(low[1:] - prev_close)))
|
||||||
|
if len(tr) < n:
|
||||||
|
return float(np.mean(tr)) if len(tr) else 0.0
|
||||||
|
return float(np.mean(tr[-n:]))
|
||||||
|
|
||||||
|
|
||||||
|
def _adx(high: np.ndarray, low: np.ndarray, close: np.ndarray, n: int = 14) -> float:
|
||||||
|
"""Simplified ADX approximation."""
|
||||||
|
if len(close) < n + 2:
|
||||||
|
return 15.0
|
||||||
|
up = high[1:] - high[:-1]
|
||||||
|
down = low[:-1] - low[1:]
|
||||||
|
plus_dm = np.where((up > down) & (up > 0), up, 0.0)
|
||||||
|
minus_dm = np.where((down > up) & (down > 0), down, 0.0)
|
||||||
|
tr = np.maximum(high[1:] - low[1:], np.maximum(np.abs(high[1:] - close[:-1]), np.abs(low[1:] - close[:-1])))
|
||||||
|
atr = np.mean(tr[-n:]) or 1e-9
|
||||||
|
plus_di = 100 * np.mean(plus_dm[-n:]) / atr
|
||||||
|
minus_di = 100 * np.mean(minus_dm[-n:]) / atr
|
||||||
|
denom = plus_di + minus_di
|
||||||
|
if denom < 1e-9:
|
||||||
|
return 10.0
|
||||||
|
dx = 100 * abs(plus_di - minus_di) / denom
|
||||||
|
return float(min(60.0, dx))
|
||||||
|
|
||||||
|
|
||||||
|
def compute_feature_snapshot(frame: OHLCVFrame) -> dict[str, Any]:
|
||||||
|
"""Compute technical snapshot dict from OHLCV (no I/O)."""
|
||||||
|
if frame.empty or len(frame) < 5:
|
||||||
|
return {"ts_code": frame.ts_code, "timeframe": frame.timeframe, "bars": len(frame)}
|
||||||
|
|
||||||
|
close = np.asarray(frame.close, dtype=float)
|
||||||
|
high = np.asarray(frame.high, dtype=float)
|
||||||
|
low = np.asarray(frame.low, dtype=float)
|
||||||
|
volume = np.asarray(frame.volume, dtype=float)
|
||||||
|
open_ = np.asarray(frame.open, dtype=float)
|
||||||
|
|
||||||
|
ma20 = _sma(close, 20)
|
||||||
|
ma60 = _sma(close, 60)
|
||||||
|
ma120 = _sma(close, min(120, len(close)))
|
||||||
|
atr = _atr(high, low, close, 14)
|
||||||
|
vol_ma20 = _sma(volume, 20) or 1e-9
|
||||||
|
volume_ratio = float(volume[-1] / vol_ma20)
|
||||||
|
|
||||||
|
look = min(60, len(close))
|
||||||
|
window_h = high[-look:]
|
||||||
|
window_l = low[-look:]
|
||||||
|
range_high = float(np.max(window_h))
|
||||||
|
range_low = float(np.min(window_l))
|
||||||
|
rng = max(range_high - range_low, 1e-9)
|
||||||
|
range_pct_60 = float(rng / close[-1]) if close[-1] else 0.0
|
||||||
|
range_position = float((close[-1] - range_low) / rng)
|
||||||
|
|
||||||
|
# Spring / UTAD hints
|
||||||
|
pierce_below = max(0.0, (range_low - low[-1]) / close[-1]) if close[-1] else 0.0
|
||||||
|
# if previous bars broke below and last close back in range
|
||||||
|
prior_low = float(np.min(low[-6:-1])) if len(low) >= 6 else float(low[-2])
|
||||||
|
pierce_below = max(pierce_below, max(0.0, (range_low - prior_low) / close[-1]))
|
||||||
|
close_back_in_range = 1.0 if close[-1] >= range_low else 0.0
|
||||||
|
reclaim_speed = 0.0
|
||||||
|
if pierce_below > 0 and close[-1] >= range_low:
|
||||||
|
reclaim_speed = min(1.0, (close[-1] - low[-1]) / max(atr, 1e-9) / 2)
|
||||||
|
|
||||||
|
pierce_above = max(0.0, (high[-1] - range_high) / close[-1])
|
||||||
|
fail_back = 1.0 if pierce_above > 0 and close[-1] <= range_high else 0.0
|
||||||
|
breakout_above = 1.0 if close[-1] > range_high and volume_ratio >= 1.0 else -1.0
|
||||||
|
|
||||||
|
# pullback hold: close near ma20 from above after being higher
|
||||||
|
pullback_hold = 0.0
|
||||||
|
if len(close) >= 5 and close[-1] > ma20 and close[-3] > close[-1] and (close[-1] - ma20) / max(atr, 1e-9) < 1.5:
|
||||||
|
pullback_hold = 0.8
|
||||||
|
|
||||||
|
ma60_prev = _sma(close[:-5], 60) if len(close) > 65 else ma60
|
||||||
|
ma60_slope = (ma60 - ma60_prev) / max(abs(ma60_prev), 1e-9)
|
||||||
|
|
||||||
|
# volume trend: recent 10 vs prior 10
|
||||||
|
if len(volume) >= 20:
|
||||||
|
volume_trend = float(np.mean(volume[-10:]) / (np.mean(volume[-20:-10]) + 1e-9) - 1.0)
|
||||||
|
else:
|
||||||
|
volume_trend = 0.0
|
||||||
|
|
||||||
|
bar_range_atr = float((high[-1] - low[-1]) / max(atr, 1e-9))
|
||||||
|
bounce_from_low = float((close[-1] - float(np.min(low[-10:]))) / close[-1]) if close[-1] else 0.0
|
||||||
|
gap_up_pct = float((open_[-1] - close[-2]) / close[-2]) if len(close) >= 2 and close[-2] else 0.0
|
||||||
|
after_strength = 0.0
|
||||||
|
if len(close) >= 4 and close[-3] > close[-4]:
|
||||||
|
after_strength = 0.7
|
||||||
|
|
||||||
|
spring_score_hint = 0.0
|
||||||
|
if pierce_below >= 0.002 and close_back_in_range:
|
||||||
|
spring_score_hint = min(90.0, 50 + pierce_below * 1500 + reclaim_speed * 20)
|
||||||
|
utad_score_hint = min(90.0, 50 + pierce_above * 1500) if pierce_above >= 0.002 and fail_back else 0.0
|
||||||
|
|
||||||
|
# swing
|
||||||
|
swing_high = float(np.max(high[-20:])) if len(high) >= 5 else float(high[-1])
|
||||||
|
swing_low = float(np.min(low[-20:])) if len(low) >= 5 else float(low[-1])
|
||||||
|
|
||||||
|
return {
|
||||||
|
"ts_code": frame.ts_code,
|
||||||
|
"timeframe": frame.timeframe,
|
||||||
|
"bars": len(frame),
|
||||||
|
"close": float(close[-1]),
|
||||||
|
"open": float(open_[-1]),
|
||||||
|
"high": float(high[-1]),
|
||||||
|
"low": float(low[-1]),
|
||||||
|
"volume": float(volume[-1]),
|
||||||
|
"ma20": ma20,
|
||||||
|
"ma60": ma60,
|
||||||
|
"ma120": ma120,
|
||||||
|
"ma60_slope": float(ma60_slope),
|
||||||
|
"atr": atr,
|
||||||
|
"adx": _adx(high, low, close),
|
||||||
|
"volume_ma20": float(vol_ma20),
|
||||||
|
"volume_ratio": volume_ratio,
|
||||||
|
"volume_trend": volume_trend,
|
||||||
|
"range_high": range_high,
|
||||||
|
"range_low": range_low,
|
||||||
|
"range_pct_60": range_pct_60,
|
||||||
|
"range_position": range_position,
|
||||||
|
"pierce_below_range": pierce_below,
|
||||||
|
"pierce_above_range": pierce_above,
|
||||||
|
"close_back_in_range": close_back_in_range,
|
||||||
|
"reclaim_speed": reclaim_speed,
|
||||||
|
"fail_back_into_range": fail_back,
|
||||||
|
"breakout_above_range": breakout_above,
|
||||||
|
"pullback_hold": pullback_hold,
|
||||||
|
"bar_range_atr": bar_range_atr,
|
||||||
|
"bounce_from_low": bounce_from_low,
|
||||||
|
"gap_up_pct": gap_up_pct,
|
||||||
|
"after_strength": after_strength,
|
||||||
|
"spring_score_hint": spring_score_hint,
|
||||||
|
"utad_score_hint": utad_score_hint,
|
||||||
|
"swing_high": swing_high,
|
||||||
|
"swing_low": swing_low,
|
||||||
|
"trade_date": str(frame.trade_dates[-1]) if frame.trade_dates else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Minimum bars before a timeframe is considered usable (no cross-TF borrow)
|
||||||
|
_MIN_BARS = {"1d": 40, "1w": 26, "1M": 18}
|
||||||
|
|
||||||
|
|
||||||
|
class FeatureEngine:
|
||||||
|
"""Pure Feature Engine — no database access."""
|
||||||
|
|
||||||
|
name = "Feature"
|
||||||
|
version = "1.0.0"
|
||||||
|
|
||||||
|
def run(self, frame: OHLCVFrame | None, timeframe: str | None = None) -> EngineResult:
|
||||||
|
tf = timeframe or (frame.timeframe if frame else "1d")
|
||||||
|
min_bars = _MIN_BARS.get(tf, 30)
|
||||||
|
|
||||||
|
if frame is None or frame.empty or len(frame) < min_bars:
|
||||||
|
bars = 0 if frame is None or frame.empty else len(frame)
|
||||||
|
return EngineResult(
|
||||||
|
name=self.name,
|
||||||
|
version=self.version,
|
||||||
|
confidence=10.0,
|
||||||
|
score=10.0,
|
||||||
|
reasons=[f"{tf} bars={bars} < min={min_bars},标记 insufficient"],
|
||||||
|
warnings=["insufficient_features"],
|
||||||
|
metrics={"bars": bars, "min_bars": min_bars},
|
||||||
|
payload={
|
||||||
|
"ts_code": getattr(frame, "ts_code", ""),
|
||||||
|
"timeframe": tf,
|
||||||
|
"bars": bars,
|
||||||
|
"insufficient": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
snap = compute_feature_snapshot(frame)
|
||||||
|
snap["insufficient"] = False
|
||||||
|
conf = 90.0 if snap.get("bars", 0) >= 60 else 50.0 + min(40.0, snap.get("bars", 0) * 0.5)
|
||||||
|
warnings = []
|
||||||
|
if snap.get("bars", 0) < 60:
|
||||||
|
warnings.append("bars偏少,特征可靠性中等")
|
||||||
|
return EngineResult(
|
||||||
|
name=self.name,
|
||||||
|
version=self.version,
|
||||||
|
confidence=conf,
|
||||||
|
score=conf,
|
||||||
|
reasons=[f"computed {snap.get('bars', 0)} bars {tf}"],
|
||||||
|
warnings=warnings,
|
||||||
|
metrics={"bars": snap.get("bars", 0)},
|
||||||
|
payload=snap,
|
||||||
|
)
|
||||||
@@ -0,0 +1,363 @@
|
|||||||
|
"""Paths + OHLCV cache + DATA_SERVICE fetch (crypto continuous calendar)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
import time
|
||||||
|
from datetime import date, datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Iterable
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from crypto_wyckoff.domain_models import OHLCVFrame
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
DATA_DIR = Path(os.environ.get("CRYPTO_WYCKOFF_DATA", str(_REPO_ROOT / "data" / "crypto_wyckoff")))
|
||||||
|
BARS_DB = DATA_DIR / "bars.sqlite"
|
||||||
|
SCAN_DB = DATA_DIR / "scan.sqlite"
|
||||||
|
|
||||||
|
DATA_SERVICE_URL = os.environ.get(
|
||||||
|
"DATA_SERVICE_URL",
|
||||||
|
os.environ.get("DATASVC_URL", "https://provider.jackyu66.com"),
|
||||||
|
).rstrip("/")
|
||||||
|
|
||||||
|
# Continuous crypto: bar counts (not A-share weekend-padded calendar multipliers)
|
||||||
|
# Provider has many TFs; 1M is resampled locally from daily UTC months.
|
||||||
|
LOOKBACK = {
|
||||||
|
"1h": 500,
|
||||||
|
"2h": 400,
|
||||||
|
"4h": 300,
|
||||||
|
"6h": 280,
|
||||||
|
"8h": 250,
|
||||||
|
"12h": 220,
|
||||||
|
"1d": 250,
|
||||||
|
"1w": 104,
|
||||||
|
"1M": 60,
|
||||||
|
}
|
||||||
|
# Default D/W/M stack (kept for compat); combos may request more TFs from provider.
|
||||||
|
TF_PROVIDER = ("1h", "4h", "8h", "1d", "1w")
|
||||||
|
TF_LIST = ("1d", "1w", "1M")
|
||||||
|
LOCAL_ONLY_TFS = frozenset({"1M"})
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_dirs() -> None:
|
||||||
|
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _symbol_key(symbol: str) -> str:
|
||||||
|
return symbol.replace("/", "_").replace(":", "_")
|
||||||
|
|
||||||
|
|
||||||
|
def _bars_conn() -> sqlite3.Connection:
|
||||||
|
ensure_dirs()
|
||||||
|
conn = sqlite3.connect(str(BARS_DB), timeout=60)
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS bars (
|
||||||
|
symbol TEXT NOT NULL,
|
||||||
|
tf TEXT NOT NULL,
|
||||||
|
ts INTEGER NOT NULL,
|
||||||
|
open REAL, high REAL, low REAL, close REAL, volume REAL,
|
||||||
|
PRIMARY KEY (symbol, tf, ts)
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_bars_sym_tf ON bars(symbol, tf)")
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_candles(
|
||||||
|
symbol: str,
|
||||||
|
tf: str,
|
||||||
|
*,
|
||||||
|
limit: int | None = None,
|
||||||
|
start_ms: int | None = None,
|
||||||
|
end_ms: int | None = None,
|
||||||
|
timeout: float = 15.0,
|
||||||
|
) -> list[dict]:
|
||||||
|
params: dict = {"symbol": symbol, "tf": tf}
|
||||||
|
if limit is not None:
|
||||||
|
params["limit"] = int(limit)
|
||||||
|
if start_ms is not None:
|
||||||
|
params["start"] = int(start_ms)
|
||||||
|
if end_ms is not None:
|
||||||
|
params["end"] = int(end_ms)
|
||||||
|
resp = requests.get(f"{DATA_SERVICE_URL}/api/candles", params=params, timeout=timeout)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
if not isinstance(data, list):
|
||||||
|
return []
|
||||||
|
out = []
|
||||||
|
for row in data:
|
||||||
|
try:
|
||||||
|
ts = int(float(row["timestamp"]))
|
||||||
|
out.append(
|
||||||
|
{
|
||||||
|
"ts": ts,
|
||||||
|
"open": float(row["open"]),
|
||||||
|
"high": float(row["high"]),
|
||||||
|
"low": float(row["low"]),
|
||||||
|
"close": float(row["close"]),
|
||||||
|
"volume": float(row.get("volume") or 0),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except (KeyError, TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
out.sort(key=lambda r: r["ts"])
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def upsert_bars(symbol: str, tf: str, rows: list[dict]) -> int:
|
||||||
|
if not rows:
|
||||||
|
return 0
|
||||||
|
conn = _bars_conn()
|
||||||
|
try:
|
||||||
|
conn.executemany(
|
||||||
|
"""
|
||||||
|
INSERT INTO bars(symbol, tf, ts, open, high, low, close, volume)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(symbol, tf, ts) DO UPDATE SET
|
||||||
|
open=excluded.open, high=excluded.high, low=excluded.low,
|
||||||
|
close=excluded.close, volume=excluded.volume
|
||||||
|
""",
|
||||||
|
[
|
||||||
|
(symbol, tf, r["ts"], r["open"], r["high"], r["low"], r["close"], r["volume"])
|
||||||
|
for r in rows
|
||||||
|
],
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return len(rows)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def is_intraday_tf(tf: str) -> bool:
|
||||||
|
"""True for minute/hour TFs that need clock time on charts."""
|
||||||
|
t = (tf or "").strip()
|
||||||
|
return t.endswith("m") or t.endswith("h")
|
||||||
|
|
||||||
|
|
||||||
|
def load_bars_with_ts(
|
||||||
|
symbol: str, tf: str, lookback: int | None = None
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Return OHLCV rows with UTC ms ts (for chart labels).
|
||||||
|
|
||||||
|
``datetime`` is wall-clock in Asia/Shanghai (UTC+8) for display.
|
||||||
|
"""
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
tz_cn = ZoneInfo("Asia/Shanghai")
|
||||||
|
if lookback is None:
|
||||||
|
try:
|
||||||
|
from crypto_wyckoff.combos import lookback_for
|
||||||
|
|
||||||
|
lookback = lookback_for(tf)
|
||||||
|
except Exception:
|
||||||
|
lookback = LOOKBACK.get(tf, 100)
|
||||||
|
lookback = lookback or LOOKBACK.get(tf, 100)
|
||||||
|
conn = _bars_conn()
|
||||||
|
try:
|
||||||
|
cur = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT ts, open, high, low, close, volume FROM bars
|
||||||
|
WHERE symbol=? AND tf=?
|
||||||
|
ORDER BY ts DESC LIMIT ?
|
||||||
|
""",
|
||||||
|
(symbol, tf, lookback),
|
||||||
|
)
|
||||||
|
rows = list(reversed(cur.fetchall()))
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
out = []
|
||||||
|
for ts, o, h, l, c, v in rows:
|
||||||
|
dt_utc = datetime.fromtimestamp(ts / 1000.0, tz=timezone.utc)
|
||||||
|
dt_cn = dt_utc.astimezone(tz_cn)
|
||||||
|
out.append(
|
||||||
|
{
|
||||||
|
"ts": int(ts),
|
||||||
|
"datetime": dt_cn.strftime("%Y-%m-%dT%H:%M:%S+08:00"),
|
||||||
|
"date": dt_cn.strftime("%Y-%m-%d"),
|
||||||
|
"open": o,
|
||||||
|
"high": h,
|
||||||
|
"low": l,
|
||||||
|
"close": c,
|
||||||
|
"volume": v,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def load_frame(symbol: str, tf: str, lookback: int | None = None) -> OHLCVFrame | None:
|
||||||
|
rows = load_bars_with_ts(symbol, tf, lookback)
|
||||||
|
if not rows:
|
||||||
|
return None
|
||||||
|
return OHLCVFrame(
|
||||||
|
ts_code=symbol,
|
||||||
|
timeframe=tf,
|
||||||
|
trade_dates=[
|
||||||
|
datetime.fromtimestamp(r["ts"] / 1000.0, tz=timezone.utc).date() for r in rows
|
||||||
|
],
|
||||||
|
open=[r["open"] for r in rows],
|
||||||
|
high=[r["high"] for r in rows],
|
||||||
|
low=[r["low"] for r in rows],
|
||||||
|
close=[r["close"] for r in rows],
|
||||||
|
volume=[r["volume"] for r in rows],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def bar_count(symbol: str, tf: str) -> int:
|
||||||
|
conn = _bars_conn()
|
||||||
|
try:
|
||||||
|
cur = conn.execute(
|
||||||
|
"SELECT COUNT(*) FROM bars WHERE symbol=? AND tf=?", (symbol, tf)
|
||||||
|
)
|
||||||
|
return int(cur.fetchone()[0])
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def rebuild_monthly_from_daily(symbol: str) -> int:
|
||||||
|
"""Aggregate UTC calendar-month OHLCV from local daily bars (provider has no 1M)."""
|
||||||
|
conn = _bars_conn()
|
||||||
|
try:
|
||||||
|
cur = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT ts, open, high, low, close, volume FROM bars
|
||||||
|
WHERE symbol=? AND tf='1d' ORDER BY ts ASC
|
||||||
|
""",
|
||||||
|
(symbol,),
|
||||||
|
)
|
||||||
|
daily = cur.fetchall()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
if not daily:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
months: dict[tuple[int, int], dict] = {}
|
||||||
|
for ts, o, h, l, c, v in daily:
|
||||||
|
dt = datetime.fromtimestamp(ts / 1000.0, tz=timezone.utc)
|
||||||
|
key = (dt.year, dt.month)
|
||||||
|
# month bar open timestamp = first day 00:00 UTC
|
||||||
|
month_ts = int(datetime(dt.year, dt.month, 1, tzinfo=timezone.utc).timestamp() * 1000)
|
||||||
|
if key not in months:
|
||||||
|
months[key] = {
|
||||||
|
"ts": month_ts,
|
||||||
|
"open": o,
|
||||||
|
"high": h,
|
||||||
|
"low": l,
|
||||||
|
"close": c,
|
||||||
|
"volume": v or 0.0,
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
m = months[key]
|
||||||
|
m["high"] = max(m["high"], h)
|
||||||
|
m["low"] = min(m["low"], l)
|
||||||
|
m["close"] = c
|
||||||
|
m["volume"] = (m["volume"] or 0) + (v or 0)
|
||||||
|
|
||||||
|
rows = sorted(months.values(), key=lambda r: r["ts"])
|
||||||
|
# drop stale months then upsert
|
||||||
|
conn = _bars_conn()
|
||||||
|
try:
|
||||||
|
conn.execute("DELETE FROM bars WHERE symbol=? AND tf='1M'", (symbol,))
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
return upsert_bars(symbol, "1M", rows)
|
||||||
|
|
||||||
|
|
||||||
|
def backfill_symbol(symbol: str, tfs: Iterable[str] = TF_LIST) -> dict:
|
||||||
|
"""Pull history for requested TFs; monthly derived from daily when needed."""
|
||||||
|
wanted = list(dict.fromkeys(tfs))
|
||||||
|
stats: dict = {}
|
||||||
|
need_monthly = "1M" in wanted
|
||||||
|
if need_monthly and "1d" not in wanted:
|
||||||
|
wanted = ["1d", *wanted]
|
||||||
|
|
||||||
|
for tf in wanted:
|
||||||
|
if tf in LOCAL_ONLY_TFS:
|
||||||
|
continue
|
||||||
|
need = LOOKBACK.get(tf, 100)
|
||||||
|
if tf == "1d" and need_monthly:
|
||||||
|
need = max(need, LOOKBACK["1M"] * 31)
|
||||||
|
try:
|
||||||
|
rows = fetch_candles(symbol, tf, limit=need)
|
||||||
|
n = upsert_bars(symbol, tf, rows)
|
||||||
|
stats[tf] = n
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("backfill %s %s failed: %s", symbol, tf, e)
|
||||||
|
stats[tf] = 0
|
||||||
|
time.sleep(0.05)
|
||||||
|
|
||||||
|
if need_monthly:
|
||||||
|
try:
|
||||||
|
stats["1M"] = rebuild_monthly_from_daily(symbol)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("monthly rebuild %s failed: %s", symbol, e)
|
||||||
|
stats["1M"] = 0
|
||||||
|
return stats
|
||||||
|
|
||||||
|
|
||||||
|
def tip_update_symbol(symbol: str, tfs: Iterable[str] = TF_LIST) -> bool:
|
||||||
|
"""Update forming tip bars (limit=3). Returns True if any bar changed."""
|
||||||
|
wanted = list(dict.fromkeys(tfs))
|
||||||
|
changed = False
|
||||||
|
for tf in wanted:
|
||||||
|
if tf in LOCAL_ONLY_TFS:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
rows = fetch_candles(symbol, tf, limit=3)
|
||||||
|
if not rows:
|
||||||
|
continue
|
||||||
|
before = _tip_fingerprint(symbol, tf)
|
||||||
|
upsert_bars(symbol, tf, rows)
|
||||||
|
after = _tip_fingerprint(symbol, tf)
|
||||||
|
if before != after:
|
||||||
|
changed = True
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug("tip %s %s: %s", symbol, tf, e)
|
||||||
|
time.sleep(0.02)
|
||||||
|
if "1M" in wanted:
|
||||||
|
before_m = _tip_fingerprint(symbol, "1M")
|
||||||
|
try:
|
||||||
|
rebuild_monthly_from_daily(symbol)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug("monthly tip %s: %s", symbol, e)
|
||||||
|
after_m = _tip_fingerprint(symbol, "1M")
|
||||||
|
if before_m != after_m:
|
||||||
|
changed = True
|
||||||
|
return changed
|
||||||
|
|
||||||
|
|
||||||
|
def _tip_fingerprint(symbol: str, tf: str) -> tuple | None:
|
||||||
|
conn = _bars_conn()
|
||||||
|
try:
|
||||||
|
cur = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT ts, open, high, low, close, volume FROM bars
|
||||||
|
WHERE symbol=? AND tf=? ORDER BY ts DESC LIMIT 1
|
||||||
|
""",
|
||||||
|
(symbol, tf),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
return tuple(row) if row else None
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_symbols_from_provider() -> list[str]:
|
||||||
|
try:
|
||||||
|
resp = requests.get(f"{DATA_SERVICE_URL}/health", timeout=8)
|
||||||
|
resp.raise_for_status()
|
||||||
|
payload = resp.json()
|
||||||
|
symbols = payload.get("symbols") or payload.get("symbol_list") or []
|
||||||
|
return [s for s in symbols if isinstance(s, str)]
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("health symbols failed: %s", e)
|
||||||
|
return []
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
"""Phase Engine — Phase A–E via Rule Registry."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from crypto_wyckoff.domain_models import EngineResult, WyckoffPhase
|
||||||
|
from crypto_wyckoff.rules.registry import rule_registry
|
||||||
|
|
||||||
|
|
||||||
|
class PhaseEngine:
|
||||||
|
name = "Phase"
|
||||||
|
version = "1.0.0"
|
||||||
|
|
||||||
|
def run(self, cycle: EngineResult, feature: EngineResult, timeframe: str) -> EngineResult:
|
||||||
|
if feature.payload.get("insufficient") or cycle.payload.get("cycle") == "Unknown":
|
||||||
|
return EngineResult(
|
||||||
|
name=self.name,
|
||||||
|
version=self.version,
|
||||||
|
confidence=20.0,
|
||||||
|
score=30.0,
|
||||||
|
reasons=["数据/周期不足,Phase=None"],
|
||||||
|
warnings=["insufficient_features"],
|
||||||
|
payload={
|
||||||
|
"phase": WyckoffPhase.NONE.value,
|
||||||
|
"timeframe": timeframe,
|
||||||
|
"cycle": cycle.payload.get("cycle"),
|
||||||
|
"structure_score": 30.0,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
context = {
|
||||||
|
"features": feature.payload,
|
||||||
|
"cycle": cycle.payload,
|
||||||
|
"timeframe": timeframe,
|
||||||
|
}
|
||||||
|
hits = []
|
||||||
|
for rule in rule_registry.by_category("phase", timeframe):
|
||||||
|
hit = rule.evaluate(context)
|
||||||
|
if hit and hit.phase:
|
||||||
|
hits.append(hit)
|
||||||
|
|
||||||
|
if not hits:
|
||||||
|
return EngineResult(
|
||||||
|
name=self.name,
|
||||||
|
version=self.version,
|
||||||
|
confidence=40.0,
|
||||||
|
score=cycle.score * 0.5,
|
||||||
|
reasons=["未识别明确 Phase"],
|
||||||
|
payload={
|
||||||
|
"phase": WyckoffPhase.NONE.value,
|
||||||
|
"timeframe": timeframe,
|
||||||
|
"cycle": cycle.payload.get("cycle"),
|
||||||
|
"structure_score": cycle.score * 0.5,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
best = max(hits, key=lambda h: h.confidence)
|
||||||
|
structure_score = best.score
|
||||||
|
# Phase D/E stronger structure
|
||||||
|
if best.phase in (WyckoffPhase.D.value, WyckoffPhase.E.value):
|
||||||
|
structure_score = max(structure_score, 80.0)
|
||||||
|
elif best.phase == WyckoffPhase.C.value:
|
||||||
|
structure_score = max(structure_score, 72.0)
|
||||||
|
|
||||||
|
return EngineResult(
|
||||||
|
name=self.name,
|
||||||
|
version=self.version,
|
||||||
|
confidence=best.confidence,
|
||||||
|
score=structure_score,
|
||||||
|
reasons=best.reasons,
|
||||||
|
metrics=best.metrics,
|
||||||
|
payload={
|
||||||
|
"phase": best.phase,
|
||||||
|
"timeframe": timeframe,
|
||||||
|
"cycle": cycle.payload.get("cycle"),
|
||||||
|
"rule_id": best.rule_id,
|
||||||
|
"structure_score": structure_score,
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
"""Scan pipeline: load local frames → engines → store (per TF combo)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from datetime import date, datetime, timezone
|
||||||
|
|
||||||
|
from crypto_wyckoff.combos import ROLE_HIGH, ROLE_LOW, ROLE_MID, get_combo, lookback_for
|
||||||
|
from crypto_wyckoff.cycle import CycleEngine
|
||||||
|
from crypto_wyckoff.decision import DecisionEngine
|
||||||
|
from crypto_wyckoff.domain_models import WyckoffScanRow
|
||||||
|
from crypto_wyckoff.event import EventEngine
|
||||||
|
from crypto_wyckoff.features import FeatureEngine
|
||||||
|
from crypto_wyckoff.io import load_frame
|
||||||
|
from crypto_wyckoff.phase import PhaseEngine
|
||||||
|
from crypto_wyckoff.plan import PlanEngine
|
||||||
|
from crypto_wyckoff.signal import SignalEngine
|
||||||
|
from crypto_wyckoff.store import upsert_row
|
||||||
|
from crypto_wyckoff.symbols_cn import display_name_cn
|
||||||
|
from crypto_wyckoff.version import WYCKOFF_ENGINE_VERSION
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def analyze_symbol(
|
||||||
|
low_frame,
|
||||||
|
mid_frame,
|
||||||
|
high_frame,
|
||||||
|
*,
|
||||||
|
feature_eng: FeatureEngine,
|
||||||
|
cycle_eng: CycleEngine,
|
||||||
|
phase_eng: PhaseEngine,
|
||||||
|
event_eng: EventEngine,
|
||||||
|
signal_eng: SignalEngine,
|
||||||
|
decision_eng: DecisionEngine,
|
||||||
|
plan_eng: PlanEngine,
|
||||||
|
) -> dict:
|
||||||
|
"""Run engines with D/W/M *role* aliases so existing rules match.
|
||||||
|
|
||||||
|
Frames may be any TF combo (e.g. 1h/4h/8h); rules still see 1d/1w/1M roles.
|
||||||
|
"""
|
||||||
|
f_d = feature_eng.run(low_frame, ROLE_LOW)
|
||||||
|
f_w = feature_eng.run(mid_frame, ROLE_MID)
|
||||||
|
f_m = feature_eng.run(high_frame, ROLE_HIGH)
|
||||||
|
|
||||||
|
c_m = cycle_eng.run(f_m, ROLE_HIGH)
|
||||||
|
c_w = cycle_eng.run(f_w, ROLE_MID)
|
||||||
|
|
||||||
|
p_w = phase_eng.run(c_w, f_w, ROLE_MID)
|
||||||
|
p_d = phase_eng.run(c_w, f_d, ROLE_LOW)
|
||||||
|
|
||||||
|
e_w = event_eng.run(c_w, p_w, f_w, ROLE_MID)
|
||||||
|
e_d = event_eng.run(c_w, p_d, f_d, ROLE_LOW)
|
||||||
|
|
||||||
|
s_d = signal_eng.run(e_d, p_d)
|
||||||
|
decision = decision_eng.run(c_m, c_w, p_w, e_w, e_d, s_d)
|
||||||
|
plan = plan_eng.run(f_d, decision)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"f_d": f_d, "f_w": f_w, "f_m": f_m,
|
||||||
|
"c_m": c_m, "c_w": c_w, "p_w": p_w,
|
||||||
|
"e_w": e_w, "e_d": e_d, "s_d": s_d,
|
||||||
|
"decision": decision, "plan": plan,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _to_row(
|
||||||
|
trade_date: date,
|
||||||
|
symbol: str,
|
||||||
|
result: dict,
|
||||||
|
*,
|
||||||
|
combo_id: str,
|
||||||
|
combo_label: str,
|
||||||
|
) -> WyckoffScanRow:
|
||||||
|
d = result["decision"]
|
||||||
|
p = result["plan"]
|
||||||
|
c_m, c_w, p_w = result["c_m"], result["c_w"], result["p_w"]
|
||||||
|
e_w, e_d, s_d = result["e_w"], result["e_d"], result["s_d"]
|
||||||
|
f_d, f_w, f_m = result["f_d"], result["f_w"], result["f_m"]
|
||||||
|
|
||||||
|
snapshot = {
|
||||||
|
"combo_id": combo_id,
|
||||||
|
"combo_label": combo_label,
|
||||||
|
"daily": {k: f_d.payload.get(k) for k in (
|
||||||
|
"ma20", "ma60", "ma120", "atr", "adx", "volume_ratio",
|
||||||
|
"range_high", "range_low", "swing_high", "swing_low", "close",
|
||||||
|
)},
|
||||||
|
"weekly": {k: f_w.payload.get(k) for k in ("ma20", "ma60", "adx", "close")},
|
||||||
|
"monthly": {k: f_m.payload.get(k) for k in ("ma20", "ma60", "adx", "close")},
|
||||||
|
}
|
||||||
|
markers = []
|
||||||
|
for key, typ in (("entry", "entry"), ("stop", "stop"), ("target1", "target1"), ("target2", "target2")):
|
||||||
|
if p.payload.get(key) is not None:
|
||||||
|
markers.append({"type": typ, "price": p.payload[key]})
|
||||||
|
|
||||||
|
return WyckoffScanRow(
|
||||||
|
trade_date=trade_date,
|
||||||
|
ts_code=symbol,
|
||||||
|
name=display_name_cn(symbol),
|
||||||
|
industry="crypto",
|
||||||
|
engine_version=WYCKOFF_ENGINE_VERSION,
|
||||||
|
m_cycle=c_m.payload.get("cycle", "Unknown"),
|
||||||
|
cycle_confidence=c_m.confidence,
|
||||||
|
trend_score=float(d.payload.get("trend_score", c_m.score)),
|
||||||
|
w_cycle=c_w.payload.get("cycle", "Unknown"),
|
||||||
|
w_phase=p_w.payload.get("phase", "None"),
|
||||||
|
w_current_event=e_w.payload.get("current_event", "None"),
|
||||||
|
w_recent_events_json=json.dumps(
|
||||||
|
e_w.payload.get("active_events") or e_w.payload.get("recent_events") or [],
|
||||||
|
ensure_ascii=False,
|
||||||
|
),
|
||||||
|
phase_confidence=p_w.confidence,
|
||||||
|
structure_score=float(d.payload.get("structure_score", p_w.score)),
|
||||||
|
d_current_event=e_d.payload.get("current_event", "None"),
|
||||||
|
d_recent_events_json=json.dumps(
|
||||||
|
e_d.payload.get("active_events") or e_d.payload.get("recent_events") or [],
|
||||||
|
ensure_ascii=False,
|
||||||
|
),
|
||||||
|
event_confidence=e_d.confidence,
|
||||||
|
entry_score=float(d.payload.get("entry_score", e_d.score)),
|
||||||
|
entry=p.payload.get("entry"),
|
||||||
|
stop=p.payload.get("stop"),
|
||||||
|
target1=p.payload.get("target1"),
|
||||||
|
target2=p.payload.get("target2"),
|
||||||
|
rr=p.payload.get("rr"),
|
||||||
|
alignment=float(d.payload.get("alignment", 0)),
|
||||||
|
stars=int(d.payload.get("stars", 1)),
|
||||||
|
decision_signal=d.payload.get("decision_signal", "Watch"),
|
||||||
|
signal_confidence=s_d.confidence,
|
||||||
|
overall_confidence=float(d.payload.get("overall_confidence", d.confidence)),
|
||||||
|
overall_score=float(d.payload.get("overall_score", d.score)),
|
||||||
|
risk=d.payload.get("risk", "Medium"),
|
||||||
|
reasons_json=json.dumps(d.reasons + d.warnings, ensure_ascii=False),
|
||||||
|
feature_snapshot_json=json.dumps(snapshot, ensure_ascii=False),
|
||||||
|
markers_json=json.dumps(markers, ensure_ascii=False),
|
||||||
|
scanned_at=datetime.now(timezone.utc),
|
||||||
|
combo_id=combo_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_ENGINES = None
|
||||||
|
|
||||||
|
|
||||||
|
def _engines():
|
||||||
|
global _ENGINES
|
||||||
|
if _ENGINES is None:
|
||||||
|
_ENGINES = {
|
||||||
|
"feature_eng": FeatureEngine(),
|
||||||
|
"cycle_eng": CycleEngine(),
|
||||||
|
"phase_eng": PhaseEngine(),
|
||||||
|
"event_eng": EventEngine(),
|
||||||
|
"signal_eng": SignalEngine(),
|
||||||
|
"decision_eng": DecisionEngine(),
|
||||||
|
"plan_eng": PlanEngine(),
|
||||||
|
}
|
||||||
|
return _ENGINES
|
||||||
|
|
||||||
|
|
||||||
|
def analyze_and_store(
|
||||||
|
symbol: str,
|
||||||
|
trade_date: date | None = None,
|
||||||
|
*,
|
||||||
|
combo_id: str | None = None,
|
||||||
|
) -> WyckoffScanRow | None:
|
||||||
|
eng = _engines()
|
||||||
|
combo = get_combo(combo_id)
|
||||||
|
low_tf, mid_tf, high_tf = combo["low"], combo["mid"], combo["high"]
|
||||||
|
|
||||||
|
low = load_frame(symbol, low_tf, lookback_for(low_tf))
|
||||||
|
mid = load_frame(symbol, mid_tf, lookback_for(mid_tf))
|
||||||
|
high = load_frame(symbol, high_tf, lookback_for(high_tf))
|
||||||
|
if low is None or len(low) < 40:
|
||||||
|
return None
|
||||||
|
result = analyze_symbol(low, mid, high, **eng)
|
||||||
|
td = trade_date or (
|
||||||
|
low.trade_dates[-1] if low.trade_dates else datetime.now(timezone.utc).date()
|
||||||
|
)
|
||||||
|
row = _to_row(td, symbol, result, combo_id=combo["id"], combo_label=combo["label"])
|
||||||
|
upsert_row(row)
|
||||||
|
return row
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
"""Plan Engine — Entry / Stop / Target / RR only when Decision is tradable."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from crypto_wyckoff.domain_models import DecisionSignal, EngineResult
|
||||||
|
|
||||||
|
|
||||||
|
_TRADABLE = {
|
||||||
|
DecisionSignal.STRONG_BUY.value,
|
||||||
|
DecisionSignal.BUY.value,
|
||||||
|
DecisionSignal.SELL.value,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class PlanEngine:
|
||||||
|
name = "Plan"
|
||||||
|
version = "1.0.0"
|
||||||
|
|
||||||
|
def run(self, daily_feature: EngineResult, decision: EngineResult) -> EngineResult:
|
||||||
|
f = daily_feature.payload
|
||||||
|
close = float(f.get("close") or 0)
|
||||||
|
atr = float(f.get("atr") or 0) or close * 0.02
|
||||||
|
swing_low = float(f.get("swing_low") or close - 2 * atr)
|
||||||
|
swing_high = float(f.get("swing_high") or close + 2 * atr)
|
||||||
|
range_high = float(f.get("range_high") or swing_high)
|
||||||
|
signal = decision.payload.get("decision_signal", DecisionSignal.WATCH.value)
|
||||||
|
|
||||||
|
entry = stop = t1 = t2 = rr = None
|
||||||
|
reasons: list[str] = []
|
||||||
|
|
||||||
|
if signal not in _TRADABLE or close <= 0:
|
||||||
|
reasons.append(f"无交易计划(信号={signal})")
|
||||||
|
return EngineResult(
|
||||||
|
name=self.name,
|
||||||
|
version=self.version,
|
||||||
|
confidence=decision.confidence,
|
||||||
|
score=decision.score,
|
||||||
|
reasons=reasons,
|
||||||
|
payload={
|
||||||
|
"entry": None,
|
||||||
|
"stop": None,
|
||||||
|
"target1": None,
|
||||||
|
"target2": None,
|
||||||
|
"rr": None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if signal in (DecisionSignal.STRONG_BUY.value, DecisionSignal.BUY.value):
|
||||||
|
entry = round(close, 4)
|
||||||
|
stop = round(min(swing_low, close - 1.5 * atr), 4)
|
||||||
|
risk = max(entry - stop, 1e-6)
|
||||||
|
t1 = round(entry + 2.0 * risk, 4)
|
||||||
|
t2 = round(max(range_high, entry + 3.0 * risk), 4)
|
||||||
|
rr = round((t1 - entry) / risk, 2)
|
||||||
|
reasons.append(f"入场={entry} 止损={stop} 目标一={t1} 盈亏比={rr}")
|
||||||
|
else: # Sell
|
||||||
|
entry = round(close, 4)
|
||||||
|
stop = round(max(swing_high, close + 1.5 * atr), 4)
|
||||||
|
risk = max(stop - entry, 1e-6)
|
||||||
|
t1 = round(entry - 2.0 * risk, 4)
|
||||||
|
t2 = round(entry - 3.0 * risk, 4)
|
||||||
|
rr = round((entry - t1) / risk, 2)
|
||||||
|
reasons.append(f"做空计划 入场={entry} 止损={stop} 目标一={t1}")
|
||||||
|
|
||||||
|
return EngineResult(
|
||||||
|
name=self.name,
|
||||||
|
version=self.version,
|
||||||
|
confidence=decision.confidence,
|
||||||
|
score=decision.score,
|
||||||
|
reasons=reasons,
|
||||||
|
payload={
|
||||||
|
"entry": entry,
|
||||||
|
"stop": stop,
|
||||||
|
"target1": t1,
|
||||||
|
"target2": t2,
|
||||||
|
"rr": rr,
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from crypto_wyckoff.rules.registry import rule_registry
|
||||||
|
|
||||||
|
__all__ = ["rule_registry"]
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
"""Rule protocol for Wyckoff Rule Registry."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RuleHit:
|
||||||
|
"""A single rule match."""
|
||||||
|
|
||||||
|
rule_id: str
|
||||||
|
event: str | None = None
|
||||||
|
phase: str | None = None
|
||||||
|
cycle: str | None = None
|
||||||
|
confidence: float = 0.0
|
||||||
|
score: float = 0.0
|
||||||
|
reasons: list[str] = field(default_factory=list)
|
||||||
|
metrics: dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class WyckoffRule(ABC):
|
||||||
|
"""Pluggable rule. Engines iterate registry; never hardcode rule lists."""
|
||||||
|
|
||||||
|
rule_id: str
|
||||||
|
category: str # cycle | phase | event
|
||||||
|
timeframes: tuple[str, ...] = ("1d", "1w", "1M")
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def evaluate(self, context: dict[str, Any]) -> RuleHit | None:
|
||||||
|
"""Return RuleHit if matched, else None. Pure — no I/O."""
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
"""Cycle classification rules (monthly / weekly)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from crypto_wyckoff.domain_models import WyckoffCycle
|
||||||
|
from crypto_wyckoff.rules.base import RuleHit, WyckoffRule
|
||||||
|
|
||||||
|
|
||||||
|
def _f(ctx: dict[str, Any], key: str, default: float = 0.0) -> float:
|
||||||
|
v = ctx.get("features", {}).get(key, default)
|
||||||
|
try:
|
||||||
|
return float(v) if v is not None else default
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
class MarkupCycleRule(WyckoffRule):
|
||||||
|
rule_id = "cycle_markup"
|
||||||
|
category = "cycle"
|
||||||
|
timeframes = ("1M", "1w")
|
||||||
|
|
||||||
|
def evaluate(self, context: dict[str, Any]) -> RuleHit | None:
|
||||||
|
close = _f(context, "close")
|
||||||
|
ma20 = _f(context, "ma20")
|
||||||
|
ma60 = _f(context, "ma60")
|
||||||
|
ma120 = _f(context, "ma120")
|
||||||
|
adx = _f(context, "adx")
|
||||||
|
slope = _f(context, "ma60_slope")
|
||||||
|
if close > ma20 > ma60 and (ma60 >= ma120 or slope > 0) and adx >= 18:
|
||||||
|
conf = min(95.0, 55 + adx + (10 if close > ma120 else 0))
|
||||||
|
return RuleHit(
|
||||||
|
rule_id=self.rule_id,
|
||||||
|
cycle=WyckoffCycle.MARKUP.value,
|
||||||
|
confidence=conf,
|
||||||
|
score=conf,
|
||||||
|
reasons=["价格位于均线多头排列", f"ADX={adx:.1f}"],
|
||||||
|
metrics={"adx": adx, "slope": slope},
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class MarkdownCycleRule(WyckoffRule):
|
||||||
|
rule_id = "cycle_markdown"
|
||||||
|
category = "cycle"
|
||||||
|
timeframes = ("1M", "1w")
|
||||||
|
|
||||||
|
def evaluate(self, context: dict[str, Any]) -> RuleHit | None:
|
||||||
|
close = _f(context, "close")
|
||||||
|
ma20 = _f(context, "ma20")
|
||||||
|
ma60 = _f(context, "ma60")
|
||||||
|
ma120 = _f(context, "ma120")
|
||||||
|
adx = _f(context, "adx")
|
||||||
|
slope = _f(context, "ma60_slope")
|
||||||
|
if close < ma20 < ma60 and (ma60 <= ma120 or slope < 0) and adx >= 18:
|
||||||
|
conf = min(95.0, 55 + adx + (10 if close < ma120 else 0))
|
||||||
|
return RuleHit(
|
||||||
|
rule_id=self.rule_id,
|
||||||
|
cycle=WyckoffCycle.MARKDOWN.value,
|
||||||
|
confidence=conf,
|
||||||
|
score=conf,
|
||||||
|
reasons=["价格位于均线空头排列", f"ADX={adx:.1f}"],
|
||||||
|
metrics={"adx": adx},
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class AccumulationCycleRule(WyckoffRule):
|
||||||
|
rule_id = "cycle_accumulation"
|
||||||
|
category = "cycle"
|
||||||
|
timeframes = ("1M", "1w")
|
||||||
|
|
||||||
|
def evaluate(self, context: dict[str, Any]) -> RuleHit | None:
|
||||||
|
adx = _f(context, "adx")
|
||||||
|
range_pct = _f(context, "range_pct_60")
|
||||||
|
close = _f(context, "close")
|
||||||
|
ma120 = _f(context, "ma120")
|
||||||
|
vol_trend = _f(context, "volume_trend")
|
||||||
|
# Range-bound after decline: strictly at/below MA120 (mutually exclusive vs Distribution)
|
||||||
|
if adx < 22 and range_pct < 0.28 and close <= ma120:
|
||||||
|
conf = 60 + (10 if vol_trend > 0 else 0) + (10 if close < ma120 else 0)
|
||||||
|
return RuleHit(
|
||||||
|
rule_id=self.rule_id,
|
||||||
|
cycle=WyckoffCycle.ACCUMULATION.value,
|
||||||
|
confidence=min(90.0, conf),
|
||||||
|
score=min(90.0, conf),
|
||||||
|
reasons=["低趋势强度区间震荡", "疑似吸筹区间"],
|
||||||
|
metrics={"adx": adx, "range_pct_60": range_pct},
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class DistributionCycleRule(WyckoffRule):
|
||||||
|
rule_id = "cycle_distribution"
|
||||||
|
category = "cycle"
|
||||||
|
timeframes = ("1M", "1w")
|
||||||
|
|
||||||
|
def evaluate(self, context: dict[str, Any]) -> RuleHit | None:
|
||||||
|
adx = _f(context, "adx")
|
||||||
|
range_pct = _f(context, "range_pct_60")
|
||||||
|
close = _f(context, "close")
|
||||||
|
ma120 = _f(context, "ma120")
|
||||||
|
vol_trend = _f(context, "volume_trend")
|
||||||
|
# Range-bound near highs: strictly above MA120 (mutually exclusive vs Accumulation)
|
||||||
|
if adx < 22 and range_pct < 0.28 and close > ma120:
|
||||||
|
conf = 60 + (10 if vol_trend < 0 else 0) + (10 if close > ma120 else 0)
|
||||||
|
return RuleHit(
|
||||||
|
rule_id=self.rule_id,
|
||||||
|
cycle=WyckoffCycle.DISTRIBUTION.value,
|
||||||
|
confidence=min(90.0, conf),
|
||||||
|
score=min(90.0, conf),
|
||||||
|
reasons=["高位低趋势震荡", "疑似派发区间"],
|
||||||
|
metrics={"adx": adx, "range_pct_60": range_pct},
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def build_rules() -> list[WyckoffRule]:
|
||||||
|
# Order: trend cycles first (more decisive), then range cycles
|
||||||
|
return [
|
||||||
|
MarkupCycleRule(),
|
||||||
|
MarkdownCycleRule(),
|
||||||
|
AccumulationCycleRule(),
|
||||||
|
DistributionCycleRule(),
|
||||||
|
]
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
"""Event rules: Spring/SOS/LPS/UTAD/SC/AR/ST/..."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from crypto_wyckoff.domain_models import WyckoffCycle, WyckoffEvent, WyckoffPhase
|
||||||
|
from crypto_wyckoff.rules.base import RuleHit, WyckoffRule
|
||||||
|
|
||||||
|
|
||||||
|
def _f(ctx: dict[str, Any], key: str, default: float = 0.0) -> float:
|
||||||
|
v = ctx.get("features", {}).get(key, default)
|
||||||
|
try:
|
||||||
|
return float(v) if v is not None else default
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def _cycle(ctx: dict[str, Any]) -> str:
|
||||||
|
return (ctx.get("cycle") or {}).get("cycle") or ""
|
||||||
|
|
||||||
|
|
||||||
|
def _phase(ctx: dict[str, Any]) -> str:
|
||||||
|
return (ctx.get("phase") or {}).get("phase") or ""
|
||||||
|
|
||||||
|
|
||||||
|
class SpringRule(WyckoffRule):
|
||||||
|
rule_id = "event_spring"
|
||||||
|
category = "event"
|
||||||
|
timeframes = ("1d",)
|
||||||
|
|
||||||
|
def evaluate(self, context: dict[str, Any]) -> RuleHit | None:
|
||||||
|
cycle = _cycle(context)
|
||||||
|
if cycle not in (WyckoffCycle.ACCUMULATION.value, WyckoffCycle.RE_ACCUMULATION.value,
|
||||||
|
WyckoffCycle.MARKUP.value):
|
||||||
|
# Allow spring only in accumulative contexts; Decision will filter MTF
|
||||||
|
if cycle == WyckoffCycle.DISTRIBUTION.value:
|
||||||
|
pass # still detect for facts but lower confidence
|
||||||
|
pierce = _f(context, "pierce_below_range")
|
||||||
|
reclaim = _f(context, "reclaim_speed")
|
||||||
|
vol_ratio = _f(context, "volume_ratio")
|
||||||
|
close_in_range = _f(context, "close_back_in_range")
|
||||||
|
if pierce >= 0.002 and close_in_range >= 0.5 and reclaim >= 0.3:
|
||||||
|
strength = min(98.0, 50 + pierce * 2000 + reclaim * 20 + (15 if vol_ratio < 1.2 else 5))
|
||||||
|
return RuleHit(
|
||||||
|
rule_id=self.rule_id,
|
||||||
|
event=WyckoffEvent.SPRING.value,
|
||||||
|
confidence=strength,
|
||||||
|
score=strength,
|
||||||
|
reasons=[
|
||||||
|
f"跌破区间后收回 (pierce={pierce:.3%})",
|
||||||
|
f"回收速度={reclaim:.2f}",
|
||||||
|
f"量比={vol_ratio:.2f}",
|
||||||
|
],
|
||||||
|
metrics={"pierce": pierce, "reclaim": reclaim, "volume_ratio": vol_ratio},
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class TestRule(WyckoffRule):
|
||||||
|
rule_id = "event_test"
|
||||||
|
category = "event"
|
||||||
|
timeframes = ("1d", "1w")
|
||||||
|
|
||||||
|
def evaluate(self, context: dict[str, Any]) -> RuleHit | None:
|
||||||
|
pos = _f(context, "range_position")
|
||||||
|
vol_ratio = _f(context, "volume_ratio")
|
||||||
|
near_low = pos < 0.2
|
||||||
|
if near_low and vol_ratio < 0.85:
|
||||||
|
return RuleHit(
|
||||||
|
rule_id=self.rule_id,
|
||||||
|
event=WyckoffEvent.TEST.value,
|
||||||
|
confidence=68.0,
|
||||||
|
score=65.0,
|
||||||
|
reasons=["低位缩量回测"],
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class SOSRule(WyckoffRule):
|
||||||
|
rule_id = "event_sos"
|
||||||
|
category = "event"
|
||||||
|
timeframes = ("1d", "1w")
|
||||||
|
|
||||||
|
def evaluate(self, context: dict[str, Any]) -> RuleHit | None:
|
||||||
|
breakout = _f(context, "breakout_above_range")
|
||||||
|
vol_ratio = _f(context, "volume_ratio")
|
||||||
|
close = _f(context, "close")
|
||||||
|
ma20 = _f(context, "ma20")
|
||||||
|
if breakout >= 0.0 and vol_ratio >= 1.2 and close > ma20:
|
||||||
|
conf = min(95.0, 70 + vol_ratio * 8)
|
||||||
|
return RuleHit(
|
||||||
|
rule_id=self.rule_id,
|
||||||
|
event=WyckoffEvent.SOS.value,
|
||||||
|
confidence=conf,
|
||||||
|
score=conf,
|
||||||
|
reasons=["放量突破区间上沿 (SOS)"],
|
||||||
|
metrics={"vol_ratio": vol_ratio},
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class LPSRule(WyckoffRule):
|
||||||
|
rule_id = "event_lps"
|
||||||
|
category = "event"
|
||||||
|
timeframes = ("1d", "1w")
|
||||||
|
|
||||||
|
def evaluate(self, context: dict[str, Any]) -> RuleHit | None:
|
||||||
|
# Pullback hold above broken range / MA20 after prior strength
|
||||||
|
pullback = _f(context, "pullback_hold")
|
||||||
|
vol_ratio = _f(context, "volume_ratio")
|
||||||
|
above_ma = _f(context, "close") > _f(context, "ma20")
|
||||||
|
if pullback >= 0.5 and above_ma and vol_ratio <= 1.1:
|
||||||
|
return RuleHit(
|
||||||
|
rule_id=self.rule_id,
|
||||||
|
event=WyckoffEvent.LPS.value,
|
||||||
|
confidence=74.0,
|
||||||
|
score=76.0,
|
||||||
|
reasons=["突破后缩量回踩支撑 (LPS)"],
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class SCRule(WyckoffRule):
|
||||||
|
rule_id = "event_sc"
|
||||||
|
category = "event"
|
||||||
|
timeframes = ("1w", "1d")
|
||||||
|
|
||||||
|
def evaluate(self, context: dict[str, Any]) -> RuleHit | None:
|
||||||
|
vol_ratio = _f(context, "volume_ratio")
|
||||||
|
bar_range = _f(context, "bar_range_atr")
|
||||||
|
pos = _f(context, "range_position")
|
||||||
|
if vol_ratio >= 1.8 and bar_range >= 1.5 and pos < 0.35:
|
||||||
|
return RuleHit(
|
||||||
|
rule_id=self.rule_id,
|
||||||
|
event=WyckoffEvent.SC.value,
|
||||||
|
confidence=72.0,
|
||||||
|
score=70.0,
|
||||||
|
reasons=["低位放量宽幅,疑似 Selling Climax"],
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class ARRule(WyckoffRule):
|
||||||
|
rule_id = "event_ar"
|
||||||
|
category = "event"
|
||||||
|
timeframes = ("1w", "1d")
|
||||||
|
|
||||||
|
def evaluate(self, context: dict[str, Any]) -> RuleHit | None:
|
||||||
|
# Automatic rally: bounce from lows
|
||||||
|
bounce = _f(context, "bounce_from_low")
|
||||||
|
if bounce >= 0.04:
|
||||||
|
return RuleHit(
|
||||||
|
rule_id=self.rule_id,
|
||||||
|
event=WyckoffEvent.AR.value,
|
||||||
|
confidence=65.0,
|
||||||
|
score=62.0,
|
||||||
|
reasons=["低点后自动反弹 (AR)"],
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class STRule(WyckoffRule):
|
||||||
|
rule_id = "event_st"
|
||||||
|
category = "event"
|
||||||
|
timeframes = ("1w", "1d")
|
||||||
|
|
||||||
|
def evaluate(self, context: dict[str, Any]) -> RuleHit | None:
|
||||||
|
pos = _f(context, "range_position")
|
||||||
|
vol_ratio = _f(context, "volume_ratio")
|
||||||
|
if 0.15 < pos < 0.45 and vol_ratio < 1.0:
|
||||||
|
return RuleHit(
|
||||||
|
rule_id=self.rule_id,
|
||||||
|
event=WyckoffEvent.ST.value,
|
||||||
|
confidence=60.0,
|
||||||
|
score=58.0,
|
||||||
|
reasons=["次级测试 (ST)"],
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class UTADRule(WyckoffRule):
|
||||||
|
rule_id = "event_utad"
|
||||||
|
category = "event"
|
||||||
|
timeframes = ("1w", "1d")
|
||||||
|
|
||||||
|
def evaluate(self, context: dict[str, Any]) -> RuleHit | None:
|
||||||
|
cycle = _cycle(context)
|
||||||
|
pierce_up = _f(context, "pierce_above_range")
|
||||||
|
fail = _f(context, "fail_back_into_range")
|
||||||
|
if cycle in (WyckoffCycle.DISTRIBUTION.value, WyckoffCycle.RE_DISTRIBUTION.value,
|
||||||
|
WyckoffCycle.MARKUP.value):
|
||||||
|
if pierce_up >= 0.002 and fail >= 0.5:
|
||||||
|
return RuleHit(
|
||||||
|
rule_id=self.rule_id,
|
||||||
|
event=WyckoffEvent.UTAD.value,
|
||||||
|
confidence=76.0,
|
||||||
|
score=74.0,
|
||||||
|
reasons=["冲高失败回到区间 (UTAD)"],
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class JumpRule(WyckoffRule):
|
||||||
|
rule_id = "event_jump"
|
||||||
|
category = "event"
|
||||||
|
timeframes = ("1d",)
|
||||||
|
|
||||||
|
def evaluate(self, context: dict[str, Any]) -> RuleHit | None:
|
||||||
|
gap = _f(context, "gap_up_pct")
|
||||||
|
vol_ratio = _f(context, "volume_ratio")
|
||||||
|
if gap >= 0.03 and vol_ratio >= 1.3:
|
||||||
|
return RuleHit(
|
||||||
|
rule_id=self.rule_id,
|
||||||
|
event=WyckoffEvent.JUMP.value,
|
||||||
|
confidence=70.0,
|
||||||
|
score=72.0,
|
||||||
|
reasons=["放量向上跳跃 (Jump)"],
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class BackupRule(WyckoffRule):
|
||||||
|
rule_id = "event_backup"
|
||||||
|
category = "event"
|
||||||
|
timeframes = ("1d",)
|
||||||
|
|
||||||
|
def evaluate(self, context: dict[str, Any]) -> RuleHit | None:
|
||||||
|
pullback = _f(context, "pullback_hold")
|
||||||
|
after_jump = _f(context, "after_strength")
|
||||||
|
if after_jump >= 0.5 and pullback >= 0.5:
|
||||||
|
return RuleHit(
|
||||||
|
rule_id=self.rule_id,
|
||||||
|
event=WyckoffEvent.BACKUP.value,
|
||||||
|
confidence=68.0,
|
||||||
|
score=70.0,
|
||||||
|
reasons=["跳跃后回踩 (Backup)"],
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def build_rules() -> list[WyckoffRule]:
|
||||||
|
return [
|
||||||
|
SpringRule(),
|
||||||
|
UTADRule(),
|
||||||
|
SOSRule(),
|
||||||
|
LPSRule(),
|
||||||
|
SCRule(),
|
||||||
|
JumpRule(),
|
||||||
|
BackupRule(),
|
||||||
|
TestRule(),
|
||||||
|
ARRule(),
|
||||||
|
STRule(),
|
||||||
|
]
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
"""Phase A–E rules (primarily weekly)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from crypto_wyckoff.domain_models import WyckoffCycle, WyckoffPhase
|
||||||
|
from crypto_wyckoff.rules.base import RuleHit, WyckoffRule
|
||||||
|
|
||||||
|
|
||||||
|
def _f(ctx: dict[str, Any], key: str, default: float = 0.0) -> float:
|
||||||
|
v = ctx.get("features", {}).get(key, default)
|
||||||
|
try:
|
||||||
|
return float(v) if v is not None else default
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def _cycle(ctx: dict[str, Any]) -> str:
|
||||||
|
return (ctx.get("cycle") or {}).get("cycle") or WyckoffCycle.UNKNOWN.value
|
||||||
|
|
||||||
|
|
||||||
|
class PhaseARule(WyckoffRule):
|
||||||
|
rule_id = "phase_a"
|
||||||
|
category = "phase"
|
||||||
|
timeframes = ("1w", "1d")
|
||||||
|
|
||||||
|
def evaluate(self, context: dict[str, Any]) -> RuleHit | None:
|
||||||
|
cycle = _cycle(context)
|
||||||
|
if cycle not in (WyckoffCycle.ACCUMULATION.value, WyckoffCycle.DISTRIBUTION.value,
|
||||||
|
WyckoffCycle.RE_ACCUMULATION.value, WyckoffCycle.RE_DISTRIBUTION.value):
|
||||||
|
return None
|
||||||
|
# Stopping action: high vol + large range recently, still range-bound
|
||||||
|
vol_ratio = _f(context, "volume_ratio")
|
||||||
|
range_last = _f(context, "bar_range_atr")
|
||||||
|
if vol_ratio >= 1.4 and range_last >= 1.2:
|
||||||
|
return RuleHit(
|
||||||
|
rule_id=self.rule_id,
|
||||||
|
phase=WyckoffPhase.A.value,
|
||||||
|
confidence=70.0,
|
||||||
|
score=65.0,
|
||||||
|
reasons=["放量宽幅波动,疑似 Phase A 停止行为"],
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class PhaseBRule(WyckoffRule):
|
||||||
|
rule_id = "phase_b"
|
||||||
|
category = "phase"
|
||||||
|
timeframes = ("1w", "1d")
|
||||||
|
|
||||||
|
def evaluate(self, context: dict[str, Any]) -> RuleHit | None:
|
||||||
|
cycle = _cycle(context)
|
||||||
|
if cycle not in (WyckoffCycle.ACCUMULATION.value, WyckoffCycle.DISTRIBUTION.value):
|
||||||
|
return None
|
||||||
|
adx = _f(context, "adx")
|
||||||
|
range_pct = _f(context, "range_pct_60")
|
||||||
|
pos = _f(context, "range_position") # 0=low 1=high of range
|
||||||
|
if adx < 20 and 0.25 < pos < 0.75 and range_pct < 0.30:
|
||||||
|
return RuleHit(
|
||||||
|
rule_id=self.rule_id,
|
||||||
|
phase=WyckoffPhase.B.value,
|
||||||
|
confidence=72.0,
|
||||||
|
score=68.0,
|
||||||
|
reasons=["区间中部震荡,疑似 Phase B 建仓/派发"],
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class PhaseCRule(WyckoffRule):
|
||||||
|
rule_id = "phase_c"
|
||||||
|
category = "phase"
|
||||||
|
timeframes = ("1w", "1d")
|
||||||
|
|
||||||
|
def evaluate(self, context: dict[str, Any]) -> RuleHit | None:
|
||||||
|
cycle = _cycle(context)
|
||||||
|
pos = _f(context, "range_position")
|
||||||
|
spring_like = _f(context, "spring_score_hint")
|
||||||
|
utad_like = _f(context, "utad_score_hint")
|
||||||
|
if cycle in (WyckoffCycle.ACCUMULATION.value, WyckoffCycle.RE_ACCUMULATION.value):
|
||||||
|
if pos < 0.25 or spring_like >= 50:
|
||||||
|
return RuleHit(
|
||||||
|
rule_id=self.rule_id,
|
||||||
|
phase=WyckoffPhase.C.value,
|
||||||
|
confidence=75.0 + min(15.0, spring_like * 0.15),
|
||||||
|
score=78.0,
|
||||||
|
reasons=["区间低位测试,疑似 Phase C (Spring/Test)"],
|
||||||
|
)
|
||||||
|
if cycle in (WyckoffCycle.DISTRIBUTION.value, WyckoffCycle.RE_DISTRIBUTION.value):
|
||||||
|
if pos > 0.75 or utad_like >= 50:
|
||||||
|
return RuleHit(
|
||||||
|
rule_id=self.rule_id,
|
||||||
|
phase=WyckoffPhase.C.value,
|
||||||
|
confidence=75.0,
|
||||||
|
score=78.0,
|
||||||
|
reasons=["区间高位测试,疑似 Phase C (UTAD)"],
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class PhaseDRule(WyckoffRule):
|
||||||
|
rule_id = "phase_d"
|
||||||
|
category = "phase"
|
||||||
|
timeframes = ("1w", "1d")
|
||||||
|
|
||||||
|
def evaluate(self, context: dict[str, Any]) -> RuleHit | None:
|
||||||
|
cycle = _cycle(context)
|
||||||
|
close = _f(context, "close")
|
||||||
|
ma20 = _f(context, "ma20")
|
||||||
|
range_high = _f(context, "range_high")
|
||||||
|
range_low = _f(context, "range_low")
|
||||||
|
vol_ratio = _f(context, "volume_ratio")
|
||||||
|
if cycle in (WyckoffCycle.ACCUMULATION.value, WyckoffCycle.RE_ACCUMULATION.value):
|
||||||
|
if close > ma20 and range_high > 0 and close >= range_high * 0.98 and vol_ratio >= 1.1:
|
||||||
|
return RuleHit(
|
||||||
|
rule_id=self.rule_id,
|
||||||
|
phase=WyckoffPhase.D.value,
|
||||||
|
confidence=80.0,
|
||||||
|
score=82.0,
|
||||||
|
reasons=["突破区间上沿放量,疑似 Phase D SOS"],
|
||||||
|
)
|
||||||
|
if cycle in (WyckoffCycle.DISTRIBUTION.value, WyckoffCycle.RE_DISTRIBUTION.value):
|
||||||
|
if close < ma20 and range_low > 0 and close <= range_low * 1.02:
|
||||||
|
return RuleHit(
|
||||||
|
rule_id=self.rule_id,
|
||||||
|
phase=WyckoffPhase.D.value,
|
||||||
|
confidence=80.0,
|
||||||
|
score=82.0,
|
||||||
|
reasons=["跌破区间下沿,疑似 Phase D SOW"],
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class PhaseERule(WyckoffRule):
|
||||||
|
rule_id = "phase_e"
|
||||||
|
category = "phase"
|
||||||
|
timeframes = ("1w", "1d")
|
||||||
|
|
||||||
|
def evaluate(self, context: dict[str, Any]) -> RuleHit | None:
|
||||||
|
cycle = _cycle(context)
|
||||||
|
# Markup/Markdown already imply trend continuation (Phase E of prior structure)
|
||||||
|
if cycle == WyckoffCycle.MARKUP.value:
|
||||||
|
return RuleHit(
|
||||||
|
rule_id=self.rule_id,
|
||||||
|
phase=WyckoffPhase.E.value,
|
||||||
|
confidence=78.0,
|
||||||
|
score=80.0,
|
||||||
|
reasons=["趋势上行,对应 Phase E Markup"],
|
||||||
|
)
|
||||||
|
if cycle == WyckoffCycle.MARKDOWN.value:
|
||||||
|
return RuleHit(
|
||||||
|
rule_id=self.rule_id,
|
||||||
|
phase=WyckoffPhase.E.value,
|
||||||
|
confidence=78.0,
|
||||||
|
score=80.0,
|
||||||
|
reasons=["趋势下行,对应 Phase E Markdown"],
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def build_rules() -> list[WyckoffRule]:
|
||||||
|
# More specific phases first
|
||||||
|
return [PhaseDRule(), PhaseCRule(), PhaseARule(), PhaseBRule(), PhaseERule()]
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
"""Rule Registry — register Wyckoff rules without modifying engines."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from crypto_wyckoff.rules.base import WyckoffRule
|
||||||
|
|
||||||
|
|
||||||
|
class RuleRegistry:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._rules: dict[str, WyckoffRule] = {}
|
||||||
|
|
||||||
|
def register(self, rule: WyckoffRule) -> None:
|
||||||
|
self._rules[rule.rule_id] = rule
|
||||||
|
|
||||||
|
def get(self, rule_id: str) -> WyckoffRule | None:
|
||||||
|
return self._rules.get(rule_id)
|
||||||
|
|
||||||
|
def by_category(self, category: str, timeframe: str | None = None) -> list[WyckoffRule]:
|
||||||
|
out = [r for r in self._rules.values() if r.category == category]
|
||||||
|
if timeframe:
|
||||||
|
out = [r for r in out if timeframe in r.timeframes]
|
||||||
|
return out
|
||||||
|
|
||||||
|
def all(self) -> list[WyckoffRule]:
|
||||||
|
return list(self._rules.values())
|
||||||
|
|
||||||
|
|
||||||
|
rule_registry = RuleRegistry()
|
||||||
|
|
||||||
|
|
||||||
|
def _register_defaults() -> None:
|
||||||
|
from crypto_wyckoff.rules import cycle_rules, event_rules, phase_rules
|
||||||
|
|
||||||
|
for mod in (cycle_rules, phase_rules, event_rules):
|
||||||
|
for rule in mod.build_rules():
|
||||||
|
rule_registry.register(rule)
|
||||||
|
|
||||||
|
|
||||||
|
_register_defaults()
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
"""Background tip + scan scheduler for crypto wyckoff (all enabled combos)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from crypto_wyckoff.combos import all_tfs_for_combos, list_combos
|
||||||
|
from crypto_wyckoff.io import (
|
||||||
|
backfill_symbol,
|
||||||
|
bar_count,
|
||||||
|
fetch_symbols_from_provider,
|
||||||
|
tip_update_symbol,
|
||||||
|
)
|
||||||
|
from crypto_wyckoff.pipeline import analyze_and_store
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_thread: threading.Thread | None = None
|
||||||
|
_stop = threading.Event()
|
||||||
|
_status: dict = {
|
||||||
|
"running": False,
|
||||||
|
"last_tick_at": None,
|
||||||
|
"last_error": None,
|
||||||
|
"symbols_total": 0,
|
||||||
|
"symbols_scanned": 0,
|
||||||
|
"tick_interval_sec": 60,
|
||||||
|
"backfill_done": False,
|
||||||
|
}
|
||||||
|
_status_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def _set(**kwargs):
|
||||||
|
with _status_lock:
|
||||||
|
_status.update(kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
def get_status() -> dict:
|
||||||
|
with _status_lock:
|
||||||
|
return dict(_status)
|
||||||
|
|
||||||
|
|
||||||
|
def run_tick(max_symbols: int | None = None, force_rescan: bool = False) -> dict:
|
||||||
|
"""One cycle: refresh symbols, tip-update, analyze each combo."""
|
||||||
|
symbols = fetch_symbols_from_provider()
|
||||||
|
if max_symbols:
|
||||||
|
symbols = symbols[:max_symbols]
|
||||||
|
combos = list_combos()
|
||||||
|
tfs = all_tfs_for_combos(combos)
|
||||||
|
_set(symbols_total=len(symbols), running=True, last_error=None)
|
||||||
|
scanned = 0
|
||||||
|
errors = 0
|
||||||
|
changed_n = 0
|
||||||
|
|
||||||
|
for i, sym in enumerate(symbols):
|
||||||
|
try:
|
||||||
|
# Prefer low-TF of first combo for "enough history" gate
|
||||||
|
low0 = combos[0]["low"] if combos else "1d"
|
||||||
|
if bar_count(sym, low0) < 40:
|
||||||
|
backfill_symbol(sym, tfs)
|
||||||
|
tip_changed = tip_update_symbol(sym, tfs)
|
||||||
|
if tip_changed:
|
||||||
|
changed_n += 1
|
||||||
|
if force_rescan or tip_changed:
|
||||||
|
for combo in combos:
|
||||||
|
row = analyze_and_store(sym, combo_id=combo["id"])
|
||||||
|
if row:
|
||||||
|
scanned += 1
|
||||||
|
except Exception as e:
|
||||||
|
errors += 1
|
||||||
|
if errors <= 5:
|
||||||
|
logger.warning("tick %s: %s", sym, e)
|
||||||
|
_set(last_error=str(e))
|
||||||
|
if (i + 1) % 25 == 0:
|
||||||
|
_set(symbols_scanned=scanned)
|
||||||
|
logger.info("wyckoff tick progress %s/%s scanned=%s", i + 1, len(symbols), scanned)
|
||||||
|
|
||||||
|
_set(
|
||||||
|
running=False,
|
||||||
|
symbols_scanned=scanned,
|
||||||
|
last_tick_at=datetime.now(timezone.utc).isoformat(),
|
||||||
|
backfill_done=True,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"symbols": len(symbols),
|
||||||
|
"scanned": scanned,
|
||||||
|
"changed_tips": changed_n,
|
||||||
|
"errors": errors,
|
||||||
|
"combos": [c["id"] for c in combos],
|
||||||
|
"tfs": tfs,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _loop(interval: int, max_symbols: int | None):
|
||||||
|
try:
|
||||||
|
run_tick(max_symbols=max_symbols, force_rescan=True)
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("initial tick failed: %s", e)
|
||||||
|
_set(last_error=str(e), running=False)
|
||||||
|
while not _stop.wait(interval):
|
||||||
|
try:
|
||||||
|
# Tip-driven: only force full rescan when tips change is handled inside
|
||||||
|
run_tick(max_symbols=max_symbols, force_rescan=False)
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("tick failed: %s", e)
|
||||||
|
_set(last_error=str(e), running=False)
|
||||||
|
|
||||||
|
|
||||||
|
def start_scheduler(interval_sec: int = 60, max_symbols: int | None = None) -> None:
|
||||||
|
global _thread
|
||||||
|
if _thread and _thread.is_alive():
|
||||||
|
return
|
||||||
|
_stop.clear()
|
||||||
|
_set(tick_interval_sec=interval_sec)
|
||||||
|
_thread = threading.Thread(
|
||||||
|
target=_loop,
|
||||||
|
args=(interval_sec, max_symbols),
|
||||||
|
name="crypto-wyckoff-scheduler",
|
||||||
|
daemon=True,
|
||||||
|
)
|
||||||
|
_thread.start()
|
||||||
|
logger.info("crypto wyckoff scheduler started interval=%ss", interval_sec)
|
||||||
|
|
||||||
|
|
||||||
|
def stop_scheduler() -> None:
|
||||||
|
_stop.set()
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
"""Signal Engine — timeframe-local status labels only (not tradability)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from crypto_wyckoff.domain_models import EngineResult, WyckoffEvent
|
||||||
|
|
||||||
|
|
||||||
|
class SignalEngine:
|
||||||
|
"""Maps local Event/Phase into a status label. Decision decides tradability."""
|
||||||
|
|
||||||
|
name = "Signal"
|
||||||
|
version = "1.0.0"
|
||||||
|
|
||||||
|
def run(self, event: EngineResult, phase: EngineResult | None = None) -> EngineResult:
|
||||||
|
current = event.payload.get("current_event", WyckoffEvent.NONE.value)
|
||||||
|
conf = event.confidence
|
||||||
|
label = current # status label mirrors event for V1
|
||||||
|
reasons = [f"本地事件标签: {label}"]
|
||||||
|
if phase and phase.payload.get("phase"):
|
||||||
|
reasons.append(f"本地阶段: {phase.payload.get('phase')}")
|
||||||
|
|
||||||
|
return EngineResult(
|
||||||
|
name=self.name,
|
||||||
|
version=self.version,
|
||||||
|
confidence=conf,
|
||||||
|
score=event.score,
|
||||||
|
reasons=reasons,
|
||||||
|
payload={
|
||||||
|
"signal_label": label,
|
||||||
|
"current_event": current,
|
||||||
|
"phase": (phase.payload.get("phase") if phase else None),
|
||||||
|
"active_events": event.payload.get("active_events")
|
||||||
|
or event.payload.get("recent_events", []),
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
"""SQLite persistence for crypto wyckoff scan rows (per combo)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from crypto_wyckoff.domain_models import WyckoffScanRow
|
||||||
|
from crypto_wyckoff.io import SCAN_DB, ensure_dirs
|
||||||
|
|
||||||
|
_COLS = [
|
||||||
|
"trade_date", "combo_id", "ts_code", "name", "industry", "engine_version",
|
||||||
|
"m_cycle", "cycle_confidence", "trend_score",
|
||||||
|
"w_cycle", "w_phase", "w_current_event", "w_recent_events_json",
|
||||||
|
"phase_confidence", "structure_score",
|
||||||
|
"d_current_event", "d_recent_events_json", "event_confidence", "entry_score",
|
||||||
|
"entry", "stop", "target1", "target2", "rr",
|
||||||
|
"alignment", "stars", "decision_signal", "signal_confidence",
|
||||||
|
"overall_confidence", "overall_score", "risk", "reasons_json",
|
||||||
|
"feature_snapshot_json", "markers_json", "scanned_at",
|
||||||
|
]
|
||||||
|
|
||||||
|
_CREATE_SQL = """
|
||||||
|
CREATE TABLE IF NOT EXISTS wyckoff_scan (
|
||||||
|
trade_date TEXT NOT NULL,
|
||||||
|
combo_id TEXT NOT NULL DEFAULT 'd_w_m',
|
||||||
|
ts_code TEXT NOT NULL,
|
||||||
|
name TEXT DEFAULT '',
|
||||||
|
industry TEXT DEFAULT '',
|
||||||
|
engine_version TEXT,
|
||||||
|
m_cycle TEXT, cycle_confidence REAL, trend_score REAL,
|
||||||
|
w_cycle TEXT, w_phase TEXT, w_current_event TEXT, w_recent_events_json TEXT,
|
||||||
|
phase_confidence REAL, structure_score REAL,
|
||||||
|
d_current_event TEXT, d_recent_events_json TEXT, event_confidence REAL, entry_score REAL,
|
||||||
|
entry REAL, stop REAL, target1 REAL, target2 REAL, rr REAL,
|
||||||
|
alignment REAL, stars INTEGER, decision_signal TEXT, signal_confidence REAL,
|
||||||
|
overall_confidence REAL, overall_score REAL, risk TEXT, reasons_json TEXT,
|
||||||
|
feature_snapshot_json TEXT, markers_json TEXT, scanned_at TEXT,
|
||||||
|
PRIMARY KEY (trade_date, combo_id, ts_code)
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _migrate(c: sqlite3.Connection) -> None:
|
||||||
|
cur = c.execute(
|
||||||
|
"SELECT name FROM sqlite_master WHERE type='table' AND name='wyckoff_scan'"
|
||||||
|
)
|
||||||
|
if not cur.fetchone():
|
||||||
|
c.execute(_CREATE_SQL)
|
||||||
|
c.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_cw_score "
|
||||||
|
"ON wyckoff_scan(trade_date, combo_id, overall_score DESC)"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
cols = {r[1] for r in c.execute("PRAGMA table_info(wyckoff_scan)")}
|
||||||
|
if "combo_id" in cols:
|
||||||
|
c.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_cw_score "
|
||||||
|
"ON wyckoff_scan(trade_date, combo_id, overall_score DESC)"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Legacy PK (trade_date, ts_code) → add combo_id via table rebuild
|
||||||
|
c.execute("ALTER TABLE wyckoff_scan RENAME TO wyckoff_scan_old")
|
||||||
|
c.execute(_CREATE_SQL)
|
||||||
|
old_cols = [r[1] for r in c.execute("PRAGMA table_info(wyckoff_scan_old)")]
|
||||||
|
shared = [col for col in _COLS if col != "combo_id" and col in old_cols]
|
||||||
|
col_sql = ",".join(shared)
|
||||||
|
c.execute(
|
||||||
|
f"""
|
||||||
|
INSERT INTO wyckoff_scan (combo_id, {col_sql})
|
||||||
|
SELECT 'd_w_m', {col_sql} FROM wyckoff_scan_old
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
c.execute("DROP TABLE wyckoff_scan_old")
|
||||||
|
c.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_cw_score "
|
||||||
|
"ON wyckoff_scan(trade_date, combo_id, overall_score DESC)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _conn() -> sqlite3.Connection:
|
||||||
|
ensure_dirs()
|
||||||
|
c = sqlite3.connect(str(SCAN_DB), timeout=60)
|
||||||
|
c.row_factory = sqlite3.Row
|
||||||
|
_migrate(c)
|
||||||
|
c.commit()
|
||||||
|
return c
|
||||||
|
|
||||||
|
|
||||||
|
def upsert_row(row: WyckoffScanRow) -> None:
|
||||||
|
combo_id = getattr(row, "combo_id", None) or "d_w_m"
|
||||||
|
vals = (
|
||||||
|
row.trade_date.isoformat() if hasattr(row.trade_date, "isoformat") else str(row.trade_date),
|
||||||
|
combo_id,
|
||||||
|
row.ts_code, row.name, row.industry, row.engine_version,
|
||||||
|
row.m_cycle, row.cycle_confidence, row.trend_score,
|
||||||
|
row.w_cycle, row.w_phase, row.w_current_event, row.w_recent_events_json,
|
||||||
|
row.phase_confidence, row.structure_score,
|
||||||
|
row.d_current_event, row.d_recent_events_json, row.event_confidence, row.entry_score,
|
||||||
|
row.entry, row.stop, row.target1, row.target2, row.rr,
|
||||||
|
row.alignment, row.stars, row.decision_signal, row.signal_confidence,
|
||||||
|
row.overall_confidence, row.overall_score, row.risk, row.reasons_json,
|
||||||
|
row.feature_snapshot_json, row.markers_json,
|
||||||
|
row.scanned_at.isoformat() if isinstance(row.scanned_at, datetime) else str(row.scanned_at),
|
||||||
|
)
|
||||||
|
c = _conn()
|
||||||
|
try:
|
||||||
|
placeholders = ",".join("?" * len(_COLS))
|
||||||
|
col_sql = ",".join(_COLS)
|
||||||
|
updates = ",".join(
|
||||||
|
f"{col}=excluded.{col}"
|
||||||
|
for col in _COLS
|
||||||
|
if col not in ("trade_date", "combo_id", "ts_code")
|
||||||
|
)
|
||||||
|
c.execute(
|
||||||
|
f"""
|
||||||
|
INSERT INTO wyckoff_scan ({col_sql}) VALUES ({placeholders})
|
||||||
|
ON CONFLICT(trade_date, combo_id, ts_code) DO UPDATE SET {updates}
|
||||||
|
""",
|
||||||
|
vals,
|
||||||
|
)
|
||||||
|
c.commit()
|
||||||
|
finally:
|
||||||
|
c.close()
|
||||||
|
|
||||||
|
|
||||||
|
def latest_trade_date(combo_id: str | None = None) -> str | None:
|
||||||
|
c = _conn()
|
||||||
|
try:
|
||||||
|
if combo_id:
|
||||||
|
cur = c.execute(
|
||||||
|
"SELECT MAX(trade_date) FROM wyckoff_scan WHERE combo_id=?",
|
||||||
|
(combo_id,),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
cur = c.execute("SELECT MAX(trade_date) FROM wyckoff_scan")
|
||||||
|
row = cur.fetchone()
|
||||||
|
return row[0] if row and row[0] else None
|
||||||
|
finally:
|
||||||
|
c.close()
|
||||||
|
|
||||||
|
|
||||||
|
def count_for_date(trade_date: str | None = None, combo_id: str | None = None) -> int:
|
||||||
|
td = trade_date or latest_trade_date(combo_id)
|
||||||
|
if not td:
|
||||||
|
return 0
|
||||||
|
c = _conn()
|
||||||
|
try:
|
||||||
|
if combo_id:
|
||||||
|
cur = c.execute(
|
||||||
|
"SELECT COUNT(*) FROM wyckoff_scan WHERE trade_date=? AND combo_id=?",
|
||||||
|
(td, combo_id),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
cur = c.execute("SELECT COUNT(*) FROM wyckoff_scan WHERE trade_date=?", (td,))
|
||||||
|
return int(cur.fetchone()[0])
|
||||||
|
finally:
|
||||||
|
c.close()
|
||||||
|
|
||||||
|
|
||||||
|
def query_scan(
|
||||||
|
*,
|
||||||
|
trade_date: str | None = None,
|
||||||
|
combo_id: str | None = None,
|
||||||
|
m_cycle: str | None = None,
|
||||||
|
w_phase: str | None = None,
|
||||||
|
d_event: str | None = None,
|
||||||
|
decision_signal: str | None = None,
|
||||||
|
min_overall_score: float | None = None,
|
||||||
|
min_alignment: float | None = None,
|
||||||
|
sort: str = "overall_score",
|
||||||
|
limit: int = 100,
|
||||||
|
offset: int = 0,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
cid = combo_id or "d_w_m"
|
||||||
|
td = trade_date or latest_trade_date(cid)
|
||||||
|
if not td:
|
||||||
|
return []
|
||||||
|
sort_col = sort if sort in {
|
||||||
|
"overall_score", "alignment", "entry_score", "trend_score", "structure_score", "stars"
|
||||||
|
} else "overall_score"
|
||||||
|
clauses = ["trade_date=?", "combo_id=?"]
|
||||||
|
args: list[Any] = [td, cid]
|
||||||
|
if m_cycle:
|
||||||
|
clauses.append("m_cycle=?")
|
||||||
|
args.append(m_cycle)
|
||||||
|
if w_phase:
|
||||||
|
clauses.append("w_phase=?")
|
||||||
|
args.append(w_phase)
|
||||||
|
if d_event:
|
||||||
|
clauses.append("d_current_event=?")
|
||||||
|
args.append(d_event)
|
||||||
|
if decision_signal:
|
||||||
|
clauses.append("decision_signal=?")
|
||||||
|
args.append(decision_signal)
|
||||||
|
if min_overall_score is not None:
|
||||||
|
clauses.append("overall_score>=?")
|
||||||
|
args.append(min_overall_score)
|
||||||
|
if min_alignment is not None:
|
||||||
|
clauses.append("alignment>=?")
|
||||||
|
args.append(min_alignment)
|
||||||
|
where = " AND ".join(clauses)
|
||||||
|
args.extend([limit, offset])
|
||||||
|
c = _conn()
|
||||||
|
try:
|
||||||
|
cur = c.execute(
|
||||||
|
f"SELECT * FROM wyckoff_scan WHERE {where} ORDER BY {sort_col} DESC LIMIT ? OFFSET ?",
|
||||||
|
args,
|
||||||
|
)
|
||||||
|
return [dict(r) for r in cur.fetchall()]
|
||||||
|
finally:
|
||||||
|
c.close()
|
||||||
|
|
||||||
|
|
||||||
|
def get_symbol(
|
||||||
|
ts_code: str,
|
||||||
|
trade_date: str | None = None,
|
||||||
|
combo_id: str | None = None,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
cid = combo_id or "d_w_m"
|
||||||
|
td = trade_date or latest_trade_date(cid)
|
||||||
|
if not td:
|
||||||
|
return None
|
||||||
|
c = _conn()
|
||||||
|
try:
|
||||||
|
cur = c.execute(
|
||||||
|
"SELECT * FROM wyckoff_scan WHERE trade_date=? AND combo_id=? AND ts_code=?",
|
||||||
|
(td, cid, ts_code),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
return dict(row) if row else None
|
||||||
|
finally:
|
||||||
|
c.close()
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
"""Crypto symbol → Chinese display name for screener UI."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
# Base asset → 中文名(覆盖 provider 当前币对;未知则回退 base)
|
||||||
|
_BASE_CN: dict[str, str] = {
|
||||||
|
"BTC": "比特币",
|
||||||
|
"ETH": "以太坊",
|
||||||
|
"SOL": "索拉纳",
|
||||||
|
"XAU": "黄金",
|
||||||
|
"XAG": "白银",
|
||||||
|
"SAGA": "Saga",
|
||||||
|
"CL": "原油",
|
||||||
|
"ZEC": "大零币",
|
||||||
|
"XRP": "瑞波币",
|
||||||
|
"DOGE": "狗狗币",
|
||||||
|
"BNB": "币安币",
|
||||||
|
"SUI": "Sui",
|
||||||
|
"BILL": "Bill",
|
||||||
|
"BZ": "BZ",
|
||||||
|
"LAB": "Lab",
|
||||||
|
"TON": "通联币",
|
||||||
|
"CRCL": "Circle",
|
||||||
|
"SNDK": "SNDK",
|
||||||
|
"1000PEPE": "千倍佩佩",
|
||||||
|
"PEPE": "佩佩",
|
||||||
|
"CHIP": "CHIP",
|
||||||
|
"WIF": "狗帽子",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def base_asset(symbol: str) -> str:
|
||||||
|
"""BTC/USDT:USDT → BTC;1000PEPE/USDT:USDT → 1000PEPE."""
|
||||||
|
s = (symbol or "").strip()
|
||||||
|
if not s:
|
||||||
|
return ""
|
||||||
|
head = s.split(":")[0]
|
||||||
|
return head.split("/")[0].upper() if "/" in head else head.upper()
|
||||||
|
|
||||||
|
|
||||||
|
def display_name_cn(symbol: str) -> str:
|
||||||
|
base = base_asset(symbol)
|
||||||
|
if not base:
|
||||||
|
return symbol or ""
|
||||||
|
return _BASE_CN.get(base, base)
|
||||||
|
|
||||||
|
|
||||||
|
def symbol_name_map(symbols: list[str] | None = None) -> dict[str, str]:
|
||||||
|
if not symbols:
|
||||||
|
return {f"{k}/USDT:USDT": v for k, v in _BASE_CN.items()}
|
||||||
|
return {s: display_name_cn(s) for s in symbols}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
"""Wyckoff Screener engine version — bump when rules change."""
|
||||||
|
|
||||||
|
WYCKOFF_ENGINE_VERSION = "v1.0.0"
|
||||||
|
ARCHITECTURE_VERSION = "1.0"
|
||||||
+10
-3
@@ -22,18 +22,25 @@
|
|||||||
- 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 Reviewed:TR 评分硬化 + VP 少系列 + 阶段/门闩/单测(无币种参数)
|
- ECR-004 Reviewed:TR 评分硬化 + VP 少系列 + 阶段/门闩/单测(无币种参数)
|
||||||
|
- ECR-007 Final Approval / `276481e`:Wyckoff Live Structure(`live.py`);Confirmed ≠ Live;execution 仅 confirmed
|
||||||
|
- ECR-008 Reviewed:主站 `chart_tv.js` → `chart_tv_{lifecycle,shell,indicators,chan,overlays,finalize}.js` + 薄门面
|
||||||
|
- ECR-009 Implementing:`/wyckoff_crypto` 独立选股页(`crypto_wyckoff/`);D/W + 本地月线;60s tip
|
||||||
|
- 威科夫数据随主 analyze 默认返回;UI 开关仅显隐叠层
|
||||||
|
- Live 观察:主图左下角 Cycle Summary(「形成中」= FORMING);无单独 Live 图层
|
||||||
|
|
||||||
## 硬约束提醒
|
## 硬约束提醒
|
||||||
|
|
||||||
- `/api/analyze` 字段可增不可删
|
- `/api/analyze` 字段可增不可删
|
||||||
- 无 ADR 不改笔/段/中枢/买卖点语义
|
- 无 ADR 不改笔/段/中枢/买卖点语义
|
||||||
- 威科夫为独立叠层(ECR-003);勿借机改缠论算法
|
- 威科夫为独立叠层(ECR-003/007);Crypto Screener 为独立页(ECR-009),勿混进缠论引擎
|
||||||
- 交易 L2+ → RISK_REVIEW + EXP;Live 须 Human
|
- Live candidate **不得**进入 execution;交易 L2+ → RISK_REVIEW + EXP;Live 须 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` 仍偏大,可后续再拆
|
||||||
|
- ECR-009:月线历史受日线深度限制;Cycle 规则在 crypto 上可能偏 Unknown,看效果再调参
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
# Backend Design: ECR-007 Wyckoff Live Structure
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
|-------|-------|
|
||||||
|
| ID | BD-2026-007 |
|
||||||
|
| ECR | ECR-007 |
|
||||||
|
| Change Level | L2 |
|
||||||
|
| Status | Approved |
|
||||||
|
| Author | Architect (LOOP-RUN-005 Planner) |
|
||||||
|
| Date | 2026-08-07 |
|
||||||
|
| Risk | High (domain / execution boundary) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
- 问题:Confirmed 引擎已存在;需要独立 Live 推演层供观察,且不得成为交易执行输入。
|
||||||
|
- 非目标:改 Confirmed 门槛;自动交易;策略。
|
||||||
|
- 依赖:ECR-003/004 威科夫;WYCKOFF-LIVE-STRUCTURE-001(FROZEN)。
|
||||||
|
|
||||||
|
## Architecture Change / Change Boundary
|
||||||
|
|
||||||
|
```text
|
||||||
|
OHLCV
|
||||||
|
→ detect_trading_ranges (Confirmed path)
|
||||||
|
→ detect_bias_and_events / build_phases ← Confirmed(阈值不降)
|
||||||
|
→ analyze_live_structure ← Live(只读 confirmed)
|
||||||
|
→ cycles[i] = { lifecycle, confirmed, live }
|
||||||
|
→ API analyze + Summary UI
|
||||||
|
→ execution_signal_from_wyckoff(confirmed only)
|
||||||
|
```
|
||||||
|
|
||||||
|
| Layer | May change | Must not |
|
||||||
|
|-------|------------|----------|
|
||||||
|
| Confirmed | assemble into `confirmed{}` | relax Spring/SOS rules |
|
||||||
|
| Live | `live.py` heuristics | write into confirmed.events |
|
||||||
|
| Execution helper | source=confirmed gate | consume candidates |
|
||||||
|
| UI | Summary partition | treat Live as order |
|
||||||
|
|
||||||
|
## Backend Change Boundary
|
||||||
|
|
||||||
|
Live outputs are **observation**. Execution boundary:
|
||||||
|
|
||||||
|
```python
|
||||||
|
assert execution_signal.source == "confirmed"
|
||||||
|
# live-only payload → None
|
||||||
|
```
|
||||||
|
|
||||||
|
## Data contract
|
||||||
|
|
||||||
|
See WYCKOFF-LIVE-STRUCTURE-001. Top-level `phases`/`events` mirror **Confirmed** only.
|
||||||
|
|
||||||
|
## delivery_constraints
|
||||||
|
|
||||||
|
- BD Status Approved
|
||||||
|
- TEST_REPORT commands/result/date
|
||||||
|
- CODE_REVIEW handoff
|
||||||
|
- TRACEABILITY commit
|
||||||
|
- out_of_scope + execution_source_confirmed_only
|
||||||
|
|
||||||
|
## Test Plan
|
||||||
|
|
||||||
|
1. Live candidates not in confirmed.events
|
||||||
|
2. CONFIRMED lifecycle when Spring+SOS confirmed
|
||||||
|
3. execution_signal source=confirmed; live-only → None
|
||||||
|
4. analyze contract keys include live/lifecycle
|
||||||
|
|
||||||
|
## Rollback
|
||||||
|
|
||||||
|
Remove live assembly path; Summary falls back to confirmed-only.
|
||||||
@@ -1,5 +1,25 @@
|
|||||||
# CHANGELOG
|
# CHANGELOG
|
||||||
|
|
||||||
|
## Unreleased — 2026-08-07
|
||||||
|
|
||||||
|
### ECR-009(L2,进行中)
|
||||||
|
|
||||||
|
- 独立页 `/wyckoff_crypto`:移植 A_Share_DP D/W/M 威科夫选股引擎至数字货币
|
||||||
|
- 本地 `data/crypto_wyckoff/`;60s tip;月线由日线 UTC 自然月聚合(provider 无 1M)
|
||||||
|
- API:`/api/wyckoff_crypto/*`;不碰主站 analyze / 缠论叠层
|
||||||
|
|
||||||
|
### ECR-008(L3,Reviewed)
|
||||||
|
|
||||||
|
- 主站 `chart_tv.js` 拆为 lifecycle / shell / indicators / chan / overlays / finalize + 薄门面
|
||||||
|
- 行为冻结;`initTradingView` / `disposeTradingViewCharts` 对外不变;无 Vite/TS
|
||||||
|
|
||||||
|
### ECR-007(L2,LOOP-RUN-005)
|
||||||
|
|
||||||
|
- Wyckoff **Live Structure**:`live.py` + engine 组装 `lifecycle` / `confirmed` / `live`
|
||||||
|
- Event candidates(Spring/SOS/LPS/UTAD)+ 可解释 confidence;Summary Confirmed/Live 分区
|
||||||
|
- `execution_signal_from_wyckoff` **仅** `source=confirmed`;Live-only → None
|
||||||
|
- **No** Confirmed 门槛降低;**No** strategies / 自动交易
|
||||||
|
|
||||||
## Unreleased — 2026-08-06
|
## Unreleased — 2026-08-06
|
||||||
|
|
||||||
### ECR-004(L2,Reviewed)
|
### ECR-004(L2,Reviewed)
|
||||||
@@ -7,6 +27,7 @@
|
|||||||
- 威科夫 TR 评分选段(防吞前置趋势);阶段非重叠最小跨度
|
- 威科夫 TR 评分选段(防吞前置趋势);阶段非重叠最小跨度
|
||||||
- 主站 VP Top-8 + bins≤24;填充线减负
|
- 主站 VP Top-8 + bins≤24;填充线减负
|
||||||
- `elements_only` 时不跑威科夫;收紧单测(无币种独立参数)
|
- `elements_only` 时不跑威科夫;收紧单测(无币种独立参数)
|
||||||
|
- **后续**:威科夫随主 `/api/analyze` 默认一并返回;前端开关只控制绘制(不再勾选才加载)
|
||||||
|
|
||||||
### ECR-003(L2,Reviewed)
|
### ECR-003(L2,Reviewed)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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,25 @@
|
|||||||
|
# ECR-009
|
||||||
|
|
||||||
|
**Title:** Crypto Wyckoff Screener 独立页(D/W/M)
|
||||||
|
**Status:** Implementing
|
||||||
|
**Date:** 2026-08-07
|
||||||
|
**Change Level:** L2
|
||||||
|
|
||||||
|
## Change
|
||||||
|
|
||||||
|
新增 `crypto_wyckoff/` 包(移植 A_Share_DP 引擎)+ `/wyckoff_crypto` 页 + `/api/wyckoff_crypto/*`;本地缓存 K 线;60s tip 更新。
|
||||||
|
|
||||||
|
周期组合:内置 `8h/4h/1h`(默认)与 `1d/1w/1M`;UI 下拉切换;可添加自定义高/中/低组合(规则引擎仍按 D/W/M 角色映射)。
|
||||||
|
|
||||||
|
## Forbidden
|
||||||
|
|
||||||
|
- 改缠论算法、主站叠层、`/api/analyze`、`config/`/`strategies/`
|
||||||
|
- 自动下单
|
||||||
|
|
||||||
|
## Acceptance
|
||||||
|
|
||||||
|
- [ ] 页面可列出扫描结果(decision/cycle/phase/event)
|
||||||
|
- [ ] 本地 `data/crypto_wyckoff/` 有 K 线与 scan
|
||||||
|
- [ ] 调度可跑 tip 更新
|
||||||
|
- [ ] Decision 门闩单测通过
|
||||||
|
- [ ] 下拉可选 `8h/4h/1h`,可添加新组合
|
||||||
@@ -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,31 @@
|
|||||||
|
# ENGINEERING_SPEC — ECR-009 Crypto Wyckoff Screener
|
||||||
|
|
||||||
|
**Level:** L2 · 独立页
|
||||||
|
**Date:** 2026-08-07
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
数字货币 D/W/M 威科夫选股观察页(A_Share_DP 引擎语义);24/7 tip 每分钟更新。
|
||||||
|
|
||||||
|
## Package
|
||||||
|
|
||||||
|
`crypto_wyckoff/`:features → cycle/phase/event/signal → decision → plan;本地 `data/crypto_wyckoff/`。
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
- `GET /wyckoff_crypto`
|
||||||
|
- `GET /api/wyckoff_crypto/meta|status|scan`
|
||||||
|
- `GET /api/wyckoff_crypto/symbol/<symbol>`
|
||||||
|
- `POST /api/wyckoff_crypto/tick`
|
||||||
|
|
||||||
|
## Env
|
||||||
|
|
||||||
|
- `CRYPTO_WYCKOFF_DISABLE=1` 关闭调度
|
||||||
|
- `CRYPTO_WYCKOFF_INTERVAL=60`
|
||||||
|
- `CRYPTO_WYCKOFF_MAX_SYMBOLS=N` 小样本调试
|
||||||
|
- `DATA_SERVICE_URL` 默认 provider.jackyu66.com
|
||||||
|
|
||||||
|
## Crypto calendar
|
||||||
|
|
||||||
|
UTC 连续盘;回填不做 A 股周末放大。
|
||||||
|
**月线**:provider 无 `1M`,由本地日线按 **UTC 自然月** OHLCV 聚合;日/周直接拉 `1d`/`1w`。
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# Handoff
|
||||||
|
|
||||||
|
**From:** Architect
|
||||||
|
**To:** Engineer
|
||||||
|
**ECR:** ECR-007
|
||||||
|
**State:** build
|
||||||
|
**Date:** 2026-08-07
|
||||||
|
|
||||||
|
## Artifacts
|
||||||
|
- [x] ECR-007 Approved
|
||||||
|
- [x] BACKEND_DESIGN BD-2026-007
|
||||||
|
- [x] Note WYCKOFF-LIVE-STRUCTURE-001 FROZEN
|
||||||
|
- [ ] TEST_REPORT / CODE_REVIEW
|
||||||
|
|
||||||
|
## Restrictions
|
||||||
|
- Do not lower Confirmed thresholds
|
||||||
|
- Do not let Live feed execution
|
||||||
|
- Do not touch strategies/**
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
Ship Confirmed/Live separation + tests + Summary; Gate PASS.
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# Code Review — ECR-007
|
||||||
|
|
||||||
|
**From:** Reviewer
|
||||||
|
**To:** Guardian / Human
|
||||||
|
**ECR:** ECR-007
|
||||||
|
**BD:** BD-2026-007
|
||||||
|
**Date:** 2026-08-07
|
||||||
|
**Decision:** PASS
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
|
||||||
|
| Item | Result | Notes |
|
||||||
|
|------|--------|-------|
|
||||||
|
| State machine boundary | PASS | lifecycle UNKNOWN/FORMING/CONFIRMED/COMPLETED; cycles[0]=ACTIVE |
|
||||||
|
| confidence explainability | PASS | cycle/phase/event/structure/volume/overall — not black-box |
|
||||||
|
| backward compatibility | PASS | top-level phases/events still Confirmed mirror |
|
||||||
|
| Live ≠ execution | PASS | execution_signal_from_wyckoff source=confirmed; live-only None |
|
||||||
|
| Confirmed thresholds | PASS | no intentional cut for Live; structural support fix is robustness (eaten spring) |
|
||||||
|
|
||||||
|
## Findings
|
||||||
|
|
||||||
|
1. Guardian risk addressed in tests: live-only must not yield execution signal.
|
||||||
|
2. Summary UI partitions Confirmed vs Live (observation).
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
**PASS**
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# Handoff — Engineer → Reviewer
|
||||||
|
|
||||||
|
**ECR:** ECR-007
|
||||||
|
**Date:** 2026-08-07
|
||||||
|
|
||||||
|
## Delivered
|
||||||
|
|
||||||
|
- `chanlun/analysis/wyckoff/live.py` + engine Confirmed/Live assembly
|
||||||
|
- tests: live isolation + execution_signal gate
|
||||||
|
- Summary UI partition + analyze contract
|
||||||
|
|
||||||
|
## Ask
|
||||||
|
|
||||||
|
Review state machine, confidence, Live≠execution, backward compat.
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# HANDOFF — Architect → Engineer(ECR-008)
|
||||||
|
|
||||||
|
**From:** Architect
|
||||||
|
**To:** Engineer
|
||||||
|
**ECR:** ECR-008
|
||||||
|
**Date:** 2026-08-07
|
||||||
|
|
||||||
|
## Mission
|
||||||
|
|
||||||
|
按 ENG-008 拆分 `chart_tv.js`;剪切粘贴优先;禁止改绘制语义。
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
1. 抽出 `disposeTradingViewCharts` → `chart_tv_lifecycle.js`
|
||||||
|
2. 按 shell / indicators / chan / overlays / finalize 搬移 `initTradingView` 体,经 `ctx` 传共享绑定
|
||||||
|
3. 门面 `initTradingView` 仅:dispose → build ctx → 顺序调用
|
||||||
|
4. 更新 `index.html` script 顺序与 `?v=`
|
||||||
|
5. `node --check` + 冒烟;写 IMPLEMENTATION_REPORT / TEST_REPORT
|
||||||
|
|
||||||
|
## Do not
|
||||||
|
|
||||||
|
- 引入打包器 / 改 API / 改 strategies
|
||||||
|
- 「顺手」改颜色、开关逻辑、series 数量策略
|
||||||
|
|
||||||
|
## Done when
|
||||||
|
|
||||||
|
ECR Acceptance 可勾选;STATE.owner → reviewer。
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# Idea: 拆分主站巨型 chart_tv.js
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
`web/static/js/app/chart_tv.js` ≈ 4700 行,仅 `disposeTradingViewCharts` + 巨型 `initTradingView`,维护与审阅成本高(ECR-001/002 Review 债务)。
|
||||||
|
|
||||||
|
## Observation
|
||||||
|
|
||||||
|
ECR-002 明确将 chart_tv 拆分列为可选且未做;后续威科夫/Live 改动都挤在同一文件。
|
||||||
|
|
||||||
|
## Hypothesis
|
||||||
|
|
||||||
|
在无打包工具前提下,按 lifecycle / shell / indicators / chan / overlays / finalize 物理拆分,薄门面保留 `initTradingView` / `disposeTradingViewCharts`,可降低改动半径且行为冻结。
|
||||||
|
|
||||||
|
## Expected Impact
|
||||||
|
|
||||||
|
主站前端可维护性提升;与 `chart_sync` / `chart_view` 边界更清晰。
|
||||||
|
|
||||||
|
## Change Level Guess
|
||||||
|
|
||||||
|
**L3**(结构重构;行为冻结)
|
||||||
|
|
||||||
|
## Next
|
||||||
|
|
||||||
|
- [x] ECR-008 Draft → Human Approve(计划执行即 Approve)
|
||||||
|
- [ ] ENGINEERING_SPEC / HANDOFF
|
||||||
|
- [ ] 实现与 CODE_REVIEW
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# Idea: Crypto Wyckoff Screener(独立页)
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
主站威科夫是图叠层;需要 A_Share_DP 式 D/W/M 多周期选股/决策观察,用于数字货币。
|
||||||
|
|
||||||
|
## Hypothesis
|
||||||
|
|
||||||
|
独立包 + 独立页,币对来自 DATA_SERVICE,本地缓存 1d/1w/1M,每分钟 tip 更新,不碰缠论主链路。
|
||||||
|
|
||||||
|
## Change Level Guess
|
||||||
|
|
||||||
|
**L2**(新行为面;不改 strategies)
|
||||||
@@ -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。
|
||||||
@@ -34,10 +34,11 @@ Trading System(缠论分析引擎 + 可视化 Web;Freqtrade 策略目录独
|
|||||||
|
|
||||||
## Active anchors
|
## Active anchors
|
||||||
|
|
||||||
- ECR: ECR-002/003/004 Reviewed(威科夫 + 硬化)
|
- ECR: ECR-002/003/004 Reviewed;ECR-007 Final Approval(待合入 `dev`);ECR-008 Reviewed(chart_tv 拆分)
|
||||||
- EXP: N/A
|
- EXP: N/A
|
||||||
- TRACEABILITY: `docs/TRACEABILITY.md`
|
- TRACEABILITY: `docs/TRACEABILITY.md`
|
||||||
- Memory: `docs/AGENT_MEMORY.md`
|
- Memory: `docs/AGENT_MEMORY.md`
|
||||||
|
- Loop archive: `docs/runs/LOOP-RUN-005/`
|
||||||
|
|
||||||
## Pointers
|
## Pointers
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
# STATE
|
# STATE
|
||||||
|
|
||||||
**owner:** idle
|
**owner:** engineer
|
||||||
**active_ecr:** none(ECR-004 Reviewed;待本批提交合入)
|
**active_ecr:** ECR-009(crypto wyckoff screener)
|
||||||
**phase:** post-review
|
**phase:** implementing
|
||||||
**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 · 待合入 `dev` |
|
||||||
|
| ECR-008 | L3 | Done (Reviewed) | chart_tv 拆分 |
|
||||||
|
| ECR-009 | L2 | Implementing | `/wyckoff_crypto` · D/W/M |
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- ECR-004:**Approve**(14 passed);无币种独立参数
|
- ECR-009:打开 http://localhost:8128/wyckoff_crypto ;默认组合 `8h/4h/1h`,可下拉切 `1d/1w/1M` 或「添加组合」
|
||||||
|
- 可用 `CRYPTO_WYCKOFF_MAX_SYMBOLS` 限流;月线仍由日线 UTC 聚合
|
||||||
- 未请求新 system tag
|
- 未请求新 system tag
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
ecr: ECR-007
|
||||||
|
owner: human
|
||||||
|
phase: done
|
||||||
|
updated: 2026-08-07
|
||||||
|
backend_design: BD-2026-007
|
||||||
|
loop: LOOP-RUN-005
|
||||||
|
gate: PASS
|
||||||
|
decision: FINAL_APPROVAL
|
||||||
|
implementation_commit: 276481e
|
||||||
|
notes: LOOP-RUN-005 DONE · Human Gate #2 Final Approval · archived to docs/runs/LOOP-RUN-005/
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
ecr: ECR-008
|
||||||
|
owner: idle
|
||||||
|
phase: done
|
||||||
|
updated: 2026-08-07
|
||||||
|
change_level: L3
|
||||||
|
decision: Approve
|
||||||
|
notes: chart_tv split Reviewed · node --check PASS · browser smoke pending Human
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
ecr: ECR-009
|
||||||
|
owner: engineer
|
||||||
|
phase: implementing
|
||||||
|
updated: 2026-08-07
|
||||||
|
notes: crypto wyckoff screener · D/W/M · 24/7 tip
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# TEST_REPORT — ECR-007
|
||||||
|
|
||||||
|
**Date:** 2026-08-07
|
||||||
|
**BD:** BD-2026-007
|
||||||
|
**Loop:** LOOP-RUN-005
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PYTHONPATH=. python -m pytest tests/test_wyckoff.py -q
|
||||||
|
PYTHONPATH=. python -m pytest web/tests/test_analyze_contract.py -q
|
||||||
|
```
|
||||||
|
|
||||||
|
## Result
|
||||||
|
|
||||||
|
```text
|
||||||
|
tests/test_wyckoff.py ………… 9 passed
|
||||||
|
web/tests/test_analyze_contract.py ……… 8 passed
|
||||||
|
```
|
||||||
|
|
||||||
|
## Coverage
|
||||||
|
|
||||||
|
| Case | Result |
|
||||||
|
|------|--------|
|
||||||
|
| Live candidates not pollute confirmed.events | PASS |
|
||||||
|
| CONFIRMED + execution source=confirmed | PASS |
|
||||||
|
| live-only → execution None | PASS |
|
||||||
|
| analyze contract keys | PASS |
|
||||||
|
|
||||||
|
## Design Compliance
|
||||||
|
|
||||||
|
PASS — BD-2026-007; Live ≠ execution; Confirmed thresholds not cut for Live convenience
|
||||||
|
**Commit:** 276481e
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# TEST_REPORT — ECR-008
|
||||||
|
|
||||||
|
**Date:** 2026-08-07
|
||||||
|
**ECR:** ECR-008
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node --check web/static/js/app/chart_tv_lifecycle.js
|
||||||
|
node --check web/static/js/app/chart_tv_shell.js
|
||||||
|
node --check web/static/js/app/chart_tv_indicators.js
|
||||||
|
node --check web/static/js/app/chart_tv_chan.js
|
||||||
|
node --check web/static/js/app/chart_tv_overlays.js
|
||||||
|
node --check web/static/js/app/chart_tv_finalize.js
|
||||||
|
node --check web/static/js/app/chart_tv.js
|
||||||
|
```
|
||||||
|
|
||||||
|
## Result
|
||||||
|
|
||||||
|
```text
|
||||||
|
ALL_CHECK_OK(2026-08-07)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Manual smoke checklist
|
||||||
|
|
||||||
|
| Case | Result |
|
||||||
|
|------|--------|
|
||||||
|
| 符号导出:`disposeTradingViewCharts` / `initTradingView` / 各 `chartTv*` | PASS(全局函数存在于对应文件) |
|
||||||
|
| 语法 | PASS |
|
||||||
|
| 浏览器:首屏 / 自动刷新 dispose / 威科夫 / 三周期元素 | 待 Human 硬刷新 `?v=20260807f` 目测 |
|
||||||
|
|
||||||
|
## Design Compliance
|
||||||
|
|
||||||
|
PASS — 无打包器;行为冻结搬移;API/strategies 未改。
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# TEST_REPORT — ECR-009
|
||||||
|
|
||||||
|
**Date:** 2026-08-07
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PYTHONPATH=. python -m pytest tests/test_crypto_wyckoff_decision.py -q
|
||||||
|
CRYPTO_WYCKOFF_DISABLE=1 PYTHONPATH=.:web python -m pytest web/tests/test_wyckoff_crypto_routes.py -q
|
||||||
|
# Manual / live:
|
||||||
|
# cd web && CRYPTO_WYCKOFF_MAX_SYMBOLS=5 PYTHONPATH=..:. python app.py
|
||||||
|
# curl -I http://127.0.0.1:8128/wyckoff_crypto
|
||||||
|
```
|
||||||
|
|
||||||
|
## Result
|
||||||
|
|
||||||
|
| Check | Result |
|
||||||
|
|-------|--------|
|
||||||
|
| Decision gate unit | 2 passed |
|
||||||
|
| Route page/meta/scan | 补测(本文件) |
|
||||||
|
| Live HTTP 2026-08-07 | `GET /wyckoff_crypto` → 200(需先启动 web) |
|
||||||
|
|
||||||
|
## Note
|
||||||
|
|
||||||
|
此前冒烟只做了引擎 tick,**未**在交付前保持 Flask 常驻并给浏览器 URL——属 ESS 测试缺口,已补路由测试与本报告。
|
||||||
@@ -44,3 +44,28 @@
|
|||||||
| 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 + 填充 3;bins≤24 | 人工 + ENG |
|
| ECR-004 | VP/填充少 series | ENG-004 | `chart_tv.js` Top-8 + 填充 3;bins≤24 | 人工 + ENG |
|
||||||
| ECR-004 | 阶段最小长度 + elements_only 门闩 | ENG-004 | `events.py` + `analyze.py` | 契约 `elements_only` |
|
| ECR-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 |
|
||||||
|
|
||||||
|
## ECR-009
|
||||||
|
|
||||||
|
| ECR | Requirement | Spec | Code | Test | Commit |
|
||||||
|
|-----|-------------|------|------|------|--------|
|
||||||
|
| ECR-009 | Crypto D/W/M screener 独立页 | ENG-009 | `crypto_wyckoff/` + `/wyckoff_crypto` | `test_crypto_wyckoff_decision` | ec08de0 |
|
||||||
|
| ECR-009 | 月线本地聚合 | ENG-009 | `io.rebuild_monthly_from_daily` | smoke tip | ec08de0 |
|
||||||
|
| ECR-009 | 不碰 analyze/缠论 | ECR-009 Forbidden | 新 API 前缀 | 人工 | ec08de0 |
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
# WYCKOFF-LIVE-STRUCTURE-001
|
||||||
|
|
||||||
|
**Status:** FROZEN
|
||||||
|
**Depends on:** WYCKOFF-MULTI-CYCLE-001
|
||||||
|
**Scope:** Live / Developing 结构层(独立于 Confirmed Engine)
|
||||||
|
|
||||||
|
## 核心原则
|
||||||
|
|
||||||
|
| Layer | 定位 |
|
||||||
|
|-------|------|
|
||||||
|
| Confirmed Engine | 历史结构事实 |
|
||||||
|
| Live Engine | 当前结构推演 |
|
||||||
|
|
||||||
|
禁止:
|
||||||
|
|
||||||
|
- 降低 Spring/SOS Confirmed 条件
|
||||||
|
- 用 Live candidate 替代 Confirmed event
|
||||||
|
- Execution 消费 Live / FORMING / Candidate / Prediction
|
||||||
|
|
||||||
|
## 状态机
|
||||||
|
|
||||||
|
```
|
||||||
|
UNKNOWN → FORMING → CONFIRMED → COMPLETED
|
||||||
|
```
|
||||||
|
|
||||||
|
## 数据契约(Live 不进 events[])
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"cycles": [{
|
||||||
|
"id": 0,
|
||||||
|
"lifecycle": "FORMING",
|
||||||
|
"confirmed": { "phases": [], "events": [] },
|
||||||
|
"live": {
|
||||||
|
"phase_candidate": "D",
|
||||||
|
"event_candidates": [{ "type": "SOS", "confidence": 0.62, "confirmed": false }],
|
||||||
|
"next_expected": "LPS",
|
||||||
|
"confidence": { "cycle": 0.72, "phase": 0.68, "event": 0.55, "overall": 0.65 }
|
||||||
|
}
|
||||||
|
}],
|
||||||
|
"live": { "...": "顶层镜像 cycles[0].live,便于 Summary" }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
兼容:顶层 `phases` / `events` 仍镜像 **Confirmed**(= ACTIVE cycle 的 confirmed 内容)。
|
||||||
|
|
||||||
|
## Candidate v1(仅启发式)
|
||||||
|
|
||||||
|
- Range Formation:横盘时长、波动收敛 → Potential Trading Range
|
||||||
|
- Phase C candidate:测低 / 下影 / 缩量
|
||||||
|
- Event candidates:Spring / SOS / LPS / UTAD only
|
||||||
|
|
||||||
|
## Confidence
|
||||||
|
|
||||||
|
可解释分层:`cycle` / `phase` / `event` / `overall`(structure+volume+event 加权),禁止黑盒 “AI probability”。
|
||||||
|
|
||||||
|
## Execution
|
||||||
|
|
||||||
|
```
|
||||||
|
assert execution_signal.source == "confirmed"
|
||||||
|
```
|
||||||
|
|
||||||
|
## No Change
|
||||||
|
|
||||||
|
- Confirmed 检测阈值、MULTI-CYCLE-001 排序、缠论 / strategies / chan_tv
|
||||||
|
|
||||||
|
## Only Change
|
||||||
|
|
||||||
|
- `chanlun/analysis/wyckoff/live.py`
|
||||||
|
- engine 组装 `lifecycle` / `confirmed` / `live`
|
||||||
|
- Summary 面板分区
|
||||||
|
- 测例
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
# WYCKOFF-LIVE-VALIDATION-001
|
||||||
|
|
||||||
|
**Status:** DRAFT(待确认执行后 FROZEN)
|
||||||
|
**Depends on:** WYCKOFF-LIVE-STRUCTURE-001(已 FROZEN)
|
||||||
|
**Goal:** 验证 Live 是否有预测价值,而非继续加事件规则
|
||||||
|
|
||||||
|
## 不做
|
||||||
|
|
||||||
|
- 不新增 BC / AR / ST / UT / UTAD(v1 已够)
|
||||||
|
- 不降低 Confirmed 门槛
|
||||||
|
- 不让 Execution 消费 Live
|
||||||
|
|
||||||
|
## 目标指标(先看演化,不看「准确率」口号)
|
||||||
|
|
||||||
|
### 1) Candidate → Confirmed 转化率
|
||||||
|
|
||||||
|
```
|
||||||
|
candidate_to_confirmed_rate = confirmed_count / candidate_count
|
||||||
|
```
|
||||||
|
|
||||||
|
按 event type 分组:Spring / SOS / LPS / UTAD。
|
||||||
|
|
||||||
|
### 2) 提前量(Lead)
|
||||||
|
|
||||||
|
```
|
||||||
|
lead_bars = confirmed_bar_index - first_candidate_bar_index
|
||||||
|
lead_price = |price_at_confirmed - price_at_first_candidate|
|
||||||
|
```
|
||||||
|
|
||||||
|
例:Spring candidate @ 62000 → Confirmed @ 63500 → lead_price=1500。
|
||||||
|
|
||||||
|
### 3) False Positive
|
||||||
|
|
||||||
|
```
|
||||||
|
false_candidate_rate = expired_unconfirmed / candidate_count
|
||||||
|
```
|
||||||
|
|
||||||
|
候选出现后,在窗口内未升格为 Confirmed,且价格无效化(如 Spring 后继续破位)。
|
||||||
|
|
||||||
|
## 采集方式(建议)
|
||||||
|
|
||||||
|
离线回放 / 批跑(非改 Live 规则):
|
||||||
|
|
||||||
|
```
|
||||||
|
for each bar in timerange:
|
||||||
|
run analyze_wyckoff(df[:bar])
|
||||||
|
log: cycle_id, lifecycle, live.candidates[], confirmed.events[]
|
||||||
|
```
|
||||||
|
|
||||||
|
输出:`reports/wyckoff_live_validation_{symbol}_{tf}_{date}.json` + 简表 CSV。
|
||||||
|
|
||||||
|
## Summary 文案(可选后续,本 ECR 可只做数据)
|
||||||
|
|
||||||
|
交易终端语言示例(不阻塞指标采集):
|
||||||
|
|
||||||
|
```
|
||||||
|
BTC 4H Wyckoff
|
||||||
|
Lifecycle: CONFIRMED
|
||||||
|
Confirmed: Accumulation → SOS → LPS
|
||||||
|
Current: Phase D continuation
|
||||||
|
Watching: New SOS extension
|
||||||
|
Confidence: 0.60
|
||||||
|
Risk: Below LPS invalidation
|
||||||
|
```
|
||||||
|
|
||||||
|
## 验收
|
||||||
|
|
||||||
|
1. 能对 BTC 4h(及可选 1h)跑出至少一类 Spring/SOS 的转化率与提前量
|
||||||
|
2. 报告可复现(固定 timerange + seed/数据快照说明)
|
||||||
|
3. 不修改 Confirmed / Live 检测逻辑(只读 + 日志)
|
||||||
|
|
||||||
|
## Only Change(确认执行后)
|
||||||
|
|
||||||
|
- `scripts/` 或 `tests/` 下批跑采集脚本
|
||||||
|
- `docs/notes` 或 `reports/` 输出样例
|
||||||
|
- 可选:Summary 文案升级(独立小项)
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
# WYCKOFF-MULTI-CYCLE-001
|
||||||
|
|
||||||
|
**Status:** FROZEN
|
||||||
|
**Scope:** Wyckoff Cycle Detection Layer
|
||||||
|
|
||||||
|
## No Change
|
||||||
|
|
||||||
|
- `chan.py` / 笔 / 段 / 中枢
|
||||||
|
- `strategies/`
|
||||||
|
- `/chan_tv`
|
||||||
|
|
||||||
|
## Only Change
|
||||||
|
|
||||||
|
- wyckoff range detection
|
||||||
|
- wyckoff engine payload
|
||||||
|
- API localization
|
||||||
|
- chart rendering
|
||||||
|
- tests
|
||||||
|
|
||||||
|
## Frozen Rules
|
||||||
|
|
||||||
|
1. 每个 TF 最大 8 个周期
|
||||||
|
2. `cycles[0]` 永远为 ACTIVE;`cycles[1:]` 为 HISTORICAL
|
||||||
|
3. **禁止**用 `cycles[-1]` 判断 active;唯一来源:`active_cycle = cycles[0]`
|
||||||
|
4. 周期不可重叠;按时间倒序(近 → 远)
|
||||||
|
5. 顶层字段只镜像 `cycles[0]`
|
||||||
|
6. 历史 cycle 只用于展示/分析,不参与当前交易决策
|
||||||
|
7. 多 TF 只同步 active cycle(`prefer_start_time` ← 主 TF `cycles[0]`)
|
||||||
|
8. 每个 cycle 必须可追溯:`period` / `status` / `role` / `confidence`
|
||||||
|
9. 嵌套箱:`overlap_ratio < 0.2` 才可并存;否则丢弃
|
||||||
|
10. 验收重点:历史周期稳定复现 + active 不漂移
|
||||||
|
|
||||||
|
## Layer Duties
|
||||||
|
|
||||||
|
```
|
||||||
|
range.py
|
||||||
|
_detect_in_window() → TradingRange # 仅起止、高低、结构分
|
||||||
|
detect_trading_ranges() → list[TR] # 倒序扫 + 过滤 + mask
|
||||||
|
|
||||||
|
engine.py
|
||||||
|
phases / events / VP / confidence aggregation → cycles[]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Filter Order(不可改)
|
||||||
|
|
||||||
|
```
|
||||||
|
candidate window
|
||||||
|
→ detect range
|
||||||
|
→ quality filter
|
||||||
|
→ trend contamination filter
|
||||||
|
→ overlap filter (<0.2)
|
||||||
|
→ accept cycle
|
||||||
|
→ mask
|
||||||
|
```
|
||||||
|
|
||||||
|
禁止先 mask 再判断质量。
|
||||||
|
|
||||||
|
## Display / Summary (2026-08-06)
|
||||||
|
|
||||||
|
- 图面阶段标记:`{TF} C{id} Phase {X}`;事件:`{TF} C{id} {Event}`
|
||||||
|
- Cycle Summary 面板:消费 `cycles[0]`,写入 `window.wyckoffCycleSummary`
|
||||||
|
- 检测算法本轮不改;质量阈值 / 历史层折叠为后续项
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"ecr": "ECR-007",
|
||||||
|
"result": "PASS",
|
||||||
|
"ess_version": "v1.0",
|
||||||
|
"gate_version": "0.1.2",
|
||||||
|
"project_profile": "unknown",
|
||||||
|
"checks": {
|
||||||
|
"artifact": true,
|
||||||
|
"role_boundary": true,
|
||||||
|
"backend_boundary": true,
|
||||||
|
"traceability": true,
|
||||||
|
"tests": true
|
||||||
|
},
|
||||||
|
"violations": [],
|
||||||
|
"errors": [],
|
||||||
|
"warnings": [],
|
||||||
|
"timestamp": "2026-08-06T19:14:19Z"
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# LOOP-RUN-005 — ECR-007 archive
|
||||||
|
|
||||||
|
**Feature:** WYCKOFF-LIVE-STRUCTURE
|
||||||
|
**ECR:** ECR-007 · **BD:** BD-2026-007
|
||||||
|
**Decision:** FINAL_APPROVAL · gate PASS
|
||||||
|
**Implementation:** `276481e`
|
||||||
|
|
||||||
|
## Contents
|
||||||
|
|
||||||
|
| Path | Note |
|
||||||
|
|------|------|
|
||||||
|
| `task.yaml` / `result.yaml` / `human_interventions.yaml` | Loop runner state |
|
||||||
|
| `ECR-007-gate-report.json` | ess-gate-check PASS |
|
||||||
|
| `artifacts/` | plan · gate · code_review · test_report |
|
||||||
|
|
||||||
|
Code diff 以 git commit `276481e` 为准(未归档 192KB `diff.patch`)。
|
||||||
|
|
||||||
|
Working dirs `.gates/` / `loop/` 已忽略,勿再提交。
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"stage": "code_reviewer",
|
||||||
|
"decision": "PASS",
|
||||||
|
"checks": {
|
||||||
|
"state_machine_boundary": "PASS",
|
||||||
|
"confidence_explainability": "PASS",
|
||||||
|
"backward_compatibility": "PASS",
|
||||||
|
"live_ne_execution": "PASS",
|
||||||
|
"confirmed_thresholds": "PASS"
|
||||||
|
},
|
||||||
|
"artifact": "docs/HANDOFF/ECR-007-code-review.md"
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"ecr": "ECR-007",
|
||||||
|
"result": "PASS",
|
||||||
|
"ess_version": "v1.0",
|
||||||
|
"gate_version": "0.1.2",
|
||||||
|
"project_profile": "unknown",
|
||||||
|
"checks": {
|
||||||
|
"artifact": true,
|
||||||
|
"role_boundary": true,
|
||||||
|
"backend_boundary": true,
|
||||||
|
"traceability": true,
|
||||||
|
"tests": true
|
||||||
|
},
|
||||||
|
"violations": [],
|
||||||
|
"errors": [],
|
||||||
|
"warnings": [],
|
||||||
|
"timestamp": "2026-08-06T19:14:19Z"
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
artifact_schema:
|
||||||
|
version: 1
|
||||||
|
|
||||||
|
# LOOP-RUN-005 Planner — domain-state complexity (observe Confirmed vs Live)
|
||||||
|
|
||||||
|
layers:
|
||||||
|
- id: confirmed_engine
|
||||||
|
role: historical structure facts (range/phases/events) — thresholds UNCHANGED
|
||||||
|
- id: live_engine
|
||||||
|
role: FORMING candidates + confidence — independent of Confirmed writes
|
||||||
|
- id: lifecycle
|
||||||
|
role: UNKNOWN → FORMING → CONFIRMED → COMPLETED per cycle
|
||||||
|
- id: api_contract
|
||||||
|
role: analyze payload cycles[].confirmed / cycles[].live / top-level live mirror
|
||||||
|
- id: summary_ui
|
||||||
|
role: Confirmed vs Live partitioned Summary (observation only)
|
||||||
|
|
||||||
|
delivery_constraints:
|
||||||
|
required:
|
||||||
|
- commit_exists_in_traceability_or_test_report
|
||||||
|
- bd_status_format_approved
|
||||||
|
- test_report_with_commands_result_date
|
||||||
|
- code_review_handoff
|
||||||
|
- out_of_scope_declared
|
||||||
|
- execution_source_confirmed_only
|
||||||
|
gate:
|
||||||
|
ecr: ECR-007
|
||||||
|
command: ess-gate-check --ecr ECR-007
|
||||||
|
|
||||||
|
out_of_scope:
|
||||||
|
- execution signal automation / auto trading
|
||||||
|
- strategy / maker / decide_quotes / strategies/**
|
||||||
|
- lowering Confirmed Spring/SOS thresholds
|
||||||
|
- using Live candidate as Confirmed event or execution input
|
||||||
|
- Subagents / Adapter v0.2 / auto-retry
|
||||||
|
- chan algorithm (笔/线段/中枢) changes
|
||||||
|
|
||||||
|
scope:
|
||||||
|
files:
|
||||||
|
- chanlun/analysis/wyckoff/live.py
|
||||||
|
- chanlun/analysis/wyckoff/engine.py
|
||||||
|
- chanlun/analysis/wyckoff/__init__.py
|
||||||
|
- chanlun/analysis/wyckoff/events.py
|
||||||
|
- chanlun/analysis/wyckoff/range.py
|
||||||
|
- tests/test_wyckoff.py
|
||||||
|
- web/api/analyze.py
|
||||||
|
- web/static/js/app/ui.js
|
||||||
|
- web/templates/index.html
|
||||||
|
- web/tests/test_analyze_contract.py
|
||||||
|
- tests/fixtures/analyze_contract_keys.json
|
||||||
|
- docs/notes/WYCKOFF-LIVE-STRUCTURE-001.md
|
||||||
|
- docs/ECR/ECR-007-wyckoff-live-structure.md
|
||||||
|
- docs/BACKEND_DESIGN/BD-2026-007-wyckoff-live-structure.md
|
||||||
|
- docs/ENGINEERING_SPEC/ECR-007-wyckoff-live-structure.md
|
||||||
|
- docs/HANDOFF/ECR-007-architect-to-engineer.md
|
||||||
|
- docs/HANDOFF/ECR-007-code-review.md
|
||||||
|
- docs/HANDOFF/ECR-007-engineer-to-reviewer.md
|
||||||
|
- docs/TEST_REPORT/ECR-007.md
|
||||||
|
- docs/STATE/ECR-007.md
|
||||||
|
- docs/TRACEABILITY.md
|
||||||
|
- docs/CHANGELOG/CHANGELOG.md
|
||||||
|
|
||||||
|
boundary:
|
||||||
|
forbidden:
|
||||||
|
- strategies/
|
||||||
|
- decide_quotes / maker
|
||||||
|
- Live → execution_signal
|
||||||
|
- ESS / Loop v1.1 / Adapter v0.1
|
||||||
|
|
||||||
|
acceptance:
|
||||||
|
- lifecycle + confirmed/live separation in analyze_wyckoff output
|
||||||
|
- event_candidates confirmed=false; not in top-level events unless Confirmed
|
||||||
|
- execution_signal_from_wyckoff source==confirmed; live-only → None
|
||||||
|
- Summary shows Confirmed vs Live partition
|
||||||
|
- pytest test_wyckoff + analyze_contract green
|
||||||
|
- ess-gate-check ECR-007
|
||||||
|
|
||||||
|
risks: |
|
||||||
|
Primary Guardian risk: Live candidate mistaken for execution signal.
|
||||||
|
Code Review: state machine boundary, confidence explainability, backward compat of phases/events.
|
||||||
|
|
||||||
|
notes: |
|
||||||
|
Planner must name Confirmed / Live / Lifecycle / Event Candidate explicitly.
|
||||||
|
delivery_constraints include execution_source_confirmed_only.
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"stage": "validator",
|
||||||
|
"result": "PASS",
|
||||||
|
"commands": [
|
||||||
|
"PYTHONPATH=. python -m pytest tests/test_wyckoff.py -q",
|
||||||
|
"PYTHONPATH=. python -m pytest web/tests/test_analyze_contract.py -q"
|
||||||
|
],
|
||||||
|
"summary": "17 passed (9 wyckoff + 8 contract)",
|
||||||
|
"notes": "Live isolation + execution_signal confirmed-only"
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
interventions:
|
||||||
|
- stage: START_AUTHORIZATION
|
||||||
|
reason: "authorize LOOP-RUN-005 ECR-007 Wyckoff Live Structure (supervised; Adapter v0.1 STABLE)"
|
||||||
|
note: "Human Gate #1 — Goal + Authorization merged"
|
||||||
|
- stage: FINAL_APPROVAL
|
||||||
|
reason: "LOOP-RUN-005 approved — proceed to --approve and archive"
|
||||||
|
note: "Human Gate #2"
|
||||||
|
notes: |
|
||||||
|
No Plan Mode; no mid-build confirm; no Subagents / Adapter v0.2 / auto-retry.
|
||||||
|
Live ≠ execution signal held; TR-COMMIT BLOCK→PASS retained as training signal.
|
||||||
|
Final Approval distinct from Start Authorization.
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
loop:
|
||||||
|
id: LOOP-RUN-005
|
||||||
|
feature: ECR-007-WYCKOFF-LIVE-STRUCTURE
|
||||||
|
ecr: ECR-007
|
||||||
|
current_state: DONE
|
||||||
|
retry_count: 0
|
||||||
|
history:
|
||||||
|
- state: CREATED
|
||||||
|
timestamp: '2026-08-06T19:12:00Z'
|
||||||
|
actor: runner
|
||||||
|
result: INIT
|
||||||
|
- state: CREATED
|
||||||
|
timestamp: '2026-08-06T19:13:48Z'
|
||||||
|
actor: runner
|
||||||
|
result: PASS
|
||||||
|
detail: →PLANNING
|
||||||
|
- state: PLANNING
|
||||||
|
timestamp: '2026-08-06T19:13:48Z'
|
||||||
|
actor: runner
|
||||||
|
result: PASS
|
||||||
|
detail: →BUILDING
|
||||||
|
- state: BUILDING
|
||||||
|
timestamp: '2026-08-06T19:14:34Z'
|
||||||
|
actor: runner
|
||||||
|
result: PASS
|
||||||
|
detail: →VALIDATING
|
||||||
|
- state: VALIDATING
|
||||||
|
timestamp: '2026-08-06T19:14:34Z'
|
||||||
|
actor: runner
|
||||||
|
result: PASS
|
||||||
|
detail: →CODE_REVIEW
|
||||||
|
- state: CODE_REVIEW
|
||||||
|
timestamp: '2026-08-06T19:14:34Z'
|
||||||
|
actor: runner
|
||||||
|
result: PASS
|
||||||
|
detail: →GUARDING
|
||||||
|
- state: GUARDING
|
||||||
|
timestamp: '2026-08-06T19:14:34Z'
|
||||||
|
actor: runner
|
||||||
|
result: PASS
|
||||||
|
detail: →READY_FOR_APPROVAL
|
||||||
|
- state: READY_FOR_APPROVAL
|
||||||
|
timestamp: '2026-08-06T19:19:41Z'
|
||||||
|
actor: runner
|
||||||
|
result: APPROVED
|
||||||
|
- state: DONE
|
||||||
|
timestamp: '2026-08-06T19:19:41Z'
|
||||||
|
actor: runner
|
||||||
|
result: DONE
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
# LOOP-RUN-005 — ECR-007 Wyckoff Live Structure
|
||||||
|
# Adapter v0.1 STABLE · single agent · supervised
|
||||||
|
# Human Gate #1: Start Authorization granted
|
||||||
|
|
||||||
|
id: LOOP-RUN-005
|
||||||
|
feature: ECR-007-WYCKOFF-LIVE-STRUCTURE
|
||||||
|
ecr: ECR-007
|
||||||
|
project_profile: "2026.08"
|
||||||
|
|
||||||
|
goal: |
|
||||||
|
验证 Engineering Loop v1.1 + Adapter v0.1 在高领域状态复杂度 Feature 下的执行稳定性。
|
||||||
|
实现 Wyckoff Confirmed + Live Structure 分层,观察层与执行层严格隔离。
|
||||||
|
|
||||||
|
authorization:
|
||||||
|
approved_by: human
|
||||||
|
feature: ECR-007
|
||||||
|
run: LOOP-RUN-005
|
||||||
|
constraints:
|
||||||
|
- no_ess_change
|
||||||
|
- no_loop_v1_1_change
|
||||||
|
- no_adapter_v0_1_change
|
||||||
|
- single_agent
|
||||||
|
- supervised
|
||||||
|
- no_subagents
|
||||||
|
- no_auto_retry
|
||||||
|
- no_live_as_execution_signal
|
||||||
|
- no_confirmed_threshold_lowering
|
||||||
|
|
||||||
|
constraints:
|
||||||
|
allowed:
|
||||||
|
- "chanlun/analysis/wyckoff/**"
|
||||||
|
- "tests/test_wyckoff.py"
|
||||||
|
- "tests/fixtures/**"
|
||||||
|
- "tests/generate_golden.py"
|
||||||
|
- "tests/test_golden_pipeline.py"
|
||||||
|
- "web/api/analyze.py"
|
||||||
|
- "web/api/pages.py"
|
||||||
|
- "web/static/js/app/**"
|
||||||
|
- "web/templates/index.html"
|
||||||
|
- "web/tests/**"
|
||||||
|
- "web/services/runtime/timeframes.py"
|
||||||
|
- "docs/**"
|
||||||
|
- "loop/**"
|
||||||
|
forbidden:
|
||||||
|
- "strategies/**"
|
||||||
|
- "**/decide_quotes*"
|
||||||
|
- "maker/**"
|
||||||
|
- "skills/engineering-spec-system/**"
|
||||||
|
- "docs/architecture/ENGINEERING-LOOP-V1.1.md"
|
||||||
|
notes:
|
||||||
|
- Confirmed detection thresholds UNCHANGED
|
||||||
|
- Live candidates must never replace Confirmed events
|
||||||
|
- execution_signal_from_wyckoff source must be confirmed only
|
||||||
|
|
||||||
|
acceptance:
|
||||||
|
criteria:
|
||||||
|
- Confirmed logic unchanged (events.py confirm rules not relaxed)
|
||||||
|
- execution only consumes confirmed
|
||||||
|
- Live ≠ execution signal
|
||||||
|
- lifecycle transitions verifiable (UNKNOWN/FORMING/CONFIRMED/COMPLETED)
|
||||||
|
- API contract + Summary display Confirmed/Live separation
|
||||||
|
- Artifact chain complete
|
||||||
|
commands:
|
||||||
|
- "PYTHONPATH=. python -m pytest tests/test_wyckoff.py -q"
|
||||||
|
- "PYTHONPATH=. python -m pytest web/tests/test_analyze_contract.py -q"
|
||||||
|
|
||||||
|
execution:
|
||||||
|
autonomy: supervised
|
||||||
|
adapter: none
|
||||||
|
|
||||||
|
ess:
|
||||||
|
gate_command: "python ${ESS_ROOT}/scripts/ess-gate-check.py --project . --ecr ECR-007"
|
||||||
|
|
||||||
|
observe:
|
||||||
|
planner_domain: Confirmed + Live + Lifecycle + Event Candidate
|
||||||
|
guardian_risk: live_candidate_must_not_become_execution_signal
|
||||||
|
human_gates: start_authorization + final_approval
|
||||||
+8
-4
@@ -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]=ACTIVE;phases/events=Confirmed;live=Developing(WYCKOFF-LIVE-STRUCTURE-001);Execution 仅 Confirmed;见 docs/notes/"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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_wyckoff;include_wyckoff=0 可跳过;elements_only 时不返回",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
"""Unit tests for TF combo validation."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from crypto_wyckoff.combos import (
|
||||||
|
add_combo,
|
||||||
|
delete_combo,
|
||||||
|
get_combo,
|
||||||
|
list_combos,
|
||||||
|
validate_combo,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_builtin_default_is_h8_4_1():
|
||||||
|
c = get_combo(None)
|
||||||
|
assert c["id"] == "h8_4_1"
|
||||||
|
assert (c["high"], c["mid"], c["low"]) == ("8h", "4h", "1h")
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_order():
|
||||||
|
assert validate_combo("8h", "4h", "1h") is None
|
||||||
|
assert validate_combo("1h", "4h", "8h") is not None
|
||||||
|
assert validate_combo("8h", "8h", "1h") is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_includes_dwm():
|
||||||
|
ids = {c["id"] for c in list_combos()}
|
||||||
|
assert "h8_4_1" in ids
|
||||||
|
assert "d_w_m" in ids
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
"""Decision engine MTF gate tests (ported semantics)."""
|
||||||
|
|
||||||
|
from crypto_wyckoff.domain_models import (
|
||||||
|
DecisionSignal,
|
||||||
|
EngineResult,
|
||||||
|
WyckoffCycle,
|
||||||
|
WyckoffEvent,
|
||||||
|
WyckoffPhase,
|
||||||
|
)
|
||||||
|
from crypto_wyckoff.decision import DecisionEngine
|
||||||
|
|
||||||
|
|
||||||
|
def _er(name, payload, score=70, confidence=70):
|
||||||
|
return EngineResult(name=name, score=score, confidence=confidence, payload=payload)
|
||||||
|
|
||||||
|
|
||||||
|
def test_monthly_distribution_daily_spring_is_watch():
|
||||||
|
eng = DecisionEngine()
|
||||||
|
monthly = _er("Cycle", {"cycle": WyckoffCycle.DISTRIBUTION.value, "trend_score": 40}, score=40)
|
||||||
|
weekly_c = _er("Cycle", {"cycle": WyckoffCycle.ACCUMULATION.value, "trend_score": 70}, score=70)
|
||||||
|
weekly_p = _er(
|
||||||
|
"Phase",
|
||||||
|
{"phase": WyckoffPhase.B.value, "cycle": WyckoffCycle.ACCUMULATION.value, "structure_score": 65},
|
||||||
|
score=65,
|
||||||
|
)
|
||||||
|
weekly_e = _er("Event", {"current_event": WyckoffEvent.ST.value, "recent_events": ["SC", "AR", "ST"]}, score=60)
|
||||||
|
daily_e = _er(
|
||||||
|
"Event",
|
||||||
|
{"current_event": WyckoffEvent.SPRING.value, "recent_events": ["SC", "AR", "ST", "Spring"], "entry_score": 92},
|
||||||
|
score=92,
|
||||||
|
confidence=92,
|
||||||
|
)
|
||||||
|
daily_s = _er("Signal", {"signal_label": "Spring", "current_event": "Spring"}, confidence=92, score=92)
|
||||||
|
out = eng.run(monthly, weekly_c, weekly_p, weekly_e, daily_e, daily_s)
|
||||||
|
assert out.payload["decision_signal"] == DecisionSignal.WATCH.value
|
||||||
|
assert out.payload["d_event"] == WyckoffEvent.SPRING.value
|
||||||
|
|
||||||
|
|
||||||
|
def test_bull_alignment_can_strong_buy():
|
||||||
|
eng = DecisionEngine()
|
||||||
|
monthly = _er("Cycle", {"cycle": WyckoffCycle.MARKUP.value, "trend_score": 90}, score=90, confidence=90)
|
||||||
|
weekly_c = _er("Cycle", {"cycle": WyckoffCycle.ACCUMULATION.value, "trend_score": 85}, score=85, confidence=85)
|
||||||
|
weekly_p = _er(
|
||||||
|
"Phase",
|
||||||
|
{"phase": WyckoffPhase.D.value, "cycle": WyckoffCycle.ACCUMULATION.value, "structure_score": 88},
|
||||||
|
score=88,
|
||||||
|
confidence=88,
|
||||||
|
)
|
||||||
|
weekly_e = _er("Event", {"current_event": WyckoffEvent.SOS.value, "recent_events": ["SOS"]}, score=85, confidence=85)
|
||||||
|
daily_e = _er(
|
||||||
|
"Event",
|
||||||
|
{
|
||||||
|
"current_event": WyckoffEvent.SPRING.value,
|
||||||
|
"recent_events": ["SC", "AR", "ST", "Spring", "Test"],
|
||||||
|
"active_events": ["SC", "AR", "ST", "Spring"],
|
||||||
|
"entry_score": 92,
|
||||||
|
},
|
||||||
|
score=92,
|
||||||
|
confidence=92,
|
||||||
|
)
|
||||||
|
daily_s = _er("Signal", {"signal_label": "Spring"}, confidence=92, score=92)
|
||||||
|
out = eng.run(monthly, weekly_c, weekly_p, weekly_e, daily_e, daily_s)
|
||||||
|
assert out.payload["decision_signal"] in (
|
||||||
|
DecisionSignal.STRONG_BUY.value,
|
||||||
|
DecisionSignal.BUY.value,
|
||||||
|
)
|
||||||
@@ -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
@@ -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"])
|
||||||
|
# 模拟 4h:TR.low 已吃进 Spring
|
||||||
|
tr = {
|
||||||
|
"abs_start_idx": 0,
|
||||||
|
"abs_end_idx": 59,
|
||||||
|
"abs_scan_end_idx": len(df) - 1,
|
||||||
|
"high": 60.0,
|
||||||
|
"low": 38.0,
|
||||||
|
"mid": 49.0,
|
||||||
|
"tol": 1.0,
|
||||||
|
}
|
||||||
|
from chanlun.analysis.wyckoff.events import detect_bias_and_events, build_phases
|
||||||
|
|
||||||
|
bias, ev, _ = detect_bias_and_events(df, tr)
|
||||||
|
ph = build_phases(df, tr, bias, ev)
|
||||||
|
assert "Spring" in {e["type"] for e in ev}
|
||||||
|
assert "C" in {p["phase"] for p in ph}
|
||||||
|
assert bias == "accumulation"
|
||||||
|
|
||||||
|
|
||||||
def test_range_scoring_skips_pretrend():
|
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 不含 candidate;live 可有 Spring candidate。"""
|
||||||
|
from chanlun.analysis.wyckoff.live import analyze_live_structure
|
||||||
|
|
||||||
|
rng = np.random.default_rng(11)
|
||||||
|
t0 = pd.Timestamp("2024-05-01", tz="UTC")
|
||||||
|
rows = []
|
||||||
|
lo, hi = 40.0, 60.0
|
||||||
|
for i in range(40):
|
||||||
|
c = lo + (hi - lo) * (0.35 + 0.3 * rng.random())
|
||||||
|
o = c
|
||||||
|
h = min(hi, max(o, c) + 0.8)
|
||||||
|
l = max(lo, min(o, c) - 0.8)
|
||||||
|
rows.append((t0 + pd.Timedelta(hours=i), o, h, l, c, 100.0))
|
||||||
|
# 正在测下沿:长下影,尚未形成 Confirmed Spring 所需的刺破+收回序列写进 events 引擎
|
||||||
|
rows.append((t0 + pd.Timedelta(hours=40), 42.0, 44.0, 39.5, 42.5, 70.0))
|
||||||
|
df = pd.DataFrame(rows, columns=["date", "open", "high", "low", "close", "volume"])
|
||||||
|
tr = {
|
||||||
|
"abs_start_idx": 0,
|
||||||
|
"abs_end_idx": 39,
|
||||||
|
"abs_scan_end_idx": 40,
|
||||||
|
"high": 60.0,
|
||||||
|
"low": 40.0,
|
||||||
|
"mid": 50.0,
|
||||||
|
"tol": 1.0,
|
||||||
|
"atr": 1.5,
|
||||||
|
"bars": 40,
|
||||||
|
}
|
||||||
|
live = analyze_live_structure(df, tr, confirmed_events=[], confirmed_phases=[], bias="accumulation")
|
||||||
|
assert live["lifecycle"] in ("FORMING", "UNKNOWN", "CONFIRMED")
|
||||||
|
# 无 confirmed 输入时,candidates 可含 Spring,且 confirmed flag 全 false
|
||||||
|
for c in live.get("event_candidates") or []:
|
||||||
|
assert c.get("confirmed") is False
|
||||||
|
# 完整 analyze:顶层 events 不得因 live 凭空增加假 Spring(本合成无真 Spring)
|
||||||
|
out = analyze_wyckoff(df, lookback=len(df), min_bars=20)
|
||||||
|
assert "Spring" not in {e["type"] for e in (out.get("events") or [])} or out["lifecycle"] == "CONFIRMED"
|
||||||
|
# live 与 confirmed 分离
|
||||||
|
c0 = (out.get("cycles") or [{}])[0]
|
||||||
|
if c0.get("live") and c0["live"].get("event_candidates"):
|
||||||
|
for c in c0["live"]["event_candidates"]:
|
||||||
|
assert c.get("confirmed") is False
|
||||||
|
confirmed_types = {e["type"] for e in (c0.get("confirmed") or {}).get("events") or []}
|
||||||
|
for c in c0["live"]["event_candidates"]:
|
||||||
|
# candidate 不应出现在 confirmed(同 type 且仅 candidate)
|
||||||
|
if c["type"] not in confirmed_types:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def test_confirmed_upgrade_and_execution_isolation():
|
||||||
|
"""有 Spring+SOS 确认 → lifecycle CONFIRMED;execution.source==confirmed。"""
|
||||||
|
from chanlun.analysis.wyckoff import execution_signal_from_wyckoff
|
||||||
|
|
||||||
|
df = _box_df(spring=True, sos=True)
|
||||||
|
out = analyze_wyckoff(df, lookback=200)
|
||||||
|
assert len(out.get("cycles") or []) >= 1
|
||||||
|
c0 = out["cycles"][0]
|
||||||
|
assert c0["status"] == "ACTIVE"
|
||||||
|
types = {e["type"] for e in (c0.get("confirmed") or {}).get("events") or out.get("events") or []}
|
||||||
|
assert "Spring" in types and "SOS" in types
|
||||||
|
assert c0.get("lifecycle") == "CONFIRMED"
|
||||||
|
# live 不得把已确认事件再标为 candidate
|
||||||
|
for c in (c0.get("live") or {}).get("event_candidates") or []:
|
||||||
|
assert c["type"] not in types
|
||||||
|
sig = execution_signal_from_wyckoff(out)
|
||||||
|
assert sig is not None
|
||||||
|
assert sig["source"] == "confirmed"
|
||||||
|
# 仅 live、无 confirmed 时不得给 execution
|
||||||
|
empty_live_only = {
|
||||||
|
"cycles": [{
|
||||||
|
"id": 0,
|
||||||
|
"lifecycle": "FORMING",
|
||||||
|
"confirmed": {"events": [], "phases": []},
|
||||||
|
"live": {"event_candidates": [{"type": "Spring", "confirmed": False}]},
|
||||||
|
}],
|
||||||
|
"events": [],
|
||||||
|
}
|
||||||
|
assert execution_signal_from_wyckoff(empty_live_only) is None
|
||||||
|
|
||||||
|
|
||||||
|
def _make_box_segment(t0, n, lo, hi, freq_hours, rng, base_i=0):
|
||||||
|
rows = []
|
||||||
|
for i in range(n):
|
||||||
|
c = lo + (hi - lo) * (0.3 + 0.4 * rng.random())
|
||||||
|
o = c + rng.normal(0, 0.2)
|
||||||
|
h = min(hi + 0.3, max(o, c) + 0.4)
|
||||||
|
l = max(lo - 0.3, min(o, c) - 0.4)
|
||||||
|
if i % 8 == 0:
|
||||||
|
h = hi - 0.1
|
||||||
|
if i % 8 == 3:
|
||||||
|
l = lo + 0.1
|
||||||
|
rows.append((t0 + pd.Timedelta(hours=freq_hours * (base_i + i)), o, h, l, c, 90.0))
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def test_multi_cycle_two_boxes_with_trend():
|
||||||
|
"""Test A:双箱 + 中间趋势;cycles[0] 更新、不重叠、顶层镜像 cycles[0]。"""
|
||||||
|
rng = np.random.default_rng(3)
|
||||||
|
t0 = pd.Timestamp("2024-01-01", tz="UTC")
|
||||||
|
rows = []
|
||||||
|
# 早箱 100-110
|
||||||
|
rows += _make_box_segment(t0, 50, 100.0, 110.0, 1, rng, 0)
|
||||||
|
# 中间上涨趋势
|
||||||
|
price = 110.0
|
||||||
|
for i in range(40):
|
||||||
|
price += 0.8 + rng.random() * 0.3
|
||||||
|
o, c = price - 0.2, price
|
||||||
|
h, l = max(o, c) + 0.3, min(o, c) - 0.3
|
||||||
|
rows.append((t0 + pd.Timedelta(hours=50 + i), o, h, l, c, 100.0))
|
||||||
|
# 近端箱
|
||||||
|
lo2, hi2 = price - 4, price + 4
|
||||||
|
rows += _make_box_segment(t0, 50, lo2, hi2, 1, rng, 90)
|
||||||
|
df = pd.DataFrame(rows, columns=["date", "open", "high", "low", "close", "volume"])
|
||||||
|
|
||||||
|
out = analyze_wyckoff(df, lookback=len(df), min_bars=24, max_cycles=8)
|
||||||
|
cycles = out.get("cycles") or []
|
||||||
|
assert len(cycles) >= 2
|
||||||
|
assert cycles[0]["status"] == "ACTIVE"
|
||||||
|
assert cycles[1]["status"] == "HISTORICAL"
|
||||||
|
# 时间倒序:C0.end > C1.end
|
||||||
|
e0 = pd.Timestamp(cycles[0]["period"]["end_time"])
|
||||||
|
e1 = pd.Timestamp(cycles[1]["period"]["end_time"])
|
||||||
|
assert e0 > e1
|
||||||
|
# 不重叠
|
||||||
|
a0 = cycles[0]["trading_range"]
|
||||||
|
# 用引擎内部 abs 不在 payload;用 period 时间近似
|
||||||
|
s0 = pd.Timestamp(cycles[0]["period"]["start_time"])
|
||||||
|
s1 = pd.Timestamp(cycles[1]["period"]["start_time"])
|
||||||
|
# C1 应完全在 C0 之前
|
||||||
|
assert e1 <= s0 or (e1 - s0).total_seconds() <= 3600
|
||||||
|
# 顶层 == cycles[0]
|
||||||
|
assert out["trading_range"]["start_time"] == cycles[0]["trading_range"]["start_time"]
|
||||||
|
assert out["trading_range"]["high"] == cycles[0]["trading_range"]["high"]
|
||||||
|
assert out["trading_range"]["low"] == cycles[0]["trading_range"]["low"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_multi_cycle_nested_box_no_overlap():
|
||||||
|
"""Test B:大箱套小箱不得产出 overlap_ratio>=0.2 的两段。"""
|
||||||
|
rng = np.random.default_rng(5)
|
||||||
|
t0 = pd.Timestamp("2024-03-01", tz="UTC")
|
||||||
|
# 大箱 80 根
|
||||||
|
rows = _make_box_segment(t0, 80, 40.0, 60.0, 1, rng, 0)
|
||||||
|
df = pd.DataFrame(rows, columns=["date", "open", "high", "low", "close", "volume"])
|
||||||
|
trs = detect_trading_ranges(df, lookback=len(df), min_bars=20, max_cycles=8)
|
||||||
|
# 任意两段 overlap < 0.2
|
||||||
|
for i in range(len(trs)):
|
||||||
|
for j in range(i + 1, len(trs)):
|
||||||
|
r = _overlap_ratio(
|
||||||
|
int(trs[i]["abs_start_idx"]),
|
||||||
|
int(trs[i]["abs_end_idx"]),
|
||||||
|
int(trs[j]["abs_start_idx"]),
|
||||||
|
int(trs[j]["abs_end_idx"]),
|
||||||
|
)
|
||||||
|
assert r < 0.2, f"overlap {r} between {i} and {j}"
|
||||||
|
|
||||||
|
out = analyze_wyckoff(df, lookback=len(df), min_bars=20, max_cycles=8)
|
||||||
|
cycles = out.get("cycles") or []
|
||||||
|
assert len(cycles) >= 1
|
||||||
|
assert cycles[0]["status"] == "ACTIVE"
|
||||||
|
# 若有两段,时间窗也不应高度重叠
|
||||||
|
if len(cycles) >= 2:
|
||||||
|
# period 不重叠:历史 end <= active start(允许 1h 容差)
|
||||||
|
assert pd.Timestamp(cycles[1]["period"]["end_time"]) <= pd.Timestamp(
|
||||||
|
cycles[0]["period"]["start_time"]
|
||||||
|
) + pd.Timedelta(hours=2)
|
||||||
|
|||||||
+201
-25
@@ -2,9 +2,103 @@
|
|||||||
from flask import Blueprint, jsonify, request
|
from flask import Blueprint, jsonify, request
|
||||||
from services.runtime import * # noqa: F403
|
from services.runtime import * # noqa: F403
|
||||||
from services import runtime as R
|
from services import runtime as R
|
||||||
|
# import * 不会带出下划线私有名;结构区缓存需显式导入
|
||||||
|
from services.runtime.state import _zone_cache
|
||||||
|
from services.runtime.timeframes import _zone_cache_ttl
|
||||||
|
|
||||||
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 +119,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 +346,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 +525,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 +755,110 @@ 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] 的 start(WYCKOFF-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 bins(A+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}")
|
return jsonify(result)
|
||||||
import traceback
|
|
||||||
traceback.print_exc()
|
|
||||||
result['wyckoff'] = {
|
def _serialize_kl_tail(df, limit: int):
|
||||||
'trading_range': None,
|
"""只序列化最近 limit 根,供自动刷新增量合并。"""
|
||||||
'bias': 'unknown',
|
if df is None or getattr(df, "empty", True):
|
||||||
'phases': [],
|
return []
|
||||||
'events': [],
|
tail = df.tail(limit)
|
||||||
'volume_profile': {'bins': [], 'poc': None, 'vah': None, 'val': None, 'bin_count': 0},
|
clean = clean_dataframe_for_json(tail)
|
||||||
'volume_confirm': {'avg_volume': 0.0, 'event_checks': {}},
|
records = clean.to_dict("records")
|
||||||
'error': str(e),
|
for row in records:
|
||||||
}
|
d = row.get("date")
|
||||||
|
if hasattr(d, "isoformat"):
|
||||||
|
try:
|
||||||
|
row["date"] = d.isoformat()
|
||||||
|
except Exception:
|
||||||
|
row["date"] = str(d)
|
||||||
|
# timestamp 统一成 int ms,便于前端按 key 合并
|
||||||
|
ts = row.get("timestamp")
|
||||||
|
if ts is not None:
|
||||||
|
try:
|
||||||
|
row["timestamp"] = int(ts)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
elif hasattr(d, "timestamp"):
|
||||||
|
try:
|
||||||
|
row["timestamp"] = int(d.timestamp() * 1000)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return records
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/api/klines/recent")
|
||||||
|
def klines_recent():
|
||||||
|
"""轻量拉取最近 N 根 K 线(不做缠论/威科夫),供主站自动刷新增量。"""
|
||||||
|
symbol = (request.args.get("symbol") or "").strip()
|
||||||
|
if not symbol:
|
||||||
|
return jsonify({"error": "交易对不能为空"}), 400
|
||||||
|
|
||||||
|
timeframe = request.args.get("timeframe", "5m")
|
||||||
|
try:
|
||||||
|
limit = int(request.args.get("limit", 2))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
limit = 2
|
||||||
|
limit = max(1, min(limit, 20))
|
||||||
|
|
||||||
|
element_timeframe = request.args.get("element_timeframe") or None
|
||||||
|
sub_sub_timeframe = request.args.get("sub_sub_timeframe") or None
|
||||||
|
|
||||||
|
# 只取尾部:不传 start/end,避免全量窗口回拉
|
||||||
|
df = get_kl_data(symbol, timeframe, limit=limit)
|
||||||
|
if df is None:
|
||||||
|
return jsonify({"error": "获取数据失败"}), 502
|
||||||
|
if len(df) == 0:
|
||||||
|
return jsonify({"error": "没有数据"}), 404
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"partial": True,
|
||||||
|
"symbol": symbol,
|
||||||
|
"timeframe": timeframe,
|
||||||
|
"limit": limit,
|
||||||
|
"kline_data": _serialize_kl_tail(df, limit),
|
||||||
|
}
|
||||||
|
|
||||||
|
if element_timeframe:
|
||||||
|
edf = get_kl_data(symbol, element_timeframe, limit=limit)
|
||||||
|
result["element_timeframe"] = element_timeframe
|
||||||
|
result["element_kline_data"] = _serialize_kl_tail(edf, limit) if edf is not None else []
|
||||||
|
|
||||||
|
if sub_sub_timeframe:
|
||||||
|
sdf = get_kl_data(symbol, sub_sub_timeframe, limit=limit)
|
||||||
|
result["sub_sub_timeframe"] = sub_sub_timeframe
|
||||||
|
result["sub_sub_kline_data"] = _serialize_kl_tail(sdf, limit) if sdf is not None else []
|
||||||
|
|
||||||
return jsonify(result)
|
return jsonify(result)
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,236 @@
|
|||||||
|
"""Crypto Wyckoff Screener API + page (independent of /api/analyze)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
|
||||||
|
from flask import Blueprint, jsonify, render_template, request
|
||||||
|
|
||||||
|
from crypto_wyckoff.combos import (
|
||||||
|
ALLOWED_TFS,
|
||||||
|
add_combo,
|
||||||
|
delete_combo,
|
||||||
|
get_combo,
|
||||||
|
list_combos,
|
||||||
|
)
|
||||||
|
from crypto_wyckoff.domain_models import DecisionSignal, WyckoffCycle, WyckoffEvent, WyckoffPhase
|
||||||
|
from crypto_wyckoff.scheduler import get_status, run_tick, start_scheduler
|
||||||
|
from crypto_wyckoff import store as wyckoff_store
|
||||||
|
from crypto_wyckoff.symbols_cn import display_name_cn, symbol_name_map
|
||||||
|
from crypto_wyckoff.version import ARCHITECTURE_VERSION, WYCKOFF_ENGINE_VERSION
|
||||||
|
|
||||||
|
bp = Blueprint("wyckoff_crypto", __name__)
|
||||||
|
|
||||||
|
_scheduler_started = False
|
||||||
|
_sched_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_scheduler() -> None:
|
||||||
|
global _scheduler_started
|
||||||
|
with _sched_lock:
|
||||||
|
if _scheduler_started:
|
||||||
|
return
|
||||||
|
if os.environ.get("CRYPTO_WYCKOFF_DISABLE", "").lower() in ("1", "true", "yes"):
|
||||||
|
return
|
||||||
|
interval = int(os.environ.get("CRYPTO_WYCKOFF_INTERVAL", "60"))
|
||||||
|
max_sym = os.environ.get("CRYPTO_WYCKOFF_MAX_SYMBOLS")
|
||||||
|
max_symbols = int(max_sym) if max_sym else None
|
||||||
|
start_scheduler(interval_sec=interval, max_symbols=max_symbols)
|
||||||
|
_scheduler_started = True
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_int(raw, default: int, *, lo: int | None = None, hi: int | None = None) -> int:
|
||||||
|
try:
|
||||||
|
v = int(raw)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
v = default
|
||||||
|
if lo is not None:
|
||||||
|
v = max(lo, v)
|
||||||
|
if hi is not None:
|
||||||
|
v = min(hi, v)
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/wyckoff_crypto")
|
||||||
|
def page():
|
||||||
|
ensure_scheduler()
|
||||||
|
return render_template("wyckoff_crypto.html")
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/api/wyckoff_crypto/meta")
|
||||||
|
def meta():
|
||||||
|
ensure_scheduler()
|
||||||
|
combo_id = request.args.get("combo_id")
|
||||||
|
combo = get_combo(combo_id)
|
||||||
|
latest = wyckoff_store.latest_trade_date(combo["id"])
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"architecture_version": ARCHITECTURE_VERSION,
|
||||||
|
"engine_version": WYCKOFF_ENGINE_VERSION,
|
||||||
|
"latest_trade_date": latest,
|
||||||
|
"scan_count": wyckoff_store.count_for_date(latest, combo["id"]),
|
||||||
|
"cycles": [c.value for c in WyckoffCycle],
|
||||||
|
"phases": [p.value for p in WyckoffPhase],
|
||||||
|
"events": [e.value for e in WyckoffEvent],
|
||||||
|
"decision_signals": [s.value for s in DecisionSignal],
|
||||||
|
"timezone": "Asia/Shanghai",
|
||||||
|
"utc_offset": "+08:00",
|
||||||
|
"timeframes": [combo["low"], combo["mid"], combo["high"]],
|
||||||
|
"combo": combo,
|
||||||
|
"combos": list_combos(),
|
||||||
|
"allowed_tfs": list(ALLOWED_TFS),
|
||||||
|
"symbol_names": symbol_name_map(),
|
||||||
|
"default_symbol": "BTC/USDT:USDT",
|
||||||
|
"status": get_status(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/api/wyckoff_crypto/combos", methods=["GET"])
|
||||||
|
def combos_list():
|
||||||
|
ensure_scheduler()
|
||||||
|
return jsonify({"combos": list_combos(), "allowed_tfs": list(ALLOWED_TFS)})
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/api/wyckoff_crypto/combos", methods=["POST"])
|
||||||
|
def combos_add():
|
||||||
|
ensure_scheduler()
|
||||||
|
body = request.get_json(silent=True) or {}
|
||||||
|
high = (body.get("high") or request.args.get("high") or "").strip()
|
||||||
|
mid = (body.get("mid") or request.args.get("mid") or "").strip()
|
||||||
|
low = (body.get("low") or request.args.get("low") or "").strip()
|
||||||
|
label = (body.get("label") or request.args.get("label") or "").strip() or None
|
||||||
|
try:
|
||||||
|
row = add_combo(high, mid, low, label=label)
|
||||||
|
except ValueError as e:
|
||||||
|
return jsonify({"error": str(e)}), 400
|
||||||
|
return jsonify({"ok": True, "combo": row, "combos": list_combos()})
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/api/wyckoff_crypto/combos/<combo_id>", methods=["DELETE"])
|
||||||
|
def combos_delete(combo_id: str):
|
||||||
|
ensure_scheduler()
|
||||||
|
try:
|
||||||
|
removed = delete_combo(combo_id)
|
||||||
|
except ValueError as e:
|
||||||
|
return jsonify({"error": str(e)}), 400
|
||||||
|
if not removed:
|
||||||
|
return jsonify({"error": "not_found"}), 404
|
||||||
|
return jsonify({"ok": True, "combos": list_combos()})
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/api/wyckoff_crypto/status")
|
||||||
|
def status():
|
||||||
|
ensure_scheduler()
|
||||||
|
return jsonify(get_status())
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/api/wyckoff_crypto/scan")
|
||||||
|
def scan():
|
||||||
|
ensure_scheduler()
|
||||||
|
combo = get_combo(request.args.get("combo_id"))
|
||||||
|
rows = wyckoff_store.query_scan(
|
||||||
|
trade_date=request.args.get("trade_date"),
|
||||||
|
combo_id=combo["id"],
|
||||||
|
m_cycle=request.args.get("m_cycle"),
|
||||||
|
w_phase=request.args.get("w_phase"),
|
||||||
|
d_event=request.args.get("d_event"),
|
||||||
|
decision_signal=request.args.get("decision_signal"),
|
||||||
|
min_overall_score=_float_or_none(request.args.get("min_overall_score")),
|
||||||
|
min_alignment=_float_or_none(request.args.get("min_alignment")),
|
||||||
|
sort=request.args.get("sort") or "overall_score",
|
||||||
|
limit=_safe_int(request.args.get("limit"), 100, lo=1, hi=500),
|
||||||
|
offset=_safe_int(request.args.get("offset"), 0, lo=0),
|
||||||
|
)
|
||||||
|
for row in rows:
|
||||||
|
row["name"] = display_name_cn(row.get("ts_code") or "")
|
||||||
|
return jsonify({"rows": rows, "count": len(rows), "combo": combo})
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/api/wyckoff_crypto/symbol/<path:symbol>")
|
||||||
|
def symbol_detail(symbol: str):
|
||||||
|
ensure_scheduler()
|
||||||
|
combo = get_combo(request.args.get("combo_id"))
|
||||||
|
row = wyckoff_store.get_symbol(symbol, request.args.get("trade_date"), combo["id"])
|
||||||
|
if not row:
|
||||||
|
return jsonify({"error": "not_found"}), 404
|
||||||
|
return jsonify(row)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/api/wyckoff_crypto/tick", methods=["POST"])
|
||||||
|
def manual_tick():
|
||||||
|
"""Manual one-shot tick (debug). Optional JSON/query max_symbols."""
|
||||||
|
ensure_scheduler()
|
||||||
|
body = request.get_json(silent=True) or {}
|
||||||
|
max_sym = request.args.get("max_symbols") or body.get("max_symbols")
|
||||||
|
max_symbols = int(max_sym) if max_sym not in (None, "") else None
|
||||||
|
|
||||||
|
def _job():
|
||||||
|
try:
|
||||||
|
run_tick(max_symbols=max_symbols, force_rescan=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
threading.Thread(target=_job, daemon=True).start()
|
||||||
|
return jsonify({"ok": True, "started": True})
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/api/wyckoff_crypto/klines")
|
||||||
|
def klines():
|
||||||
|
"""Local cached OHLCV for chart (combo TFs)."""
|
||||||
|
ensure_scheduler()
|
||||||
|
from crypto_wyckoff.io import is_intraday_tf, load_bars_with_ts
|
||||||
|
|
||||||
|
symbol = request.args.get("symbol") or ""
|
||||||
|
combo = get_combo(request.args.get("combo_id"))
|
||||||
|
allowed = {combo["low"], combo["mid"], combo["high"]}
|
||||||
|
tf = request.args.get("tf") or combo["low"]
|
||||||
|
limit = _safe_int(request.args.get("limit"), 180, lo=1, hi=500)
|
||||||
|
if not symbol or tf not in allowed:
|
||||||
|
return jsonify({"error": "bad_request", "allowed": sorted(allowed)}), 400
|
||||||
|
items = load_bars_with_ts(symbol, tf, lookback=limit)
|
||||||
|
return jsonify({
|
||||||
|
"items": items,
|
||||||
|
"symbol": symbol,
|
||||||
|
"tf": tf,
|
||||||
|
"count": len(items),
|
||||||
|
"intraday": is_intraday_tf(tf),
|
||||||
|
"combo": combo,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/api/wyckoff_crypto/overlay")
|
||||||
|
def overlay():
|
||||||
|
"""Phase/event overlay for chart."""
|
||||||
|
ensure_scheduler()
|
||||||
|
from crypto_wyckoff.annotate import annotate_symbol
|
||||||
|
|
||||||
|
symbol = request.args.get("symbol") or ""
|
||||||
|
combo = get_combo(request.args.get("combo_id"))
|
||||||
|
allowed = {combo["low"], combo["mid"], combo["high"]}
|
||||||
|
tf = request.args.get("tf") or combo["low"]
|
||||||
|
bars = _safe_int(request.args.get("bars"), 180, lo=20, hi=400)
|
||||||
|
if not symbol or tf not in allowed:
|
||||||
|
return jsonify({"error": "bad_request", "allowed": sorted(allowed)}), 400
|
||||||
|
try:
|
||||||
|
data = annotate_symbol(symbol, freq=tf, lookback=bars, combo_id=combo["id"])
|
||||||
|
except Exception:
|
||||||
|
return jsonify({
|
||||||
|
"error": "overlay_failed",
|
||||||
|
"phases": [],
|
||||||
|
"events": [],
|
||||||
|
"levels": {},
|
||||||
|
"zones": [],
|
||||||
|
"combo_id": combo["id"],
|
||||||
|
}), 500
|
||||||
|
return jsonify(data)
|
||||||
|
|
||||||
|
|
||||||
|
def _float_or_none(v):
|
||||||
|
if v in (None, ""):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return float(v)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
@@ -15,6 +15,7 @@ from api.analyze import bp as analyze_bp
|
|||||||
from api.pages import bp as pages_bp
|
from api.pages import bp as pages_bp
|
||||||
from api.symbols import bp as symbols_bp
|
from api.symbols import bp as symbols_bp
|
||||||
from api.trend import bp as trend_bp
|
from api.trend import bp as trend_bp
|
||||||
|
from api.wyckoff_crypto import bp as wyckoff_crypto_bp, ensure_scheduler
|
||||||
|
|
||||||
|
|
||||||
def create_app() -> Flask:
|
def create_app() -> Flask:
|
||||||
@@ -23,6 +24,12 @@ def create_app() -> Flask:
|
|||||||
app.register_blueprint(analyze_bp)
|
app.register_blueprint(analyze_bp)
|
||||||
app.register_blueprint(symbols_bp)
|
app.register_blueprint(symbols_bp)
|
||||||
app.register_blueprint(trend_bp)
|
app.register_blueprint(trend_bp)
|
||||||
|
app.register_blueprint(wyckoff_crypto_bp)
|
||||||
|
# Start crypto wyckoff tip scheduler (daemon); disable with CRYPTO_WYCKOFF_DISABLE=1
|
||||||
|
try:
|
||||||
|
ensure_scheduler()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
return app
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -25,8 +25,8 @@ def analyze_chan(df, symbol=None, timeframe=None):
|
|||||||
zs_list = chan.calculate_seg_zs(seg_list)
|
zs_list = chan.calculate_seg_zs(seg_list)
|
||||||
# 计算笔中枢(BI中枢)并拍平成列表
|
# 计算笔中枢(BI中枢)并拍平成列表
|
||||||
|
|
||||||
#bi_zs_list = chan.cal_bi_zs_list_pure(bi_list)
|
bi_zs_list = chan.cal_bi_zs_list_pure(bi_list)
|
||||||
bi_zs_list = chan.cal_bi_zs(seg_list)
|
#bi_zs_list = chan.cal_bi_zs(seg_list)
|
||||||
bsp_list = []
|
bsp_list = []
|
||||||
if len(bi_zs_list) > 0:
|
if len(bi_zs_list) > 0:
|
||||||
bsp_list = chan.find_all_bsp(bi_list, bi_zs_list)
|
bsp_list = chan.find_all_bsp(bi_list, bi_zs_list)
|
||||||
|
|||||||
@@ -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、次 1h、次次 15m。
|
||||||
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', '1h', '15m'] 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(['1h', '15m'], labels_ordered, default_main, timeframe_keys)
|
||||||
try:
|
default_sub_sub = _prefer_smaller(['15m', '5m'], 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
|
||||||
|
|
||||||
|
|||||||
@@ -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' :
|
||||||
|
|||||||
+106
-19
@@ -9,11 +9,26 @@ function updateTradingViewData() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 保存当前的可视范围
|
// 优先用请求前冻结的视窗;否则现场拍(自动刷新短间隔 delta≈0,两种都稳)
|
||||||
|
const frozen = window._preserveViewOnRefresh;
|
||||||
|
const oldBarCount = window._preserveViewBarCount || 0;
|
||||||
|
let savedScrollPosition = null;
|
||||||
if (tvWidget.mainChart) {
|
if (tvWidget.mainChart) {
|
||||||
tvWidget.state.visibleRange = tvWidget.mainChart.timeScale().getVisibleRange();
|
const ts = tvWidget.mainChart.timeScale();
|
||||||
tvWidget.state.logicalRange = tvWidget.mainChart.timeScale().getVisibleLogicalRange();
|
if (frozen) {
|
||||||
|
tvWidget.state.visibleRange = frozen.visibleRange;
|
||||||
|
tvWidget.state.logicalRange = frozen.logicalRange;
|
||||||
|
savedScrollPosition = (typeof frozen.scrollPosition === 'number') ? frozen.scrollPosition : null;
|
||||||
|
} else {
|
||||||
|
tvWidget.state.visibleRange = ts.getVisibleRange();
|
||||||
|
tvWidget.state.logicalRange = ts.getVisibleLogicalRange();
|
||||||
|
try {
|
||||||
|
savedScrollPosition = ts.scrollPosition ? ts.scrollPosition() : null;
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
window._preserveViewOnRefresh = null;
|
||||||
|
window._preserveViewBarCount = 0;
|
||||||
|
|
||||||
// 检查是否显示原始K线
|
// 检查是否显示原始K线
|
||||||
const showOriginalKline = $('#showOriginalKline').is(':checked');
|
const showOriginalKline = $('#showOriginalKline').is(':checked');
|
||||||
@@ -71,6 +86,24 @@ function updateTradingViewData() {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LWC 不允许 null/NaN;时间用整秒,避免 Line 渲染抛 Value is null
|
||||||
|
candles = (candles || []).filter(function (c) {
|
||||||
|
return c && c.time != null &&
|
||||||
|
isFinite(Number(c.open)) && isFinite(Number(c.high)) &&
|
||||||
|
isFinite(Number(c.low)) && isFinite(Number(c.close));
|
||||||
|
}).map(function (c) {
|
||||||
|
return {
|
||||||
|
time: Math.floor(Number(c.time)),
|
||||||
|
open: Number(c.open),
|
||||||
|
high: Number(c.high),
|
||||||
|
low: Number(c.low),
|
||||||
|
close: Number(c.close)
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const newBarCount = candles.length;
|
||||||
|
const barDelta = (oldBarCount > 0 && newBarCount > 0) ? (newBarCount - oldBarCount) : 0;
|
||||||
|
|
||||||
// 更新主系列数据(根据klineType)
|
// 更新主系列数据(根据klineType)
|
||||||
const klineType = ($('#klineType').val() || (showOriginalKline ? 'candlestick' : 'line'));
|
const klineType = ($('#klineType').val() || (showOriginalKline ? 'candlestick' : 'line'));
|
||||||
@@ -270,23 +303,77 @@ function updateTradingViewData() {
|
|||||||
// 更新EMA52显示
|
// 更新EMA52显示
|
||||||
updateEMA52Display(currentData);
|
updateEMA52Display(currentData);
|
||||||
|
|
||||||
// 恢复之前的可视范围 - 优先使用visibleRange以确保时间轴对齐
|
// 与自动刷新一致:增量更新绝不碰 barSpacing(缩放本来就留在图表实例上)。
|
||||||
|
// 一写 barSpacing,LWC 会按右边缘重锚 → 放大往右、缩小往左。
|
||||||
|
// 这里只在 setData 之后把位置扳回刷新前的 logical / time 窗口。
|
||||||
if (tvWidget.mainChart) {
|
if (tvWidget.mainChart) {
|
||||||
if (tvWidget.state.visibleRange) {
|
const charts = [
|
||||||
console.log('🔄 恢复可见范围:', tvWidget.state.visibleRange);
|
tvWidget.mainChart,
|
||||||
tvWidget.mainChart.timeScale().setVisibleRange(tvWidget.state.visibleRange);
|
tvWidget.volumeChart,
|
||||||
if (tvWidget.volumeChart) tvWidget.volumeChart.timeScale().setVisibleRange(tvWidget.state.visibleRange);
|
tvWidget.atrChart,
|
||||||
if (tvWidget.atrChart) tvWidget.atrChart.timeScale().setVisibleRange(tvWidget.state.visibleRange);
|
tvWidget.macdChart,
|
||||||
if (tvWidget.macdChart) tvWidget.macdChart.timeScale().setVisibleRange(tvWidget.state.visibleRange);
|
tvWidget.chanMacdChart
|
||||||
if (tvWidget.chanMacdChart) tvWidget.chanMacdChart.timeScale().setVisibleRange(tvWidget.state.visibleRange);
|
].filter(Boolean);
|
||||||
} else if (tvWidget.state.logicalRange) {
|
|
||||||
console.log('🔄 恢复逻辑范围:', tvWidget.state.logicalRange);
|
const vr = tvWidget.state.visibleRange;
|
||||||
tvWidget.mainChart.timeScale().setVisibleLogicalRange(tvWidget.state.logicalRange);
|
const lr = tvWidget.state.logicalRange;
|
||||||
if (tvWidget.volumeChart) tvWidget.volumeChart.timeScale().setVisibleLogicalRange(tvWidget.state.logicalRange);
|
const savedScroll = savedScrollPosition;
|
||||||
if (tvWidget.atrChart) tvWidget.atrChart.timeScale().setVisibleLogicalRange(tvWidget.state.logicalRange);
|
|
||||||
if (tvWidget.macdChart) tvWidget.macdChart.timeScale().setVisibleLogicalRange(tvWidget.state.logicalRange);
|
const applyPosition = function (tag) {
|
||||||
if (tvWidget.chanMacdChart) tvWidget.chanMacdChart.timeScale().setVisibleLogicalRange(tvWidget.state.logicalRange);
|
let ok = false;
|
||||||
}
|
if (lr && lr.from !== undefined && lr.to !== undefined && newBarCount > 0) {
|
||||||
|
// 视窗超出当前 K 线数量时,LWC Line 绘制会抛 Value is null
|
||||||
|
const span = Math.max(1, lr.to - lr.from);
|
||||||
|
let to = lr.to;
|
||||||
|
let from = lr.from;
|
||||||
|
const maxTo = newBarCount - 1 + 8;
|
||||||
|
if (to > maxTo) {
|
||||||
|
to = maxTo;
|
||||||
|
from = to - span;
|
||||||
|
}
|
||||||
|
if (from < -8) {
|
||||||
|
from = -8;
|
||||||
|
to = from + span;
|
||||||
|
}
|
||||||
|
const clamped = { from: from, to: to };
|
||||||
|
charts.forEach(c => {
|
||||||
|
try {
|
||||||
|
c.timeScale().setVisibleLogicalRange(clamped);
|
||||||
|
ok = true;
|
||||||
|
} catch (e) {}
|
||||||
|
});
|
||||||
|
if (ok) console.log('🔄 恢复位置 logical' + (tag || '') + ':', clamped);
|
||||||
|
}
|
||||||
|
if (!ok && vr && vr.from !== undefined && vr.to !== undefined) {
|
||||||
|
charts.forEach(c => {
|
||||||
|
try {
|
||||||
|
c.timeScale().setVisibleRange(vr);
|
||||||
|
ok = true;
|
||||||
|
} catch (e) {}
|
||||||
|
});
|
||||||
|
if (ok) console.log('🔄 恢复位置 time' + (tag || '') + ':', vr);
|
||||||
|
}
|
||||||
|
if (!ok && typeof savedScroll === 'number') {
|
||||||
|
const pos = savedScroll + (barDelta || 0);
|
||||||
|
charts.forEach(c => {
|
||||||
|
try { c.timeScale().scrollToPosition(pos, false); } catch (e) {}
|
||||||
|
});
|
||||||
|
console.log('🔄 恢复位置 scroll' + (tag || '') + ':', pos);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
applyPosition('');
|
||||||
|
setTimeout(function () { applyPosition('@0'); }, 0);
|
||||||
|
setTimeout(function () { applyPosition('@50'); }, 50);
|
||||||
|
// 增量 setData 常不触发可见时间范围回调,但价格轴会变:补刷分型竖边
|
||||||
|
var bumpFxVert = function () {
|
||||||
|
if (typeof window._redrawFxBoxVerticalOverlay === 'function') {
|
||||||
|
window._redrawFxBoxVerticalOverlay();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
bumpFxVert();
|
||||||
|
setTimeout(bumpFxVert, 0);
|
||||||
|
setTimeout(bumpFxVert, 50);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('增量更新图表完成');
|
console.log('增量更新图表完成');
|
||||||
|
|||||||
+13
-4603
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,239 @@
|
|||||||
|
/* 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 hasPendingRestoreView = !!window._pendingRestoreView;
|
||||||
|
const pendingView = window._pendingRestoreView;
|
||||||
|
const syncTimeScaleSettings = () => {
|
||||||
|
const baseOptions = {
|
||||||
|
timeVisible: true,
|
||||||
|
secondsVisible: false,
|
||||||
|
borderColor: '#ddd',
|
||||||
|
lockVisibleTimeRangeOnResize: true,
|
||||||
|
// 关键:确保所有图表边缘行为完全一致
|
||||||
|
fixLeftEdge: false,
|
||||||
|
fixRightEdge: false,
|
||||||
|
// 确保时间刻度行为一致
|
||||||
|
ticksVisible: true,
|
||||||
|
minimumHeight: 0,
|
||||||
|
};
|
||||||
|
// 有待恢复视图时不要先写 barSpacing/rightOffset(会钉右缘导致图往右偏),
|
||||||
|
// 交给后面 setVisibleRange 一次锁定位置+缩放。
|
||||||
|
if (!pendingView) {
|
||||||
|
baseOptions.barSpacing = symbolConfig.type === 'a_stock' ? 6 : 10;
|
||||||
|
baseOptions.rightOffset = 12;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 allChartsNow = [mainChart, volumeChart, atrChart]
|
||||||
|
.concat(showMacd && macdChart ? [macdChart] : [])
|
||||||
|
.concat(showMacd && chanMacdChart ? [chanMacdChart] : []);
|
||||||
|
if (hasPendingRestoreView && pendingView) {
|
||||||
|
restoreChartViewState(allChartsNow, pendingView, { preferTime: true });
|
||||||
|
} else {
|
||||||
|
// 显示最近 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 立即同步其他图表到主图表的范围(无 pending 时)
|
||||||
|
setTimeout(() => {
|
||||||
|
if (window._pendingRestoreView) {
|
||||||
|
restoreChartViewState(allChartsNow, window._pendingRestoreView, { preferTime: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
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: 26, color: '#FF8C00', name: 'EMA26', visible: false }, // 橙色
|
||||||
|
{ type: 'EMA', length: 52, color: '#000000', name: 'EMA52', visible: true }, // 黑色 · 默认开
|
||||||
|
{ type: 'SMA', length: 30, color: '#1E90FF', name: 'MA30', visible: true }, // 蓝色 · 默认开
|
||||||
|
{ type: 'SMA', length: 250, color: '#800080', name: 'MA250', visible: true } // 紫色 · 默认开
|
||||||
|
];
|
||||||
|
|
||||||
|
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, { preferTime: true });
|
||||||
|
} 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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 = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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 = Math.floor(date.getTime() / 1000);
|
||||||
|
return {
|
||||||
|
time: timestamp,
|
||||||
|
open: parseFloat(kline.open),
|
||||||
|
high: parseFloat(kline.high),
|
||||||
|
low: parseFloat(kline.low),
|
||||||
|
close: parseFloat(kline.close),
|
||||||
|
};
|
||||||
|
}).filter((c) => isFinite(c.time) && isFinite(c.open) && isFinite(c.high) && isFinite(c.low) && isFinite(c.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 = Math.floor(date.getTime() / 1000);
|
||||||
|
return {
|
||||||
|
time: timestamp,
|
||||||
|
open: parseFloat(kline.open),
|
||||||
|
high: parseFloat(kline.high),
|
||||||
|
low: parseFloat(kline.low),
|
||||||
|
close: parseFloat(kline.close),
|
||||||
|
};
|
||||||
|
}).filter((c) => isFinite(c.time) && isFinite(c.open) && isFinite(c.high) && isFinite(c.low) && isFinite(c.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;
|
||||||
|
}
|
||||||
+147
-18
@@ -1,4 +1,46 @@
|
|||||||
/* chart_view.js — split from chart.js */
|
/* chart_view.js — split from chart.js */
|
||||||
|
|
||||||
|
/** 用尾部 N 根合并进已有 K 线(同 timestamp 覆盖,更新则追加) */
|
||||||
|
function mergeKlineTail(existing, incoming) {
|
||||||
|
if (!Array.isArray(incoming) || !incoming.length) {
|
||||||
|
return Array.isArray(existing) ? existing : [];
|
||||||
|
}
|
||||||
|
if (!Array.isArray(existing) || !existing.length) {
|
||||||
|
return incoming.slice();
|
||||||
|
}
|
||||||
|
const out = existing.slice();
|
||||||
|
const barTs = (row) => {
|
||||||
|
if (row && row.timestamp != null && row.timestamp !== '') {
|
||||||
|
const n = Number(row.timestamp);
|
||||||
|
if (!Number.isNaN(n)) return n;
|
||||||
|
}
|
||||||
|
const t = row && row.date != null ? new Date(row.date).getTime() : NaN;
|
||||||
|
return Number.isNaN(t) ? null : t;
|
||||||
|
};
|
||||||
|
for (let i = 0; i < incoming.length; i++) {
|
||||||
|
const row = incoming[i];
|
||||||
|
const ts = barTs(row);
|
||||||
|
if (ts == null) continue;
|
||||||
|
let idx = -1;
|
||||||
|
const scanFrom = Math.max(0, out.length - 8);
|
||||||
|
for (let j = out.length - 1; j >= scanFrom; j--) {
|
||||||
|
if (barTs(out[j]) === ts) {
|
||||||
|
idx = j;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (idx >= 0) {
|
||||||
|
out[idx] = Object.assign({}, out[idx], row);
|
||||||
|
} else {
|
||||||
|
const lastTs = barTs(out[out.length - 1]);
|
||||||
|
if (lastTs == null || ts > lastTs) {
|
||||||
|
out.push(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
function updateChart(options) {
|
function updateChart(options) {
|
||||||
options = options || {};
|
options = options || {};
|
||||||
// 只显示旋转加载图标
|
// 只显示旋转加载图标
|
||||||
@@ -13,7 +55,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() || '';
|
||||||
@@ -47,9 +89,88 @@ function updateChart(options) {
|
|||||||
if (options.fromAutoRefresh && window._analyzeXhr && window._analyzeXhr.readyState !== 4) {
|
if (options.fromAutoRefresh && window._analyzeXhr && window._analyzeXhr.readyState !== 4) {
|
||||||
try { window._analyzeXhr.abort(); } catch (e) {}
|
try { window._analyzeXhr.abort(); } catch (e) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 请求发出前冻结视窗(与自动刷新同一套;避免等响应时/setData 后 logical 索引漂移)
|
||||||
|
try {
|
||||||
|
if (tvWidget && tvWidget.mainChart) {
|
||||||
|
window._preserveViewOnRefresh = captureChartViewState(tvWidget.mainChart);
|
||||||
|
const prev = currentData && (
|
||||||
|
($('#subSubPeriodKline').is(':checked') && currentData.sub_sub_kline_data) ||
|
||||||
|
($('#elementPeriodKline').is(':checked') && currentData.element_kline_data) ||
|
||||||
|
currentData.kline_data
|
||||||
|
);
|
||||||
|
window._preserveViewBarCount = Array.isArray(prev) ? prev.length : 0;
|
||||||
|
console.log('📌 刷新前冻结视窗 bars=', window._preserveViewBarCount, window._preserveViewOnRefresh);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
window._preserveViewOnRefresh = null;
|
||||||
|
window._preserveViewBarCount = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestId = ++lastRequestId;
|
||||||
|
const chartsReady = !!(tvWidget && tvWidget.state && tvWidget.state.isInitialized && tvWidget.mainChart);
|
||||||
|
const hasBaseline = !!(currentData && Array.isArray(currentData.kline_data) && currentData.kline_data.length);
|
||||||
|
const baselineSymbol = (currentData && currentData.symbol) || window._lastChartSymbol || '';
|
||||||
|
// 自动刷新常态:只拉最近 2 根;换币对后基线不一致则禁止尾部合并(否则会叠旧缠论)
|
||||||
|
// fullAnalyze(约每 1 分钟)走全量 analyze 更新缠论
|
||||||
|
const useRecentTail = !!(
|
||||||
|
options.fromAutoRefresh &&
|
||||||
|
!options.fullAnalyze &&
|
||||||
|
chartsReady &&
|
||||||
|
hasBaseline &&
|
||||||
|
baselineSymbol &&
|
||||||
|
baselineSymbol === symbol
|
||||||
|
);
|
||||||
|
|
||||||
|
if (useRecentTail) {
|
||||||
|
console.log('自动刷新 → /api/klines/recent limit=2');
|
||||||
|
window._analyzeXhr = $.ajax({
|
||||||
|
url: '/api/klines/recent',
|
||||||
|
data: {
|
||||||
|
symbol: symbol,
|
||||||
|
timeframe: timeframe,
|
||||||
|
limit: 2,
|
||||||
|
element_timeframe: elementTimeframe || undefined,
|
||||||
|
sub_sub_timeframe: subSubTimeframe || undefined
|
||||||
|
},
|
||||||
|
success: function(partial) {
|
||||||
|
$('#refreshLoadingSpinner').hide();
|
||||||
|
if (requestId !== lastRequestId) return;
|
||||||
|
if (!partial || !Array.isArray(partial.kline_data)) {
|
||||||
|
console.warn('recent 响应无效,回退全量 analyze');
|
||||||
|
updateChart({ incremental: true, reason: 'recent-fallback' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
currentData.kline_data = mergeKlineTail(currentData.kline_data, partial.kline_data);
|
||||||
|
if (Array.isArray(partial.element_kline_data)) {
|
||||||
|
currentData.element_kline_data = mergeKlineTail(
|
||||||
|
currentData.element_kline_data, partial.element_kline_data
|
||||||
|
);
|
||||||
|
if (partial.element_timeframe) {
|
||||||
|
currentData.element_timeframe = partial.element_timeframe;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Array.isArray(partial.sub_sub_kline_data)) {
|
||||||
|
currentData.sub_sub_kline_data = mergeKlineTail(
|
||||||
|
currentData.sub_sub_kline_data, partial.sub_sub_kline_data
|
||||||
|
);
|
||||||
|
if (partial.sub_sub_timeframe) {
|
||||||
|
currentData.sub_sub_timeframe = partial.sub_sub_timeframe;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
refreshChart(currentData, { incremental: true, skipTables: true });
|
||||||
|
},
|
||||||
|
error: function(jqXHR, textStatus, errorThrown) {
|
||||||
|
$('#refreshLoadingSpinner').hide();
|
||||||
|
if (textStatus === 'abort') return;
|
||||||
|
console.warn('recent 失败,回退全量 analyze:', errorThrown);
|
||||||
|
updateChart({ incremental: true, reason: 'recent-error-fallback' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 发送请求
|
// 手动 / 首拉:全量 analyze
|
||||||
const requestId = ++lastRequestId; // 标记本次请求
|
|
||||||
window._analyzeXhr = $.ajax({
|
window._analyzeXhr = $.ajax({
|
||||||
url: '/api/analyze',
|
url: '/api/analyze',
|
||||||
data: {
|
data: {
|
||||||
@@ -62,8 +183,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) {
|
||||||
// 隐藏加载图标
|
// 隐藏加载图标
|
||||||
@@ -75,18 +196,31 @@ function updateChart(options) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 保存当前数据
|
// 保存当前数据
|
||||||
|
const prevSymbol = (currentData && currentData.symbol) || window._lastChartSymbol || '';
|
||||||
if (currentData) {
|
if (currentData) {
|
||||||
// 覆盖前断开旧引用,帮助GC尽快回收
|
// 覆盖前断开旧引用,帮助GC尽快回收
|
||||||
delete currentData.original_kline_data;
|
delete currentData.original_kline_data;
|
||||||
delete currentData.original_macd;
|
delete currentData.original_macd;
|
||||||
}
|
}
|
||||||
currentData = data;
|
currentData = data;
|
||||||
|
window._lastChartSymbol = symbol;
|
||||||
|
window._lastFullAnalyzeAt = Date.now();
|
||||||
|
if (typeof renderWyckoffCycleSummary === 'function') {
|
||||||
|
renderWyckoffCycleSummary();
|
||||||
|
}
|
||||||
|
|
||||||
refreshChart(data, {
|
// 有图则增量;笔/段/中枢/结构区只在全量 init 绘制
|
||||||
incremental: options.incremental !== undefined
|
// 换币对 / 手动分析 / 结构区:必须全量重建,否则会残留旧币对叠层
|
||||||
? !!options.incremental
|
const ready = !!(tvWidget && tvWidget.state && tvWidget.state.isInitialized && tvWidget.mainChart);
|
||||||
: !!options.fromAutoRefresh
|
const structureZonesOn = $('#showMainStructureZone').is(':checked');
|
||||||
});
|
const symbolChanged = !!(prevSymbol && prevSymbol !== symbol);
|
||||||
|
let wantIncremental = options.incremental !== undefined
|
||||||
|
? !!options.incremental
|
||||||
|
: (ready || !!options.fromAutoRefresh);
|
||||||
|
if (structureZonesOn || options.fullAnalyze || symbolChanged || options.incremental === false) {
|
||||||
|
wantIncremental = false;
|
||||||
|
}
|
||||||
|
refreshChart(data, { incremental: wantIncremental });
|
||||||
},
|
},
|
||||||
error: function(jqXHR, textStatus, errorThrown) {
|
error: function(jqXHR, textStatus, errorThrown) {
|
||||||
// 隐藏加载图标
|
// 隐藏加载图标
|
||||||
@@ -118,24 +252,21 @@ function captureChartViewState(chart) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function restoreChartViewState(charts, viewState) {
|
function restoreChartViewState(charts, viewState) {
|
||||||
|
// 全量重建备用:先缩放,再位置;不要在位置前写 rightOffset(会右边缘锚定)
|
||||||
if (!viewState || !Array.isArray(charts) || charts.length === 0) return;
|
if (!viewState || !Array.isArray(charts) || charts.length === 0) return;
|
||||||
const validCharts = charts.filter(c => c && c.timeScale);
|
const validCharts = charts.filter(c => c && c.timeScale);
|
||||||
if (validCharts.length === 0) return;
|
if (validCharts.length === 0) return;
|
||||||
|
|
||||||
validCharts.forEach(c => {
|
validCharts.forEach(c => {
|
||||||
try {
|
try {
|
||||||
const optionsPatch = {};
|
if (typeof viewState.barSpacing === 'number') {
|
||||||
if (typeof viewState.barSpacing === 'number') optionsPatch.barSpacing = viewState.barSpacing;
|
c.timeScale().applyOptions({ barSpacing: viewState.barSpacing });
|
||||||
if (typeof viewState.rightOffset === 'number') optionsPatch.rightOffset = viewState.rightOffset;
|
|
||||||
if (Object.keys(optionsPatch).length) {
|
|
||||||
c.timeScale().applyOptions(optionsPatch);
|
|
||||||
}
|
}
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
});
|
});
|
||||||
|
|
||||||
let restored = false;
|
let restored = false;
|
||||||
|
|
||||||
// 优先按逻辑范围恢复(对新数据更稳健)
|
|
||||||
if (viewState.logicalRange && viewState.logicalRange.from !== undefined && viewState.logicalRange.to !== undefined) {
|
if (viewState.logicalRange && viewState.logicalRange.from !== undefined && viewState.logicalRange.to !== undefined) {
|
||||||
validCharts.forEach(c => {
|
validCharts.forEach(c => {
|
||||||
try {
|
try {
|
||||||
@@ -145,7 +276,6 @@ function restoreChartViewState(charts, viewState) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 逻辑范围失败时,回退到时间可见范围
|
|
||||||
if (!restored && viewState.visibleRange && viewState.visibleRange.from !== undefined && viewState.visibleRange.to !== undefined) {
|
if (!restored && viewState.visibleRange && viewState.visibleRange.from !== undefined && viewState.visibleRange.to !== undefined) {
|
||||||
validCharts.forEach(c => {
|
validCharts.forEach(c => {
|
||||||
try {
|
try {
|
||||||
@@ -155,7 +285,6 @@ function restoreChartViewState(charts, viewState) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 最后回退到滚动位置
|
|
||||||
if (!restored && typeof viewState.scrollPosition === 'number') {
|
if (!restored && typeof viewState.scrollPosition === 'number') {
|
||||||
validCharts.forEach(c => {
|
validCharts.forEach(c => {
|
||||||
try { c.timeScale().scrollToPosition(viewState.scrollPosition, false); } catch (e) {}
|
try { c.timeScale().scrollToPosition(viewState.scrollPosition, false); } catch (e) {}
|
||||||
|
|||||||
@@ -85,33 +85,24 @@ $(document).on('change', '#showMainBiZs', function() {
|
|||||||
$(document).on('change', '#showMainStructureZone', function() {
|
$(document).on('change', '#showMainStructureZone', function() {
|
||||||
const on = $('#showMainStructureZone').is(':checked');
|
const on = $('#showMainStructureZone').is(':checked');
|
||||||
console.log('结构区切换为:', on);
|
console.log('结构区切换为:', on);
|
||||||
// 勾选后才向服务器请求多周期结构区数据;取消勾选仅重绘,不重复拉取
|
// 勾选后才向服务器请求多周期结构区数据;结构区叠层只在全量 init 里绘制,必须 incremental:false
|
||||||
if (on) {
|
if (on) {
|
||||||
updateChart();
|
updateChart({ incremental: false });
|
||||||
} else {
|
} else {
|
||||||
updateChartDisplay();
|
updateChartDisplay();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 威科夫主开关:勾选才请求;子项仅本地重绘
|
// 区间/阶段/时间/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() {
|
||||||
|
|||||||
+297
-22
@@ -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() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 设置默认时间范围
|
// 设置默认时间范围:最近 1 个月
|
||||||
function setDefaultTimeRange() {
|
function setDefaultTimeRange() {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const oneDayAgo = new Date(now.getTime() - (24 * 60 * 60 * 1000));
|
const daysBack = 30;
|
||||||
|
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) {
|
||||||
@@ -244,6 +493,9 @@ $(document).ready(function() {
|
|||||||
let autoRefreshTimer = null;
|
let autoRefreshTimer = null;
|
||||||
let nextRefreshTime = null;
|
let nextRefreshTime = null;
|
||||||
let autoRefreshTick = 0;
|
let autoRefreshTick = 0;
|
||||||
|
/** 自动刷新时,缠论全量重算间隔(毫秒);时间戳见 window._lastFullAnalyzeAt */
|
||||||
|
const AUTO_FULL_ANALYZE_MS = 60 * 1000;
|
||||||
|
|
||||||
// 初始化自动刷新功能
|
// 初始化自动刷新功能
|
||||||
function initAutoRefresh() {
|
function initAutoRefresh() {
|
||||||
// 监听自动刷新勾选框变化
|
// 监听自动刷新勾选框变化
|
||||||
@@ -270,10 +522,10 @@ function startAutoRefresh() {
|
|||||||
stopAutoRefresh();
|
stopAutoRefresh();
|
||||||
|
|
||||||
// 获取刷新频率(分钟)
|
// 获取刷新频率(分钟)
|
||||||
const interval = parseFloat($('#refreshInterval').val()) || 5;
|
const interval = parseFloat($('#refreshInterval').val()) || (5 / 60);
|
||||||
const intervalMs = interval * 60 * 1000;
|
const intervalMs = interval * 60 * 1000;
|
||||||
|
|
||||||
console.log(`开始自动刷新,频率: ${interval}分钟 (${intervalMs}毫秒)`);
|
console.log(`开始自动刷新,频率: ${interval}分钟 (${intervalMs}毫秒);缠论全量每 ${AUTO_FULL_ANALYZE_MS / 1000}s`);
|
||||||
|
|
||||||
// 计算下次刷新时间
|
// 计算下次刷新时间
|
||||||
nextRefreshTime = new Date(Date.now() + intervalMs);
|
nextRefreshTime = new Date(Date.now() + intervalMs);
|
||||||
@@ -282,16 +534,36 @@ function startAutoRefresh() {
|
|||||||
// 启动定时器
|
// 启动定时器
|
||||||
autoRefreshTick = 0;
|
autoRefreshTick = 0;
|
||||||
autoRefreshTimer = setInterval(function() {
|
autoRefreshTimer = setInterval(function() {
|
||||||
// 更新结束时间为当前时间
|
// 刷新前先钉住当前缩放/位置(updateEndTime / 请求返回前都可能被改写)
|
||||||
|
if (tvWidget && tvWidget.mainChart && typeof captureChartViewState === 'function') {
|
||||||
|
try {
|
||||||
|
window._pendingRestoreView = captureChartViewState(tvWidget.mainChart);
|
||||||
|
} catch (e) {
|
||||||
|
window._pendingRestoreView = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新结束时间显示(仅 UI)
|
||||||
updateEndTimeToNow();
|
updateEndTimeToNow();
|
||||||
|
|
||||||
// 多数周期增量更新;每隔若干次全量重建以刷新笔/段/中枢(dispose 已防泄漏)
|
|
||||||
autoRefreshTick += 1;
|
autoRefreshTick += 1;
|
||||||
const fullRebuild = (autoRefreshTick % 6) === 0;
|
const now = Date.now();
|
||||||
updateChart({
|
const lastFull = window._lastFullAnalyzeAt || 0;
|
||||||
fromAutoRefresh: true,
|
const needFullAnalyze = !lastFull || (now - lastFull >= AUTO_FULL_ANALYZE_MS);
|
||||||
incremental: !fullRebuild
|
// 常态:/api/klines/recent 合并尾部 K;满 1 分钟:全量 /api/analyze 刷新缠论
|
||||||
});
|
if (needFullAnalyze) {
|
||||||
|
console.log('自动刷新 → 全量缠论 analyze(距上次', lastFull ? Math.round((now - lastFull) / 1000) + 's' : '首次', ')');
|
||||||
|
updateChart({
|
||||||
|
fromAutoRefresh: true,
|
||||||
|
fullAnalyze: true,
|
||||||
|
incremental: true
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
updateChart({
|
||||||
|
fromAutoRefresh: true,
|
||||||
|
incremental: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// 更新下次刷新时间
|
// 更新下次刷新时间
|
||||||
nextRefreshTime = new Date(Date.now() + intervalMs);
|
nextRefreshTime = new Date(Date.now() + intervalMs);
|
||||||
@@ -535,7 +807,8 @@ function refreshChart(data, options) {
|
|||||||
// 自动刷新:增量更新,避免每次销毁/重建 Lightweight Charts
|
// 自动刷新:增量更新,避免每次销毁/重建 Lightweight Charts
|
||||||
if (preferIncremental && chartsReady) {
|
if (preferIncremental && chartsReady) {
|
||||||
try {
|
try {
|
||||||
if (tvWidget.mainChart) {
|
// 若定时器已捕获则保留;否则此刻再捕获一次
|
||||||
|
if (!window._pendingRestoreView && tvWidget.mainChart) {
|
||||||
try {
|
try {
|
||||||
window._pendingRestoreView = captureChartViewState(tvWidget.mainChart);
|
window._pendingRestoreView = captureChartViewState(tvWidget.mainChart);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -543,7 +816,10 @@ function refreshChart(data, options) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
updateTradingViewData();
|
updateTradingViewData();
|
||||||
updateTables(data);
|
// recent-tail 刷新结构未变,跳过表格重绘以提速
|
||||||
|
if (!options.skipTables) {
|
||||||
|
updateTables(data);
|
||||||
|
}
|
||||||
if (currentData && currentData.ema52_dict) {
|
if (currentData && currentData.ema52_dict) {
|
||||||
updateEMA52Display(currentData);
|
updateEMA52Display(currentData);
|
||||||
}
|
}
|
||||||
@@ -555,7 +831,7 @@ function refreshChart(data, options) {
|
|||||||
|
|
||||||
// 保存当前缩放(barSpacing)和滚动位置(scrollPosition)到 window
|
// 保存当前缩放(barSpacing)和滚动位置(scrollPosition)到 window
|
||||||
// tvWidget 会在 initTradingView 内被重建,所以必须存到 window 上
|
// tvWidget 会在 initTradingView 内被重建,所以必须存到 window 上
|
||||||
if (tvWidget && tvWidget.mainChart) {
|
if (!window._pendingRestoreView && tvWidget && tvWidget.mainChart) {
|
||||||
try {
|
try {
|
||||||
window._pendingRestoreView = captureChartViewState(tvWidget.mainChart);
|
window._pendingRestoreView = captureChartViewState(tvWidget.mainChart);
|
||||||
console.log('📌 保存图表视图:', JSON.stringify(window._pendingRestoreView));
|
console.log('📌 保存图表视图:', JSON.stringify(window._pendingRestoreView));
|
||||||
@@ -563,6 +839,8 @@ function refreshChart(data, options) {
|
|||||||
console.warn('保存图表视图失败:', e);
|
console.warn('保存图表视图失败:', e);
|
||||||
window._pendingRestoreView = null;
|
window._pendingRestoreView = null;
|
||||||
}
|
}
|
||||||
|
} else if (window._pendingRestoreView) {
|
||||||
|
console.log('📌 使用已保存图表视图:', JSON.stringify(window._pendingRestoreView));
|
||||||
}
|
}
|
||||||
|
|
||||||
initTradingView($('#symbol').val(), $('#timeframe').val());
|
initTradingView($('#symbol').val(), $('#timeframe').val());
|
||||||
@@ -596,14 +874,14 @@ $('#showElementMacdDiv').change(function() {
|
|||||||
refreshChartOnly();
|
refreshChartOnly();
|
||||||
});
|
});
|
||||||
|
|
||||||
// 绑定分型类型显示开关
|
// 绑定分型类型显示开关(与笔一致:全量重建,避免增量路径标记未对齐)
|
||||||
$('#showKlcFxType').change(function() {
|
$('#showKlcFxType').change(function() {
|
||||||
refreshChartOnly();
|
updateChartDisplay();
|
||||||
});
|
});
|
||||||
|
|
||||||
// 绑定小周期分型显示开关
|
// 绑定小周期分型显示开关
|
||||||
$('#showElementKlcFxType').change(function() {
|
$('#showElementKlcFxType').change(function() {
|
||||||
refreshChart(currentData);
|
updateChartDisplay();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
@@ -616,10 +894,7 @@ $('#showElementBollinger').change(function() {
|
|||||||
updateChartDisplay();
|
updateChartDisplay();
|
||||||
});
|
});
|
||||||
|
|
||||||
// 绑定K线周期切换
|
// K线周期切换由 macd_ui.js 统一走 updateChartDisplay(勿再绑 refreshChart,会重复且易漏对齐)
|
||||||
$('input[name="klinePeriod"]').change(function() {
|
|
||||||
refreshChart(currentData);
|
|
||||||
});
|
|
||||||
|
|
||||||
// 绑定主图U显示开关
|
// 绑定主图U显示开关
|
||||||
$('#toggleUOnMain').change(function() {
|
$('#toggleUOnMain').change(function() {
|
||||||
|
|||||||
+21
-5
@@ -4,6 +4,16 @@ window.App.Charts = (function() {
|
|||||||
// 依赖 Indicators
|
// 依赖 Indicators
|
||||||
const Indicators = (window.App && window.App.Indicators) || {};
|
const Indicators = (window.App && window.App.Indicators) || {};
|
||||||
|
|
||||||
|
function sanitizeLinePoints(points) {
|
||||||
|
if (!Array.isArray(points)) return [];
|
||||||
|
return points.filter(function (p) {
|
||||||
|
return p && p.time != null && p.value != null &&
|
||||||
|
isFinite(Number(p.time)) && isFinite(Number(p.value));
|
||||||
|
}).map(function (p) {
|
||||||
|
return { time: Math.floor(Number(p.time)), value: Number(p.value) };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function addMovingAveragesToChart(candleData) {
|
function addMovingAveragesToChart(candleData) {
|
||||||
if (!window.tvWidget || !tvWidget.mainChart || !candleData || candleData.length === 0) return;
|
if (!window.tvWidget || !tvWidget.mainChart || !candleData || candleData.length === 0) return;
|
||||||
if (!window.movingAverages) return;
|
if (!window.movingAverages) return;
|
||||||
@@ -21,6 +31,8 @@ window.App.Charts = (function() {
|
|||||||
try {
|
try {
|
||||||
const maData = Indicators.calculateMA(candleData, maConfig.type, maConfig.length, maConfig.source);
|
const maData = Indicators.calculateMA(candleData, maConfig.type, maConfig.length, maConfig.source);
|
||||||
const smoothedData = maConfig.smoothType !== 'none' ? (window.applySmoothToMA ? window.applySmoothToMA(maData, maConfig.smoothType, maConfig.smoothLength) : maData) : maData;
|
const smoothedData = maConfig.smoothType !== 'none' ? (window.applySmoothToMA ? window.applySmoothToMA(maData, maConfig.smoothType, maConfig.smoothLength) : maData) : maData;
|
||||||
|
const cleanData = sanitizeLinePoints(smoothedData);
|
||||||
|
if (!cleanData.length) return;
|
||||||
const maSeries = tvWidget.mainChart.addLineSeries({
|
const maSeries = tvWidget.mainChart.addLineSeries({
|
||||||
color: maConfig.color,
|
color: maConfig.color,
|
||||||
lineWidth: maConfig.lineWidth || 2,
|
lineWidth: maConfig.lineWidth || 2,
|
||||||
@@ -30,8 +42,8 @@ window.App.Charts = (function() {
|
|||||||
priceLineVisible: false,
|
priceLineVisible: false,
|
||||||
crosshairMarkerVisible: true,
|
crosshairMarkerVisible: true,
|
||||||
});
|
});
|
||||||
maSeries.setData(smoothedData);
|
maSeries.setData(cleanData);
|
||||||
maConfig.data = smoothedData;
|
maConfig.data = cleanData;
|
||||||
tvWidget.series.maSeries.push(maSeries);
|
tvWidget.series.maSeries.push(maSeries);
|
||||||
} catch(e) {}
|
} catch(e) {}
|
||||||
});
|
});
|
||||||
@@ -51,12 +63,16 @@ window.App.Charts = (function() {
|
|||||||
if (!bbConfig.visible) return;
|
if (!bbConfig.visible) return;
|
||||||
try {
|
try {
|
||||||
const bbData = Indicators.calculateBB(candleData, bbConfig.length, bbConfig.upperMultiplier, bbConfig.lowerMultiplier, bbConfig.source);
|
const bbData = Indicators.calculateBB(candleData, bbConfig.length, bbConfig.upperMultiplier, bbConfig.lowerMultiplier, bbConfig.source);
|
||||||
|
const upper = sanitizeLinePoints(bbData.map(item => ({ time: item.time, value: item.upper })));
|
||||||
|
const middle = sanitizeLinePoints(bbData.map(item => ({ time: item.time, value: item.middle })));
|
||||||
|
const lower = sanitizeLinePoints(bbData.map(item => ({ time: item.time, value: item.lower })));
|
||||||
|
if (!upper.length || !middle.length || !lower.length) return;
|
||||||
const upperSeries = tvWidget.mainChart.addLineSeries({ color: bbConfig.upperColor, lineWidth: bbConfig.lineWidth || 2, lineStyle: bbConfig.lineStyle || 0, lastValueVisible: false, priceLineVisible: false, crosshairMarkerVisible: true });
|
const upperSeries = tvWidget.mainChart.addLineSeries({ color: bbConfig.upperColor, lineWidth: bbConfig.lineWidth || 2, lineStyle: bbConfig.lineStyle || 0, lastValueVisible: false, priceLineVisible: false, crosshairMarkerVisible: true });
|
||||||
const middleSeries = tvWidget.mainChart.addLineSeries({ color: bbConfig.middleColor, lineWidth: bbConfig.lineWidth || 2, lineStyle: bbConfig.lineStyle || 0, lastValueVisible: false, priceLineVisible: false, crosshairMarkerVisible: true });
|
const middleSeries = tvWidget.mainChart.addLineSeries({ color: bbConfig.middleColor, lineWidth: bbConfig.lineWidth || 2, lineStyle: bbConfig.lineStyle || 0, lastValueVisible: false, priceLineVisible: false, crosshairMarkerVisible: true });
|
||||||
const lowerSeries = tvWidget.mainChart.addLineSeries({ color: bbConfig.lowerColor, lineWidth: bbConfig.lineWidth || 2, lineStyle: bbConfig.lineStyle || 0, lastValueVisible: false, priceLineVisible: false, crosshairMarkerVisible: true });
|
const lowerSeries = tvWidget.mainChart.addLineSeries({ color: bbConfig.lowerColor, lineWidth: bbConfig.lineWidth || 2, lineStyle: bbConfig.lineStyle || 0, lastValueVisible: false, priceLineVisible: false, crosshairMarkerVisible: true });
|
||||||
upperSeries.setData(bbData.map(item => ({ time: item.time, value: item.upper })));
|
upperSeries.setData(upper);
|
||||||
middleSeries.setData(bbData.map(item => ({ time: item.time, value: item.middle })));
|
middleSeries.setData(middle);
|
||||||
lowerSeries.setData(bbData.map(item => ({ time: item.time, value: item.lower })));
|
lowerSeries.setData(lower);
|
||||||
bbConfig.data = bbData;
|
bbConfig.data = bbData;
|
||||||
tvWidget.series.bbSeries.push(upperSeries, middleSeries, lowerSeries);
|
tvWidget.series.bbSeries.push(upperSeries, middleSeries, lowerSeries);
|
||||||
} catch(e) {}
|
} catch(e) {}
|
||||||
|
|||||||
@@ -63,7 +63,10 @@ window.App.Indicators = (function() {
|
|||||||
default:
|
default:
|
||||||
value = sourceData[i];
|
value = sourceData[i];
|
||||||
}
|
}
|
||||||
result.push({ time: data[i].time, value });
|
if (value == null || !isFinite(value) || data[i].time == null || !isFinite(Number(data[i].time))) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
result.push({ time: Math.floor(Number(data[i].time)), value: Number(value) });
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|||||||
+147
-37
@@ -22,8 +22,8 @@
|
|||||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
<!-- TradingView Widget BEGIN -->
|
<!-- TradingView Widget BEGIN -->
|
||||||
<script src="https://cdn.jsdelivr.net/npm/lightweight-charts@4.0.1/dist/lightweight-charts.standalone.production.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/lightweight-charts@4.0.1/dist/lightweight-charts.standalone.production.js"></script>
|
||||||
<script defer src="{{ url_for('static', filename='js/indicators.js') }}"></script>
|
<script defer src="{{ url_for('static', filename='js/indicators.js') }}?v=20260809i"></script>
|
||||||
<script defer src="{{ url_for('static', filename='js/charts.js') }}"></script>
|
<script defer src="{{ url_for('static', filename='js/charts.js') }}?v=20260809i"></script>
|
||||||
<!-- TradingView Widget END -->
|
<!-- TradingView Widget END -->
|
||||||
<script>
|
<script>
|
||||||
window.AVAILABLE_TIMEFRAMES = JSON.parse('{{ timeframe_keys_json | safe }}');
|
window.AVAILABLE_TIMEFRAMES = JSON.parse('{{ timeframe_keys_json | safe }}');
|
||||||
@@ -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;
|
||||||
@@ -907,7 +982,7 @@
|
|||||||
<input type="datetime-local" id="end_time" class="form-control">
|
<input type="datetime-local" id="end_time" class="form-control">
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-1">
|
<div class="col-md-1">
|
||||||
<button class="btn btn-primary w-100" onclick="updateChart()" style="padding: 8px 6px; font-size: 14px;">
|
<button class="btn btn-primary w-100" onclick="updateEndTimeToNow(); updateChart({ incremental: false, fullAnalyze: true })" style="padding: 8px 6px; font-size: 14px;">
|
||||||
分析
|
分析
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -953,14 +1028,14 @@
|
|||||||
<div class="d-flex align-items-center mb-2">
|
<div class="d-flex align-items-center mb-2">
|
||||||
<label for="refreshInterval" class="form-label me-2 mb-0">自动刷新:</label>
|
<label for="refreshInterval" class="form-label me-2 mb-0">自动刷新:</label>
|
||||||
<select id="refreshInterval" class="form-select form-select-sm me-2" style="width: 80px;">
|
<select id="refreshInterval" class="form-select form-select-sm me-2" style="width: 80px;">
|
||||||
<option value="0.0833">5秒</option>
|
<option value="0.0833" selected>5秒</option>
|
||||||
<option value="0.1667">10秒</option>
|
<option value="0.1667">10秒</option>
|
||||||
<option value="0.25">15秒</option>
|
<option value="0.25">15秒</option>
|
||||||
<option value="0.5">30秒</option>
|
<option value="0.5">30秒</option>
|
||||||
<option value="1">1分钟</option>
|
<option value="1">1分钟</option>
|
||||||
<option value="2">2分钟</option>
|
<option value="2">2分钟</option>
|
||||||
<option value="3">3分钟</option>
|
<option value="3">3分钟</option>
|
||||||
<option value="5" selected>5分钟</option>
|
<option value="5">5分钟</option>
|
||||||
<option value="10">10分钟</option>
|
<option value="10">10分钟</option>
|
||||||
</select>
|
</select>
|
||||||
<div class="form-check form-check-inline me-2">
|
<div class="form-check form-check-inline me-2">
|
||||||
@@ -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">
|
||||||
@@ -1279,18 +1383,24 @@
|
|||||||
</div>
|
</div>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
|
||||||
<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') }}?v=20260808i"></script>
|
||||||
<script defer src="{{ url_for('static', filename='js/app/state.js') }}"></script>
|
<script defer src="{{ url_for('static', filename='js/app/state.js') }}?v=20260808i"></script>
|
||||||
<script defer src="{{ url_for('static', filename='js/app/trend.js') }}"></script>
|
<script defer src="{{ url_for('static', filename='js/app/trend.js') }}?v=20260808i"></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=20260808j"></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=20260808i"></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=20260809q"></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=20260808i"></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=20260809j"></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=20260808i"></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=20260808i"></script>
|
||||||
<script defer src="{{ url_for('static', filename='js/app/overlays.js') }}"></script>
|
<script defer src="{{ url_for('static', filename='js/app/chart_tv_overlays.js') }}?v=20260809o"></script>
|
||||||
<script defer src="{{ url_for('static', filename='js/app/main.js') }}"></script>
|
<script defer src="{{ url_for('static', filename='js/app/chart_tv_finalize.js') }}?v=20260809d"></script>
|
||||||
|
<script defer src="{{ url_for('static', filename='js/app/chart_tv.js') }}?v=20260808i"></script>
|
||||||
|
<script defer src="{{ url_for('static', filename='js/app/chart_sync.js') }}?v=20260809j"></script>
|
||||||
|
<script defer src="{{ url_for('static', filename='js/app/chart_tables.js') }}?v=20260808i"></script>
|
||||||
|
<script defer src="{{ url_for('static', filename='js/app/ui.js') }}?v=20260809j"></script>
|
||||||
|
<script defer src="{{ url_for('static', filename='js/app/overlays.js') }}?v=20260808i"></script>
|
||||||
|
<script defer src="{{ url_for('static', filename='js/app/main.js') }}?v=20260808i"></script>
|
||||||
|
|
||||||
<!-- 均线配置弹窗 -->
|
<!-- 均线配置弹窗 -->
|
||||||
<div id="maConfigModal" class="ma-config-modal">
|
<div id="maConfigModal" class="ma-config-modal">
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -64,11 +64,33 @@ def test_analyze_route_registered():
|
|||||||
|
|
||||||
rules = {r.rule for r in app.url_map.iter_rules()}
|
rules = {r.rule for r in app.url_map.iter_rules()}
|
||||||
assert "/api/analyze" in rules
|
assert "/api/analyze" in rules
|
||||||
|
assert "/api/klines/recent" in rules
|
||||||
assert "/api/chart_metadata" in rules
|
assert "/api/chart_metadata" in rules
|
||||||
assert "/" in rules
|
assert "/" in rules
|
||||||
assert "/chan_tv" in rules
|
assert "/chan_tv" in rules
|
||||||
|
|
||||||
|
|
||||||
|
def test_klines_recent_returns_tail_only():
|
||||||
|
from app import app
|
||||||
|
|
||||||
|
df = make_ohlcv(n=30)
|
||||||
|
# analyze 蓝图 star-import 后绑定在 api.analyze 命名空间
|
||||||
|
with patch("api.analyze.get_kl_data", return_value=df):
|
||||||
|
client = app.test_client()
|
||||||
|
resp = client.get(
|
||||||
|
"/api/klines/recent",
|
||||||
|
query_string={"symbol": "BTC/USDT:USDT", "timeframe": "5m", "limit": 2},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.get_json()
|
||||||
|
assert body.get("partial") is True
|
||||||
|
assert body.get("limit") == 2
|
||||||
|
assert isinstance(body.get("kline_data"), list)
|
||||||
|
assert len(body["kline_data"]) == 2
|
||||||
|
assert "bi_list" not in body
|
||||||
|
assert "wyckoff" not in body
|
||||||
|
|
||||||
|
|
||||||
def test_contract_keys_stable():
|
def test_contract_keys_stable():
|
||||||
assert "bi_list" in CONTRACT_KEYS and "seg_list" in CONTRACT_KEYS
|
assert "bi_list" in CONTRACT_KEYS and "seg_list" in CONTRACT_KEYS
|
||||||
for k in ("kline_data", "macd", "zs_list", "bsp_list", "chan_macd"):
|
for k in ("kline_data", "macd", "zs_list", "bsp_list", "chan_macd"):
|
||||||
@@ -127,11 +149,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 +172,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"]
|
|
||||||
for k in WYCKOFF_KEYS:
|
|
||||||
assert k in w, f"missing wyckoff key: {k}"
|
def test_analyze_http_wyckoff_for_three_timeframes():
|
||||||
|
"""主/次/次次均返回各自 wyckoff 载荷。"""
|
||||||
|
from app import app
|
||||||
|
from services.runtime import add_indicators
|
||||||
|
|
||||||
|
df = add_indicators(make_ohlcv(300))
|
||||||
|
df = df.copy()
|
||||||
|
if "timestamp" not in df.columns:
|
||||||
|
df["timestamp"] = (pd.to_datetime(df["date"]).astype("int64") // 10**6).astype("int64")
|
||||||
|
|
||||||
|
with patch("api.analyze.get_kl_data", return_value=df):
|
||||||
|
client = app.test_client()
|
||||||
|
resp = client.get(
|
||||||
|
"/api/analyze",
|
||||||
|
query_string={
|
||||||
|
"symbol": "BTC/USDT:USDT",
|
||||||
|
"timeframe": "4h",
|
||||||
|
"element_timeframe": "2h",
|
||||||
|
"sub_sub_timeframe": "1h",
|
||||||
|
"timezone": "Asia/Shanghai",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.data[:500]
|
||||||
|
payload = resp.get_json()
|
||||||
|
assert payload is not None and "error" not in payload
|
||||||
|
assert "wyckoff" in payload
|
||||||
|
assert "element_wyckoff" in payload
|
||||||
|
assert "sub_sub_wyckoff" in payload
|
||||||
|
for key in ("wyckoff", "element_wyckoff", "sub_sub_wyckoff"):
|
||||||
|
for k in WYCKOFF_KEYS:
|
||||||
|
assert k in payload[key], f"missing {k} in {key}"
|
||||||
|
|
||||||
|
|
||||||
def test_analyze_http_wyckoff_skipped_when_elements_only():
|
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 +233,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]
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
"""ECR-009: page/API smoke without requiring live provider during assert."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
# Ensure repo root + web on path like app.py
|
||||||
|
_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
_WEB = os.path.join(_ROOT, "web")
|
||||||
|
for p in (_ROOT, _WEB):
|
||||||
|
if p not in sys.path:
|
||||||
|
sys.path.insert(0, p)
|
||||||
|
|
||||||
|
os.environ.setdefault("CRYPTO_WYCKOFF_DISABLE", "1")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def client():
|
||||||
|
from app import create_app
|
||||||
|
|
||||||
|
app = create_app()
|
||||||
|
app.config["TESTING"] = True
|
||||||
|
with app.test_client() as c:
|
||||||
|
yield c
|
||||||
|
|
||||||
|
|
||||||
|
def test_wyckoff_crypto_page_ok(client):
|
||||||
|
resp = client.get("/wyckoff_crypto")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert b"Crypto Wyckoff Screener" in resp.data
|
||||||
|
assert b"fCombo" in resp.data
|
||||||
|
assert b"chartCanvas" in resp.data
|
||||||
|
|
||||||
|
|
||||||
|
def test_wyckoff_crypto_meta_ok(client):
|
||||||
|
resp = client.get("/api/wyckoff_crypto/meta")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.get_json()
|
||||||
|
assert "engine_version" in data
|
||||||
|
assert data.get("combo", {}).get("id") == "h8_4_1"
|
||||||
|
assert data["combo"]["low"] == "1h"
|
||||||
|
ids = {c["id"] for c in data.get("combos") or []}
|
||||||
|
assert "h8_4_1" in ids and "d_w_m" in ids
|
||||||
|
|
||||||
|
|
||||||
|
def test_wyckoff_crypto_scan_ok(client):
|
||||||
|
resp = client.get("/api/wyckoff_crypto/scan?limit=5&combo_id=h8_4_1")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.get_json()
|
||||||
|
assert "rows" in data
|
||||||
|
assert data.get("combo", {}).get("id") == "h8_4_1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_wyckoff_crypto_klines_bad_request(client):
|
||||||
|
resp = client.get("/api/wyckoff_crypto/klines")
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_wyckoff_crypto_klines_ok(client):
|
||||||
|
resp = client.get(
|
||||||
|
"/api/wyckoff_crypto/klines?symbol=BTC/USDT:USDT&tf=1h&limit=10&combo_id=h8_4_1"
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.get_json()
|
||||||
|
assert "items" in data
|
||||||
|
assert data.get("tf") == "1h"
|
||||||
|
assert data.get("intraday") is True
|
||||||
|
if data["items"]:
|
||||||
|
assert "datetime" in data["items"][0]
|
||||||
|
assert "ts" in data["items"][0]
|
||||||
|
assert "T" in data["items"][0]["datetime"]
|
||||||
|
assert "+08:00" in data["items"][0]["datetime"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_wyckoff_crypto_klines_bad_limit_ok(client):
|
||||||
|
resp = client.get(
|
||||||
|
"/api/wyckoff_crypto/klines?symbol=BTC/USDT:USDT&tf=1h&limit=abc&combo_id=h8_4_1"
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_wyckoff_crypto_overlay_ok(client):
|
||||||
|
resp = client.get(
|
||||||
|
"/api/wyckoff_crypto/overlay?symbol=BTC/USDT:USDT&tf=1h&bars=60&combo_id=h8_4_1"
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.get_json()
|
||||||
|
assert "phases" in data
|
||||||
|
assert "events" in data
|
||||||
|
|
||||||
|
|
||||||
|
def test_combos_add_and_list(client, tmp_path, monkeypatch):
|
||||||
|
from crypto_wyckoff import combos as cm
|
||||||
|
|
||||||
|
monkeypatch.setattr(cm, "_COMBOS_FILE", tmp_path / "combos.json")
|
||||||
|
monkeypatch.setattr(cm, "_cache", None)
|
||||||
|
|
||||||
|
resp = client.get("/api/wyckoff_crypto/combos")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert len(resp.get_json()["combos"]) >= 2
|
||||||
|
|
||||||
|
bad = client.post(
|
||||||
|
"/api/wyckoff_crypto/combos",
|
||||||
|
json={"high": "1h", "mid": "4h", "low": "8h"},
|
||||||
|
)
|
||||||
|
assert bad.status_code == 400
|
||||||
|
|
||||||
|
ok = client.post(
|
||||||
|
"/api/wyckoff_crypto/combos",
|
||||||
|
json={"high": "12h", "mid": "4h", "low": "1h", "label": "12h/4h/1h"},
|
||||||
|
)
|
||||||
|
assert ok.status_code == 200
|
||||||
|
cid = ok.get_json()["combo"]["id"]
|
||||||
|
assert cid == "12h_4h_1h"
|
||||||
|
|
||||||
|
deleted = client.delete(f"/api/wyckoff_crypto/combos/{cid}")
|
||||||
|
assert deleted.status_code == 200
|
||||||
|
|
||||||
|
builtin = client.delete("/api/wyckoff_crypto/combos/h8_4_1")
|
||||||
|
assert builtin.status_code == 400
|
||||||
Reference in New Issue
Block a user