Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7e16edd2c9 | ||
|
|
5c10e35b76 | ||
|
|
8c165f11cd | ||
|
|
97e77847d0 | ||
|
|
90499533fb | ||
|
|
8ee11317d3 |
@@ -1,6 +1,7 @@
|
||||
"""威科夫分析(启发式):交易区间 / 阶段 / 事件 / Volume Profile。"""
|
||||
"""威科夫分析(启发式):交易区间 / 阶段 / 事件 / Volume Profile / Live。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from .engine import analyze_wyckoff
|
||||
from .live import execution_signal_from_wyckoff
|
||||
|
||||
__all__ = ["analyze_wyckoff"]
|
||||
__all__ = ["analyze_wyckoff", "execution_signal_from_wyckoff"]
|
||||
|
||||
@@ -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 typing import Any, Dict, Optional
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from .events import build_phases, detect_bias_and_events
|
||||
from .range import detect_trading_range
|
||||
from .live import analyze_live_structure
|
||||
from .range import detect_trading_ranges
|
||||
from .volume_profile import compute_volume_profile
|
||||
|
||||
|
||||
@@ -21,31 +27,55 @@ def _fmt_time(v) -> Optional[str]:
|
||||
return str(v)
|
||||
|
||||
|
||||
def analyze_wyckoff(df: pd.DataFrame, lookback: int = 120, vp_bins: int = 50) -> Dict[str, Any]:
|
||||
"""
|
||||
对主周期 OHLCV DataFrame 做威科夫启发式分析。
|
||||
需要列: open, high, low, close, volume;建议有 date 或 timestamp。
|
||||
"""
|
||||
empty = {
|
||||
def _empty(vp_bins: int) -> Dict[str, Any]:
|
||||
return {
|
||||
"cycles": [],
|
||||
"trading_range": None,
|
||||
"bias": "unknown",
|
||||
"phases": [],
|
||||
"events": [],
|
||||
"volume_profile": {"bins": [], "poc": None, "vah": None, "val": None, "bin_count": vp_bins},
|
||||
"volume_confirm": {"avg_volume": 0.0, "event_checks": {}},
|
||||
"live": None,
|
||||
}
|
||||
if df is None or len(df) < 30:
|
||||
return empty
|
||||
if not all(c in df.columns for c in ("open", "high", "low", "close")):
|
||||
return empty
|
||||
work = df.copy()
|
||||
if "volume" not in work.columns:
|
||||
work["volume"] = 1.0
|
||||
|
||||
tr = detect_trading_range(work, lookback=lookback)
|
||||
if tr is None:
|
||||
return empty
|
||||
|
||||
def _confidence_for_confirmed(
|
||||
tr: Dict[str, Any],
|
||||
phases: List[Dict[str, Any]],
|
||||
events: List[Dict[str, Any]],
|
||||
) -> Dict[str, float]:
|
||||
range_c = float(tr.get("range_confidence") or 0.5)
|
||||
labels = {p.get("phase") for p in phases}
|
||||
phase_c = 0.35
|
||||
if "A" in labels and "B" in labels:
|
||||
phase_c += 0.15
|
||||
if "C" in labels:
|
||||
phase_c += 0.2
|
||||
if "D" in labels or "E" in labels:
|
||||
phase_c += 0.15
|
||||
phase_c = min(0.95, phase_c)
|
||||
types = {e.get("type") for e in events}
|
||||
event_c = 0.25
|
||||
for t in ("Spring", "UTAD", "SOS", "SOW", "LPS", "LPSY"):
|
||||
if t in types:
|
||||
event_c += 0.12
|
||||
event_c = min(0.95, event_c)
|
||||
overall = 0.4 * range_c + 0.3 * phase_c + 0.3 * event_c
|
||||
return {
|
||||
"range": round(range_c, 3),
|
||||
"phase": round(phase_c, 3),
|
||||
"event": round(event_c, 3),
|
||||
"overall": round(overall, 3),
|
||||
}
|
||||
|
||||
|
||||
def _build_cycle(
|
||||
work: pd.DataFrame,
|
||||
tr: Dict[str, Any],
|
||||
cycle_id: int,
|
||||
vp_bins: int,
|
||||
) -> Dict[str, Any]:
|
||||
bias, events, volume_confirm = detect_bias_and_events(work, tr)
|
||||
phases = build_phases(work, tr, bias, events)
|
||||
vp = compute_volume_profile(
|
||||
@@ -54,27 +84,113 @@ def analyze_wyckoff(df: pd.DataFrame, lookback: int = 120, vp_bins: int = 50) ->
|
||||
int(tr["abs_end_idx"]),
|
||||
bin_count=vp_bins,
|
||||
)
|
||||
|
||||
trading_range = {
|
||||
"start_time": _fmt_time(tr.get("start_time")),
|
||||
"end_time": _fmt_time(tr.get("end_time")),
|
||||
"high": float(tr["high"]),
|
||||
"low": float(tr["low"]),
|
||||
"mid": float(tr["mid"]),
|
||||
"active": bool(tr.get("active", True)),
|
||||
"bars": int(tr.get("bars", 0)),
|
||||
}
|
||||
for ev in events:
|
||||
ev["time"] = _fmt_time(ev.get("time"))
|
||||
for ph in phases:
|
||||
ph["start_time"] = _fmt_time(ph.get("start_time"))
|
||||
ph["end_time"] = _fmt_time(ph.get("end_time"))
|
||||
|
||||
is_active = cycle_id == 0
|
||||
trading_range = {
|
||||
"start_time": _fmt_time(tr.get("start_time")),
|
||||
"end_time": _fmt_time(tr.get("end_time")),
|
||||
"high": float(tr["high"]),
|
||||
"low": float(tr["low"]),
|
||||
"mid": float(tr["mid"]),
|
||||
"active": bool(is_active),
|
||||
"bars": int(tr.get("bars", 0)),
|
||||
}
|
||||
conf = _confidence_for_confirmed(tr, phases, events)
|
||||
|
||||
# Live 层:仅 ACTIVE 周期做推演;历史周期归档为 COMPLETED
|
||||
if is_active:
|
||||
live = analyze_live_structure(
|
||||
work, tr, confirmed_events=events, confirmed_phases=phases, bias=bias,
|
||||
)
|
||||
lifecycle = live.get("lifecycle") or "FORMING"
|
||||
else:
|
||||
live = None
|
||||
lifecycle = "COMPLETED"
|
||||
|
||||
return {
|
||||
"id": int(cycle_id),
|
||||
"role": "latest" if is_active else "historical",
|
||||
# MULTI-CYCLE:时间线角色
|
||||
"status": "ACTIVE" if is_active else "HISTORICAL",
|
||||
# LIVE-STRUCTURE:生命周期
|
||||
"lifecycle": lifecycle,
|
||||
"direction": "latest" if is_active else "historical",
|
||||
"period": {
|
||||
"start_time": _fmt_time(tr.get("start_time")),
|
||||
"end_time": _fmt_time(tr.get("end_time")),
|
||||
"bars": int(tr.get("bars", 0)),
|
||||
},
|
||||
"confidence": conf,
|
||||
"trading_range": trading_range,
|
||||
"bias": bias,
|
||||
# 兼容旧读法:顶层 phases/events = confirmed
|
||||
"phases": phases,
|
||||
"events": events,
|
||||
"confirmed": {
|
||||
"phases": phases,
|
||||
"events": events,
|
||||
"volume_confirm": volume_confirm,
|
||||
},
|
||||
"live": live,
|
||||
"volume_profile": vp,
|
||||
"volume_confirm": volume_confirm,
|
||||
}
|
||||
|
||||
|
||||
def analyze_wyckoff(
|
||||
df: pd.DataFrame,
|
||||
lookback: int = 120,
|
||||
vp_bins: int = 50,
|
||||
min_bars: int = 24,
|
||||
atr_mult: float = 1.2,
|
||||
range_start_time=None,
|
||||
prefer_start_time=None,
|
||||
max_cycles: int = 8,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
多周期威科夫分析。
|
||||
cycles[0] = ACTIVE;顶层 phases/events 只镜像 Confirmed。
|
||||
顶层 live 镜像 cycles[0].live。
|
||||
"""
|
||||
empty = _empty(vp_bins)
|
||||
if df is None or len(df) < 30:
|
||||
return empty
|
||||
if not all(c in df.columns for c in ("open", "high", "low", "close")):
|
||||
return empty
|
||||
work = df.copy()
|
||||
if "volume" not in work.columns:
|
||||
work["volume"] = 1.0
|
||||
|
||||
trs = detect_trading_ranges(
|
||||
work,
|
||||
lookback=lookback,
|
||||
min_bars=max(8, int(min_bars)),
|
||||
atr_mult=atr_mult,
|
||||
max_cycles=max(1, min(8, int(max_cycles))),
|
||||
prefer_start_time=prefer_start_time,
|
||||
range_start_time=range_start_time,
|
||||
)
|
||||
if not trs:
|
||||
return empty
|
||||
|
||||
cycles: List[Dict[str, Any]] = []
|
||||
for i, tr in enumerate(trs):
|
||||
cycles.append(_build_cycle(work, tr, cycle_id=i, vp_bins=vp_bins))
|
||||
|
||||
active = cycles[0]
|
||||
return {
|
||||
"cycles": cycles,
|
||||
"trading_range": active["trading_range"],
|
||||
"bias": active["bias"],
|
||||
"phases": active["confirmed"]["phases"],
|
||||
"events": active["confirmed"]["events"],
|
||||
"volume_profile": active["volume_profile"],
|
||||
"volume_confirm": active["volume_confirm"],
|
||||
"live": active.get("live"),
|
||||
"lifecycle": active.get("lifecycle"),
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""威科夫阶段与事件(启发式)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Tuple
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
@@ -29,6 +29,9 @@ def detect_bias_and_events(
|
||||
) -> Tuple[str, List[Dict[str, Any]], Dict[str, Any]]:
|
||||
"""
|
||||
返回 bias、events、volume_confirm。
|
||||
|
||||
Spring/UTAD 相对「结构高低」判定:取区间内次低/次高(剔除单根极值),
|
||||
避免箱体把假破低点吃进 lo 后永远刺不破、从而无 C 阶段。
|
||||
"""
|
||||
hi = float(tr["high"])
|
||||
lo = float(tr["low"])
|
||||
@@ -38,6 +41,24 @@ def detect_bias_and_events(
|
||||
e = int(tr["abs_end_idx"])
|
||||
events: List[Dict[str, Any]] = []
|
||||
|
||||
# 结构边界:用次低/次高作假破参照(至少 8 根才启用)
|
||||
seg = df.iloc[s : e + 1]
|
||||
event_lo, event_hi = lo, hi
|
||||
if len(seg) >= 8:
|
||||
lows = seg["low"].astype(float)
|
||||
highs = seg["high"].astype(float)
|
||||
# nsmallest(2) 的较大者 = 次低;nlargest(2) 的较小者 = 次高
|
||||
event_lo = float(lows.nsmallest(min(2, len(lows))).iloc[-1])
|
||||
event_hi = float(highs.nlargest(min(2, len(highs))).iloc[-1])
|
||||
# 勿比公布箱沿更「松」:结构带应在箱内
|
||||
event_lo = max(event_lo, lo)
|
||||
event_hi = min(event_hi, hi)
|
||||
# 若次低仍等于极值(多根同价),略抬参照便于识别收回
|
||||
if abs(event_lo - lo) < 1e-12:
|
||||
event_lo = lo + max(tol * 0.35, (hi - lo) * 0.02)
|
||||
if abs(event_hi - hi) < 1e-12:
|
||||
event_hi = hi - max(tol * 0.35, (hi - lo) * 0.02)
|
||||
|
||||
# 扫描区间内及之后(含 tail_reserve)
|
||||
scan_end = int(tr.get("abs_scan_end_idx", min(len(df) - 1, e + 15)))
|
||||
scan_end = min(len(df) - 1, max(scan_end, e))
|
||||
@@ -57,8 +78,8 @@ def detect_bias_and_events(
|
||||
avg_v = _avg_vol(df, i)
|
||||
ratio = vol / avg_v if avg_v else 0.0
|
||||
|
||||
# Spring: pierce below low then close back above low
|
||||
if spring is None and low < lo - tol * 0.5 and close >= lo - tol * 0.2:
|
||||
# Spring: pierce below structural support then close back
|
||||
if spring is None and low < event_lo - tol * 0.35 and close >= event_lo - tol * 0.35:
|
||||
vol_ok = ratio <= 1.35 or (i + 1 <= scan_end and float(df.iloc[min(i + 1, scan_end)]["volume"]) / avg_v < 1.2)
|
||||
spring = {
|
||||
"type": "Spring",
|
||||
@@ -70,8 +91,8 @@ def detect_bias_and_events(
|
||||
"idx": i,
|
||||
}
|
||||
|
||||
# UTAD: pierce above high then close back below
|
||||
if utad is None and high > hi + tol * 0.5 and close <= hi + tol * 0.2:
|
||||
# UTAD: pierce above structural resistance then close back
|
||||
if utad is None and high > event_hi + tol * 0.35 and close <= event_hi + tol * 0.35:
|
||||
vol_ok = ratio >= 0.8
|
||||
utad = {
|
||||
"type": "UTAD",
|
||||
@@ -154,11 +175,15 @@ def detect_bias_and_events(
|
||||
}
|
||||
break
|
||||
|
||||
# 冲突清理:已判定吸筹且有 SOS 时,丢弃更早的 UTAD(避免阶段/图面误导)
|
||||
# 派发且有 SOW 时,丢弃更晚才合理的 Spring 假信号同理在偏置后再滤
|
||||
keep = []
|
||||
for ev in (spring, sos, lps, utad, sod, lpsy):
|
||||
if ev:
|
||||
events.append({k: v for k, v in ev.items() if k != "idx"})
|
||||
if not ev:
|
||||
continue
|
||||
keep.append(ev)
|
||||
|
||||
# bias
|
||||
# bias(先算)
|
||||
last_c = float(df["close"].iloc[-1])
|
||||
bias = "unknown"
|
||||
if sos and (not sod or int(sos.get("idx", 0)) >= int(sod.get("idx", 0))):
|
||||
@@ -174,6 +199,16 @@ def detect_bias_and_events(
|
||||
else:
|
||||
bias = "distribution"
|
||||
|
||||
filtered = []
|
||||
for ev in keep:
|
||||
if bias == "accumulation" and ev["type"] == "UTAD" and sos and int(ev["idx"]) <= int(sos["idx"]):
|
||||
continue
|
||||
if bias == "distribution" and ev["type"] == "Spring" and sod and int(ev["idx"]) <= int(sod["idx"]):
|
||||
continue
|
||||
filtered.append(ev)
|
||||
|
||||
events = [{k: v for k, v in ev.items() if k != "idx"} for ev in filtered]
|
||||
|
||||
avg_volume = float(df["volume"].astype(float).iloc[max(0, e - 20) : e + 1].mean()) if "volume" in df.columns else 0.0
|
||||
volume_confirm = {
|
||||
"avg_volume": avg_volume,
|
||||
@@ -189,59 +224,146 @@ def build_phases(
|
||||
events: List[Dict[str, Any]],
|
||||
min_bars: int = 3,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""按时间切分 A–E 粗阶段;保证非重叠且每段至少 min_bars 根(空间不足则截断尾部阶段)。"""
|
||||
"""
|
||||
按威科夫事件锚点切分 A–E(启发式)。
|
||||
|
||||
吸筹:A停止 → B筑底 → C测试(Spring) → D拉升(SOS…LPS) → E离开
|
||||
派发:A停止 → B筑顶 → C测试(UTAD) → D派发(SOW…LPSY) → E离开
|
||||
|
||||
无 Spring/UTAD 时:若已有 SOS/SOW,用突破前末次沿带测试补 C;仍无则省略 C。
|
||||
"""
|
||||
s = int(tr["abs_start_idx"])
|
||||
e = int(tr["abs_end_idx"])
|
||||
hi = float(tr["high"])
|
||||
lo = float(tr["low"])
|
||||
n_last = len(df) - 1
|
||||
min_span = max(2, min_bars - 1)
|
||||
range_len = max(1, e - s)
|
||||
|
||||
event_idx = {}
|
||||
for ev in events:
|
||||
t = ev.get("time")
|
||||
for i in range(s, min(len(df), e + 20)):
|
||||
def _match_idx(t) -> Optional[int]:
|
||||
if t is None:
|
||||
return None
|
||||
lo = max(0, s - 2)
|
||||
hi = min(len(df), e + 40)
|
||||
for i in range(lo, hi):
|
||||
if _bar_time(df, i) == t:
|
||||
event_idx[ev["type"]] = i
|
||||
return i
|
||||
try:
|
||||
tt = pd.Timestamp(t)
|
||||
sample = None
|
||||
if "date" in df.columns and len(df):
|
||||
sample = df["date"].iloc[min(s, n_last)]
|
||||
if sample is not None and getattr(sample, "tzinfo", None) is not None and tt.tzinfo is None:
|
||||
tt = tt.tz_localize(sample.tzinfo)
|
||||
for i in range(lo, hi):
|
||||
bt = _bar_time(df, i)
|
||||
try:
|
||||
if abs((pd.Timestamp(bt) - tt).total_seconds()) <= 1:
|
||||
return i
|
||||
except Exception:
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
event_idx: Dict[str, int] = {}
|
||||
for ev in events:
|
||||
idx = _match_idx(ev.get("time"))
|
||||
if idx is not None:
|
||||
event_idx[str(ev.get("type"))] = idx
|
||||
|
||||
accum = bias != "distribution"
|
||||
if accum:
|
||||
c_ev = event_idx.get("Spring")
|
||||
d_ev = event_idx.get("SOS")
|
||||
d_tail = event_idx.get("LPS") or d_ev
|
||||
else:
|
||||
c_ev = event_idx.get("UTAD")
|
||||
d_ev = event_idx.get("SOW")
|
||||
d_tail = event_idx.get("LPSY") or d_ev
|
||||
|
||||
# 有 D 无明确测试事件时:用突破前最后一次触及下/上沿作为 C(次级测试)
|
||||
if c_ev is None and d_ev is not None:
|
||||
band = lo + (hi - lo) * 0.28 if accum else hi - (hi - lo) * 0.28
|
||||
for i in range(int(d_ev) - 1, s + 1, -1):
|
||||
row = df.iloc[i]
|
||||
if accum and float(row["low"]) <= band:
|
||||
c_ev = i
|
||||
break
|
||||
if not accum and float(row["high"]) >= band:
|
||||
c_ev = i
|
||||
break
|
||||
|
||||
a_end = s + max(min_bars, (e - s) // 5)
|
||||
c_anchor = event_idx.get("Spring") or event_idx.get("UTAD") or (s + (e - s) // 2)
|
||||
d_anchor = event_idx.get("SOS") or event_idx.get("SOW") or e
|
||||
|
||||
def _lab(phase: str) -> str:
|
||||
if bias == "distribution":
|
||||
m = {"A": "A停止上涨", "B": "B筑顶", "C": "C测试", "D": "D派发", "E": "E下跌"}
|
||||
else:
|
||||
if accum:
|
||||
m = {"A": "A停止下跌", "B": "B筑底", "C": "C测试", "D": "D拉升", "E": "E离开"}
|
||||
else:
|
||||
m = {"A": "A停止上涨", "B": "B筑顶", "C": "C测试", "D": "D派发", "E": "E离开"}
|
||||
return m.get(phase, phase)
|
||||
|
||||
# 理想切点(随后再强制非重叠 + 最小跨度)
|
||||
raw = [
|
||||
("A", s, a_end),
|
||||
("B", a_end, c_anchor),
|
||||
("C", c_anchor, d_anchor),
|
||||
("D", d_anchor, min(n_last, d_anchor + max(min_bars, (e - s) // 6))),
|
||||
("E", min(n_last, d_anchor + max(min_bars, (e - s) // 6)), min(n_last, max(e, d_anchor + max(min_bars * 2, 8)))),
|
||||
]
|
||||
a_end = s + max(min_bars, range_len // 5)
|
||||
|
||||
c_start = c_end = None
|
||||
if c_ev is not None:
|
||||
c_start = max(s, int(c_ev) - 1)
|
||||
c_end = min(n_last, int(c_ev) + 1)
|
||||
|
||||
if d_ev is not None:
|
||||
d_start = int(d_ev)
|
||||
d_end = min(n_last, max(int(d_tail or d_ev), d_start) + max(min_bars, range_len // 8))
|
||||
if d_tail is not None:
|
||||
d_end = max(d_end, min(n_last, int(d_tail) + 1))
|
||||
else:
|
||||
d_start = d_end = None
|
||||
|
||||
if c_start is not None:
|
||||
b_end = max(a_end + 1, c_start)
|
||||
elif d_start is not None:
|
||||
b_end = max(a_end + 1, d_start)
|
||||
else:
|
||||
b_end = max(a_end + 1, e)
|
||||
|
||||
if d_end is not None:
|
||||
e_start = min(n_last, d_end)
|
||||
e_end = n_last
|
||||
else:
|
||||
e_start = e_end = None
|
||||
|
||||
raw = [("A", s, a_end), ("B", a_end, b_end)]
|
||||
if c_start is not None and c_end is not None:
|
||||
raw.append(("C", c_start, c_end))
|
||||
if d_start is not None and d_end is not None:
|
||||
raw.append(("D", d_start, d_end))
|
||||
if e_start is not None and e_end is not None and e_end > e_start:
|
||||
raw.append(("E", e_start, e_end))
|
||||
|
||||
phases: List[Dict[str, Any]] = []
|
||||
cursor = s
|
||||
for phase, _a, _b in raw:
|
||||
if cursor >= n_last:
|
||||
break
|
||||
a = max(int(_a), cursor)
|
||||
b = int(max(_b, a + min_span))
|
||||
b = int(max(int(_b), a))
|
||||
need = 1 if phase == "C" else min_span
|
||||
if b < a + need:
|
||||
b = min(n_last, a + need)
|
||||
b = int(np.clip(b, a, n_last))
|
||||
if b - a < min_span:
|
||||
# 尾部空间不足:并入上一段终点并停止新增
|
||||
if phases:
|
||||
phases[-1]["end_time"] = _bar_time(df, n_last)
|
||||
break
|
||||
if b < a:
|
||||
continue
|
||||
if phases and phases[-1].get("_a") == a and phases[-1].get("_b") == b:
|
||||
continue
|
||||
phases.append(
|
||||
{
|
||||
"phase": phase,
|
||||
"label": _lab(phase),
|
||||
"start_time": _bar_time(df, a),
|
||||
"end_time": _bar_time(df, b),
|
||||
"_a": a,
|
||||
"_b": b,
|
||||
}
|
||||
)
|
||||
cursor = b
|
||||
for p in phases:
|
||||
p.pop("_a", None)
|
||||
p.pop("_b", None)
|
||||
return phases
|
||||
|
||||
@@ -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 typing import Any, Dict, Optional
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
MAX_CYCLES = 8
|
||||
OVERLAP_RATIO_MAX = 0.2
|
||||
|
||||
|
||||
def _atr(df: pd.DataFrame, period: int = 14) -> pd.Series:
|
||||
high = df["high"].astype(float)
|
||||
@@ -23,6 +30,15 @@ def _atr(df: pd.DataFrame, period: int = 14) -> pd.Series:
|
||||
return tr.rolling(period, min_periods=max(3, period // 2)).mean()
|
||||
|
||||
|
||||
def _robust_width(seg: pd.DataFrame) -> float:
|
||||
"""用 90/10 分位估宽,避免单根影线把长窗卡死。"""
|
||||
h = seg["high"].astype(float)
|
||||
l = seg["low"].astype(float)
|
||||
if len(seg) < 6:
|
||||
return float(h.max() - l.min())
|
||||
return float(np.nanpercentile(h, 90) - np.nanpercentile(l, 10))
|
||||
|
||||
|
||||
def _score_segment(
|
||||
length: int,
|
||||
near_hi: int,
|
||||
@@ -31,27 +47,161 @@ def _score_segment(
|
||||
width: float,
|
||||
atr: float,
|
||||
) -> float:
|
||||
"""触边密度 + 箱内比例 − 相对宽度;弱奖励长度以免只追最长。"""
|
||||
touch_density = (near_hi + near_lo) / float(max(length, 1))
|
||||
"""结构质量分(非 Phase/Event)。"""
|
||||
touch = min(near_hi, 6) + min(near_lo, 6)
|
||||
width_pen = (width / atr) if atr > 0 else width
|
||||
return touch_density * 50.0 + float(inside) * 30.0 - width_pen * 3.0 + min(length / 40.0, 2.0)
|
||||
return float(touch) * 4.0 + float(inside) * 25.0 - width_pen * 3.0 + min(length / 40.0, 2.0)
|
||||
|
||||
|
||||
def detect_trading_range(
|
||||
def _time_col(df: pd.DataFrame) -> Optional[str]:
|
||||
if "date" in df.columns:
|
||||
return "date"
|
||||
if "timestamp" in df.columns:
|
||||
return "timestamp"
|
||||
return None
|
||||
|
||||
|
||||
def _bar_index_at_or_after(work: pd.DataFrame, ts: Any) -> Optional[int]:
|
||||
col = _time_col(work)
|
||||
if col is None or ts is None:
|
||||
return None
|
||||
try:
|
||||
target = pd.Timestamp(ts)
|
||||
except Exception:
|
||||
return None
|
||||
series = pd.to_datetime(work[col], utc=True, errors="coerce")
|
||||
if target.tzinfo is None:
|
||||
target = target.tz_localize("UTC")
|
||||
else:
|
||||
target = target.tz_convert("UTC")
|
||||
if series.isna().all():
|
||||
return None
|
||||
ge = series >= target
|
||||
if ge.any():
|
||||
return int(np.flatnonzero(ge.to_numpy())[0])
|
||||
return 0
|
||||
|
||||
|
||||
def _pack_range(
|
||||
work: pd.DataFrame,
|
||||
df: pd.DataFrame,
|
||||
lookback: int = 120,
|
||||
start_i: int,
|
||||
end_i: int,
|
||||
hi: float,
|
||||
lo: float,
|
||||
tol: float,
|
||||
last_atr: float,
|
||||
score: float,
|
||||
n: int,
|
||||
window_offset: int = 0,
|
||||
) -> Dict[str, Any]:
|
||||
"""组装 TradingRange(仅结构字段)。"""
|
||||
mid = (hi + lo) / 2.0
|
||||
last_c = float(work["close"].iloc[min(end_i, len(work) - 1)])
|
||||
price_in_box = (lo - tol * 1.5) <= last_c <= (hi + tol * 1.5)
|
||||
bars = int(end_i - start_i + 1)
|
||||
# 结构置信:归一化 score(启发式)
|
||||
range_conf = float(np.clip(score / 55.0, 0.05, 0.99))
|
||||
best = {
|
||||
"start_idx": int(start_i),
|
||||
"end_idx": int(end_i),
|
||||
"high": float(hi),
|
||||
"low": float(lo),
|
||||
"mid": float(mid),
|
||||
"active": bool(price_in_box),
|
||||
"atr": float(last_atr),
|
||||
"tol": float(tol),
|
||||
"bars": bars,
|
||||
"score": float(score),
|
||||
"quality": float(score),
|
||||
"range_confidence": range_conf,
|
||||
}
|
||||
|
||||
def _ts(row) -> Any:
|
||||
col = _time_col(work)
|
||||
if col and pd.notna(row[col]):
|
||||
return row[col]
|
||||
return None
|
||||
|
||||
best["start_time"] = _ts(work.iloc[best["start_idx"]])
|
||||
best["end_time"] = _ts(work.iloc[best["end_idx"]])
|
||||
# window_offset:slice 相对父 DataFrame 的起点;勿用 len(df)-len(work)
|
||||
offset = int(window_offset)
|
||||
best["abs_start_idx"] = offset + best["start_idx"]
|
||||
best["abs_end_idx"] = offset + best["end_idx"]
|
||||
best["abs_scan_end_idx"] = offset + n - 1
|
||||
return best
|
||||
|
||||
|
||||
def _overlap_ratio(a0: int, a1: int, b0: int, b1: int) -> float:
|
||||
"""两闭区间重叠长度 / 较短区间长度。"""
|
||||
lo = max(a0, b0)
|
||||
hi = min(a1, b1)
|
||||
if hi < lo:
|
||||
return 0.0
|
||||
overlap = hi - lo + 1
|
||||
shorter = min(a1 - a0 + 1, b1 - b0 + 1)
|
||||
if shorter <= 0:
|
||||
return 0.0
|
||||
return float(overlap) / float(shorter)
|
||||
|
||||
|
||||
def _passes_quality(tr: Dict[str, Any], min_bars: int) -> bool:
|
||||
if tr is None:
|
||||
return False
|
||||
if int(tr.get("bars") or 0) < max(8, min_bars // 2):
|
||||
return False
|
||||
if float(tr.get("score") or 0) < 12.0:
|
||||
return False
|
||||
hi = float(tr["high"])
|
||||
lo = float(tr["low"])
|
||||
atr = float(tr.get("atr") or 0) or 1.0
|
||||
if (hi - lo) / atr > 12.0:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _passes_trend_filter(work: pd.DataFrame, tr: Dict[str, Any]) -> bool:
|
||||
"""趋势污染:定向位移过大则非震荡箱。"""
|
||||
s = int(tr["start_idx"])
|
||||
e = int(tr["end_idx"])
|
||||
seg = work.iloc[s : e + 1]
|
||||
if len(seg) < 8:
|
||||
return False
|
||||
c0 = float(seg["close"].iloc[0])
|
||||
c1 = float(seg["close"].iloc[-1])
|
||||
atr = float(tr.get("atr") or 0) or 1.0
|
||||
drift = abs(c1 - c0) / atr
|
||||
# 相对箱宽:漂移占箱宽过大 → 趋势
|
||||
width = max(float(tr["high"]) - float(tr["low"]), atr)
|
||||
drift_frac = abs(c1 - c0) / width
|
||||
if drift > 6.0 and drift_frac > 0.55:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _detect_in_window(
|
||||
df: pd.DataFrame,
|
||||
win_start: int,
|
||||
win_end: int,
|
||||
min_bars: int = 24,
|
||||
atr_mult: float = 1.2,
|
||||
tail_reserve: int = 12,
|
||||
prefer_start_time: Any = None,
|
||||
range_start_time: Any = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
在最近 lookback 根内寻找高低点波动受控的连续段作为交易区间。
|
||||
尾部预留 tail_reserve 根用于事件(Spring/SOS),不参与箱体边界计算。
|
||||
在硬门槛之上按评分取最优段(非仅最长窗口)。
|
||||
在 df[win_start:win_end+1] 内检测单个 TradingRange。
|
||||
只返回箱体结构,不含 Phase/Event/VP。
|
||||
"""
|
||||
if df is None or len(df) < min_bars + 5:
|
||||
if df is None or win_end < win_start:
|
||||
return None
|
||||
work = df.tail(lookback).reset_index(drop=True)
|
||||
slice_df = df.iloc[win_start : win_end + 1].reset_index(drop=True)
|
||||
lookback = len(slice_df)
|
||||
if lookback < min_bars + 5:
|
||||
return None
|
||||
|
||||
work = slice_df
|
||||
n = len(work)
|
||||
reserve = min(tail_reserve, max(0, n - min_bars - 2))
|
||||
core_end = n - reserve if reserve > 0 else n
|
||||
@@ -68,61 +218,225 @@ def detect_trading_range(
|
||||
if not np.isfinite(last_atr) or last_atr <= 0:
|
||||
last_atr = float(core["close"].iloc[-1]) * 0.01
|
||||
|
||||
best = None
|
||||
best_score = float("-inf")
|
||||
eff_atr_mult = float(atr_mult)
|
||||
if lookback >= 280:
|
||||
eff_atr_mult = atr_mult * 1.7
|
||||
elif lookback >= 160:
|
||||
eff_atr_mult = atr_mult * 1.3
|
||||
width_factor = 3.8 + min(2.2, max(0.0, (lookback - 80) / 100.0))
|
||||
max_width = last_atr * eff_atr_mult * width_factor
|
||||
tol = last_atr * eff_atr_mult * 0.35
|
||||
|
||||
prefer_i = None
|
||||
if prefer_start_time is not None:
|
||||
prefer_i = _bar_index_at_or_after(work, prefer_start_time)
|
||||
|
||||
if range_start_time is not None:
|
||||
start_i = _bar_index_at_or_after(work, range_start_time)
|
||||
if start_i is not None and start_i <= core_end - 8:
|
||||
seg = work.iloc[start_i:core_end]
|
||||
hi = float(seg["high"].max())
|
||||
lo = float(seg["low"].min())
|
||||
rw = _robust_width(seg)
|
||||
if 0 < rw <= max_width * 1.15:
|
||||
near_hi = int((seg["high"] >= hi - tol).sum())
|
||||
near_lo = int((seg["low"] <= lo + tol).sum())
|
||||
inside = float(((seg["close"] >= lo - tol) & (seg["close"] <= hi + tol)).mean())
|
||||
if near_hi >= 2 and near_lo >= 2 and inside >= 0.70:
|
||||
score = _score_segment(len(seg), near_hi, near_lo, inside, rw, last_atr)
|
||||
return _pack_range(
|
||||
work, df, start_i, core_end - 1, hi, lo, tol, last_atr, score, n,
|
||||
window_offset=win_start,
|
||||
)
|
||||
|
||||
eff_min_bars = max(8, int(min_bars))
|
||||
cn = len(core)
|
||||
for length in range(min(cn, lookback), min_bars - 1, -4):
|
||||
seg = core.iloc[-length:]
|
||||
max_bars = min(cn, max(eff_min_bars * 2, min(96, max(eff_min_bars + 8, int(cn * 0.5)))))
|
||||
cands: List[Tuple[float, int, int, int, float, float, float]] = []
|
||||
|
||||
def _try_seg(start_i: int, end_i: int, prefer_boost: float = 0.0) -> None:
|
||||
if end_i - start_i + 1 < eff_min_bars:
|
||||
return
|
||||
if start_i < 0 or end_i >= cn or start_i > end_i:
|
||||
return
|
||||
seg = work.iloc[start_i : end_i + 1]
|
||||
hi = float(seg["high"].max())
|
||||
lo = float(seg["low"].min())
|
||||
width = hi - lo
|
||||
if width <= 0 or width > last_atr * atr_mult * 3.5:
|
||||
continue
|
||||
tol = last_atr * atr_mult * 0.35
|
||||
rw = _robust_width(seg)
|
||||
if rw <= 0 or rw > max_width:
|
||||
return
|
||||
raw_w = hi - lo
|
||||
if raw_w > max_width * 1.35:
|
||||
return
|
||||
near_hi = int((seg["high"] >= hi - tol).sum())
|
||||
near_lo = int((seg["low"] <= lo + tol).sum())
|
||||
if near_hi < 2 or near_lo < 2:
|
||||
continue
|
||||
return
|
||||
inside = float(((seg["close"] >= lo - tol) & (seg["close"] <= hi + tol)).mean())
|
||||
if inside < 0.75:
|
||||
continue
|
||||
score = _score_segment(length, near_hi, near_lo, inside, width, last_atr)
|
||||
if score <= best_score:
|
||||
continue
|
||||
if inside < 0.72:
|
||||
return
|
||||
length = end_i - start_i + 1
|
||||
score = _score_segment(length, near_hi, near_lo, inside, rw, last_atr) + prefer_boost
|
||||
cands.append((score, length, start_i, end_i, hi, lo, rw))
|
||||
|
||||
for length in range(min(cn, max_bars), eff_min_bars - 1, -4):
|
||||
start_i = cn - length
|
||||
end_i = cn - 1
|
||||
mid = (hi + lo) / 2.0
|
||||
last_c = float(work["close"].iloc[-1])
|
||||
active = (lo - tol * 1.5) <= last_c <= (hi + tol * 1.5)
|
||||
best_score = score
|
||||
best = {
|
||||
"start_idx": int(start_i),
|
||||
"end_idx": int(end_i),
|
||||
"high": hi,
|
||||
"low": lo,
|
||||
"mid": mid,
|
||||
"active": bool(active),
|
||||
"atr": last_atr,
|
||||
"tol": tol,
|
||||
"bars": int(length),
|
||||
"score": float(score),
|
||||
}
|
||||
boost = 0.0
|
||||
if prefer_i is not None:
|
||||
dist = abs(start_i - int(prefer_i))
|
||||
if dist <= 6:
|
||||
boost = 10.0
|
||||
elif dist <= 14:
|
||||
boost = 4.0
|
||||
elif start_i > int(prefer_i) + 16:
|
||||
boost = -10.0
|
||||
_try_seg(start_i, cn - 1, boost)
|
||||
|
||||
if best is None:
|
||||
if prefer_i is not None:
|
||||
pi = int(prefer_i)
|
||||
if 0 <= pi < cn:
|
||||
align_max = min(cn, max(max_bars, int(cn * 0.65)))
|
||||
alen = cn - pi
|
||||
if eff_min_bars <= alen <= align_max:
|
||||
_try_seg(pi, cn - 1, prefer_boost=18.0)
|
||||
elif alen > align_max:
|
||||
start_i = max(0, cn - align_max)
|
||||
if start_i > pi:
|
||||
start_i = pi
|
||||
end_i = min(cn - 1, pi + align_max - 1)
|
||||
else:
|
||||
end_i = cn - 1
|
||||
_try_seg(start_i, end_i, prefer_boost=12.0)
|
||||
|
||||
if not cands:
|
||||
return None
|
||||
|
||||
def _ts(row) -> Any:
|
||||
if "date" in work.columns and pd.notna(row["date"]):
|
||||
return row["date"]
|
||||
if "timestamp" in work.columns:
|
||||
return row["timestamp"]
|
||||
return None
|
||||
cands.sort(key=lambda x: x[0], reverse=True)
|
||||
best_score = cands[0][0]
|
||||
band = max(4.0, abs(best_score) * 0.10)
|
||||
near = [c for c in cands if c[0] >= best_score - band]
|
||||
chosen = max(near, key=lambda x: (x[1], x[0]))
|
||||
score, _length, start_i, end_i, hi, lo, _rw = chosen
|
||||
return _pack_range(work, df, start_i, end_i, hi, lo, tol, last_atr, score, n, window_offset=win_start)
|
||||
|
||||
best["start_time"] = _ts(work.iloc[best["start_idx"]])
|
||||
# 区间时间结束取 core 末,事件可落在其后
|
||||
best["end_time"] = _ts(work.iloc[best["end_idx"]])
|
||||
|
||||
def detect_trading_ranges(
|
||||
df: pd.DataFrame,
|
||||
lookback: Optional[int] = None,
|
||||
min_bars: int = 24,
|
||||
atr_mult: float = 1.2,
|
||||
tail_reserve: int = 12,
|
||||
max_cycles: int = MAX_CYCLES,
|
||||
prefer_start_time: Any = None,
|
||||
range_start_time: Any = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
倒序切多段 TradingRange(近→远)。
|
||||
过滤顺序:detect → quality → trend → overlap → accept → mask。
|
||||
返回列表已按时间倒序,调用方将 [0] 标为 ACTIVE。
|
||||
"""
|
||||
if df is None or len(df) < min_bars + 5:
|
||||
return []
|
||||
lb = int(lookback) if lookback is not None else len(df)
|
||||
work = df.tail(lb).reset_index(drop=True)
|
||||
n = len(work)
|
||||
occupied: List[Dict[str, Any]] = []
|
||||
accepted: List[Dict[str, Any]] = []
|
||||
|
||||
# 搜索右端从 n-1 往左收缩;每接受一段后右端移到该段 start 之前
|
||||
search_end = n - 1
|
||||
prefer = prefer_start_time
|
||||
hard_start = range_start_time
|
||||
|
||||
while len(accepted) < max(1, int(max_cycles)) and search_end >= min_bars + 4:
|
||||
# 在剩余历史内从右往左试多个右边界,避免历史箱必须贴住 search_end
|
||||
# (否则中间趋势会挡住更早的真实箱)
|
||||
cand = None
|
||||
step = max(4, min(12, (search_end - min_bars) // 10 or 4))
|
||||
for end_try in range(search_end, min_bars + 4, -step):
|
||||
trial = _detect_in_window(
|
||||
work,
|
||||
0,
|
||||
end_try,
|
||||
min_bars=min_bars,
|
||||
atr_mult=atr_mult,
|
||||
tail_reserve=tail_reserve,
|
||||
prefer_start_time=prefer if len(accepted) == 0 and end_try == search_end else None,
|
||||
range_start_time=hard_start if len(accepted) == 0 and end_try == search_end else None,
|
||||
)
|
||||
# 1) detect
|
||||
if trial is None:
|
||||
continue
|
||||
# 2) quality
|
||||
if not _passes_quality(trial, min_bars):
|
||||
continue
|
||||
# 3) trend contamination
|
||||
if not _passes_trend_filter(work, trial):
|
||||
continue
|
||||
# 4) overlap with accepted
|
||||
a0, a1 = int(trial["abs_start_idx"]), int(trial["abs_end_idx"])
|
||||
overlap_bad = False
|
||||
for occ in occupied:
|
||||
ratio = _overlap_ratio(a0, a1, int(occ["start"]), int(occ["end"]))
|
||||
if ratio >= OVERLAP_RATIO_MAX:
|
||||
overlap_bad = True
|
||||
break
|
||||
if overlap_bad:
|
||||
continue
|
||||
# 取最靠右的合格箱(倒序第一段)
|
||||
cand = trial
|
||||
break
|
||||
|
||||
if cand is None:
|
||||
break
|
||||
|
||||
# 5) accept
|
||||
accepted.append(cand)
|
||||
a0, a1 = int(cand["abs_start_idx"]), int(cand["abs_end_idx"])
|
||||
# 6) mask
|
||||
occupied.append(
|
||||
{
|
||||
"start": a0,
|
||||
"end": max(a1, int(cand.get("abs_scan_end_idx", a1))),
|
||||
"quality": float(cand.get("quality") or 0),
|
||||
"high": float(cand["high"]),
|
||||
"low": float(cand["low"]),
|
||||
}
|
||||
)
|
||||
# 下一轮只在更早窗口搜
|
||||
search_end = int(cand["abs_start_idx"]) - 1
|
||||
hard_start = None
|
||||
prefer = None
|
||||
|
||||
# abs_* 目前相对 work;若 df 比 work 长需加 offset
|
||||
offset = len(df) - len(work)
|
||||
best["abs_start_idx"] = offset + best["start_idx"]
|
||||
best["abs_end_idx"] = offset + best["end_idx"]
|
||||
best["abs_scan_end_idx"] = offset + n - 1
|
||||
return best
|
||||
if offset:
|
||||
for tr in accepted:
|
||||
tr["abs_start_idx"] = int(tr["abs_start_idx"]) + offset
|
||||
tr["abs_end_idx"] = int(tr["abs_end_idx"]) + offset
|
||||
tr["abs_scan_end_idx"] = int(tr["abs_scan_end_idx"]) + offset
|
||||
|
||||
return accepted
|
||||
|
||||
|
||||
def detect_trading_range(
|
||||
df: pd.DataFrame,
|
||||
lookback: int = 120,
|
||||
min_bars: int = 24,
|
||||
atr_mult: float = 1.2,
|
||||
tail_reserve: int = 12,
|
||||
range_start_time: Any = None,
|
||||
prefer_start_time: Any = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""兼容旧接口:返回倒序列表中的第一段(ACTIVE 候选)。"""
|
||||
ranges = detect_trading_ranges(
|
||||
df,
|
||||
lookback=lookback,
|
||||
min_bars=min_bars,
|
||||
atr_mult=atr_mult,
|
||||
tail_reserve=tail_reserve,
|
||||
max_cycles=1,
|
||||
prefer_start_time=prefer_start_time,
|
||||
range_start_time=range_start_time,
|
||||
)
|
||||
return ranges[0] if ranges else None
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
"""增量更新:新K只追加 KLU/KLC,笔与笔中枢在当前列表上重算。
|
||||
|
||||
不改 init_TF_DF 的整段语义。笔必须整表重扫:最后一笔 is_sure 允许收回
|
||||
(OWN_CHAN_ZS_001 上 60 天出现 7 次)。笔中枢用 cal_bi_zs_list_pure。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
import pandas as pd
|
||||
from pandas import DataFrame
|
||||
from technical.util import resample_to_interval
|
||||
|
||||
from chanlun.core.ChanEnum import Chan_FX_TYPE, Chan_KLC_FX, Chan_KLC_STATE
|
||||
from chanlun.core.ChanKLU import ChanKLU
|
||||
|
||||
|
||||
class IncrementalBuilderMixin:
|
||||
def init_stream(self, df, interval=1, timeframe=None):
|
||||
"""用历史K线初始化流式状态,之后用 append_bar / replace_last_bar。"""
|
||||
if df is None or df.empty:
|
||||
raise ValueError("DataFrame for stream is empty.")
|
||||
if "date" not in df.columns:
|
||||
raise ValueError(f"DataFrame missing 'date' column. Columns: {df.columns.tolist()}")
|
||||
self.timeframe = timeframe
|
||||
self.interval = interval
|
||||
if interval == 1:
|
||||
self.dataframe = df.copy()
|
||||
else:
|
||||
self.dataframe = resample_to_interval(df, interval)
|
||||
self.dataframe = self.add_indicators(self.dataframe)
|
||||
self.klu_list = []
|
||||
self.klc_list = []
|
||||
self.bi_list = []
|
||||
self.bi_zs_list = []
|
||||
self.seg_list = []
|
||||
self.zs_list = []
|
||||
self.bsp_list = []
|
||||
self.klc_fx_list = []
|
||||
self.big_zs_list = []
|
||||
self._klc_feed_last_klu = None
|
||||
for i in range(len(self.dataframe)):
|
||||
self._append_row_at(i, rebuild=False)
|
||||
self.rebuild_bi_zs()
|
||||
return self
|
||||
|
||||
def append_bar(self, row):
|
||||
"""追加一根已收盘K线。同一时间戳则改为替换最后一根。"""
|
||||
self._ensure_stream_state()
|
||||
item = self._normalize_row(row)
|
||||
if self.klu_list and self.klu_list[-1].time == self._row_time_str(item):
|
||||
return self.replace_last_bar(item)
|
||||
self._append_item_to_dataframe(item)
|
||||
self.dataframe = self.add_indicators(self.dataframe)
|
||||
self._append_row_at(len(self.dataframe) - 1, rebuild=True)
|
||||
return self
|
||||
|
||||
def replace_last_bar(self, row):
|
||||
"""更新最后一根K(未完成K线走新OHLC)。包含关系从 KLU 列表重放。"""
|
||||
self._ensure_stream_state()
|
||||
if not self.klu_list:
|
||||
return self.append_bar(row)
|
||||
item = self._normalize_row(row)
|
||||
idx = self.dataframe.index[-1]
|
||||
for key, val in item.items():
|
||||
self.dataframe.at[idx, key] = val
|
||||
self.dataframe = self.add_indicators(self.dataframe)
|
||||
self._apply_item_to_klu(self.klu_list[-1], self.dataframe.iloc[-1])
|
||||
self._rebuild_klc_from_klu()
|
||||
self.rebuild_bi_zs()
|
||||
return self
|
||||
|
||||
def rebuild_bi_zs(self):
|
||||
"""在当前 KLC 上重算笔 + cal_bi_zs_list_pure。会先清分型标记。"""
|
||||
self._reset_klc_bi_marks(self.klc_list)
|
||||
self.bi_list = self.cal_bi_list(self.klc_list) if self.klc_list else []
|
||||
self.bi_zs_list = self.cal_bi_zs_list_pure(self.bi_list) if self.bi_list else []
|
||||
return self.bi_zs_list
|
||||
|
||||
def _ensure_stream_state(self):
|
||||
if not hasattr(self, "klu_list") or self.klu_list is None:
|
||||
self.klu_list = []
|
||||
if not hasattr(self, "klc_list") or self.klc_list is None:
|
||||
self.klc_list = []
|
||||
if not hasattr(self, "dataframe") or self.dataframe is None:
|
||||
self.dataframe = DataFrame(
|
||||
columns=["date", "open", "high", "low", "close", "volume"]
|
||||
)
|
||||
if not hasattr(self, "_klc_feed_last_klu"):
|
||||
self._klc_feed_last_klu = self.klu_list[-1] if self.klu_list else None
|
||||
if not hasattr(self, "bi_zs_list"):
|
||||
self.bi_zs_list = []
|
||||
|
||||
def _rebuild_klc_from_klu(self):
|
||||
self.klc_list = []
|
||||
last_klu = None
|
||||
for klu in self.klu_list:
|
||||
self._push_klu_into_klc_list(self.klc_list, klu, last_klu)
|
||||
last_klu = klu
|
||||
self._klc_feed_last_klu = last_klu
|
||||
|
||||
def _append_row_at(self, idx, rebuild=True):
|
||||
item = self.dataframe.iloc[idx]
|
||||
klu = self._klu_from_item(item, idx)
|
||||
if self.klu_list:
|
||||
self.klu_list[-1].set_next(klu)
|
||||
klu.set_pre(self.klu_list[-1])
|
||||
self._push_klu_into_klc_list(self.klc_list, klu, self._klc_feed_last_klu)
|
||||
self._klc_feed_last_klu = klu
|
||||
self.klu_list.append(klu)
|
||||
if rebuild:
|
||||
self.rebuild_bi_zs()
|
||||
|
||||
def _klu_from_item(self, item, idx):
|
||||
klu = ChanKLU(
|
||||
self._item_time_str(item),
|
||||
item["open"],
|
||||
item["high"],
|
||||
item["low"],
|
||||
item["close"],
|
||||
item["volume"],
|
||||
)
|
||||
klu.set_idx(idx)
|
||||
if not hasattr(klu, "ema13"):
|
||||
klu.ema13 = 0
|
||||
if "macd" in item:
|
||||
klu.set_indicators(item)
|
||||
return klu
|
||||
|
||||
def _apply_item_to_klu(self, klu, item):
|
||||
klu.time = self._item_time_str(item)
|
||||
klu.open = item["open"]
|
||||
klu.high = item["high"]
|
||||
klu.low = item["low"]
|
||||
klu.close = item["close"]
|
||||
klu.volume = item["volume"]
|
||||
klu.range = klu.high - klu.low
|
||||
klu.body = abs(klu.close - klu.open)
|
||||
if "macd" in item:
|
||||
klu.set_indicators(item)
|
||||
|
||||
def _reset_klc_bi_marks(self, klc_list):
|
||||
for klc in klc_list:
|
||||
klc.fx = Chan_FX_TYPE.UNKNOWN
|
||||
klc.klc_fx_type = Chan_KLC_FX.UNKNOWN
|
||||
klc.klc_state = Chan_KLC_STATE.UNKNOWN
|
||||
klc.bi = None
|
||||
klc.fx_confirmed = False
|
||||
|
||||
def _item_time_str(self, item):
|
||||
date = item["date"]
|
||||
if hasattr(date, "to_pydatetime"):
|
||||
date = date.to_pydatetime()
|
||||
if isinstance(date, datetime):
|
||||
return date.strftime("%Y-%m-%d %H:%M:%S")
|
||||
return str(date)
|
||||
|
||||
def _row_time_str(self, item):
|
||||
return self._item_time_str(item)
|
||||
|
||||
def _normalize_row(self, row):
|
||||
if isinstance(row, pd.Series):
|
||||
return row
|
||||
return pd.Series(row)
|
||||
|
||||
def _append_item_to_dataframe(self, item):
|
||||
row_df = DataFrame([item])
|
||||
if self.dataframe is None or self.dataframe.empty:
|
||||
self.dataframe = row_df
|
||||
else:
|
||||
self.dataframe = pd.concat([self.dataframe, row_df], ignore_index=True)
|
||||
@@ -55,8 +55,8 @@ class IndicatorsBuilderMixin:
|
||||
return None
|
||||
|
||||
def add_indicators(self, df):
|
||||
fast = 26
|
||||
slow = 52
|
||||
fast = 12
|
||||
slow = 26
|
||||
period = 9
|
||||
macd = ta.MACD(df, fastperiod=fast, slowperiod=slow, signalperiod=period)
|
||||
bb365 = ta.BBANDS(df, timeperiod=365, nbdevup=3.0, nbdevdn=3.0, matype=0)
|
||||
|
||||
@@ -86,7 +86,8 @@ class KlineBuilderMixin:
|
||||
return Chan_FX_TYPE.UNKNOWN
|
||||
|
||||
def check_fx(self, klc):
|
||||
if klc.pre and klc.next:
|
||||
# 右K未完成(仍在包含合并)时不分型:否则确认笔会随 next 扩区间被 check_*_fx 收回
|
||||
if klc.pre and klc.next and klc.next.end_klu is not None:
|
||||
if klc.high > klc.pre.high and klc.high > klc.next.high and klc.low > klc.pre.low and klc.low > klc.next.low:
|
||||
#if (klc.close > klc.ema52 or klc.next.close > klc.next.ema52) and klc.macd > 0:
|
||||
klc.set_fx(Chan_FX_TYPE.TOP)
|
||||
@@ -171,6 +172,43 @@ class KlineBuilderMixin:
|
||||
def get_kl_data(self, dataframe:DataFrame):
|
||||
return self.cal_kl_data(dataframe)
|
||||
|
||||
def _push_klu_into_klc_list(self, klc_list, klu, last_klu):
|
||||
"""把一根 KLU 并入包含K线列表。与 get_klc_list 的几何规则相同。"""
|
||||
if len(klc_list) > 0:
|
||||
last_klc = klc_list[-1]
|
||||
if klu.exception:
|
||||
ddir = Chan_KLINE_DIR.DOWN
|
||||
if last_klc.high < klu.high:
|
||||
ddir = Chan_KLINE_DIR.UP
|
||||
klc = ChanKLC(klu, index=len(klc_list), ddir=ddir)
|
||||
klc.high = klu.close if klu.close > klu.open else klu.open
|
||||
klc.low = klu.open if klu.close > klu.open else klu.close
|
||||
klc_list.append(klc)
|
||||
last_klc.set_next(klc)
|
||||
klc.set_pre(last_klc)
|
||||
last_klc.set_end_klu(last_klu)
|
||||
klc.set_pre_fx()
|
||||
else:
|
||||
included = last_klc.check_klu_included(klu)
|
||||
if not included:
|
||||
ddir = Chan_KLINE_DIR.DOWN
|
||||
if last_klc.high < klu.high:
|
||||
ddir = Chan_KLINE_DIR.UP
|
||||
klc = ChanKLC(klu, index=len(klc_list), ddir=ddir)
|
||||
klc_list.append(klc)
|
||||
last_klc.set_next(klc)
|
||||
klc.set_pre(last_klc)
|
||||
last_klc.set_end_klu(last_klu)
|
||||
klc.set_pre_fx()
|
||||
else:
|
||||
last_klc.add_klu(klu)
|
||||
else:
|
||||
ddir = Chan_KLINE_DIR.UP
|
||||
if klu.open > klu.close:
|
||||
ddir = Chan_KLINE_DIR.DOWN
|
||||
klc = ChanKLC(klu, 0, ddir)
|
||||
klc_list.append(klc)
|
||||
|
||||
def get_klc_list(self, klu_list):
|
||||
klc_list = []
|
||||
last_klu = None
|
||||
@@ -198,41 +236,7 @@ class KlineBuilderMixin:
|
||||
ema_down_list.append(ema_down_count)
|
||||
#print(last_klu.time, ema_down_count, "DOWN END")
|
||||
ema_down_count = 0
|
||||
if len(klc_list) > 0:
|
||||
last_klc = klc_list[-1]
|
||||
if klu.exception:
|
||||
ddir = Chan_KLINE_DIR.DOWN
|
||||
if last_klc.high < klu.high:
|
||||
ddir = Chan_KLINE_DIR.UP
|
||||
klc = ChanKLC(klu, index=len(klc_list), ddir=ddir)
|
||||
klc.high = klu.close if klu.close > klu.open else klu.open
|
||||
klc.low = klu.open if klu.close > klu.open else klu.close
|
||||
klc_list.append(klc)
|
||||
last_klc.set_next(klc)
|
||||
klc.set_pre(last_klc)
|
||||
last_klc.set_end_klu(last_klu)
|
||||
klc.set_pre_fx()
|
||||
#print(klu.time, klu.high, klu.low, klu.close, klu.open, klu.exception)
|
||||
else:
|
||||
included = last_klc.check_klu_included(klu)
|
||||
if not included:
|
||||
ddir = Chan_KLINE_DIR.DOWN
|
||||
if last_klc.high < klu.high:
|
||||
ddir = Chan_KLINE_DIR.UP
|
||||
klc = ChanKLC(klu, index=len(klc_list), ddir=ddir)
|
||||
klc_list.append(klc)
|
||||
last_klc.set_next(klc)
|
||||
klc.set_pre(last_klc)
|
||||
last_klc.set_end_klu(last_klu)
|
||||
klc.set_pre_fx()
|
||||
else:
|
||||
last_klc.add_klu(klu)
|
||||
else:
|
||||
ddir = Chan_KLINE_DIR.UP
|
||||
if klu.open > klu.close:
|
||||
ddir = Chan_KLINE_DIR.DOWN
|
||||
klc = ChanKLC(klu, 0, ddir)
|
||||
klc_list.append(klc)
|
||||
self._push_klu_into_klc_list(klc_list, klu, last_klu)
|
||||
last_klu = klu
|
||||
klc_list = self.cal_trend(klc_list)
|
||||
#print(ema52_up_list, ema52_down_list)
|
||||
|
||||
@@ -355,9 +355,12 @@ class ZsBuilderMixin:
|
||||
return bi_zs_list
|
||||
|
||||
def get_zs_range(bis):
|
||||
zg = min(bi.high for bi in bis)
|
||||
zd = max(bi.low for bi in bis)
|
||||
return zg, zd
|
||||
bis_list = bis[0:3]
|
||||
zg = min(bi.high for bi in bis_list)
|
||||
zd = max(bi.low for bi in bis_list)
|
||||
dd = min(bi.low for bi in bis_list)
|
||||
gg = max(bi.high for bi in bis_list)
|
||||
return zg, zd, dd, gg
|
||||
|
||||
def is_bi_overlap_range(bi, zg, zd):
|
||||
return bi.high >= zd and bi.low <= zg
|
||||
@@ -375,8 +378,8 @@ class ZsBuilderMixin:
|
||||
zs.bi_list = list(bis)
|
||||
for bi in zs.bi_list:
|
||||
bi.set_bi_zs(zs)
|
||||
zs.set_gg(max(bi.high for bi in zs.bi_list))
|
||||
zs.set_dd(min(bi.low for bi in zs.bi_list))
|
||||
#zs.set_gg(max(bi.high for bi in zs.bi_list))
|
||||
#zs.set_dd(min(bi.low for bi in zs.bi_list))
|
||||
zs.classify_zs()
|
||||
|
||||
last_zs = None
|
||||
@@ -394,7 +397,7 @@ class ZsBuilderMixin:
|
||||
start_idx += 1
|
||||
continue
|
||||
|
||||
zg, zd = get_zs_range([bi1, bi2, bi3])
|
||||
zg, zd, dd, gg = get_zs_range([bi1, bi2, bi3])
|
||||
if zg <= zd:
|
||||
start_idx += 1
|
||||
continue
|
||||
@@ -420,6 +423,8 @@ class ZsBuilderMixin:
|
||||
zs = ChanBIZS(bi1, len(bi_zs_list), zs_dir)
|
||||
zs.set_zg(zg)
|
||||
zs.set_zd(zd)
|
||||
zs.set_dd(dd)
|
||||
zs.set_gg(gg)
|
||||
|
||||
set_zs_bi_list(zs, bis_for_zs)
|
||||
zs.set_end_bi(bis_for_zs[-1], bis_for_zs[-1].sure_time)
|
||||
|
||||
@@ -178,6 +178,15 @@ class ChanLun():
|
||||
def cal_bi_zs_list(self, bi_list):
|
||||
#return self.tf_df.cal_bi_zs(bi_list)
|
||||
return self.tf_df.cal_bi_zs_list(bi_list)
|
||||
def cal_bi_zs_list_pure(self, bi_list):
|
||||
return self.tf_df.cal_bi_zs_list_pure(bi_list)
|
||||
def init_stream(self, dataframe, interval=1, timeframe=None):
|
||||
self.tf_df.init_stream(dataframe, interval, timeframe)
|
||||
return self.tf_df
|
||||
def append_bar(self, row):
|
||||
return self.tf_df.append_bar(row)
|
||||
def replace_last_bar(self, row):
|
||||
return self.tf_df.replace_last_bar(row)
|
||||
def get_bi_zs_list(self, bi_list):
|
||||
return self.tf_df.get_bi_zs_list(bi_list)
|
||||
def get_decimal(self, value):
|
||||
|
||||
@@ -31,12 +31,13 @@ from chanlun.core.ChanZS import ChanZS, ChanZS_Big
|
||||
from chanlun.indicators.ChanMACD import ChanMACD
|
||||
from chanlun.pipeline.builders.bi import BiBuilderMixin
|
||||
from chanlun.pipeline.builders.bsp import BspBuilderMixin
|
||||
from chanlun.pipeline.builders.incremental import IncrementalBuilderMixin
|
||||
from chanlun.pipeline.builders.indicators import IndicatorsBuilderMixin
|
||||
from chanlun.pipeline.builders.kline import KlineBuilderMixin
|
||||
from chanlun.pipeline.builders.seg import SegBuilderMixin
|
||||
from chanlun.pipeline.builders.zs import ZsBuilderMixin
|
||||
|
||||
class TF_DF(IndicatorsBuilderMixin, KlineBuilderMixin, BiBuilderMixin, SegBuilderMixin, ZsBuilderMixin, BspBuilderMixin):
|
||||
class TF_DF(IndicatorsBuilderMixin, KlineBuilderMixin, BiBuilderMixin, SegBuilderMixin, ZsBuilderMixin, BspBuilderMixin, IncrementalBuilderMixin):
|
||||
def __init__(self, df=None, interval=0, timeframe=None):
|
||||
if df is not None:
|
||||
self.init_TF_DF(df, interval, timeframe)
|
||||
@@ -59,12 +60,14 @@ class TF_DF(IndicatorsBuilderMixin, KlineBuilderMixin, BiBuilderMixin, SegBuilde
|
||||
self.klc_list = []
|
||||
self.bi_list = []
|
||||
self.zs_list = []
|
||||
self.bi_zs_list = []
|
||||
self.bsp_list = []
|
||||
self.seg_list = []
|
||||
self.klc_fx_list = []
|
||||
self.klu_list = self.cal_kl_data(self.dataframe)
|
||||
self.klc_list = self.get_klc_list(self.klu_list)
|
||||
self.bi_list = self.cal_bi_list(self.klc_list)
|
||||
self.bi_zs_list = self.cal_bi_zs_list_pure(self.bi_list)
|
||||
self.seg_list = self.get_seg_list(self.bi_list)
|
||||
self.zs_list = self.get_zs_list(self.bi_list, self.seg_list)
|
||||
self.big_zs_list = self.get_big_zs_list(self.zs_list)
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
from __future__ import annotations
|
||||
@@ -0,0 +1,141 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
import pandas as pd
|
||||
|
||||
_CHAN = Path(__file__).resolve().parents[2]
|
||||
if str(_CHAN) not in sys.path:
|
||||
sys.path.insert(0, str(_CHAN))
|
||||
|
||||
from chanlun.pipeline.timeframe import TF_DF # noqa: E402
|
||||
|
||||
|
||||
def _zigzag_df(n=160, step=8):
|
||||
dates = pd.date_range("2024-01-01", periods=n, freq="5min")
|
||||
rows = []
|
||||
price = 100.0
|
||||
for i, date in enumerate(dates):
|
||||
up = (i // step) % 2 == 0
|
||||
if up:
|
||||
o = price
|
||||
c = price + 1.5
|
||||
h = c + 0.3
|
||||
l = o - 0.2
|
||||
else:
|
||||
o = price
|
||||
c = price - 1.5
|
||||
h = o + 0.2
|
||||
l = c - 0.3
|
||||
price = c
|
||||
rows.append(
|
||||
{
|
||||
"date": date,
|
||||
"open": o,
|
||||
"high": h,
|
||||
"low": l,
|
||||
"close": c,
|
||||
"volume": 1.0,
|
||||
}
|
||||
)
|
||||
return pd.DataFrame(rows)
|
||||
|
||||
|
||||
def _sure_bi_key(bi):
|
||||
return (str(bi.start_time), bi.dir.name, round(float(bi.high), 6), round(float(bi.low), 6))
|
||||
|
||||
|
||||
def _zs_key(zs):
|
||||
return (
|
||||
str(zs.start_time),
|
||||
round(float(zs.zg), 6),
|
||||
round(float(zs.zd), 6),
|
||||
len(zs.bi_list),
|
||||
)
|
||||
|
||||
|
||||
class TestIncremental(unittest.TestCase):
|
||||
def test_init_stream_matches_batch_push(self):
|
||||
df = _zigzag_df()
|
||||
stream = TF_DF()
|
||||
stream.init_stream(df, 1, "5m")
|
||||
|
||||
batch = TF_DF()
|
||||
indexed = batch.add_indicators(df.copy())
|
||||
klu = batch.cal_kl_data(indexed)
|
||||
klc = []
|
||||
last = None
|
||||
for k in klu:
|
||||
batch._push_klu_into_klc_list(klc, k, last)
|
||||
last = k
|
||||
batch.klc_list = klc
|
||||
batch.rebuild_bi_zs()
|
||||
|
||||
self.assertEqual(len(stream.klu_list), len(klu))
|
||||
self.assertEqual(len(stream.klc_list), len(klc))
|
||||
self.assertEqual(
|
||||
[_sure_bi_key(b) for b in stream.bi_list if b.is_sure],
|
||||
[_sure_bi_key(b) for b in batch.bi_list if b.is_sure],
|
||||
)
|
||||
self.assertEqual(
|
||||
[_zs_key(z) for z in stream.bi_zs_list],
|
||||
[_zs_key(z) for z in batch.bi_zs_list],
|
||||
)
|
||||
|
||||
def test_append_bar_matches_init_stream(self):
|
||||
df = _zigzag_df()
|
||||
stream = TF_DF()
|
||||
stream.init_stream(df, 1, "5m")
|
||||
|
||||
inc = TF_DF()
|
||||
for _, row in df.iterrows():
|
||||
inc.append_bar(row)
|
||||
|
||||
self.assertEqual(len(inc.klu_list), len(stream.klu_list))
|
||||
self.assertEqual(len(inc.klc_list), len(stream.klc_list))
|
||||
self.assertEqual(
|
||||
[_sure_bi_key(b) for b in inc.bi_list if b.is_sure],
|
||||
[_sure_bi_key(b) for b in stream.bi_list if b.is_sure],
|
||||
)
|
||||
self.assertEqual(
|
||||
[_zs_key(z) for z in inc.bi_zs_list],
|
||||
[_zs_key(z) for z in stream.bi_zs_list],
|
||||
)
|
||||
|
||||
def test_replace_last_bar_keeps_count(self):
|
||||
df = _zigzag_df(n=80)
|
||||
tf = TF_DF()
|
||||
tf.init_stream(df, 1, "5m")
|
||||
n_klu = len(tf.klu_list)
|
||||
last = df.iloc[-1].copy()
|
||||
last["close"] = float(last["close"]) + 0.01
|
||||
last["high"] = max(float(last["high"]), float(last["close"]))
|
||||
tf.replace_last_bar(last)
|
||||
self.assertEqual(len(tf.klu_list), n_klu)
|
||||
self.assertGreater(len(tf.klc_list), 0)
|
||||
|
||||
def test_check_fx_skips_forming_right_wing(self):
|
||||
from types import SimpleNamespace
|
||||
|
||||
from chanlun.core.ChanEnum import Chan_FX_TYPE
|
||||
|
||||
tf = TF_DF()
|
||||
pre = SimpleNamespace(high=10, low=8)
|
||||
nxt_open = SimpleNamespace(high=11, low=7, end_klu=None)
|
||||
nxt_done = SimpleNamespace(high=11, low=7, end_klu=object())
|
||||
center = SimpleNamespace(
|
||||
pre=pre,
|
||||
next=nxt_open,
|
||||
high=12,
|
||||
low=9,
|
||||
set_fx=lambda *_a, **_k: None,
|
||||
)
|
||||
self.assertEqual(tf.check_fx(center), Chan_FX_TYPE.UNKNOWN)
|
||||
center.next = nxt_done
|
||||
self.assertEqual(tf.check_fx(center), Chan_FX_TYPE.TOP)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,98 @@
|
||||
{
|
||||
"$schema": "https://schema.freqtrade.io/schema.json",
|
||||
"strategy": "BTC_Maker_Micro_Scalper",
|
||||
"max_open_trades": 1,
|
||||
"stake_currency": "USDT",
|
||||
"stake_amount": "unlimited",
|
||||
"tradable_balance_ratio": 0.99,
|
||||
"fiat_display_currency": "USD",
|
||||
"dry_run": true,
|
||||
"db_url": "sqlite:///tradesv3.btc_maker_micro_scalper.sqlite",
|
||||
"dry_run_wallet": 10000,
|
||||
"cancel_open_orders_on_exit": true,
|
||||
"trading_mode": "futures",
|
||||
"margin_mode": "isolated",
|
||||
"can_short": true,
|
||||
"timeframe": "1m",
|
||||
"process_only_new_candles": true,
|
||||
"fee": 0.00016,
|
||||
"unfilledtimeout": {
|
||||
"entry": 1,
|
||||
"exit": 1,
|
||||
"exit_timeout_count": 3,
|
||||
"unit": "minutes"
|
||||
},
|
||||
"order_types": {
|
||||
"entry": "limit",
|
||||
"exit": "limit",
|
||||
"stoploss": "limit",
|
||||
"stoploss_on_exchange": false
|
||||
},
|
||||
"order_time_in_force": {
|
||||
"entry": "GTC",
|
||||
"exit": "GTC"
|
||||
},
|
||||
"entry_pricing": {
|
||||
"price_side": "same",
|
||||
"use_order_book": true,
|
||||
"order_book_top": 1,
|
||||
"price_last_balance": 0.0,
|
||||
"check_depth_of_market": {
|
||||
"enabled": false,
|
||||
"bids_to_ask_delta": 1
|
||||
}
|
||||
},
|
||||
"exit_pricing": {
|
||||
"price_side": "same",
|
||||
"use_order_book": true,
|
||||
"order_book_top": 1
|
||||
},
|
||||
"exchange": {
|
||||
"name": "binance",
|
||||
"key": "YOUR_BINANCE_API_KEY",
|
||||
"secret": "YOUR_BINANCE_API_SECRET",
|
||||
"ccxt_config": {
|
||||
"proxies": {
|
||||
"http": "http://127.0.0.1:7897",
|
||||
"https": "http://127.0.0.1:7897"
|
||||
}
|
||||
},
|
||||
"ccxt_async_config": {
|
||||
"aiohttp_proxy": "http://127.0.0.1:7897"
|
||||
},
|
||||
"pair_whitelist": [
|
||||
"BTC/USDT:USDT"
|
||||
],
|
||||
"pair_blacklist": [
|
||||
"BNB/.*"
|
||||
]
|
||||
},
|
||||
"pairlists": [
|
||||
{
|
||||
"method": "StaticPairList"
|
||||
}
|
||||
],
|
||||
"telegram": {
|
||||
"enabled": false,
|
||||
"token": "",
|
||||
"chat_id": ""
|
||||
},
|
||||
"api_server": {
|
||||
"enabled": false,
|
||||
"listen_ip_address": "127.0.0.1",
|
||||
"listen_port": 8821,
|
||||
"verbosity": "error",
|
||||
"enable_openapi": false,
|
||||
"jwt_secret_key": "change_me_mms_v1",
|
||||
"ws_token": "change_me_mms_ws",
|
||||
"CORS_origins": [],
|
||||
"username": "freqtrader",
|
||||
"password": "FreqTrade007"
|
||||
},
|
||||
"bot_name": "BTC_Maker_Micro_Scalper",
|
||||
"initial_state": "running",
|
||||
"force_entry_enable": false,
|
||||
"internals": {
|
||||
"process_throttle_secs": 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
{
|
||||
"$schema": "https://schema.freqtrade.io/schema.json",
|
||||
"strategy": "BTC_Maker_Micro_Scalper_v11",
|
||||
"max_open_trades": 1,
|
||||
"stake_currency": "USDT",
|
||||
"stake_amount": "unlimited",
|
||||
"tradable_balance_ratio": 0.99,
|
||||
"fiat_display_currency": "USD",
|
||||
"dry_run": true,
|
||||
"db_url": "sqlite:///tradesv3.btc_maker_micro_scalper_v11.sqlite",
|
||||
"dry_run_wallet": 10000,
|
||||
"cancel_open_orders_on_exit": true,
|
||||
"trading_mode": "futures",
|
||||
"margin_mode": "isolated",
|
||||
"can_short": true,
|
||||
"timeframe": "1m",
|
||||
"process_only_new_candles": true,
|
||||
"fee": 0.00016,
|
||||
"unfilledtimeout": {
|
||||
"entry": 3,
|
||||
"exit": 2,
|
||||
"exit_timeout_count": 3,
|
||||
"unit": "minutes"
|
||||
},
|
||||
"order_types": {
|
||||
"entry": "limit",
|
||||
"exit": "limit",
|
||||
"stoploss": "limit",
|
||||
"stoploss_on_exchange": false
|
||||
},
|
||||
"order_time_in_force": {
|
||||
"entry": "GTC",
|
||||
"exit": "GTC"
|
||||
},
|
||||
"entry_pricing": {
|
||||
"price_side": "same",
|
||||
"use_order_book": true,
|
||||
"order_book_top": 1,
|
||||
"price_last_balance": 0.0,
|
||||
"check_depth_of_market": {
|
||||
"enabled": false,
|
||||
"bids_to_ask_delta": 1
|
||||
}
|
||||
},
|
||||
"exit_pricing": {
|
||||
"price_side": "same",
|
||||
"use_order_book": true,
|
||||
"order_book_top": 1
|
||||
},
|
||||
"exchange": {
|
||||
"name": "binance",
|
||||
"key": "YOUR_BINANCE_API_KEY",
|
||||
"secret": "YOUR_BINANCE_API_SECRET",
|
||||
"ccxt_config": {
|
||||
"proxies": {
|
||||
"http": "http://127.0.0.1:7897",
|
||||
"https": "http://127.0.0.1:7897"
|
||||
}
|
||||
},
|
||||
"ccxt_async_config": {
|
||||
"aiohttp_proxy": "http://127.0.0.1:7897"
|
||||
},
|
||||
"pair_whitelist": [
|
||||
"BTC/USDT:USDT"
|
||||
],
|
||||
"pair_blacklist": [
|
||||
"BNB/.*"
|
||||
]
|
||||
},
|
||||
"pairlists": [
|
||||
{
|
||||
"method": "StaticPairList"
|
||||
}
|
||||
],
|
||||
"telegram": {
|
||||
"enabled": false,
|
||||
"token": "",
|
||||
"chat_id": ""
|
||||
},
|
||||
"api_server": {
|
||||
"enabled": false,
|
||||
"listen_ip_address": "127.0.0.1",
|
||||
"listen_port": 8822,
|
||||
"verbosity": "error",
|
||||
"enable_openapi": false,
|
||||
"jwt_secret_key": "change_me_mms_v11",
|
||||
"ws_token": "change_me_mms_v11_ws",
|
||||
"CORS_origins": [],
|
||||
"username": "freqtrader",
|
||||
"password": "FreqTrade007"
|
||||
},
|
||||
"bot_name": "BTC_Maker_Micro_Scalper_v11",
|
||||
"initial_state": "running",
|
||||
"force_entry_enable": false,
|
||||
"internals": {
|
||||
"process_throttle_secs": 1
|
||||
}
|
||||
}
|
||||
@@ -39,8 +39,15 @@
|
||||
"name": "binance",
|
||||
"key": "hvoXanRExQvcN4tyGFvEnsSF4gqxXp6ZJnBu5lnhvlVuHaDbj2PhLBQGCLkkyeI8",
|
||||
"secret": "3UKA2oyDj7OoXrausmnaLwLlNfXmlNf2imBdmQqqKHArcJfk6X9xjaUF19wzu82l",
|
||||
"ccxt_config": {},
|
||||
"ccxt_async_config": {},
|
||||
"ccxt_config": {
|
||||
"proxies": {
|
||||
"http": "http://127.0.0.1:7897",
|
||||
"https": "http://127.0.0.1:7897"
|
||||
}
|
||||
},
|
||||
"ccxt_async_config": {
|
||||
"aiohttp_proxy": "http://127.0.0.1:7897"
|
||||
},
|
||||
"pair_whitelist": [
|
||||
"BTC/USDT:USDT"
|
||||
],
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
{
|
||||
"$schema": "https://schema.freqtrade.io/schema.json",
|
||||
"strategy": "MakerEdgeProbe",
|
||||
"max_open_trades": 1,
|
||||
"stake_currency": "USDT",
|
||||
"stake_amount": "unlimited",
|
||||
"tradable_balance_ratio": 0.99,
|
||||
"fiat_display_currency": "USD",
|
||||
"dry_run": true,
|
||||
"db_url": "sqlite:///tradesv3.maker_edge_probe.sqlite",
|
||||
"dry_run_wallet": 10000,
|
||||
"cancel_open_orders_on_exit": true,
|
||||
"trading_mode": "futures",
|
||||
"margin_mode": "isolated",
|
||||
"can_short": true,
|
||||
"timeframe": "1m",
|
||||
"process_only_new_candles": false,
|
||||
"fee": 0.00016,
|
||||
"unfilledtimeout": {
|
||||
"entry": 3,
|
||||
"exit": 2,
|
||||
"exit_timeout_count": 3,
|
||||
"unit": "minutes"
|
||||
},
|
||||
"order_types": {
|
||||
"entry": "limit",
|
||||
"exit": "limit",
|
||||
"stoploss": "market",
|
||||
"stoploss_on_exchange": false
|
||||
},
|
||||
"order_time_in_force": {
|
||||
"entry": "GTC",
|
||||
"exit": "GTC"
|
||||
},
|
||||
"entry_pricing": {
|
||||
"price_side": "same",
|
||||
"use_order_book": true,
|
||||
"order_book_top": 1
|
||||
},
|
||||
"exit_pricing": {
|
||||
"price_side": "same",
|
||||
"use_order_book": true,
|
||||
"order_book_top": 1
|
||||
},
|
||||
"exchange": {
|
||||
"name": "binance",
|
||||
"key": "YOUR_BINANCE_API_KEY",
|
||||
"secret": "YOUR_BINANCE_API_SECRET",
|
||||
"ccxt_config": {
|
||||
"proxies": {
|
||||
"http": "http://127.0.0.1:7897",
|
||||
"https": "http://127.0.0.1:7897"
|
||||
}
|
||||
},
|
||||
"ccxt_async_config": {
|
||||
"aiohttp_proxy": "http://127.0.0.1:7897"
|
||||
},
|
||||
"pair_whitelist": [
|
||||
"BTC/USDT:USDT"
|
||||
],
|
||||
"pair_blacklist": [
|
||||
"BNB/.*"
|
||||
]
|
||||
},
|
||||
"pairlists": [
|
||||
{
|
||||
"method": "StaticPairList"
|
||||
}
|
||||
],
|
||||
"telegram": {
|
||||
"enabled": false
|
||||
},
|
||||
"api_server": {
|
||||
"enabled": true,
|
||||
"listen_ip_address": "127.0.0.1",
|
||||
"listen_port": 8823,
|
||||
"verbosity": "error",
|
||||
"enable_openapi": false,
|
||||
"jwt_secret_key": "maker_edge_probe_change_me",
|
||||
"ws_token": "maker_edge_probe_ws",
|
||||
"CORS_origins": [],
|
||||
"username": "freqtrader",
|
||||
"password": "FreqTrade007"
|
||||
},
|
||||
"bot_name": "MakerEdgeProbe",
|
||||
"initial_state": "running",
|
||||
"force_entry_enable": false,
|
||||
"internals": {
|
||||
"process_throttle_secs": 2
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
{
|
||||
"$schema": "https://schema.freqtrade.io/schema.json",
|
||||
"max_open_trades": 1,
|
||||
"stake_currency": "USDT",
|
||||
"stake_amount": "unlimited",
|
||||
"tradable_balance_ratio": 0.99,
|
||||
"fiat_display_currency": "USD",
|
||||
"dry_run": true,
|
||||
"db_url": "sqlite:///tradesv3.turtle_btc.sqlite",
|
||||
"dry_run_wallet": 10000,
|
||||
"cancel_open_orders_on_exit": true,
|
||||
"trading_mode": "futures",
|
||||
"margin_mode": "isolated",
|
||||
"can_short": true,
|
||||
"timeframe": "15m",
|
||||
"process_only_new_candles": true,
|
||||
"unfilledtimeout": {
|
||||
"entry": 15,
|
||||
"exit": 15,
|
||||
"exit_timeout_count": 5,
|
||||
"unit": "minutes"
|
||||
},
|
||||
"entry_pricing": {
|
||||
"price_side": "same",
|
||||
"use_order_book": true,
|
||||
"order_book_top": 1,
|
||||
"price_last_balance": 0.0,
|
||||
"check_depth_of_market": {
|
||||
"enabled": false,
|
||||
"bids_to_ask_delta": 1
|
||||
}
|
||||
},
|
||||
"exit_pricing": {
|
||||
"price_side": "same",
|
||||
"use_order_book": true,
|
||||
"order_book_top": 1
|
||||
},
|
||||
"exchange": {
|
||||
"name": "binance",
|
||||
"key": "",
|
||||
"secret": "",
|
||||
"ccxt_config": {
|
||||
"proxies": {
|
||||
"http": "http://127.0.0.1:7897",
|
||||
"https": "http://127.0.0.1:7897"
|
||||
}
|
||||
},
|
||||
"ccxt_async_config": {
|
||||
"aiohttp_proxy": "http://127.0.0.1:7897"
|
||||
},
|
||||
"pair_whitelist": [
|
||||
"BTC/USDT:USDT"
|
||||
],
|
||||
"pair_blacklist": [
|
||||
"BNB/.*"
|
||||
]
|
||||
},
|
||||
"pairlists": [
|
||||
{
|
||||
"method": "StaticPairList"
|
||||
}
|
||||
],
|
||||
"telegram": {
|
||||
"enabled": false,
|
||||
"token": "",
|
||||
"chat_id": ""
|
||||
},
|
||||
"api_server": {
|
||||
"enabled": false,
|
||||
"listen_ip_address": "127.0.0.1",
|
||||
"listen_port": 8822,
|
||||
"verbosity": "error",
|
||||
"enable_openapi": false,
|
||||
"jwt_secret_key": "turtle-btc-change-me",
|
||||
"ws_token": "turtle-btc-ws-change-me",
|
||||
"CORS_origins": [],
|
||||
"username": "freqtrader",
|
||||
"password": "FreqTrade007"
|
||||
},
|
||||
"bot_name": "turtle_btc",
|
||||
"initial_state": "running",
|
||||
"force_entry_enable": false,
|
||||
"internals": {
|
||||
"process_throttle_secs": 5
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
{
|
||||
"$schema": "https://schema.freqtrade.io/schema.json",
|
||||
"max_open_trades": 1,
|
||||
"stake_currency": "USDT",
|
||||
"stake_amount": "unlimited",
|
||||
"tradable_balance_ratio": 0.99,
|
||||
"fiat_display_currency": "USD",
|
||||
"dry_run": true,
|
||||
"db_url": "sqlite:///tradesv3.wyckoff_btc.sqlite",
|
||||
"dry_run_wallet": 10000,
|
||||
"cancel_open_orders_on_exit": true,
|
||||
"trading_mode": "futures",
|
||||
"margin_mode": "isolated",
|
||||
"can_short": true,
|
||||
"timeframe": "1h",
|
||||
"process_only_new_candles": true,
|
||||
"unfilledtimeout": {
|
||||
"entry": 60,
|
||||
"exit": 60,
|
||||
"exit_timeout_count": 5,
|
||||
"unit": "minutes"
|
||||
},
|
||||
"entry_pricing": {
|
||||
"price_side": "same",
|
||||
"use_order_book": true,
|
||||
"order_book_top": 1,
|
||||
"price_last_balance": 0.0,
|
||||
"check_depth_of_market": {
|
||||
"enabled": false,
|
||||
"bids_to_ask_delta": 1
|
||||
}
|
||||
},
|
||||
"exit_pricing": {
|
||||
"price_side": "same",
|
||||
"use_order_book": true,
|
||||
"order_book_top": 1
|
||||
},
|
||||
"exchange": {
|
||||
"name": "binance",
|
||||
"key": "",
|
||||
"secret": "",
|
||||
"ccxt_config": {
|
||||
"proxies": {
|
||||
"http": "http://127.0.0.1:7897",
|
||||
"https": "http://127.0.0.1:7897"
|
||||
}
|
||||
},
|
||||
"ccxt_async_config": {
|
||||
"aiohttp_proxy": "http://127.0.0.1:7897"
|
||||
},
|
||||
"pair_whitelist": [
|
||||
"BTC/USDT:USDT"
|
||||
],
|
||||
"pair_blacklist": [
|
||||
"BNB/.*"
|
||||
]
|
||||
},
|
||||
"pairlists": [
|
||||
{
|
||||
"method": "StaticPairList"
|
||||
}
|
||||
],
|
||||
"telegram": {
|
||||
"enabled": false,
|
||||
"token": "",
|
||||
"chat_id": ""
|
||||
},
|
||||
"api_server": {
|
||||
"enabled": false,
|
||||
"listen_ip_address": "127.0.0.1",
|
||||
"listen_port": 8823,
|
||||
"verbosity": "error",
|
||||
"enable_openapi": false,
|
||||
"jwt_secret_key": "wyckoff-btc-change-me",
|
||||
"ws_token": "wyckoff-btc-ws-change-me",
|
||||
"CORS_origins": [],
|
||||
"username": "freqtrader",
|
||||
"password": "FreqTrade007"
|
||||
},
|
||||
"bot_name": "wyckoff_btc",
|
||||
"initial_state": "running",
|
||||
"force_entry_enable": false,
|
||||
"internals": {
|
||||
"process_throttle_secs": 5
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
{
|
||||
"$schema": "https://schema.freqtrade.io/schema.json",
|
||||
"max_open_trades": 1,
|
||||
"stake_currency": "USDT",
|
||||
"stake_amount": "unlimited",
|
||||
"tradable_balance_ratio": 0.99,
|
||||
"fiat_display_currency": "USD",
|
||||
"dry_run": true,
|
||||
"db_url": "sqlite:///tradesv3.wyckoff_btc_gated.sqlite",
|
||||
"dry_run_wallet": 10000,
|
||||
"cancel_open_orders_on_exit": true,
|
||||
"trading_mode": "futures",
|
||||
"margin_mode": "isolated",
|
||||
"can_short": true,
|
||||
"timeframe": "1h",
|
||||
"process_only_new_candles": true,
|
||||
"unfilledtimeout": {
|
||||
"entry": 60,
|
||||
"exit": 60,
|
||||
"exit_timeout_count": 5,
|
||||
"unit": "minutes"
|
||||
},
|
||||
"entry_pricing": {
|
||||
"price_side": "same",
|
||||
"use_order_book": true,
|
||||
"order_book_top": 1,
|
||||
"price_last_balance": 0.0,
|
||||
"check_depth_of_market": {
|
||||
"enabled": false,
|
||||
"bids_to_ask_delta": 1
|
||||
}
|
||||
},
|
||||
"exit_pricing": {
|
||||
"price_side": "same",
|
||||
"use_order_book": true,
|
||||
"order_book_top": 1
|
||||
},
|
||||
"exchange": {
|
||||
"name": "binance",
|
||||
"key": "",
|
||||
"secret": "",
|
||||
"ccxt_config": {
|
||||
"proxies": {
|
||||
"http": "http://127.0.0.1:7897",
|
||||
"https": "http://127.0.0.1:7897"
|
||||
}
|
||||
},
|
||||
"ccxt_async_config": {
|
||||
"aiohttp_proxy": "http://127.0.0.1:7897"
|
||||
},
|
||||
"pair_whitelist": [
|
||||
"BTC/USDT:USDT"
|
||||
],
|
||||
"pair_blacklist": [
|
||||
"BNB/.*"
|
||||
]
|
||||
},
|
||||
"pairlists": [
|
||||
{
|
||||
"method": "StaticPairList"
|
||||
}
|
||||
],
|
||||
"telegram": {
|
||||
"enabled": false,
|
||||
"token": "",
|
||||
"chat_id": ""
|
||||
},
|
||||
"api_server": {
|
||||
"enabled": false,
|
||||
"listen_ip_address": "127.0.0.1",
|
||||
"listen_port": 8825,
|
||||
"verbosity": "error",
|
||||
"enable_openapi": false,
|
||||
"jwt_secret_key": "wyckoff-gated-change-me",
|
||||
"ws_token": "wyckoff-gated-ws-change-me",
|
||||
"CORS_origins": [],
|
||||
"username": "freqtrader",
|
||||
"password": "FreqTrade007"
|
||||
},
|
||||
"bot_name": "wyckoff_btc_gated",
|
||||
"initial_state": "running",
|
||||
"force_entry_enable": false,
|
||||
"internals": {
|
||||
"process_throttle_secs": 5
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
{
|
||||
"$schema": "https://schema.freqtrade.io/schema.json",
|
||||
"max_open_trades": 1,
|
||||
"stake_currency": "USDT",
|
||||
"stake_amount": "unlimited",
|
||||
"tradable_balance_ratio": 0.99,
|
||||
"fiat_display_currency": "USD",
|
||||
"dry_run": true,
|
||||
"db_url": "sqlite:///tradesv3.wyckoff_btc_lps.sqlite",
|
||||
"dry_run_wallet": 10000,
|
||||
"cancel_open_orders_on_exit": true,
|
||||
"trading_mode": "futures",
|
||||
"margin_mode": "isolated",
|
||||
"can_short": true,
|
||||
"timeframe": "1h",
|
||||
"process_only_new_candles": true,
|
||||
"unfilledtimeout": {
|
||||
"entry": 60,
|
||||
"exit": 60,
|
||||
"exit_timeout_count": 5,
|
||||
"unit": "minutes"
|
||||
},
|
||||
"entry_pricing": {
|
||||
"price_side": "same",
|
||||
"use_order_book": true,
|
||||
"order_book_top": 1,
|
||||
"price_last_balance": 0.0,
|
||||
"check_depth_of_market": {
|
||||
"enabled": false,
|
||||
"bids_to_ask_delta": 1
|
||||
}
|
||||
},
|
||||
"exit_pricing": {
|
||||
"price_side": "same",
|
||||
"use_order_book": true,
|
||||
"order_book_top": 1
|
||||
},
|
||||
"exchange": {
|
||||
"name": "binance",
|
||||
"key": "",
|
||||
"secret": "",
|
||||
"ccxt_config": {
|
||||
"proxies": {
|
||||
"http": "http://127.0.0.1:7897",
|
||||
"https": "http://127.0.0.1:7897"
|
||||
}
|
||||
},
|
||||
"ccxt_async_config": {
|
||||
"aiohttp_proxy": "http://127.0.0.1:7897"
|
||||
},
|
||||
"pair_whitelist": [
|
||||
"BTC/USDT:USDT"
|
||||
],
|
||||
"pair_blacklist": [
|
||||
"BNB/.*"
|
||||
]
|
||||
},
|
||||
"pairlists": [
|
||||
{
|
||||
"method": "StaticPairList"
|
||||
}
|
||||
],
|
||||
"telegram": {
|
||||
"enabled": false,
|
||||
"token": "",
|
||||
"chat_id": ""
|
||||
},
|
||||
"api_server": {
|
||||
"enabled": false,
|
||||
"listen_ip_address": "127.0.0.1",
|
||||
"listen_port": 8824,
|
||||
"verbosity": "error",
|
||||
"enable_openapi": false,
|
||||
"jwt_secret_key": "wyckoff-lps-change-me",
|
||||
"ws_token": "wyckoff-lps-ws-change-me",
|
||||
"CORS_origins": [],
|
||||
"username": "freqtrader",
|
||||
"password": "FreqTrade007"
|
||||
},
|
||||
"bot_name": "wyckoff_btc_lps",
|
||||
"initial_state": "running",
|
||||
"force_entry_enable": false,
|
||||
"internals": {
|
||||
"process_throttle_secs": 5
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
{
|
||||
"$schema": "https://schema.freqtrade.io/schema.json",
|
||||
"max_open_trades": 1,
|
||||
"stake_currency": "USDT",
|
||||
"stake_amount": "unlimited",
|
||||
"tradable_balance_ratio": 0.99,
|
||||
"fiat_display_currency": "USD",
|
||||
"dry_run": true,
|
||||
"db_url": "sqlite:///tradesv3.wyckoff_btc_v1_baseline.sqlite",
|
||||
"dry_run_wallet": 10000,
|
||||
"cancel_open_orders_on_exit": true,
|
||||
"trading_mode": "futures",
|
||||
"margin_mode": "isolated",
|
||||
"can_short": true,
|
||||
"timeframe": "1h",
|
||||
"process_only_new_candles": true,
|
||||
"unfilledtimeout": {
|
||||
"entry": 60,
|
||||
"exit": 60,
|
||||
"exit_timeout_count": 5,
|
||||
"unit": "minutes"
|
||||
},
|
||||
"entry_pricing": {
|
||||
"price_side": "same",
|
||||
"use_order_book": true,
|
||||
"order_book_top": 1,
|
||||
"price_last_balance": 0.0,
|
||||
"check_depth_of_market": {
|
||||
"enabled": false,
|
||||
"bids_to_ask_delta": 1
|
||||
}
|
||||
},
|
||||
"exit_pricing": {
|
||||
"price_side": "same",
|
||||
"use_order_book": true,
|
||||
"order_book_top": 1
|
||||
},
|
||||
"exchange": {
|
||||
"name": "binance",
|
||||
"key": "",
|
||||
"secret": "",
|
||||
"ccxt_config": {
|
||||
"proxies": {
|
||||
"http": "http://127.0.0.1:7897",
|
||||
"https": "http://127.0.0.1:7897"
|
||||
}
|
||||
},
|
||||
"ccxt_async_config": {
|
||||
"aiohttp_proxy": "http://127.0.0.1:7897"
|
||||
},
|
||||
"pair_whitelist": [
|
||||
"BTC/USDT:USDT"
|
||||
],
|
||||
"pair_blacklist": [
|
||||
"BNB/.*"
|
||||
]
|
||||
},
|
||||
"pairlists": [
|
||||
{
|
||||
"method": "StaticPairList"
|
||||
}
|
||||
],
|
||||
"telegram": {
|
||||
"enabled": false,
|
||||
"token": "",
|
||||
"chat_id": ""
|
||||
},
|
||||
"api_server": {
|
||||
"enabled": false,
|
||||
"listen_ip_address": "127.0.0.1",
|
||||
"listen_port": 8823,
|
||||
"verbosity": "error",
|
||||
"enable_openapi": false,
|
||||
"jwt_secret_key": "wyckoff-v1-baseline-change-me",
|
||||
"ws_token": "wyckoff-v1-baseline-ws-change-me",
|
||||
"CORS_origins": [],
|
||||
"username": "freqtrader",
|
||||
"password": "FreqTrade007"
|
||||
},
|
||||
"bot_name": "wyckoff_btc_v1_baseline",
|
||||
"initial_state": "running",
|
||||
"force_entry_enable": false,
|
||||
"internals": {
|
||||
"process_throttle_secs": 5
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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"
|
||||
@@ -0,0 +1,9 @@
|
||||
"""Wyckoff research engines — Decision / Market State(不改 Spring Baseline 信号定义)。"""
|
||||
|
||||
from .market_state import compute_market_state_8h, spring_gate_mask, utad_gate_mask
|
||||
|
||||
__all__ = [
|
||||
"compute_market_state_8h",
|
||||
"spring_gate_mask",
|
||||
"utad_gate_mask",
|
||||
]
|
||||
@@ -0,0 +1,155 @@
|
||||
"""
|
||||
Market State Engine v1 — 因果可计算(无未来函数)
|
||||
|
||||
仅使用截至当前 8h K 线已收盘信息:
|
||||
EMA50/200、ADX、EMA slope、价格相对 MA200 距离
|
||||
|
||||
输出 0–100 分数 + 主导状态标签(argmax),供 Decision Gate 使用。
|
||||
禁止用事后涨跌路径标注 cycle。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import talib.abstract as ta
|
||||
|
||||
|
||||
def _clip01(x: pd.Series) -> pd.Series:
|
||||
return x.clip(lower=0.0, upper=1.0)
|
||||
|
||||
|
||||
def compute_market_state_8h(df: pd.DataFrame) -> pd.DataFrame:
|
||||
"""
|
||||
在原生 8h OHLCV 上计算状态分数。
|
||||
返回列: accumulation_score, markup_score, distribution_score,
|
||||
markdown_score, range_score, market_state, allow_spring, allow_utad
|
||||
"""
|
||||
out = df.copy()
|
||||
out["ema50"] = ta.EMA(out, timeperiod=50)
|
||||
out["ema200"] = ta.EMA(out, timeperiod=200)
|
||||
out["adx"] = ta.ADX(out, timeperiod=14)
|
||||
|
||||
# slope: 过去 6 根 8h(约 2 天),仅用历史
|
||||
out["ema_slope"] = (out["ema50"] - out["ema50"].shift(6)) / out["ema50"].shift(6).replace(0, np.nan)
|
||||
out["dist_ema200"] = (out["close"] - out["ema200"]) / out["ema200"].replace(0, np.nan)
|
||||
|
||||
bull = (out["close"] > out["ema200"]) & (out["ema50"] > out["ema200"])
|
||||
bear = (out["close"] < out["ema200"]) & (out["ema50"] < out["ema200"])
|
||||
range_m = (~bull) & (~bear)
|
||||
|
||||
slope = out["ema_slope"].fillna(0.0)
|
||||
dist = out["dist_ema200"].fillna(0.0)
|
||||
adx = out["adx"].fillna(0.0)
|
||||
|
||||
# ---- 分数:连续、因果、可解释 ----
|
||||
# accumulation: 仍处熊偏结构,但下跌斜率缓和 / 略抬升(吸筹语境)
|
||||
accum = (
|
||||
0.45 * bear.astype(float)
|
||||
+ 0.35 * _clip01((slope + 0.02) / 0.04) # slope 从 -2%→+2% 映射
|
||||
+ 0.20 * _clip01((0.05 + dist) / 0.10) # 仍在 MA200 下方但不极端深
|
||||
) * 100.0
|
||||
|
||||
# markup: 牛偏 + 正斜率 + 价格在 MA200 上方
|
||||
markup = (
|
||||
0.40 * bull.astype(float)
|
||||
+ 0.35 * _clip01(slope / 0.02)
|
||||
+ 0.25 * _clip01(dist / 0.08)
|
||||
) * 100.0
|
||||
|
||||
# distribution: 牛偏但斜率走平/向下(顶部语境)
|
||||
distrib = (
|
||||
0.40 * bull.astype(float)
|
||||
+ 0.40 * _clip01((-slope) / 0.015)
|
||||
+ 0.20 * _clip01((0.12 - dist.abs()) / 0.12)
|
||||
) * 100.0
|
||||
|
||||
# markdown: 熊偏 + 明显负斜率
|
||||
markdown = (
|
||||
0.45 * bear.astype(float)
|
||||
+ 0.40 * _clip01((-slope) / 0.02)
|
||||
+ 0.15 * _clip01((-dist) / 0.10)
|
||||
) * 100.0
|
||||
|
||||
# range: 非明确牛熊,或 ADX 偏低
|
||||
range_s = (
|
||||
0.50 * range_m.astype(float)
|
||||
+ 0.30 * _clip01((22.0 - adx) / 22.0)
|
||||
+ 0.20 * (1.0 - bull.astype(float)) * (1.0 - bear.astype(float))
|
||||
) * 100.0
|
||||
|
||||
out["accumulation_score"] = accum.clip(0, 100)
|
||||
out["markup_score"] = markup.clip(0, 100)
|
||||
out["distribution_score"] = distrib.clip(0, 100)
|
||||
out["markdown_score"] = markdown.clip(0, 100)
|
||||
out["range_score"] = range_s.clip(0, 100)
|
||||
|
||||
# 主导状态:与归因研究同一套因果规则(非事后路径标注)
|
||||
# bear+非急跌斜率 → accumulation;bull+正斜率 → markup;…
|
||||
state = np.full(len(out), "range", dtype=object)
|
||||
state[(bear) & (slope < -0.01)] = "markdown"
|
||||
state[(bear) & (slope >= -0.01)] = "accumulation"
|
||||
state[(bull) & (slope > 0.005)] = "markup"
|
||||
state[(bull) & (slope <= 0.005)] = "distribution"
|
||||
out["market_state"] = state
|
||||
|
||||
# 默认 Gate v1.1:状态集合(soft 阈值由 apply_decision_gate 覆盖)
|
||||
out = apply_decision_gate(out, mode="state_set")
|
||||
return out
|
||||
|
||||
|
||||
def apply_decision_gate(
|
||||
df: pd.DataFrame,
|
||||
*,
|
||||
mode: str = "state_set",
|
||||
q_sum: float = 100.0,
|
||||
q_bad: float = 55.0,
|
||||
) -> pd.DataFrame:
|
||||
"""
|
||||
Decision Gate(因果)。
|
||||
|
||||
mode:
|
||||
- state_set: state ∈ {accumulation, markup} / UTAD 镜像
|
||||
- soft_sum: state_set 且 (accum+markup) >= q_sum
|
||||
- soft_bad_cap: state_set 且 max(distrib, range, markdown) <= q_bad
|
||||
"""
|
||||
out = df.copy()
|
||||
state = out["market_state"]
|
||||
spring_state = state.isin(["accumulation", "markup"])
|
||||
utad_state = state.isin(["distribution", "markdown"])
|
||||
|
||||
good_sum = out["accumulation_score"] + out["markup_score"]
|
||||
bad_max = out[["distribution_score", "range_score", "markdown_score"]].max(axis=1)
|
||||
# UTAD 镜像:good = distrib+markdown;bad = accum/range
|
||||
utad_good_sum = out["distribution_score"] + out["markdown_score"]
|
||||
utad_bad_max = out[["accumulation_score", "range_score", "markup_score"]].max(axis=1)
|
||||
|
||||
if mode == "state_set":
|
||||
out["allow_spring"] = spring_state
|
||||
out["allow_utad"] = utad_state
|
||||
elif mode == "soft_sum":
|
||||
out["allow_spring"] = spring_state & (good_sum >= float(q_sum))
|
||||
out["allow_utad"] = utad_state & (utad_good_sum >= float(q_sum))
|
||||
elif mode == "soft_bad_cap":
|
||||
out["allow_spring"] = spring_state & (bad_max <= float(q_bad))
|
||||
out["allow_utad"] = utad_state & (utad_bad_max <= float(q_bad))
|
||||
else:
|
||||
raise ValueError(f"unknown gate mode: {mode}")
|
||||
|
||||
out["gate_mode"] = mode
|
||||
out["gate_q_sum"] = float(q_sum)
|
||||
out["gate_q_bad"] = float(q_bad)
|
||||
return out
|
||||
|
||||
|
||||
def spring_gate_mask(dataframe: pd.DataFrame, suffix: str = "_8h") -> pd.Series:
|
||||
col = f"allow_spring{suffix}"
|
||||
if col not in dataframe.columns:
|
||||
return pd.Series(True, index=dataframe.index)
|
||||
return dataframe[col].fillna(False).astype(bool)
|
||||
|
||||
|
||||
def utad_gate_mask(dataframe: pd.DataFrame, suffix: str = "_8h") -> pd.Series:
|
||||
col = f"allow_utad{suffix}"
|
||||
if col not in dataframe.columns:
|
||||
return pd.Series(True, index=dataframe.index)
|
||||
return dataframe[col].fillna(False).astype(bool)
|
||||
@@ -0,0 +1,155 @@
|
||||
# Dry-Run Decision Checklist — GATED_V1_1_LOCKED
|
||||
|
||||
```text
|
||||
Purpose: 上线前不改规则,只验执行链
|
||||
Stack: Market State → Decision → Frozen Signal
|
||||
Version: GATED_V1_1_LOCKED
|
||||
Mode: dry-run / monitoring only
|
||||
```
|
||||
|
||||
研究线已收手。本清单是 **operational acceptance**,不是新实验。
|
||||
|
||||
---
|
||||
|
||||
## Locked defaults(不可在 dry-run 中改动)
|
||||
|
||||
| Item | Value |
|
||||
|------|--------|
|
||||
| Strategy | `Wyckoff_BTC_GATED` |
|
||||
| Spring | `V1_BASELINE` FROZEN |
|
||||
| Gate | `market_state in {accumulation, markup}` → allow Spring |
|
||||
| Soft-score | rejected |
|
||||
| Range | observe only(非交易规则) |
|
||||
| gate_version | `GATED_V1_1_LOCKED` |
|
||||
|
||||
---
|
||||
|
||||
## 1. 信号一致性
|
||||
|
||||
上线前逐项勾选:
|
||||
|
||||
- [ ] 同一根 entry candle 上,`market_state` **只使用已收盘 8h** 数据(无 lookahead;merge 后读的是上一根已完成 bias bar)
|
||||
- [ ] `allow_spring == True` **仅当** `market_state ∈ {accumulation, markup}`
|
||||
- [ ] `allow_spring == False` 当 `market_state ∈ {distribution, markdown, range}` 或缺失
|
||||
- [ ] Baseline 产生 `SPRING_LONG` 且 Gate block 时:**不下单**
|
||||
- [ ] 同上 blocked 事件:**写入决策日志**(见 §2),与 kept 同 schema
|
||||
- [ ] UTAD(若启用)镜像:`allow_utad` 仅 `{distribution, markdown}`;本清单以 Spring 为主
|
||||
|
||||
快速自检(可在 dry-run 启动后抽查最近 N 条日志):
|
||||
|
||||
```text
|
||||
assert gate_version == "GATED_V1_1_LOCKED"
|
||||
assert allow ⇒ market_state in {accumulation, markup}
|
||||
assert market_state == "distribution" ⇒ allow == false
|
||||
assert block ⇒ order_not_sent
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 日志字段(每条候选信号一行)
|
||||
|
||||
必需字段:
|
||||
|
||||
| Field | Example / notes |
|
||||
|-------|-----------------|
|
||||
| `timestamp` | entry candle open/close time(UTC) |
|
||||
| `pair` | e.g. `BTC/USDT:USDT` |
|
||||
| `signal_type` | `SPRING_LONG` / `UTAD_SHORT` |
|
||||
| `market_state` | accumulation \| markup \| distribution \| markdown \| range \| missing |
|
||||
| `allow` | `true` / `false` |
|
||||
| `gate_version` | `GATED_V1_1_LOCKED` |
|
||||
| `baseline_signal` | `SPRING_LONG`(Gate 前 Baseline 标签) |
|
||||
| `block_reason` | `not_in_allow_set` \| `state_missing` \| `state_lag` \| `""` if allow |
|
||||
|
||||
推荐附加(便于监控,非规则):
|
||||
|
||||
| Field | Notes |
|
||||
|-------|--------|
|
||||
| `bias_bar_time` | 决策所用已收盘 8h bar 时间 |
|
||||
| `accumulation_score` … `range_score` | 诊断用,**不参与默认 Gate** |
|
||||
| `would_enter` | Baseline 是否曾置 `enter_long=1` |
|
||||
| `order_sent` | dry-run 下应为 `allow` 的结果 |
|
||||
|
||||
Blocked 必须落盘;禁止静默丢弃。
|
||||
|
||||
---
|
||||
|
||||
## 3. Dry-run 监控指标
|
||||
|
||||
周期性汇总(建议日 / 周):
|
||||
|
||||
| Metric | 关注点 |
|
||||
|--------|--------|
|
||||
| `kept_n` / `blocked_n` | 量级是否合理,非零且非异常尖刺 |
|
||||
| blocked domain 分布 | **尤其 `distribution` 应仍为主要 block 源** |
|
||||
| kept trade PF / expectancy | 参考,不强求 > ungated baseline |
|
||||
| max DD(kept / 账户) | 应相对 ungated 历史继续偏低 |
|
||||
| range share among blocked | 仅观察;上升不自动改规则 |
|
||||
|
||||
### 2023+ OOS 参考阈值(研究窗,非调参目标)
|
||||
|
||||
| | Gated(研究) | 解读 |
|
||||
|--|---------------|------|
|
||||
| PF | ~1.34(baseline ~1.45) | **不强求超过 baseline** |
|
||||
| DD | ~3.4%(baseline ~7.9%) | **DD 应继续低** |
|
||||
| full DD | ~9.6% vs ~26% | 结构性降 DD 仍是成功标准 |
|
||||
|
||||
Dry-run 短期 PF 波动 **不触发规则变更**。
|
||||
|
||||
---
|
||||
|
||||
## 4. 报警条件
|
||||
|
||||
| Severity | Condition | Action |
|
||||
|----------|-----------|--------|
|
||||
| P0 | `market_state` 缺失或滞后(bias bar 过旧 / merge 失败) | 停新开仓,查数据链 |
|
||||
| P0 | Gate 放行且 `market_state ∉ {accumulation, markup}` | 立即停机排查;视为执行链 bug |
|
||||
| P0 | `distribution` 被放行 Spring | 同上 |
|
||||
| P1 | blocked 样本中 `range` **长期主导** 且 kept PF/expectancy 同步恶化 | 记观察票;**不改规则**,升级人工 review |
|
||||
| P2 | kept/blocked 比为 0 或异常尖刺(数据空洞) | 查 feed / 时区 / 8h 对齐 |
|
||||
|
||||
报警只服务执行完整性,不服务「再优化一次 Gate」。
|
||||
|
||||
---
|
||||
|
||||
## 5. 不允许事项(硬禁)
|
||||
|
||||
- 不调 Spring(TF / ATR / stoploss / entry 形态)
|
||||
- 不调 soft-score,不把 soft-score 接回默认路径
|
||||
- 不全样本扫 Gate 阈值 / 状态集合
|
||||
- 不因短期 dry-run PF 调规则
|
||||
- 不因 `range` 小样本表现把 range 升格为交易域
|
||||
- 不默认合并 ETH/SOL 进生产路径
|
||||
- 不复活 LPS 分支
|
||||
|
||||
违反任一条 = 退出 dry-run,回到研究流程(需新证据包)。
|
||||
|
||||
---
|
||||
|
||||
## 6. Go / No-Go(dry-run → 有限实盘)
|
||||
|
||||
**Go**(全部满足):
|
||||
|
||||
- [ ] §1 信号一致性全部勾选
|
||||
- [ ] §2 日志字段齐全,blocked 可见
|
||||
- [ ] §4 无未关闭的 P0
|
||||
- [ ] 监控窗内 blocked 仍以坏域为主(distribution 不消失为噪音)
|
||||
- [ ] 规则文件与运行配置仍为 `GATED_V1_1_LOCKED` / `state_set`
|
||||
|
||||
**No-Go**:
|
||||
|
||||
- 任一 P0
|
||||
- 日志无法区分 kept vs blocked
|
||||
- 发现非因果 8h 状态
|
||||
- 有人为改动 Spring / Gate 默认值
|
||||
|
||||
---
|
||||
|
||||
## Related
|
||||
|
||||
- Status: `research/SYSTEM_STATUS.md`
|
||||
- Boundary: `research/VALIDITY_BOUNDARY.md`
|
||||
- Strategy: `strategies/Wyckoff_BTC_GATED.py`
|
||||
- State engine: `engine/market_state.py`
|
||||
- Audit evidence: `scripts/wyckoff_negative_domain_audit_result.json`
|
||||
- Robustness: `scripts/wyckoff_gate_robustness_slices_result.json`
|
||||
@@ -0,0 +1,63 @@
|
||||
# Wyckoff BTC System v1 — Decision Rule Locked
|
||||
|
||||
```
|
||||
Architecture: Market State → Decision → Signal
|
||||
|
||||
Spring: FROZEN
|
||||
Gate v1.1: LOCKED DEFAULT Decision rule (PASS)
|
||||
Soft-score: REJECTED (no increment)
|
||||
Hard-score: REJECTED
|
||||
|
||||
Minimal rule:
|
||||
market_state in {accumulation, markup} -> allow Spring
|
||||
else -> block Spring
|
||||
|
||||
Primary invalidation domain: distribution
|
||||
range: observation bucket only (NOT a trading rule)
|
||||
|
||||
Validity: DEFINED
|
||||
Confidence: MEDIUM / defined-domain PASS
|
||||
Status: DEFAULT RULES FROZEN
|
||||
Next: dry-run / monitoring only(见 operational checklist)
|
||||
```
|
||||
|
||||
## Operational
|
||||
|
||||
上线前不改规则,只验执行链:
|
||||
|
||||
→ [`DRY_RUN_DECISION_CHECKLIST.md`](./DRY_RUN_DECISION_CHECKLIST.md)
|
||||
|
||||
覆盖:信号一致性 · 日志字段 · dry-run 监控 · 报警 · 硬禁 · Go/No-Go。
|
||||
|
||||
## Locked stack
|
||||
|
||||
| Layer | File | Status |
|
||||
|-------|------|--------|
|
||||
| Signal | `Wyckoff_BTC_V1_BASELINE.py` | FROZEN |
|
||||
| State | `engine/market_state.py` | causal v1.1 |
|
||||
| Decision | `Wyckoff_BTC_GATED.py` | **LOCKED state_set** |
|
||||
| Boundary | `VALIDITY_BOUNDARY.md` | active |
|
||||
|
||||
## Robustness slices (blocked Spring, by year/era)
|
||||
|
||||
证据:`scripts/wyckoff_gate_robustness_slices_result.json`
|
||||
|
||||
| Slice | blocked n | dist share | top blocked | blocked PF |
|
||||
|-------|-----------|------------|-------------|------------|
|
||||
| 2020 | 3 | **1.00** | distribution | 0.73 |
|
||||
| 2021 | 4 | **0.75** | distribution | 0.31 |
|
||||
| 2022 | 1 | 1.00 | distribution | 0 |
|
||||
| 2023 | 1 | 1.00 | distribution | 0 |
|
||||
| 2024 | 3 | 0.33 | distribution+range | 0 |
|
||||
| 2025 | 1 | 0 | range (obs) | n=1 win |
|
||||
| pre_2023 | 8 | **0.875** | distribution | 0.37 |
|
||||
| 2023plus | 5 | 0.40 | distribution+range | 1.22 |
|
||||
|
||||
Verdict: **distribution 归因在多数有样本切片上稳定**(PASS)。
|
||||
2023+ / 2024–25 中 range 占比上升 → 保持 **观察标签**,不升格为交易规则。
|
||||
|
||||
## Do not
|
||||
|
||||
- 调 Spring / soft-score / Gate 阈值
|
||||
- 因 range 小样本正 PF 开放 range 交易
|
||||
- 复活 LPS / 默认跨资产
|
||||
@@ -0,0 +1,85 @@
|
||||
# Validity Boundary — Market-State Gated Spring
|
||||
|
||||
## Definition (hard)
|
||||
|
||||
```text
|
||||
market_state in {accumulation, markup} -> allow Spring
|
||||
else -> block Spring
|
||||
```
|
||||
|
||||
Spring 信号本体 = `V1_BASELINE`(FROZEN)。
|
||||
Gate = Decision 层默认规则(state_set v1.1 = **PASS**)。
|
||||
|
||||
Soft-score / hard-score 阈值 **不进入默认规则**。
|
||||
|
||||
## Validity statement
|
||||
|
||||
Spring has positive expectancy under:
|
||||
|
||||
1. BTC market
|
||||
2. Causal `market_state ∈ {accumulation, markup}`
|
||||
3. 8h / 4h / 1h alignment
|
||||
4. Trend-compatible (range already blocked in Baseline)
|
||||
|
||||
Invalid under:
|
||||
|
||||
1. `distribution`
|
||||
2. `range`
|
||||
3. `markdown`(对 SPRING_LONG)
|
||||
4. Ungated global trading
|
||||
|
||||
## Causal state (entry-time only)
|
||||
|
||||
```
|
||||
bear & ema_slope >= -1% → accumulation
|
||||
bull & ema_slope > +0.5% → markup
|
||||
bull & ema_slope <= +0.5% → distribution
|
||||
bear & ema_slope < -1% → markdown
|
||||
else → range
|
||||
```
|
||||
|
||||
## Gate performance (net fee+slip)
|
||||
|
||||
| Window | Baseline | Gated state_set |
|
||||
|--------|----------|-----------------|
|
||||
| 2023+ | n=20 PF 1.45 DD 7.9% | n=7 PF **1.34** DD **3.4%** |
|
||||
| full | n=47 PF 0.74 DD 26% | n=17 PF **0.92** DD **9.6%** |
|
||||
|
||||
Confidence: **MEDIUM / defined-domain PASS**(full PF 仍 < 1)。
|
||||
|
||||
## Negative-domain audit
|
||||
|
||||
`scripts/wyckoff_negative_domain_audit_result.json`
|
||||
|
||||
对 Baseline 全部 `SPRING_LONG`(n=28)按因果状态拆 kept/blocked:
|
||||
|
||||
| | n | PF | 含义 |
|
||||
|--|---|-----|------|
|
||||
| Kept | 15 | 1.09 | 全部在 markup |
|
||||
| Blocked | 13 | 0.58 | **100% bad domain** |
|
||||
| Blocked × distribution | 9 | **0.38** | 主杀伤区 |
|
||||
| Blocked × range | 4 | 1.14 | 样本小,非干净杀伤 |
|
||||
|
||||
→ Gate 主要过滤 **distribution 结构性失效**,符合威科夫「Spring 是吸筹事件而非形态」的边界叙事。
|
||||
|
||||
## Default stack(LOCKED)
|
||||
|
||||
```
|
||||
8h causal market_state
|
||||
↓
|
||||
Decision: state_set Gate v1.1 ← LOCKED
|
||||
↓
|
||||
Frozen V1_BASELINE Spring / UTAD
|
||||
```
|
||||
|
||||
## Year/era robustness(冻结前确认)
|
||||
|
||||
`scripts/wyckoff_gate_robustness_slices_result.json`
|
||||
|
||||
- pre_2023 blocked:distribution share **87.5%**,blocked PF 0.37
|
||||
- 多数年份 blocked 以 distribution 为首
|
||||
- 2023+ blocked:distribution + range 并存;range **仅观察**,不改规则
|
||||
- 不因 2023+ blocked 弱正 PF 或 range n=4 回滚 Gate
|
||||
|
||||
**Primary invalidation domain = distribution(稳定)**
|
||||
**range = observation bucket only**
|
||||
@@ -0,0 +1,19 @@
|
||||
# Spring Baseline V1 — FROZEN SNAPSHOT
|
||||
|
||||
勿改本目录文件。可运行副本在:
|
||||
|
||||
- `strategies/Wyckoff_BTC_V1_BASELINE.py`
|
||||
- `config/Wyckoff_BTC_V1_BASELINE.json`
|
||||
|
||||
## Evidence (cost-adjusted)
|
||||
|
||||
| Window | Profit | n | DD | Net PF |
|
||||
|--------|--------|---|-----|--------|
|
||||
| Train | +1.66% | 12 | 3.6% | 1.17 |
|
||||
| Validate | +9.99% | 6 | 1.8% | 6.20 |
|
||||
| Test | +0.85% | 2 | 0.7% | 2.18 |
|
||||
| Full | +12.74% | 20 | 3.6% | 2.02 |
|
||||
| fee+slip 5bps | +6.78% | 20 | — | **1.45** |
|
||||
|
||||
Status: **PASS + Limited Evidence** (N=20)
|
||||
Next: Phase3 → N≥50(延历史 / 多品种),不改规则。
|
||||
@@ -0,0 +1,86 @@
|
||||
{
|
||||
"$schema": "https://schema.freqtrade.io/schema.json",
|
||||
"max_open_trades": 1,
|
||||
"stake_currency": "USDT",
|
||||
"stake_amount": "unlimited",
|
||||
"tradable_balance_ratio": 0.99,
|
||||
"fiat_display_currency": "USD",
|
||||
"dry_run": true,
|
||||
"db_url": "sqlite:///tradesv3.wyckoff_btc_v1_baseline.sqlite",
|
||||
"dry_run_wallet": 10000,
|
||||
"cancel_open_orders_on_exit": true,
|
||||
"trading_mode": "futures",
|
||||
"margin_mode": "isolated",
|
||||
"can_short": true,
|
||||
"timeframe": "1h",
|
||||
"process_only_new_candles": true,
|
||||
"unfilledtimeout": {
|
||||
"entry": 60,
|
||||
"exit": 60,
|
||||
"exit_timeout_count": 5,
|
||||
"unit": "minutes"
|
||||
},
|
||||
"entry_pricing": {
|
||||
"price_side": "same",
|
||||
"use_order_book": true,
|
||||
"order_book_top": 1,
|
||||
"price_last_balance": 0.0,
|
||||
"check_depth_of_market": {
|
||||
"enabled": false,
|
||||
"bids_to_ask_delta": 1
|
||||
}
|
||||
},
|
||||
"exit_pricing": {
|
||||
"price_side": "same",
|
||||
"use_order_book": true,
|
||||
"order_book_top": 1
|
||||
},
|
||||
"exchange": {
|
||||
"name": "binance",
|
||||
"key": "",
|
||||
"secret": "",
|
||||
"ccxt_config": {
|
||||
"proxies": {
|
||||
"http": "http://127.0.0.1:7897",
|
||||
"https": "http://127.0.0.1:7897"
|
||||
}
|
||||
},
|
||||
"ccxt_async_config": {
|
||||
"aiohttp_proxy": "http://127.0.0.1:7897"
|
||||
},
|
||||
"pair_whitelist": [
|
||||
"BTC/USDT:USDT"
|
||||
],
|
||||
"pair_blacklist": [
|
||||
"BNB/.*"
|
||||
]
|
||||
},
|
||||
"pairlists": [
|
||||
{
|
||||
"method": "StaticPairList"
|
||||
}
|
||||
],
|
||||
"telegram": {
|
||||
"enabled": false,
|
||||
"token": "",
|
||||
"chat_id": ""
|
||||
},
|
||||
"api_server": {
|
||||
"enabled": false,
|
||||
"listen_ip_address": "127.0.0.1",
|
||||
"listen_port": 8823,
|
||||
"verbosity": "error",
|
||||
"enable_openapi": false,
|
||||
"jwt_secret_key": "wyckoff-v1-baseline-change-me",
|
||||
"ws_token": "wyckoff-v1-baseline-ws-change-me",
|
||||
"CORS_origins": [],
|
||||
"username": "freqtrader",
|
||||
"password": "FreqTrade007"
|
||||
},
|
||||
"bot_name": "wyckoff_btc_v1_baseline",
|
||||
"initial_state": "running",
|
||||
"force_entry_enable": false,
|
||||
"internals": {
|
||||
"process_throttle_secs": 5
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
# --- Do not remove these libs ---
|
||||
"""
|
||||
Wyckoff BTC V1.0 BASELINE — FROZEN
|
||||
|
||||
Status: BASELINE FROZEN
|
||||
Evidence: PASS (+ Limited Evidence, N=20)
|
||||
Cost Adjusted: PASS (net PF 1.45 @ fee+slip 5bps)
|
||||
Risk: small sample — 目标积累 N>=50 再谈规模
|
||||
|
||||
Branch A: Spring Reversal
|
||||
8h bias + 4h structure + 1h Spring/UTAD
|
||||
Range disabled(regime_mode=trend)
|
||||
ATR + 结构止损
|
||||
setup_type: SPRING / UTAD
|
||||
|
||||
证据: user_data/Chan/scripts/wyckoff_v1_baseline_phase2.json
|
||||
LPS 是独立 Setup 研究,禁止并入本文件调参。
|
||||
"""
|
||||
from freqtrade.strategy import (
|
||||
IStrategy, IntParameter, DecimalParameter, CategoricalParameter,
|
||||
merge_informative_pair, stoploss_from_open, stoploss_from_absolute,
|
||||
)
|
||||
from freqtrade.persistence import Trade
|
||||
import talib.abstract as ta
|
||||
from pandas import DataFrame
|
||||
import pandas as pd
|
||||
import numpy as np
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# freqtrade backtesting -c ./user_data/Chan/config/Wyckoff_BTC_V1_BASELINE.json \
|
||||
# --strategy Wyckoff_BTC_V1_BASELINE --strategy-path ./user_data/Chan/strategies --timerange=20230101-
|
||||
|
||||
|
||||
class Wyckoff_BTC_V1_BASELINE(IStrategy):
|
||||
"""冻结基线:Spring 反转。禁止继续调参;对比实验请用独立分支。"""
|
||||
INTERFACE_VERSION = 3
|
||||
STRATEGY_VERSION = "V1.0_BASELINE"
|
||||
SETUP_FAMILY = "SPRING"
|
||||
|
||||
timeframe = "1h"
|
||||
structure_timeframe = "4h"
|
||||
bias_timeframe: Optional[str] = "8h"
|
||||
use_bias_filter = True
|
||||
# trend = bull|bear only(Range disabled — 理论一致性约束,非调参)
|
||||
regime_mode: str = "trend"
|
||||
|
||||
can_short = True
|
||||
process_only_new_candles = True
|
||||
startup_candle_count = 220
|
||||
|
||||
minimal_roi = {
|
||||
"0": 0.10,
|
||||
"1440": 0.05,
|
||||
"4320": 0.025,
|
||||
"10080": 0,
|
||||
}
|
||||
stoploss = -0.10
|
||||
use_custom_stoploss = True
|
||||
trailing_stop = True
|
||||
trailing_stop_positive = 0.02
|
||||
trailing_stop_positive_offset = 0.04
|
||||
trailing_only_offset_is_reached = True
|
||||
use_exit_signal = True
|
||||
exit_profit_only = False
|
||||
|
||||
# ---- 冻结默认值(optimize=False)----
|
||||
range_lookback = IntParameter(12, 48, default=24, space="buy", optimize=False)
|
||||
spring_pierce_pct = DecimalParameter(0.001, 0.012, default=0.004, decimals=3, space="buy", optimize=False)
|
||||
vol_spike_mult = DecimalParameter(1.1, 2.5, default=1.8, decimals=1, space="buy", optimize=False)
|
||||
adx_min = IntParameter(10, 28, default=14, space="buy", optimize=False)
|
||||
tr_pos_long_max = DecimalParameter(0.35, 0.55, default=0.45, decimals=2, space="buy", optimize=False)
|
||||
tr_pos_short_min = DecimalParameter(0.45, 0.65, default=0.55, decimals=2, space="buy", optimize=False)
|
||||
atr_sl_mult = DecimalParameter(1.2, 3.5, default=1.5, decimals=1, space="sell", optimize=False)
|
||||
atr_sl_min = DecimalParameter(0.012, 0.04, default=0.018, decimals=3, space="sell", optimize=False)
|
||||
atr_sl_max = DecimalParameter(0.05, 0.12, default=0.08, decimals=2, space="sell", optimize=False)
|
||||
time_stop_hours = IntParameter(48, 240, default=120, space="sell", optimize=False)
|
||||
|
||||
# Branch A:仅 Spring / UTAD
|
||||
use_spring_sig = CategoricalParameter([True, False], default=True, space="buy", optimize=False)
|
||||
use_utad_sig = CategoricalParameter([True, False], default=True, space="buy", optimize=False)
|
||||
use_sos_sig = CategoricalParameter([True, False], default=False, space="buy", optimize=False)
|
||||
use_sow_sig = CategoricalParameter([True, False], default=False, space="buy", optimize=False)
|
||||
|
||||
lev = 1.0
|
||||
|
||||
def informative_pairs(self):
|
||||
pairs = self.dp.current_whitelist() if self.dp else []
|
||||
tfs = {self.structure_timeframe}
|
||||
if self.bias_timeframe and self.use_bias_filter:
|
||||
tfs.add(self.bias_timeframe)
|
||||
return [(pair, tf) for pair in pairs for tf in tfs]
|
||||
|
||||
def _add_wyckoff_structure(self, df: DataFrame) -> DataFrame:
|
||||
lb = int(self.range_lookback.value)
|
||||
|
||||
df["atr"] = ta.ATR(df, timeperiod=14)
|
||||
df["ema50"] = ta.EMA(df, timeperiod=50)
|
||||
df["ema200"] = ta.EMA(df, timeperiod=200)
|
||||
df["adx"] = ta.ADX(df, timeperiod=14)
|
||||
df["rsi"] = ta.RSI(df, timeperiod=14)
|
||||
df["volume_ma"] = ta.SMA(df, timeperiod=20, price="volume")
|
||||
|
||||
df["tr_high"] = df["high"].rolling(lb).max()
|
||||
df["tr_low"] = df["low"].rolling(lb).min()
|
||||
df["tr_mid"] = (df["tr_high"] + df["tr_low"]) / 2.0
|
||||
df["tr_width"] = (df["tr_high"] - df["tr_low"]) / df["tr_mid"].replace(0, np.nan)
|
||||
df["tr_width_ma"] = df["tr_width"].rolling(lb).mean()
|
||||
|
||||
rng = (df["tr_high"] - df["tr_low"]).replace(0, np.nan)
|
||||
df["tr_pos"] = (df["close"] - df["tr_low"]) / rng
|
||||
|
||||
df["in_range"] = (df["tr_width"] < df["tr_width_ma"] * 1.35) & (df["adx"] < 28)
|
||||
df["ema50_slope"] = df["ema50"] - df["ema50"].shift(8)
|
||||
df["prior_down"] = df["ema50_slope"].shift(lb) < 0
|
||||
df["prior_up"] = df["ema50_slope"].shift(lb) > 0
|
||||
|
||||
down_bar = df["close"] < df["open"]
|
||||
up_bar = df["close"] > df["open"]
|
||||
vol_down = np.where(down_bar, df["volume"], np.nan)
|
||||
vol_up = np.where(up_bar, df["volume"], np.nan)
|
||||
df["vol_down_ma"] = pd.Series(vol_down, index=df.index).rolling(10, min_periods=3).mean()
|
||||
df["vol_up_ma"] = pd.Series(vol_up, index=df.index).rolling(10, min_periods=3).mean()
|
||||
df["effort_absorb"] = (
|
||||
df["vol_down_ma"].notna()
|
||||
& df["vol_up_ma"].notna()
|
||||
& (df["vol_up_ma"] > df["vol_down_ma"] * 1.05)
|
||||
)
|
||||
|
||||
df["accum_ctx"] = (
|
||||
df["in_range"]
|
||||
& (df["prior_down"] | (df["close"] < df["ema50"]))
|
||||
& (df["tr_pos"] < float(self.tr_pos_long_max.value))
|
||||
)
|
||||
df["distrib_ctx"] = (
|
||||
df["in_range"]
|
||||
& (df["prior_up"] | (df["close"] > df["ema50"]))
|
||||
& (df["tr_pos"] > float(self.tr_pos_short_min.value))
|
||||
)
|
||||
df["bull_bias"] = (df["close"] > df["ema200"]) & (df["ema50"] > df["ema200"])
|
||||
df["bear_bias"] = (df["close"] < df["ema200"]) & (df["ema50"] < df["ema200"])
|
||||
df["vol_spike"] = df["volume"] > df["volume_ma"] * float(self.vol_spike_mult.value)
|
||||
return df
|
||||
|
||||
def _merge_tf(self, dataframe: DataFrame, pair: str, tf: str) -> DataFrame:
|
||||
inf = self.dp.get_pair_dataframe(pair=pair, timeframe=tf)
|
||||
inf = self._add_wyckoff_structure(inf)
|
||||
keep = [
|
||||
"date", "atr", "ema50", "ema200", "adx", "rsi",
|
||||
"tr_high", "tr_low", "tr_mid", "tr_width", "tr_pos",
|
||||
"in_range", "accum_ctx", "distrib_ctx",
|
||||
"vol_spike", "effort_absorb", "prior_down", "prior_up",
|
||||
"bull_bias", "bear_bias",
|
||||
]
|
||||
inf = inf[[c for c in keep if c in inf.columns]].copy()
|
||||
return merge_informative_pair(dataframe, inf, self.timeframe, tf, ffill=True)
|
||||
|
||||
def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
|
||||
pair = metadata["pair"]
|
||||
stf = self.structure_timeframe
|
||||
dataframe = self._merge_tf(dataframe, pair, stf)
|
||||
|
||||
btf = self.bias_timeframe
|
||||
if btf and self.use_bias_filter and btf != stf:
|
||||
dataframe = self._merge_tf(dataframe, pair, btf)
|
||||
|
||||
ss = f"_{stf}"
|
||||
dataframe["atr"] = ta.ATR(dataframe, timeperiod=14)
|
||||
dataframe["ema21"] = ta.EMA(dataframe, timeperiod=21)
|
||||
dataframe["ema50"] = ta.EMA(dataframe, timeperiod=50)
|
||||
dataframe["rsi"] = ta.RSI(dataframe, timeperiod=14)
|
||||
dataframe["volume_ma"] = ta.SMA(dataframe, timeperiod=20, price="volume")
|
||||
dataframe["vol_ok"] = dataframe["volume"] > dataframe["volume_ma"] * float(self.vol_spike_mult.value)
|
||||
|
||||
tr_high = dataframe[f"tr_high{ss}"]
|
||||
tr_low = dataframe[f"tr_low{ss}"]
|
||||
pierce = float(self.spring_pierce_pct.value)
|
||||
|
||||
accum_soft = (
|
||||
dataframe[f"accum_ctx{ss}"].fillna(False).astype(bool)
|
||||
| (
|
||||
dataframe[f"in_range{ss}"].fillna(False).astype(bool)
|
||||
& dataframe[f"prior_down{ss}"].fillna(False).astype(bool)
|
||||
& (dataframe[f"tr_pos{ss}"] < float(self.tr_pos_long_max.value))
|
||||
)
|
||||
)
|
||||
distrib_soft = (
|
||||
dataframe[f"distrib_ctx{ss}"].fillna(False).astype(bool)
|
||||
| (
|
||||
dataframe[f"in_range{ss}"].fillna(False).astype(bool)
|
||||
& dataframe[f"prior_up{ss}"].fillna(False).astype(bool)
|
||||
& (dataframe[f"tr_pos{ss}"] > float(self.tr_pos_short_min.value))
|
||||
)
|
||||
)
|
||||
|
||||
if btf and self.use_bias_filter:
|
||||
bs = f"_{btf}" if btf != stf else ss
|
||||
if f"bear_bias{bs}" in dataframe.columns:
|
||||
dataframe["bias_long_ok"] = ~dataframe[f"bear_bias{bs}"].fillna(False).astype(bool)
|
||||
dataframe["bias_short_ok"] = ~dataframe[f"bull_bias{bs}"].fillna(False).astype(bool)
|
||||
else:
|
||||
dataframe["bias_long_ok"] = True
|
||||
dataframe["bias_short_ok"] = True
|
||||
else:
|
||||
dataframe["bias_long_ok"] = True
|
||||
dataframe["bias_short_ok"] = True
|
||||
|
||||
vol_mild = dataframe["volume"] > dataframe["volume_ma"] * max(1.1, float(self.vol_spike_mult.value) * 0.85)
|
||||
|
||||
dataframe["spring"] = (
|
||||
tr_low.notna()
|
||||
& (dataframe["low"] < tr_low * (1.0 - pierce))
|
||||
& (dataframe["close"] > tr_low)
|
||||
& (dataframe["close"] > dataframe["open"])
|
||||
& accum_soft
|
||||
& vol_mild
|
||||
& (dataframe["rsi"] < 58)
|
||||
& dataframe["bias_long_ok"]
|
||||
)
|
||||
dataframe["utad"] = (
|
||||
tr_high.notna()
|
||||
& (dataframe["high"] > tr_high * (1.0 + pierce))
|
||||
& (dataframe["close"] < tr_high)
|
||||
& (dataframe["close"] < dataframe["open"])
|
||||
& distrib_soft
|
||||
& vol_mild
|
||||
& (dataframe["rsi"] > 42)
|
||||
& dataframe["bias_short_ok"]
|
||||
)
|
||||
# 基线不进 SOS/SOW;保留列供 exit 参考
|
||||
dataframe["sos"] = False
|
||||
dataframe["sow"] = False
|
||||
|
||||
for col in ["spring", "utad", "sos", "sow", "vol_ok", "bias_long_ok", "bias_short_ok"]:
|
||||
dataframe[col] = dataframe[col].fillna(False).astype(bool)
|
||||
dataframe["setup_type"] = ""
|
||||
dataframe.loc[dataframe["spring"], "setup_type"] = "SPRING_LONG"
|
||||
dataframe.loc[dataframe["utad"], "setup_type"] = "UTAD_SHORT"
|
||||
return dataframe
|
||||
|
||||
def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
|
||||
dataframe["enter_long"] = 0
|
||||
dataframe["enter_short"] = 0
|
||||
dataframe["enter_tag"] = ""
|
||||
|
||||
vol_ok = dataframe["volume"] > 0
|
||||
|
||||
# 分开标签:禁止把 SPRING / UTAD 混成同一统计桶
|
||||
if bool(self.use_spring_sig.value):
|
||||
cond = vol_ok & dataframe["spring"]
|
||||
dataframe.loc[cond, ["enter_long", "enter_tag"]] = (1, "SPRING_LONG")
|
||||
|
||||
if bool(self.use_utad_sig.value):
|
||||
cond = vol_ok & dataframe["utad"]
|
||||
dataframe.loc[cond, ["enter_short", "enter_tag"]] = (1, "UTAD_SHORT")
|
||||
|
||||
self._apply_regime_filter(dataframe)
|
||||
return dataframe
|
||||
|
||||
def _apply_regime_filter(self, dataframe: DataFrame) -> None:
|
||||
rm = getattr(self, "regime_mode", "all")
|
||||
if rm == "all" or not self.bias_timeframe:
|
||||
return
|
||||
bs = f"_{self.bias_timeframe}"
|
||||
bc, ec = f"bull_bias{bs}", f"bear_bias{bs}"
|
||||
if bc not in dataframe.columns or ec not in dataframe.columns:
|
||||
return
|
||||
bull = dataframe[bc].fillna(False).astype(bool)
|
||||
bear = dataframe[ec].fillna(False).astype(bool)
|
||||
both = bull & bear
|
||||
bull, bear = bull & ~both, bear & ~both
|
||||
range_m = (~bull) & (~bear)
|
||||
if rm == "bull":
|
||||
mask = ~bull
|
||||
elif rm == "bear":
|
||||
mask = ~bear
|
||||
elif rm == "range":
|
||||
mask = ~range_m
|
||||
elif rm == "trend":
|
||||
mask = range_m # Range disabled
|
||||
else:
|
||||
return
|
||||
dataframe.loc[mask, ["enter_long", "enter_short"]] = (0, 0)
|
||||
dataframe.loc[mask, "enter_tag"] = ""
|
||||
|
||||
def populate_exit_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
|
||||
dataframe["exit_long"] = 0
|
||||
dataframe["exit_short"] = 0
|
||||
dataframe["exit_tag"] = ""
|
||||
ss = f"_{self.structure_timeframe}"
|
||||
|
||||
exit_long = dataframe["utad"] | (
|
||||
dataframe[f"distrib_ctx{ss}"].fillna(False).astype(bool)
|
||||
& (dataframe["close"] < dataframe["ema21"])
|
||||
& (dataframe["rsi"] < 45)
|
||||
)
|
||||
exit_short = dataframe["spring"] | (
|
||||
dataframe[f"accum_ctx{ss}"].fillna(False).astype(bool)
|
||||
& (dataframe["close"] > dataframe["ema21"])
|
||||
& (dataframe["rsi"] > 55)
|
||||
)
|
||||
dataframe.loc[exit_long, ["exit_long", "exit_tag"]] = (1, "wyckoff_phase_flip")
|
||||
dataframe.loc[exit_short, ["exit_short", "exit_tag"]] = (1, "wyckoff_phase_flip")
|
||||
return dataframe
|
||||
|
||||
def custom_stoploss(
|
||||
self, pair: str, trade: Trade, current_time: datetime,
|
||||
current_rate: float, current_profit: float, after_fill: bool, **kwargs,
|
||||
) -> Optional[float]:
|
||||
dataframe, _ = self.dp.get_analyzed_dataframe(pair, self.timeframe)
|
||||
if dataframe.empty:
|
||||
return None
|
||||
last = dataframe.iloc[-1]
|
||||
atr = float(last["atr"]) if pd.notna(last["atr"]) else 0.0
|
||||
if atr <= 0 or trade.open_rate <= 0:
|
||||
return None
|
||||
|
||||
atr_dist = float(self.atr_sl_mult.value) * atr
|
||||
tag = trade.enter_tag or ""
|
||||
buffer = atr * 0.15
|
||||
|
||||
if after_fill and trade.get_custom_data("struct_stop") is None:
|
||||
if trade.is_short:
|
||||
trade.set_custom_data("struct_stop", float(last["high"]) + buffer)
|
||||
else:
|
||||
trade.set_custom_data("struct_stop", float(last["low"]) - buffer)
|
||||
|
||||
struct = trade.get_custom_data("struct_stop")
|
||||
if trade.is_short:
|
||||
atr_stop = trade.open_rate + atr_dist
|
||||
stop_price = min(atr_stop, float(struct)) if struct is not None else atr_stop
|
||||
else:
|
||||
atr_stop = trade.open_rate - atr_dist
|
||||
stop_price = max(atr_stop, float(struct)) if struct is not None else atr_stop
|
||||
|
||||
raw = abs(trade.open_rate - stop_price) / trade.open_rate
|
||||
raw = min(max(raw, float(self.atr_sl_min.value)), float(self.atr_sl_max.value))
|
||||
if struct is not None and tag in (
|
||||
"SPRING_LONG", "UTAD_SHORT", "SPRING", "UTAD", "wyckoff_spring", "wyckoff_utad",
|
||||
):
|
||||
sl = stoploss_from_absolute(
|
||||
stop_price, current_rate, is_short=trade.is_short, leverage=trade.leverage
|
||||
)
|
||||
return sl if sl and sl > 0 else None
|
||||
return stoploss_from_open(
|
||||
-raw, current_profit, is_short=trade.is_short, leverage=trade.leverage
|
||||
) or None
|
||||
|
||||
def custom_exit(
|
||||
self, pair: str, trade: Trade, current_time: datetime,
|
||||
current_rate: float, current_profit: float, **kwargs,
|
||||
) -> Optional[str]:
|
||||
hours = (current_time - trade.open_date_utc).total_seconds() / 3600
|
||||
if hours > float(self.time_stop_hours.value) and current_profit < 0:
|
||||
return "wyckoff_time_stop"
|
||||
if hours > float(self.time_stop_hours.value) * 2:
|
||||
return "wyckoff_time_stop_max"
|
||||
return None
|
||||
|
||||
def leverage(
|
||||
self, pair: str, current_time: datetime, current_rate: float,
|
||||
proposed_leverage: float, max_leverage: float, entry_tag: Optional[str],
|
||||
side: str, **kwargs,
|
||||
) -> float:
|
||||
return min(self.lev, max_leverage)
|
||||
@@ -0,0 +1,460 @@
|
||||
{
|
||||
"branches": {
|
||||
"Spring_V1": {
|
||||
"wfo": {
|
||||
"train": {
|
||||
"timerange": "20230101-20250101",
|
||||
"profit_pct": 1.6587295176,
|
||||
"trades": 12,
|
||||
"dd_pct": 3.644907735100005,
|
||||
"pf": 1.1700179329477578,
|
||||
"winrate": 25.0,
|
||||
"final": 10165.87295176,
|
||||
"fee_used": 0.0005,
|
||||
"regime_loaded": "trend"
|
||||
},
|
||||
"validate": {
|
||||
"timerange": "20250101-20260101",
|
||||
"profit_pct": 9.990534148400002,
|
||||
"trades": 6,
|
||||
"dd_pct": 1.797834787912851,
|
||||
"pf": 6.201791679101682,
|
||||
"winrate": 66.66666666666666,
|
||||
"final": 10999.05341484,
|
||||
"fee_used": 0.0005,
|
||||
"regime_loaded": "trend"
|
||||
},
|
||||
"test": {
|
||||
"timerange": "20260101-",
|
||||
"profit_pct": 0.8458820224000001,
|
||||
"trades": 2,
|
||||
"dd_pct": 0.7197049309999966,
|
||||
"pf": 2.175317808681236,
|
||||
"winrate": 50.0,
|
||||
"final": 10084.58820224,
|
||||
"fee_used": 0.0005,
|
||||
"regime_loaded": "trend"
|
||||
},
|
||||
"full": {
|
||||
"timerange": "20230101-",
|
||||
"profit_pct": 12.7374753063,
|
||||
"trades": 20,
|
||||
"dd_pct": 3.644907735100005,
|
||||
"pf": 2.0183507402435503,
|
||||
"winrate": 40.0,
|
||||
"final": 11273.74753063,
|
||||
"fee_used": 0.0005,
|
||||
"regime_loaded": "trend"
|
||||
}
|
||||
},
|
||||
"regimes": {
|
||||
"trend": {
|
||||
"profit_pct": 12.7374753063,
|
||||
"trades": 20,
|
||||
"dd_pct": 3.644907735100005,
|
||||
"pf": 2.0183507402435503,
|
||||
"winrate": 40.0,
|
||||
"final": 11273.74753063,
|
||||
"fee_used": 0.0005,
|
||||
"regime_loaded": "trend"
|
||||
},
|
||||
"bull": {
|
||||
"profit_pct": 8.166882314399999,
|
||||
"trades": 12,
|
||||
"dd_pct": 3.4837023928902555,
|
||||
"pf": 1.9398544482027922,
|
||||
"winrate": 33.33333333333333,
|
||||
"final": 10816.688231439999,
|
||||
"fee_used": 0.0005,
|
||||
"regime_loaded": "bull"
|
||||
},
|
||||
"bear": {
|
||||
"profit_pct": 4.2503064875000005,
|
||||
"trades": 8,
|
||||
"dd_pct": 3.173714645599994,
|
||||
"pf": 2.084295240772406,
|
||||
"winrate": 50.0,
|
||||
"final": 10425.03064875,
|
||||
"fee_used": 0.0005,
|
||||
"regime_loaded": "bear"
|
||||
},
|
||||
"range": {
|
||||
"profit_pct": -4.3352539371,
|
||||
"trades": 6,
|
||||
"dd_pct": 4.404162180500007,
|
||||
"pf": 0.15746188404490422,
|
||||
"winrate": 16.666666666666664,
|
||||
"final": 9566.47460629,
|
||||
"fee_used": 0.0005,
|
||||
"regime_loaded": "range"
|
||||
},
|
||||
"all": {
|
||||
"profit_pct": 7.831216539699999,
|
||||
"trades": 26,
|
||||
"dd_pct": 7.883451762900004,
|
||||
"pf": 1.454582067425369,
|
||||
"winrate": 34.61538461538461,
|
||||
"final": 10783.12165397,
|
||||
"fee_used": 0.0005,
|
||||
"regime_loaded": "all"
|
||||
}
|
||||
},
|
||||
"cost_stress": {
|
||||
"fee_5bps": {
|
||||
"profit_pct": 12.7374753063,
|
||||
"trades": 20,
|
||||
"dd_pct": 3.644907735100005,
|
||||
"pf": 2.0183507402435503,
|
||||
"winrate": 40.0,
|
||||
"final": 11273.74753063,
|
||||
"fee_used": 0.0005,
|
||||
"regime_loaded": "trend"
|
||||
},
|
||||
"fee_5bps+slip_5bps": {
|
||||
"profit_pct": 6.782772099999998,
|
||||
"trades": 20,
|
||||
"dd_pct": 7.851805397900007,
|
||||
"pf": 1.4511324473780693,
|
||||
"winrate": 35.0,
|
||||
"final": 10678.27721,
|
||||
"fee_used": 0.001,
|
||||
"regime_loaded": "trend"
|
||||
},
|
||||
"fee_10bps+slip_10bps": {
|
||||
"profit_pct": 3.182909279400001,
|
||||
"trades": 20,
|
||||
"dd_pct": 9.126146157700004,
|
||||
"pf": 1.1834520309921508,
|
||||
"winrate": 35.0,
|
||||
"final": 10318.29092794,
|
||||
"fee_used": 0.002,
|
||||
"regime_loaded": "trend"
|
||||
}
|
||||
},
|
||||
"target": {
|
||||
"pf": 1.3,
|
||||
"dd": 10.0,
|
||||
"note": "Spring: PF>1.3 DD<10%"
|
||||
},
|
||||
"verdict": {
|
||||
"full_pf": 2.0183507402435503,
|
||||
"full_dd": 3.644907735100005,
|
||||
"trades_per_year": 5.555555555555555,
|
||||
"net_mid_pf": 1.4511324473780693,
|
||||
"target_pf_ok": true,
|
||||
"target_dd_ok": true
|
||||
}
|
||||
},
|
||||
"LPS_V1": {
|
||||
"wfo": {
|
||||
"train": {
|
||||
"timerange": "20230101-20250101",
|
||||
"profit_pct": -1.2518571096,
|
||||
"trades": 1,
|
||||
"dd_pct": 1.251857109600005,
|
||||
"pf": 0.0,
|
||||
"winrate": 0.0,
|
||||
"final": 9874.81428904,
|
||||
"fee_used": 0.0005,
|
||||
"regime_loaded": "trend"
|
||||
},
|
||||
"validate": {
|
||||
"timerange": "20250101-20260101",
|
||||
"profit_pct": -0.24613064569999998,
|
||||
"trades": 1,
|
||||
"dd_pct": 0.24613064569999552,
|
||||
"pf": 0.0,
|
||||
"winrate": 0.0,
|
||||
"final": 9975.38693543,
|
||||
"fee_used": 0.0005,
|
||||
"regime_loaded": "trend"
|
||||
},
|
||||
"test": {
|
||||
"timerange": "20260101-",
|
||||
"profit_pct": 0.0,
|
||||
"trades": 0,
|
||||
"dd_pct": 0.0,
|
||||
"pf": 0.0,
|
||||
"winrate": 0.0,
|
||||
"final": 10000.0,
|
||||
"fee_used": 0.0005,
|
||||
"regime_loaded": "trend"
|
||||
},
|
||||
"full": {
|
||||
"timerange": "20230101-",
|
||||
"profit_pct": -1.4952529704,
|
||||
"trades": 2,
|
||||
"dd_pct": 1.4952529703999973,
|
||||
"pf": 0.0,
|
||||
"winrate": 0.0,
|
||||
"final": 9850.47470296,
|
||||
"fee_used": 0.0005,
|
||||
"regime_loaded": "trend"
|
||||
}
|
||||
},
|
||||
"regimes": {
|
||||
"trend": {
|
||||
"profit_pct": -1.4952529704,
|
||||
"trades": 2,
|
||||
"dd_pct": 1.4952529703999973,
|
||||
"pf": 0.0,
|
||||
"winrate": 0.0,
|
||||
"final": 9850.47470296,
|
||||
"fee_used": 0.0005,
|
||||
"regime_loaded": "trend"
|
||||
},
|
||||
"bull": {
|
||||
"profit_pct": -1.4952529704,
|
||||
"trades": 2,
|
||||
"dd_pct": 1.4952529703999973,
|
||||
"pf": 0.0,
|
||||
"winrate": 0.0,
|
||||
"final": 9850.47470296,
|
||||
"fee_used": 0.0005,
|
||||
"regime_loaded": "bull"
|
||||
},
|
||||
"bear": {
|
||||
"profit_pct": 0.0,
|
||||
"trades": 0,
|
||||
"dd_pct": 0.0,
|
||||
"pf": 0.0,
|
||||
"winrate": 0.0,
|
||||
"final": 10000.0,
|
||||
"fee_used": 0.0005,
|
||||
"regime_loaded": "bear"
|
||||
},
|
||||
"range": {
|
||||
"profit_pct": 0.0,
|
||||
"trades": 0,
|
||||
"dd_pct": 0.0,
|
||||
"pf": 0.0,
|
||||
"winrate": 0.0,
|
||||
"final": 10000.0,
|
||||
"fee_used": 0.0005,
|
||||
"regime_loaded": "range"
|
||||
},
|
||||
"all": {
|
||||
"profit_pct": -1.4952529704,
|
||||
"trades": 2,
|
||||
"dd_pct": 1.4952529703999973,
|
||||
"pf": 0.0,
|
||||
"winrate": 0.0,
|
||||
"final": 9850.47470296,
|
||||
"fee_used": 0.0005,
|
||||
"regime_loaded": "all"
|
||||
}
|
||||
},
|
||||
"cost_stress": {
|
||||
"fee_5bps": {
|
||||
"profit_pct": -1.4952529704,
|
||||
"trades": 2,
|
||||
"dd_pct": 1.4952529703999973,
|
||||
"pf": 0.0,
|
||||
"winrate": 0.0,
|
||||
"final": 9850.47470296,
|
||||
"fee_used": 0.0005,
|
||||
"regime_loaded": "trend"
|
||||
},
|
||||
"fee_5bps+slip_5bps": {
|
||||
"profit_pct": -1.6902037039,
|
||||
"trades": 2,
|
||||
"dd_pct": 1.6902037039000062,
|
||||
"pf": 0.0,
|
||||
"winrate": 0.0,
|
||||
"final": 9830.97962961,
|
||||
"fee_used": 0.001,
|
||||
"regime_loaded": "trend"
|
||||
},
|
||||
"fee_10bps+slip_10bps": {
|
||||
"profit_pct": -2.0801051709,
|
||||
"trades": 2,
|
||||
"dd_pct": 2.080105170900006,
|
||||
"pf": 0.0,
|
||||
"winrate": 0.0,
|
||||
"final": 9791.98948291,
|
||||
"fee_used": 0.002,
|
||||
"regime_loaded": "trend"
|
||||
}
|
||||
},
|
||||
"target": {
|
||||
"pf": 1.2,
|
||||
"dd": 15.0,
|
||||
"note": "LPS: PF>1.2, 次数增加"
|
||||
},
|
||||
"version": "LPS_V1.1",
|
||||
"verdict": {
|
||||
"full_pf": 0.0,
|
||||
"full_dd": 1.4952529703999973,
|
||||
"trades_per_year": 0.5555555555555556,
|
||||
"net_mid_pf": 0.0,
|
||||
"target_pf_ok": false,
|
||||
"target_dd_ok": true
|
||||
}
|
||||
},
|
||||
"LPS_V2": {
|
||||
"version": "LPS_V2",
|
||||
"wfo": {
|
||||
"train": {
|
||||
"timerange": "20230101-20250101",
|
||||
"profit_pct": -3.5049591933000004,
|
||||
"trades": 3,
|
||||
"dd_pct": 3.504959193300001,
|
||||
"pf": 0.0,
|
||||
"winrate": 0.0,
|
||||
"final": 9649.50408067,
|
||||
"fee_used": 0.0005,
|
||||
"regime_loaded": "trend"
|
||||
},
|
||||
"validate": {
|
||||
"timerange": "20250101-20260101",
|
||||
"profit_pct": -1.6143743830000001,
|
||||
"trades": 2,
|
||||
"dd_pct": 1.614374382999995,
|
||||
"pf": 0.0,
|
||||
"winrate": 0.0,
|
||||
"final": 9838.5625617,
|
||||
"fee_used": 0.0005,
|
||||
"regime_loaded": "trend"
|
||||
},
|
||||
"test": {
|
||||
"timerange": "20260101-",
|
||||
"profit_pct": 1.2174468187999996,
|
||||
"trades": 2,
|
||||
"dd_pct": 1.4924489317000007,
|
||||
"pf": 1.81573767312309,
|
||||
"winrate": 50.0,
|
||||
"final": 10121.74468188,
|
||||
"fee_used": 0.0005,
|
||||
"regime_loaded": "trend"
|
||||
},
|
||||
"full": {
|
||||
"timerange": "20230101-",
|
||||
"profit_pct": -3.9055710949000004,
|
||||
"trades": 7,
|
||||
"dd_pct": 6.479119889400008,
|
||||
"pf": 0.39720654015221873,
|
||||
"winrate": 14.285714285714285,
|
||||
"final": 9609.44289051,
|
||||
"fee_used": 0.0005,
|
||||
"regime_loaded": "trend"
|
||||
}
|
||||
},
|
||||
"regimes": {
|
||||
"trend": {
|
||||
"profit_pct": -3.9055710949000004,
|
||||
"trades": 7,
|
||||
"dd_pct": 6.479119889400008,
|
||||
"pf": 0.39720654015221873,
|
||||
"winrate": 14.285714285714285,
|
||||
"final": 9609.44289051,
|
||||
"fee_used": 0.0005,
|
||||
"regime_loaded": "trend"
|
||||
},
|
||||
"bull": {
|
||||
"profit_pct": -5.0599199851,
|
||||
"trades": 5,
|
||||
"dd_pct": 5.059919985100005,
|
||||
"pf": 0.0,
|
||||
"winrate": 0.0,
|
||||
"final": 9494.00800149,
|
||||
"fee_used": 0.0005,
|
||||
"regime_loaded": "bull"
|
||||
},
|
||||
"bear": {
|
||||
"profit_pct": 1.2174468187999996,
|
||||
"trades": 2,
|
||||
"dd_pct": 1.4924489317000007,
|
||||
"pf": 1.81573767312309,
|
||||
"winrate": 50.0,
|
||||
"final": 10121.74468188,
|
||||
"fee_used": 0.0005,
|
||||
"regime_loaded": "bear"
|
||||
},
|
||||
"range": {
|
||||
"profit_pct": 0.0,
|
||||
"trades": 0,
|
||||
"dd_pct": 0.0,
|
||||
"pf": 0.0,
|
||||
"winrate": 0.0,
|
||||
"final": 10000.0,
|
||||
"fee_used": 0.0005,
|
||||
"regime_loaded": "range"
|
||||
},
|
||||
"all": {
|
||||
"profit_pct": -3.9055710949000004,
|
||||
"trades": 7,
|
||||
"dd_pct": 6.479119889400008,
|
||||
"pf": 0.39720654015221873,
|
||||
"winrate": 14.285714285714285,
|
||||
"final": 9609.44289051,
|
||||
"fee_used": 0.0005,
|
||||
"regime_loaded": "all"
|
||||
}
|
||||
},
|
||||
"cost_stress": {
|
||||
"fee_5bps": {
|
||||
"profit_pct": -3.9055710949000004,
|
||||
"trades": 7,
|
||||
"dd_pct": 6.479119889400008,
|
||||
"pf": 0.39720654015221873,
|
||||
"winrate": 14.285714285714285,
|
||||
"final": 9609.44289051,
|
||||
"fee_used": 0.0005,
|
||||
"regime_loaded": "trend"
|
||||
},
|
||||
"fee_5bps+slip_5bps": {
|
||||
"profit_pct": -4.5549168852,
|
||||
"trades": 7,
|
||||
"dd_pct": 7.020877735199993,
|
||||
"pf": 0.3512325585213674,
|
||||
"winrate": 14.285714285714285,
|
||||
"final": 9544.50831148,
|
||||
"fee_used": 0.001,
|
||||
"regime_loaded": "trend"
|
||||
},
|
||||
"fee_10bps+slip_10bps": {
|
||||
"profit_pct": -5.371762636800001,
|
||||
"trades": 7,
|
||||
"dd_pct": 8.1297357765,
|
||||
"pf": 0.33924511392759654,
|
||||
"winrate": 14.285714285714285,
|
||||
"final": 9462.82373632,
|
||||
"fee_used": 0.002,
|
||||
"regime_loaded": "trend"
|
||||
}
|
||||
},
|
||||
"target": {
|
||||
"pf": 1.2,
|
||||
"dd": 15.0,
|
||||
"note": "LPS V2: 4h SOS→1h LPS; PF>1.2; ~5-15/yr"
|
||||
},
|
||||
"verdict": {
|
||||
"full_pf": 0.39720654015221873,
|
||||
"full_dd": 6.479119889400008,
|
||||
"trades_per_year": 1.9444444444444444,
|
||||
"net_mid_pf": 0.3512325585213674,
|
||||
"target_pf_ok": false,
|
||||
"target_dd_ok": true,
|
||||
"freq_ok": false,
|
||||
"regime_logic_ok": true,
|
||||
"status": "FAIL",
|
||||
"hypothesis": "4h native SOS → 1h LPS"
|
||||
}
|
||||
}
|
||||
},
|
||||
"portfolio_note": {
|
||||
"spring_tpy": 5.555555555555555,
|
||||
"lps_tpy": 0.5555555555555556,
|
||||
"sum_tpy_approx": 6.111111111111111,
|
||||
"combined_target_tpy": "15-25",
|
||||
"lps_status": "FAIL",
|
||||
"spring_status": "PASS"
|
||||
},
|
||||
"system_status": {
|
||||
"spring": "BASELINE FROZEN / PASS + Limited Evidence",
|
||||
"lps": "FAIL",
|
||||
"spring_tpy": 5.555555555555555,
|
||||
"lps_tpy": 1.9444444444444444,
|
||||
"next": "若 LPS PASS → 组合层;否则 Spring-only"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
{
|
||||
"note": "V1 BASELINE frozen; Range disabled; Spring/UTAD only; net cost included",
|
||||
"wfo": {
|
||||
"train": {
|
||||
"timerange": "20230101-20250101",
|
||||
"profit_pct": 1.6587295176,
|
||||
"trades": 12,
|
||||
"dd_pct": 3.644907735100005,
|
||||
"pf": 1.1700179329477578,
|
||||
"winrate": 25.0,
|
||||
"final": 10165.87295176,
|
||||
"fee_used": 0.0005,
|
||||
"regime_loaded": "trend"
|
||||
},
|
||||
"validate": {
|
||||
"timerange": "20250101-20260101",
|
||||
"profit_pct": 9.990534148400002,
|
||||
"trades": 6,
|
||||
"dd_pct": 1.797834787912851,
|
||||
"pf": 6.201791679101682,
|
||||
"winrate": 66.66666666666666,
|
||||
"final": 10999.05341484,
|
||||
"fee_used": 0.0005,
|
||||
"regime_loaded": "trend"
|
||||
},
|
||||
"test": {
|
||||
"timerange": "20260101-",
|
||||
"profit_pct": 0.8458820224000001,
|
||||
"trades": 2,
|
||||
"dd_pct": 0.7197049309999966,
|
||||
"pf": 2.175317808681236,
|
||||
"winrate": 50.0,
|
||||
"final": 10084.58820224,
|
||||
"fee_used": 0.0005,
|
||||
"regime_loaded": "trend"
|
||||
},
|
||||
"full": {
|
||||
"timerange": "20230101-",
|
||||
"profit_pct": 12.7374753063,
|
||||
"trades": 20,
|
||||
"dd_pct": 3.644907735100005,
|
||||
"pf": 2.0183507402435503,
|
||||
"winrate": 40.0,
|
||||
"final": 11273.74753063,
|
||||
"fee_used": 0.0005,
|
||||
"regime_loaded": "trend"
|
||||
}
|
||||
},
|
||||
"regimes": {
|
||||
"trend": {
|
||||
"profit_pct": 12.7374753063,
|
||||
"trades": 20,
|
||||
"dd_pct": 3.644907735100005,
|
||||
"pf": 2.0183507402435503,
|
||||
"winrate": 40.0,
|
||||
"final": 11273.74753063,
|
||||
"fee_used": 0.0005,
|
||||
"regime_loaded": "trend"
|
||||
},
|
||||
"bull": {
|
||||
"profit_pct": 8.166882314399999,
|
||||
"trades": 12,
|
||||
"dd_pct": 3.4837023928902555,
|
||||
"pf": 1.9398544482027922,
|
||||
"winrate": 33.33333333333333,
|
||||
"final": 10816.688231439999,
|
||||
"fee_used": 0.0005,
|
||||
"regime_loaded": "bull"
|
||||
},
|
||||
"bear": {
|
||||
"profit_pct": 4.2503064875000005,
|
||||
"trades": 8,
|
||||
"dd_pct": 3.173714645599994,
|
||||
"pf": 2.084295240772406,
|
||||
"winrate": 50.0,
|
||||
"final": 10425.03064875,
|
||||
"fee_used": 0.0005,
|
||||
"regime_loaded": "bear"
|
||||
},
|
||||
"range": {
|
||||
"profit_pct": -4.3352539371,
|
||||
"trades": 6,
|
||||
"dd_pct": 4.404162180500007,
|
||||
"pf": 0.15746188404490422,
|
||||
"winrate": 16.666666666666664,
|
||||
"final": 9566.47460629,
|
||||
"fee_used": 0.0005,
|
||||
"regime_loaded": "range"
|
||||
},
|
||||
"all": {
|
||||
"profit_pct": 7.831216539699999,
|
||||
"trades": 26,
|
||||
"dd_pct": 7.883451762900004,
|
||||
"pf": 1.454582067425369,
|
||||
"winrate": 34.61538461538461,
|
||||
"final": 10783.12165397,
|
||||
"fee_used": 0.0005,
|
||||
"regime_loaded": "all"
|
||||
}
|
||||
},
|
||||
"cost_stress": {
|
||||
"fee_5bps": {
|
||||
"profit_pct": 12.7374753063,
|
||||
"trades": 20,
|
||||
"dd_pct": 3.644907735100005,
|
||||
"pf": 2.0183507402435503,
|
||||
"winrate": 40.0,
|
||||
"final": 11273.74753063,
|
||||
"fee_used": 0.0005,
|
||||
"regime_loaded": "trend"
|
||||
},
|
||||
"fee_5bps+slip_5bps": {
|
||||
"profit_pct": 6.782772099999998,
|
||||
"trades": 20,
|
||||
"dd_pct": 7.851805397900007,
|
||||
"pf": 1.4511324473780693,
|
||||
"winrate": 35.0,
|
||||
"final": 10678.27721,
|
||||
"fee_used": 0.001,
|
||||
"regime_loaded": "trend"
|
||||
},
|
||||
"fee_10bps+slip_10bps": {
|
||||
"profit_pct": 3.182909279400001,
|
||||
"trades": 20,
|
||||
"dd_pct": 9.126146157700004,
|
||||
"pf": 1.1834520309921508,
|
||||
"winrate": 35.0,
|
||||
"final": 10318.29092794,
|
||||
"fee_used": 0.002,
|
||||
"regime_loaded": "trend"
|
||||
}
|
||||
},
|
||||
"verdict": {
|
||||
"full_pf": 2.0183507402435503,
|
||||
"full_dd": 3.644907735100005,
|
||||
"trades_per_year": 5.555555555555555,
|
||||
"net_mid_pf": 1.4511324473780693,
|
||||
"target_pf_ok": true,
|
||||
"target_dd_ok": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
# LPS V1.1 — REJECTED
|
||||
|
||||
## Hypothesis
|
||||
|
||||
在 V1 上收紧:严格 8h bias + 吸筹前置窗口 + 每事件首次回踩
|
||||
|
||||
## Result
|
||||
|
||||
- Full: **-1.50%**, n=**2**, 全亏
|
||||
- 过滤方向正确,但过度收缩 → 无统计意义
|
||||
|
||||
## Reject reason
|
||||
|
||||
无法同时满足「理论纯度」与「可交易样本」。确认问题在事件定义,继续收紧无意义。
|
||||
@@ -0,0 +1,15 @@
|
||||
# LPS V1 — REJECTED
|
||||
|
||||
## Hypothesis
|
||||
|
||||
1h 侦测突破 + 回踩 = Wyckoff LPS(趋势跟随)
|
||||
|
||||
## Result
|
||||
|
||||
- Full: **-18.92%**, n=133, PF **0.73**
|
||||
- Regime anomaly: **trend 亏、range 赚**(反理论)
|
||||
|
||||
## Reject reason
|
||||
|
||||
捕获的是普通突破回踩噪音,不是 Accumulation → Markup 下的 Composite Operator LPS。
|
||||
定义错误,不是参数问题。
|
||||
@@ -0,0 +1,43 @@
|
||||
# LPS V2 — REJECTED(归档,不再救援)
|
||||
|
||||
## Hypothesis
|
||||
|
||||
**4h 原生 SOS Confirm → 1h LPS Entry**
|
||||
大级别事件、小级别执行(非 1h 假突破)
|
||||
|
||||
## Implementation
|
||||
|
||||
见 `Wyckoff_BTC_LPS_V2.py`
|
||||
|
||||
4h SOS: 实体收盘离开区间 + vol>MA*1.5 + close strength>0.7 + 3 根 hold
|
||||
1h LPS: 首次回踩 + 0.5~1.5 ATR + vol<breakout_vol + close>prev high
|
||||
|
||||
## Result
|
||||
|
||||
| Window | Profit | n | PF |
|
||||
|--------|--------|---|-----|
|
||||
| Train | -3.50% | 3 | 0 |
|
||||
| Validate | -1.61% | 2 | 0 |
|
||||
| Test | +1.22% | 2 | 1.82 |
|
||||
| Full | **-3.91%** | 7 | **0.40** |
|
||||
| fee+slip | -4.55% | 7 | **0.35** |
|
||||
|
||||
证据文件: `wyckoff_lps_v2_phase2_result.json`
|
||||
|
||||
## Funnel
|
||||
|
||||
```
|
||||
4h sos_raw 183 → confirmed 123 → 1h LPS 7
|
||||
```
|
||||
|
||||
SOS 识别有产出;**SOS→LPS 映射无稳定边际**。
|
||||
|
||||
## Reject reason
|
||||
|
||||
在 BTC 永续当前结构下,传统股票式 SOS→LPS→Markup 假设不成立:
|
||||
突破后常不给标准 LPS,或首次回踩已破坏结构。
|
||||
样本少/成本/Regime 均非主因。**停止优化本假设。**
|
||||
|
||||
## Reopen only if
|
||||
|
||||
成交量分布 / 订单流 / 资金费率等新信息源进入假设。
|
||||
@@ -0,0 +1,492 @@
|
||||
# --- Do not remove these libs ---
|
||||
"""
|
||||
Wyckoff BTC — Branch B: LPS Trend Continuation(独立 Setup 研究)
|
||||
|
||||
Status: RESEARCH
|
||||
Spring V1: BASELINE FROZEN(禁止改动 / 禁止与本分支合并调参)
|
||||
|
||||
LPS V2 假设(验证中):
|
||||
4h 原生 SOS Confirm → 1h LPS Entry
|
||||
不是 1h 假突破回踩
|
||||
|
||||
4h SOS:
|
||||
① close > range_high(实体收盘离开区间,非 wick)
|
||||
② volume > MA20 * 1.5
|
||||
③ close strength (close-low)/(high-low) > 0.7
|
||||
④ 随后 3 根 4h close 仍 > breakout_level
|
||||
|
||||
1h LPS:
|
||||
第一次回踩 breakout_level
|
||||
回踩深度 0.5~1.5 ATR(1h)
|
||||
volume_4h < sos_break_volume
|
||||
转强: close > previous high
|
||||
|
||||
setup_type / enter_tag: LPS / LPSY
|
||||
regime_mode=trend(Range disabled)
|
||||
"""
|
||||
from freqtrade.strategy import (
|
||||
IStrategy, IntParameter, DecimalParameter, CategoricalParameter,
|
||||
merge_informative_pair, stoploss_from_open, stoploss_from_absolute,
|
||||
)
|
||||
from freqtrade.persistence import Trade
|
||||
import talib.abstract as ta
|
||||
from pandas import DataFrame
|
||||
import pandas as pd
|
||||
import numpy as np
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# freqtrade backtesting -c ./user_data/Chan/config/Wyckoff_BTC_LPS.json \
|
||||
# --strategy Wyckoff_BTC_LPS --strategy-path ./user_data/Chan/strategies --timerange=20230101-
|
||||
|
||||
|
||||
class Wyckoff_BTC_LPS(IStrategy):
|
||||
"""LPS V2: 4h 原生 SOS → 1h LPS。不与 Spring 混用。"""
|
||||
INTERFACE_VERSION = 3
|
||||
STRATEGY_VERSION = "LPS_V2"
|
||||
SETUP_FAMILY = "LPS"
|
||||
|
||||
timeframe = "1h"
|
||||
structure_timeframe = "4h"
|
||||
bias_timeframe: Optional[str] = "8h"
|
||||
use_bias_filter = True
|
||||
regime_mode: str = "trend"
|
||||
|
||||
can_short = True
|
||||
process_only_new_candles = True
|
||||
startup_candle_count = 220
|
||||
|
||||
minimal_roi = {
|
||||
"0": 0.12,
|
||||
"1440": 0.06,
|
||||
"4320": 0.03,
|
||||
"10080": 0,
|
||||
}
|
||||
stoploss = -0.10
|
||||
use_custom_stoploss = True
|
||||
trailing_stop = True
|
||||
trailing_stop_positive = 0.025
|
||||
trailing_stop_positive_offset = 0.05
|
||||
trailing_only_offset_is_reached = True
|
||||
use_exit_signal = True
|
||||
exit_profit_only = False
|
||||
|
||||
# ---- 固定规则(不做 hyperopt)----
|
||||
range_lookback = IntParameter(12, 48, default=24, space="buy", optimize=False)
|
||||
sos_vol_mult = DecimalParameter(1.2, 2.5, default=1.5, decimals=1, space="buy", optimize=False)
|
||||
sos_close_strength = DecimalParameter(0.55, 0.90, default=0.70, decimals=2, space="buy", optimize=False)
|
||||
sos_hold_bars_4h = IntParameter(1, 6, default=3, space="buy", optimize=False)
|
||||
lps_pb_atr_min = DecimalParameter(0.3, 1.0, default=0.5, decimals=1, space="buy", optimize=False)
|
||||
lps_pb_atr_max = DecimalParameter(1.0, 2.5, default=1.5, decimals=1, space="buy", optimize=False)
|
||||
lps_max_age_1h = IntParameter(12, 120, default=72, space="buy", optimize=False)
|
||||
|
||||
atr_sl_mult = DecimalParameter(1.2, 3.5, default=1.5, decimals=1, space="sell", optimize=False)
|
||||
atr_sl_min = DecimalParameter(0.012, 0.04, default=0.018, decimals=3, space="sell", optimize=False)
|
||||
atr_sl_max = DecimalParameter(0.05, 0.12, default=0.08, decimals=2, space="sell", optimize=False)
|
||||
time_stop_hours = IntParameter(48, 240, default=168, space="sell", optimize=False)
|
||||
|
||||
use_lps_long = CategoricalParameter([True, False], default=True, space="buy", optimize=False)
|
||||
use_lps_short = CategoricalParameter([True, False], default=True, space="buy", optimize=False)
|
||||
|
||||
lev = 1.0
|
||||
|
||||
def informative_pairs(self):
|
||||
pairs = self.dp.current_whitelist() if self.dp else []
|
||||
tfs = {self.structure_timeframe}
|
||||
if self.bias_timeframe and self.use_bias_filter:
|
||||
tfs.add(self.bias_timeframe)
|
||||
return [(pair, tf) for pair in pairs for tf in tfs]
|
||||
|
||||
def _add_bias_tf(self, df: DataFrame) -> DataFrame:
|
||||
df = df.copy()
|
||||
df["ema50"] = ta.EMA(df, timeperiod=50)
|
||||
df["ema200"] = ta.EMA(df, timeperiod=200)
|
||||
df["bull_bias"] = (df["close"] > df["ema200"]) & (df["ema50"] > df["ema200"])
|
||||
df["bear_bias"] = (df["close"] < df["ema200"]) & (df["ema50"] < df["ema200"])
|
||||
return df
|
||||
|
||||
def _add_sos_structure_4h(self, df: DataFrame) -> DataFrame:
|
||||
"""在 4h 原生计算 SOS / SOW(含 hold 确认,无前视进场)。"""
|
||||
df = df.copy()
|
||||
lb = int(self.range_lookback.value)
|
||||
hold = int(self.sos_hold_bars_4h.value)
|
||||
vol_m = float(self.sos_vol_mult.value)
|
||||
strength_min = float(self.sos_close_strength.value)
|
||||
|
||||
df["atr"] = ta.ATR(df, timeperiod=14)
|
||||
df["volume_ma"] = ta.SMA(df, timeperiod=20, price="volume")
|
||||
df["ema50"] = ta.EMA(df, timeperiod=50)
|
||||
df["ema200"] = ta.EMA(df, timeperiod=200)
|
||||
df["adx"] = ta.ADX(df, timeperiod=14)
|
||||
|
||||
# 区间用「突破前」边界:shift(1) 的 rolling,避免当根抬高
|
||||
df["range_high"] = df["high"].rolling(lb).max().shift(1)
|
||||
df["range_low"] = df["low"].rolling(lb).min().shift(1)
|
||||
|
||||
bar_range = (df["high"] - df["low"]).replace(0, np.nan)
|
||||
df["close_strength"] = (df["close"] - df["low"]) / bar_range
|
||||
df["close_weakness"] = (df["high"] - df["close"]) / bar_range
|
||||
|
||||
vol_ok = df["volume"] > df["volume_ma"] * vol_m
|
||||
|
||||
# ① 实体收盘离开区间 ② 放量 ③ Effort Result
|
||||
sos_raw = (
|
||||
df["range_high"].notna()
|
||||
& (df["close"] > df["range_high"])
|
||||
& (df["close"].shift(1) <= df["range_high"])
|
||||
& vol_ok
|
||||
& (df["close_strength"] > strength_min)
|
||||
)
|
||||
sow_raw = (
|
||||
df["range_low"].notna()
|
||||
& (df["close"] < df["range_low"])
|
||||
& (df["close"].shift(1) >= df["range_low"])
|
||||
& vol_ok
|
||||
& (df["close_weakness"] > strength_min)
|
||||
)
|
||||
|
||||
# 事件位:突破当根冻结
|
||||
sos_level = df["range_high"].where(sos_raw)
|
||||
sos_vol = df["volume"].where(sos_raw)
|
||||
sos_origin = df["range_low"].where(sos_raw)
|
||||
sow_level = df["range_low"].where(sow_raw)
|
||||
sow_vol = df["volume"].where(sow_raw)
|
||||
sow_origin = df["range_high"].where(sow_raw)
|
||||
|
||||
# ④ Hold:突破后 hold 根 4h 收盘仍在突破侧 → 在第 hold 根确认(无前视)
|
||||
sos_confirmed = sos_raw.shift(hold).fillna(False)
|
||||
sow_confirmed = sow_raw.shift(hold).fillna(False)
|
||||
for k in range(hold):
|
||||
sos_confirmed = sos_confirmed & (df["close"].shift(k) > sos_level.shift(hold))
|
||||
sow_confirmed = sow_confirmed & (df["close"].shift(k) < sow_level.shift(hold))
|
||||
|
||||
# 确认当根带出冻结字段,再 ffill 供 1h 使用
|
||||
df["sos_raw"] = sos_raw.fillna(False)
|
||||
df["sow_raw"] = sow_raw.fillna(False)
|
||||
df["sos_confirmed"] = sos_confirmed.fillna(False)
|
||||
df["sow_confirmed"] = sow_confirmed.fillna(False)
|
||||
|
||||
df["sos_break_level"] = sos_level.shift(hold).where(df["sos_confirmed"])
|
||||
df["sos_break_volume"] = sos_vol.shift(hold).where(df["sos_confirmed"])
|
||||
df["sos_origin"] = sos_origin.shift(hold).where(df["sos_confirmed"])
|
||||
df["sow_break_level"] = sow_level.shift(hold).where(df["sow_confirmed"])
|
||||
df["sow_break_volume"] = sow_vol.shift(hold).where(df["sow_confirmed"])
|
||||
df["sow_origin"] = sow_origin.shift(hold).where(df["sow_confirmed"])
|
||||
|
||||
df["sos_break_level"] = df["sos_break_level"].ffill()
|
||||
df["sos_break_volume"] = df["sos_break_volume"].ffill()
|
||||
df["sos_origin"] = df["sos_origin"].ffill()
|
||||
df["sow_break_level"] = df["sow_break_level"].ffill()
|
||||
df["sow_break_volume"] = df["sow_break_volume"].ffill()
|
||||
df["sow_origin"] = df["sow_origin"].ffill()
|
||||
|
||||
df["bull_bias"] = (df["close"] > df["ema200"]) & (df["ema50"] > df["ema200"])
|
||||
df["bear_bias"] = (df["close"] < df["ema200"]) & (df["ema50"] < df["ema200"])
|
||||
return df
|
||||
|
||||
@staticmethod
|
||||
def _bars_since(event: pd.Series) -> pd.Series:
|
||||
ev = event.fillna(False).astype(bool).to_numpy()
|
||||
out = np.full(len(ev), np.nan)
|
||||
c = np.nan
|
||||
for i, e in enumerate(ev):
|
||||
if e:
|
||||
c = 0.0
|
||||
elif not np.isnan(c):
|
||||
c += 1.0
|
||||
out[i] = c
|
||||
return pd.Series(out, index=event.index)
|
||||
|
||||
@staticmethod
|
||||
def _expanding_max_since(event: pd.Series, value: pd.Series) -> pd.Series:
|
||||
"""每个 event 之后对 value 做分段累计 max。"""
|
||||
ev = event.fillna(False).astype(bool).to_numpy()
|
||||
vals = value.to_numpy(dtype=float)
|
||||
out = np.full(len(ev), np.nan)
|
||||
cur = np.nan
|
||||
active = False
|
||||
for i in range(len(ev)):
|
||||
if ev[i]:
|
||||
active = True
|
||||
cur = vals[i]
|
||||
elif active:
|
||||
if not np.isnan(vals[i]):
|
||||
cur = vals[i] if np.isnan(cur) else max(cur, vals[i])
|
||||
out[i] = cur if active else np.nan
|
||||
return pd.Series(out, index=event.index)
|
||||
|
||||
@staticmethod
|
||||
def _expanding_min_since(event: pd.Series, value: pd.Series) -> pd.Series:
|
||||
ev = event.fillna(False).astype(bool).to_numpy()
|
||||
vals = value.to_numpy(dtype=float)
|
||||
out = np.full(len(ev), np.nan)
|
||||
cur = np.nan
|
||||
active = False
|
||||
for i in range(len(ev)):
|
||||
if ev[i]:
|
||||
active = True
|
||||
cur = vals[i]
|
||||
elif active:
|
||||
if not np.isnan(vals[i]):
|
||||
cur = vals[i] if np.isnan(cur) else min(cur, vals[i])
|
||||
out[i] = cur if active else np.nan
|
||||
return pd.Series(out, index=event.index)
|
||||
|
||||
def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
|
||||
pair = metadata["pair"]
|
||||
stf = self.structure_timeframe
|
||||
btf = self.bias_timeframe
|
||||
|
||||
inf4 = self.dp.get_pair_dataframe(pair=pair, timeframe=stf)
|
||||
inf4 = self._add_sos_structure_4h(inf4)
|
||||
keep4 = [
|
||||
"date", "atr", "adx", "volume",
|
||||
"range_high", "range_low", "close_strength",
|
||||
"sos_raw", "sow_raw", "sos_confirmed", "sow_confirmed",
|
||||
"sos_break_level", "sos_break_volume", "sos_origin",
|
||||
"sow_break_level", "sow_break_volume", "sow_origin",
|
||||
"bull_bias", "bear_bias",
|
||||
]
|
||||
inf4 = inf4[[c for c in keep4 if c in inf4.columns]].copy()
|
||||
dataframe = merge_informative_pair(dataframe, inf4, self.timeframe, stf, ffill=True)
|
||||
|
||||
if btf and self.use_bias_filter and btf != stf:
|
||||
infb = self.dp.get_pair_dataframe(pair=pair, timeframe=btf)
|
||||
infb = self._add_bias_tf(infb)
|
||||
infb = infb[["date", "bull_bias", "bear_bias", "ema50", "ema200"]].copy()
|
||||
dataframe = merge_informative_pair(dataframe, infb, self.timeframe, btf, ffill=True)
|
||||
|
||||
ss = f"_{stf}"
|
||||
bs = f"_{btf}" if btf and btf != stf else ss
|
||||
|
||||
dataframe["atr"] = ta.ATR(dataframe, timeperiod=14)
|
||||
dataframe["ema21"] = ta.EMA(dataframe, timeperiod=21)
|
||||
dataframe["ema50"] = ta.EMA(dataframe, timeperiod=50)
|
||||
dataframe["volume_ma"] = ta.SMA(dataframe, timeperiod=20, price="volume")
|
||||
|
||||
# 8h bias(优先);否则退回 4h bias
|
||||
if f"bull_bias{bs}" in dataframe.columns:
|
||||
bull = dataframe[f"bull_bias{bs}"].fillna(False).astype(bool)
|
||||
bear = dataframe[f"bear_bias{bs}"].fillna(False).astype(bool)
|
||||
else:
|
||||
bull = dataframe[f"bull_bias{ss}"].fillna(False).astype(bool)
|
||||
bear = dataframe[f"bear_bias{ss}"].fillna(False).astype(bool)
|
||||
dataframe["bias_long_ok"] = bull
|
||||
dataframe["bias_short_ok"] = bear
|
||||
|
||||
sos_conf = dataframe[f"sos_confirmed{ss}"].fillna(False).astype(bool)
|
||||
sow_conf = dataframe[f"sow_confirmed{ss}"].fillna(False).astype(bool)
|
||||
# 确认沿上升沿:4h 确认映射到 1h 后的首次 True
|
||||
sos_event = sos_conf & ~sos_conf.shift(1).fillna(False)
|
||||
sow_event = sow_conf & ~sow_conf.shift(1).fillna(False)
|
||||
|
||||
sos_level = dataframe[f"sos_break_level{ss}"]
|
||||
sos_bvol = dataframe[f"sos_break_volume{ss}"]
|
||||
sos_origin = dataframe[f"sos_origin{ss}"]
|
||||
sow_level = dataframe[f"sow_break_level{ss}"]
|
||||
sow_bvol = dataframe[f"sow_break_volume{ss}"]
|
||||
sow_origin = dataframe[f"sow_origin{ss}"]
|
||||
vol4 = dataframe[f"volume{ss}"]
|
||||
|
||||
sos_age = self._bars_since(sos_event)
|
||||
sow_age = self._bars_since(sow_event)
|
||||
post_high = self._expanding_max_since(sos_event, dataframe["high"])
|
||||
post_low = self._expanding_min_since(sow_event, dataframe["low"])
|
||||
|
||||
atr = dataframe["atr"]
|
||||
pb_min = float(self.lps_pb_atr_min.value)
|
||||
pb_max = float(self.lps_pb_atr_max.value)
|
||||
max_age = float(self.lps_max_age_1h.value)
|
||||
|
||||
# 回踩深度:SOS 后高点回撤的 ATR 倍数
|
||||
retrace_long = (post_high - dataframe["low"]) / atr.replace(0, np.nan)
|
||||
retrace_short = (dataframe["high"] - post_low) / atr.replace(0, np.nan)
|
||||
|
||||
near_sos = dataframe["low"] <= (sos_level + atr * 0.35)
|
||||
near_sow = dataframe["high"] >= (sow_level - atr * 0.35)
|
||||
vol_dry_long = vol4 < sos_bvol
|
||||
vol_dry_short = vol4 < sow_bvol
|
||||
reclaim_long = dataframe["close"] > dataframe["high"].shift(1)
|
||||
reclaim_short = dataframe["close"] < dataframe["low"].shift(1)
|
||||
|
||||
first_near_long = near_sos & ~near_sos.shift(1).fillna(False)
|
||||
first_near_short = near_sow & ~near_sow.shift(1).fillna(False)
|
||||
|
||||
alive_long = (
|
||||
sos_age.notna()
|
||||
& (sos_age >= 1)
|
||||
& (sos_age <= max_age)
|
||||
& (dataframe["close"] > sos_origin)
|
||||
)
|
||||
alive_short = (
|
||||
sow_age.notna()
|
||||
& (sow_age >= 1)
|
||||
& (sow_age <= max_age)
|
||||
& (dataframe["close"] < sow_origin)
|
||||
)
|
||||
|
||||
dataframe["lps"] = (
|
||||
alive_long
|
||||
& first_near_long
|
||||
& retrace_long.between(pb_min, pb_max)
|
||||
& (dataframe["low"] > sos_origin)
|
||||
& (dataframe["close"] >= sos_level * 0.995)
|
||||
& vol_dry_long
|
||||
& reclaim_long
|
||||
& dataframe["bias_long_ok"]
|
||||
)
|
||||
dataframe["lpsy"] = (
|
||||
alive_short
|
||||
& first_near_short
|
||||
& retrace_short.between(pb_min, pb_max)
|
||||
& (dataframe["high"] < sow_origin)
|
||||
& (dataframe["close"] <= sow_level * 1.005)
|
||||
& vol_dry_short
|
||||
& reclaim_short
|
||||
& dataframe["bias_short_ok"]
|
||||
)
|
||||
|
||||
dataframe["sos"] = sos_event
|
||||
dataframe["sow"] = sow_event
|
||||
dataframe["sos_level"] = sos_level
|
||||
dataframe["sos_origin"] = sos_origin
|
||||
dataframe["sow_level"] = sow_level
|
||||
dataframe["sow_origin"] = sow_origin
|
||||
dataframe["sos_age"] = sos_age
|
||||
dataframe["sow_age"] = sow_age
|
||||
|
||||
for col in ["lps", "lpsy", "bias_long_ok", "bias_short_ok", "sos", "sow"]:
|
||||
dataframe[col] = dataframe[col].fillna(False).astype(bool)
|
||||
|
||||
dataframe["setup_type"] = ""
|
||||
dataframe.loc[dataframe["lps"], "setup_type"] = "LPS"
|
||||
dataframe.loc[dataframe["lpsy"], "setup_type"] = "LPSY"
|
||||
return dataframe
|
||||
|
||||
def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
|
||||
dataframe["enter_long"] = 0
|
||||
dataframe["enter_short"] = 0
|
||||
dataframe["enter_tag"] = ""
|
||||
vol_ok = dataframe["volume"] > 0
|
||||
|
||||
if bool(self.use_lps_long.value):
|
||||
cond = vol_ok & dataframe["lps"]
|
||||
dataframe.loc[cond, ["enter_long", "enter_tag"]] = (1, "LPS")
|
||||
|
||||
if bool(self.use_lps_short.value):
|
||||
cond = vol_ok & dataframe["lpsy"]
|
||||
dataframe.loc[cond, ["enter_short", "enter_tag"]] = (1, "LPSY")
|
||||
|
||||
self._apply_regime_filter(dataframe)
|
||||
return dataframe
|
||||
|
||||
def _apply_regime_filter(self, dataframe: DataFrame) -> None:
|
||||
rm = getattr(self, "regime_mode", "all")
|
||||
if rm == "all" or not self.bias_timeframe:
|
||||
return
|
||||
bs = f"_{self.bias_timeframe}"
|
||||
bc, ec = f"bull_bias{bs}", f"bear_bias{bs}"
|
||||
if bc not in dataframe.columns or ec not in dataframe.columns:
|
||||
return
|
||||
bull = dataframe[bc].fillna(False).astype(bool)
|
||||
bear = dataframe[ec].fillna(False).astype(bool)
|
||||
both = bull & bear
|
||||
bull, bear = bull & ~both, bear & ~both
|
||||
range_m = (~bull) & (~bear)
|
||||
if rm == "bull":
|
||||
mask = ~bull
|
||||
elif rm == "bear":
|
||||
mask = ~bear
|
||||
elif rm == "range":
|
||||
mask = ~range_m
|
||||
elif rm == "trend":
|
||||
mask = range_m
|
||||
else:
|
||||
return
|
||||
dataframe.loc[mask, ["enter_long", "enter_short"]] = (0, 0)
|
||||
dataframe.loc[mask, "enter_tag"] = ""
|
||||
|
||||
def populate_exit_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
|
||||
dataframe["exit_long"] = 0
|
||||
dataframe["exit_short"] = 0
|
||||
dataframe["exit_tag"] = ""
|
||||
# 结构失效:收盘跌破 SOS 突破位 / 升破 SOW 突破位
|
||||
exit_long = (
|
||||
dataframe["sos_level"].notna()
|
||||
& (dataframe["close"] < dataframe["sos_level"])
|
||||
& (dataframe["close"] < dataframe["ema21"])
|
||||
) | dataframe["sow"]
|
||||
exit_short = (
|
||||
dataframe["sow_level"].notna()
|
||||
& (dataframe["close"] > dataframe["sow_level"])
|
||||
& (dataframe["close"] > dataframe["ema21"])
|
||||
) | dataframe["sos"]
|
||||
dataframe.loc[exit_long.fillna(False), ["exit_long", "exit_tag"]] = (1, "lps_structure_fail")
|
||||
dataframe.loc[exit_short.fillna(False), ["exit_short", "exit_tag"]] = (1, "lps_structure_fail")
|
||||
return dataframe
|
||||
|
||||
def custom_stoploss(
|
||||
self, pair: str, trade: Trade, current_time: datetime,
|
||||
current_rate: float, current_profit: float, after_fill: bool, **kwargs,
|
||||
) -> Optional[float]:
|
||||
dataframe, _ = self.dp.get_analyzed_dataframe(pair, self.timeframe)
|
||||
if dataframe.empty:
|
||||
return None
|
||||
last = dataframe.iloc[-1]
|
||||
atr = float(last["atr"]) if pd.notna(last["atr"]) else 0.0
|
||||
if atr <= 0 or trade.open_rate <= 0:
|
||||
return None
|
||||
|
||||
atr_dist = float(self.atr_sl_mult.value) * atr
|
||||
tag = trade.enter_tag or ""
|
||||
buffer = atr * 0.15
|
||||
|
||||
if after_fill and trade.get_custom_data("struct_stop") is None:
|
||||
if tag == "LPS" and pd.notna(last.get("sos_origin")):
|
||||
trade.set_custom_data("struct_stop", float(last["sos_origin"]) - buffer)
|
||||
elif tag == "LPSY" and pd.notna(last.get("sow_origin")):
|
||||
trade.set_custom_data("struct_stop", float(last["sow_origin"]) + buffer)
|
||||
elif trade.is_short:
|
||||
trade.set_custom_data("struct_stop", float(last["high"]) + buffer)
|
||||
else:
|
||||
trade.set_custom_data("struct_stop", float(last["low"]) - buffer)
|
||||
|
||||
struct = trade.get_custom_data("struct_stop")
|
||||
if trade.is_short:
|
||||
atr_stop = trade.open_rate + atr_dist
|
||||
stop_price = min(atr_stop, float(struct)) if struct is not None else atr_stop
|
||||
else:
|
||||
atr_stop = trade.open_rate - atr_dist
|
||||
stop_price = max(atr_stop, float(struct)) if struct is not None else atr_stop
|
||||
|
||||
raw = abs(trade.open_rate - stop_price) / trade.open_rate
|
||||
raw = min(max(raw, float(self.atr_sl_min.value)), float(self.atr_sl_max.value))
|
||||
if struct is not None and tag in ("LPS", "LPSY"):
|
||||
sl = stoploss_from_absolute(
|
||||
stop_price, current_rate, is_short=trade.is_short, leverage=trade.leverage
|
||||
)
|
||||
return sl if sl and sl > 0 else None
|
||||
return stoploss_from_open(
|
||||
-raw, current_profit, is_short=trade.is_short, leverage=trade.leverage
|
||||
) or None
|
||||
|
||||
def custom_exit(
|
||||
self, pair: str, trade: Trade, current_time: datetime,
|
||||
current_rate: float, current_profit: float, **kwargs,
|
||||
) -> Optional[str]:
|
||||
hours = (current_time - trade.open_date_utc).total_seconds() / 3600
|
||||
if hours > float(self.time_stop_hours.value) and current_profit < 0:
|
||||
return "wyckoff_time_stop"
|
||||
if hours > float(self.time_stop_hours.value) * 2:
|
||||
return "wyckoff_time_stop_max"
|
||||
return None
|
||||
|
||||
def leverage(
|
||||
self, pair: str, current_time: datetime, current_rate: float,
|
||||
proposed_leverage: float, max_leverage: float, entry_tag: Optional[str],
|
||||
side: str, **kwargs,
|
||||
) -> float:
|
||||
return min(self.lev, max_leverage)
|
||||
@@ -0,0 +1,150 @@
|
||||
{
|
||||
"version": "LPS_V2",
|
||||
"wfo": {
|
||||
"train": {
|
||||
"timerange": "20230101-20250101",
|
||||
"profit_pct": -3.5049591933000004,
|
||||
"trades": 3,
|
||||
"dd_pct": 3.504959193300001,
|
||||
"pf": 0.0,
|
||||
"winrate": 0.0,
|
||||
"final": 9649.50408067,
|
||||
"fee_used": 0.0005,
|
||||
"regime_loaded": "trend"
|
||||
},
|
||||
"validate": {
|
||||
"timerange": "20250101-20260101",
|
||||
"profit_pct": -1.6143743830000001,
|
||||
"trades": 2,
|
||||
"dd_pct": 1.614374382999995,
|
||||
"pf": 0.0,
|
||||
"winrate": 0.0,
|
||||
"final": 9838.5625617,
|
||||
"fee_used": 0.0005,
|
||||
"regime_loaded": "trend"
|
||||
},
|
||||
"test": {
|
||||
"timerange": "20260101-",
|
||||
"profit_pct": 1.2174468187999996,
|
||||
"trades": 2,
|
||||
"dd_pct": 1.4924489317000007,
|
||||
"pf": 1.81573767312309,
|
||||
"winrate": 50.0,
|
||||
"final": 10121.74468188,
|
||||
"fee_used": 0.0005,
|
||||
"regime_loaded": "trend"
|
||||
},
|
||||
"full": {
|
||||
"timerange": "20230101-",
|
||||
"profit_pct": -3.9055710949000004,
|
||||
"trades": 7,
|
||||
"dd_pct": 6.479119889400008,
|
||||
"pf": 0.39720654015221873,
|
||||
"winrate": 14.285714285714285,
|
||||
"final": 9609.44289051,
|
||||
"fee_used": 0.0005,
|
||||
"regime_loaded": "trend"
|
||||
}
|
||||
},
|
||||
"regimes": {
|
||||
"trend": {
|
||||
"profit_pct": -3.9055710949000004,
|
||||
"trades": 7,
|
||||
"dd_pct": 6.479119889400008,
|
||||
"pf": 0.39720654015221873,
|
||||
"winrate": 14.285714285714285,
|
||||
"final": 9609.44289051,
|
||||
"fee_used": 0.0005,
|
||||
"regime_loaded": "trend"
|
||||
},
|
||||
"bull": {
|
||||
"profit_pct": -5.0599199851,
|
||||
"trades": 5,
|
||||
"dd_pct": 5.059919985100005,
|
||||
"pf": 0.0,
|
||||
"winrate": 0.0,
|
||||
"final": 9494.00800149,
|
||||
"fee_used": 0.0005,
|
||||
"regime_loaded": "bull"
|
||||
},
|
||||
"bear": {
|
||||
"profit_pct": 1.2174468187999996,
|
||||
"trades": 2,
|
||||
"dd_pct": 1.4924489317000007,
|
||||
"pf": 1.81573767312309,
|
||||
"winrate": 50.0,
|
||||
"final": 10121.74468188,
|
||||
"fee_used": 0.0005,
|
||||
"regime_loaded": "bear"
|
||||
},
|
||||
"range": {
|
||||
"profit_pct": 0.0,
|
||||
"trades": 0,
|
||||
"dd_pct": 0.0,
|
||||
"pf": 0.0,
|
||||
"winrate": 0.0,
|
||||
"final": 10000.0,
|
||||
"fee_used": 0.0005,
|
||||
"regime_loaded": "range"
|
||||
},
|
||||
"all": {
|
||||
"profit_pct": -3.9055710949000004,
|
||||
"trades": 7,
|
||||
"dd_pct": 6.479119889400008,
|
||||
"pf": 0.39720654015221873,
|
||||
"winrate": 14.285714285714285,
|
||||
"final": 9609.44289051,
|
||||
"fee_used": 0.0005,
|
||||
"regime_loaded": "all"
|
||||
}
|
||||
},
|
||||
"cost_stress": {
|
||||
"fee_5bps": {
|
||||
"profit_pct": -3.9055710949000004,
|
||||
"trades": 7,
|
||||
"dd_pct": 6.479119889400008,
|
||||
"pf": 0.39720654015221873,
|
||||
"winrate": 14.285714285714285,
|
||||
"final": 9609.44289051,
|
||||
"fee_used": 0.0005,
|
||||
"regime_loaded": "trend"
|
||||
},
|
||||
"fee_5bps+slip_5bps": {
|
||||
"profit_pct": -4.5549168852,
|
||||
"trades": 7,
|
||||
"dd_pct": 7.020877735199993,
|
||||
"pf": 0.3512325585213674,
|
||||
"winrate": 14.285714285714285,
|
||||
"final": 9544.50831148,
|
||||
"fee_used": 0.001,
|
||||
"regime_loaded": "trend"
|
||||
},
|
||||
"fee_10bps+slip_10bps": {
|
||||
"profit_pct": -5.371762636800001,
|
||||
"trades": 7,
|
||||
"dd_pct": 8.1297357765,
|
||||
"pf": 0.33924511392759654,
|
||||
"winrate": 14.285714285714285,
|
||||
"final": 9462.82373632,
|
||||
"fee_used": 0.002,
|
||||
"regime_loaded": "trend"
|
||||
}
|
||||
},
|
||||
"target": {
|
||||
"pf": 1.2,
|
||||
"dd": 15.0,
|
||||
"note": "LPS V2: 4h SOS→1h LPS; PF>1.2; ~5-15/yr"
|
||||
},
|
||||
"verdict": {
|
||||
"full_pf": 0.39720654015221873,
|
||||
"full_dd": 6.479119889400008,
|
||||
"trades_per_year": 1.9444444444444444,
|
||||
"net_mid_pf": 0.3512325585213674,
|
||||
"target_pf_ok": false,
|
||||
"target_dd_ok": true,
|
||||
"freq_ok": false,
|
||||
"regime_logic_ok": true,
|
||||
"status": "FAIL",
|
||||
"hypothesis": "4h native SOS → 1h LPS"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Market State Gate OOS — Baseline vs Gated(Spring 冻结)
|
||||
|
||||
比较:
|
||||
A) Wyckoff_BTC_V1_BASELINE — Spring always (within trend regime)
|
||||
B) Wyckoff_BTC_GATED — Spring only when causal state gate opens
|
||||
|
||||
阈值先验固定,不对 2023+ 做网格搜索。
|
||||
|
||||
指标: net PF / DD / n / worst year / max consecutive losses
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from user_data.Chan.scripts.wyckoff_tf_grid import install_offline_markets # noqa: E402
|
||||
|
||||
OUT = ROOT / "user_data/Chan/scripts/wyckoff_gate_oos_result.json"
|
||||
PAIR = "BTC/USDT:USDT"
|
||||
|
||||
WINDOWS = [
|
||||
("define_pre2023", "20190901-20230101"), # 观察区(不调参)
|
||||
("oos_2023plus", "20230101-"),
|
||||
("full", "20190901-"),
|
||||
("y2020", "20200101-20210101"),
|
||||
("y2021", "20210101-20220101"),
|
||||
("y2022", "20220101-20230101"),
|
||||
("y2023", "20230101-20240101"),
|
||||
("y2024", "20240101-20250101"),
|
||||
("y2025", "20250101-20260101"),
|
||||
]
|
||||
|
||||
STRATS = [
|
||||
{
|
||||
"name": "baseline",
|
||||
"strategy": "Wyckoff_BTC_V1_BASELINE",
|
||||
"config": ROOT / "user_data/Chan/config/Wyckoff_BTC_V1_BASELINE.json",
|
||||
},
|
||||
{
|
||||
"name": "gated",
|
||||
"strategy": "Wyckoff_BTC_GATED",
|
||||
"config": ROOT / "user_data/Chan/config/Wyckoff_BTC_GATED.json",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def _max_consecutive_losses(profits: list[float]) -> int:
|
||||
best = cur = 0
|
||||
for p in profits:
|
||||
if p <= 0:
|
||||
cur += 1
|
||||
best = max(best, cur)
|
||||
else:
|
||||
cur = 0
|
||||
return best
|
||||
|
||||
|
||||
def _worst_year(trades: list[dict]) -> dict[str, Any]:
|
||||
by_y: dict[str, float] = {}
|
||||
for t in trades:
|
||||
ed = t.get("open_date") or t.get("entry_date") or ""
|
||||
y = str(ed)[:4]
|
||||
if len(y) < 4:
|
||||
continue
|
||||
by_y[y] = by_y.get(y, 0.0) + float(t.get("profit_ratio") or 0.0) * 100
|
||||
if not by_y:
|
||||
return {"year": None, "sum_pct": 0.0}
|
||||
y, v = min(by_y.items(), key=lambda x: x[1])
|
||||
return {"year": y, "sum_pct": round(v, 2)}
|
||||
|
||||
|
||||
def run_one(strategy: str, config_path: Path, timerange: str) -> dict[str, Any]:
|
||||
from freqtrade.configuration import Configuration
|
||||
from freqtrade.enums import RunMode
|
||||
from freqtrade.optimize.backtesting import Backtesting
|
||||
from freqtrade.persistence import LocalTrade
|
||||
import freqtrade.optimize.optimize_reports.bt_output as bt_output
|
||||
|
||||
bt_output.show_backtest_results = lambda *a, **k: None # type: ignore
|
||||
for mod in list(sys.modules):
|
||||
if "Wyckoff_BTC" in mod:
|
||||
del sys.modules[mod]
|
||||
|
||||
config = Configuration.from_files([str(config_path)])
|
||||
config.update(
|
||||
{
|
||||
"strategy": strategy,
|
||||
"strategy_path": str(ROOT / "user_data/Chan/strategies"),
|
||||
"timerange": timerange,
|
||||
"timeframe": "1h",
|
||||
"export": "none",
|
||||
"runmode": RunMode.BACKTEST,
|
||||
"datadir": ROOT / "user_data/data/binance",
|
||||
"user_data_dir": ROOT / "user_data",
|
||||
"enable_protections": False,
|
||||
"fee": 0.0010, # 5bps fee + 5bps slip
|
||||
"exchange": {
|
||||
**config.get("exchange", {}),
|
||||
"name": "binance",
|
||||
"pair_whitelist": [PAIR],
|
||||
},
|
||||
}
|
||||
)
|
||||
bt = Backtesting(config)
|
||||
bt.start()
|
||||
st = bt.results["strategy"].get(strategy) or list(bt.results["strategy"].values())[0]
|
||||
profit = st.get("profit_total_pct")
|
||||
if profit is None:
|
||||
profit = float(st.get("profit_total") or 0) * 100
|
||||
|
||||
trade_rows = []
|
||||
profits = []
|
||||
for t in LocalTrade.bt_trades:
|
||||
pr = float(t.close_profit or 0.0)
|
||||
profits.append(pr)
|
||||
trade_rows.append(
|
||||
{
|
||||
"open_date": t.open_date_utc.isoformat() if t.open_date_utc else "",
|
||||
"enter_tag": t.enter_tag or "",
|
||||
"profit_ratio": pr,
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"timerange": timerange,
|
||||
"profit_pct": float(profit),
|
||||
"trades": int(st.get("total_trades") or 0),
|
||||
"dd_pct": float(st.get("max_drawdown_account") or 0) * 100,
|
||||
"pf": float(st.get("profit_factor") or 0),
|
||||
"winrate": float(st.get("winrate") or 0) * 100,
|
||||
"max_consec_loss": _max_consecutive_losses(profits),
|
||||
"worst_year": _worst_year(trade_rows),
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
logging.getLogger("freqtrade").setLevel(logging.ERROR)
|
||||
install_offline_markets([PAIR])
|
||||
|
||||
results: dict[str, Any] = {
|
||||
"pair": PAIR,
|
||||
"fee_model": "fee 5bps + slip 5bps",
|
||||
"gate": {
|
||||
"version": "v1.1_state_set",
|
||||
"spring": "market_state ∈ {accumulation, markup}",
|
||||
"utad": "market_state ∈ {distribution, markdown}",
|
||||
"note": "Causal 8h EMA/slope rules (= attribution labels). Scores kept for observability. Not grid-searched on 2023+.",
|
||||
"v1_score_threshold": "FAILED OOS (destroyed 2023+ PF 1.45→0.67); archived as too misaligned",
|
||||
},
|
||||
"windows": {},
|
||||
"verdict": {},
|
||||
}
|
||||
|
||||
print("===== Market State Gate OOS (BTC) =====", flush=True)
|
||||
for wname, tr in WINDOWS:
|
||||
print(f"\n--- {wname} {tr} ---", flush=True)
|
||||
block = {}
|
||||
for s in STRATS:
|
||||
r = run_one(s["strategy"], s["config"], tr)
|
||||
block[s["name"]] = r
|
||||
print(
|
||||
f" {s['name']:<9} profit={r['profit_pct']:>7.2f}% n={r['trades']:<3} "
|
||||
f"dd={r['dd_pct']:.1f}% pf={r['pf']:.2f} "
|
||||
f"mcl={r['max_consec_loss']} worst={r['worst_year']}",
|
||||
flush=True,
|
||||
)
|
||||
# delta gated - baseline
|
||||
b, g = block["baseline"], block["gated"]
|
||||
block["delta_gated_minus_baseline"] = {
|
||||
"pf": round(g["pf"] - b["pf"], 3),
|
||||
"dd_pct": round(g["dd_pct"] - b["dd_pct"], 3),
|
||||
"trades": g["trades"] - b["trades"],
|
||||
"profit_pct": round(g["profit_pct"] - b["profit_pct"], 3),
|
||||
"max_consec_loss": g["max_consec_loss"] - b["max_consec_loss"],
|
||||
}
|
||||
results["windows"][wname] = block
|
||||
|
||||
oos_b = results["windows"]["oos_2023plus"]["baseline"]
|
||||
oos_g = results["windows"]["oos_2023plus"]["gated"]
|
||||
full_b = results["windows"]["full"]["baseline"]
|
||||
full_g = results["windows"]["full"]["gated"]
|
||||
pre_b = results["windows"]["define_pre2023"]["baseline"]
|
||||
pre_g = results["windows"]["define_pre2023"]["gated"]
|
||||
|
||||
results["verdict"] = {
|
||||
"oos_gated_pf_ge_baseline": oos_g["pf"] >= oos_b["pf"] - 1e-9,
|
||||
"oos_gated_pf_ge_1_2": oos_g["pf"] >= 1.2,
|
||||
"oos_gated_dd_le_baseline": oos_g["dd_pct"] <= oos_b["dd_pct"] + 1e-9,
|
||||
"full_gated_pf_gt_baseline": full_g["pf"] > full_b["pf"],
|
||||
"pre2023_not_catastrophically_worse": pre_g["pf"] >= pre_b["pf"] - 0.15,
|
||||
"status": (
|
||||
"PASS"
|
||||
if (
|
||||
oos_g["pf"] >= 1.2
|
||||
and oos_g["dd_pct"] <= oos_b["dd_pct"] + 0.5
|
||||
and full_g["pf"] > full_b["pf"]
|
||||
)
|
||||
else "PARTIAL"
|
||||
if (oos_g["pf"] >= oos_b["pf"] and full_g["pf"] >= full_b["pf"])
|
||||
else "FAIL"
|
||||
),
|
||||
"note": "Gate must not destroy 2023+ edge; should improve or stabilize full-sample robustness.",
|
||||
}
|
||||
print("\n===== Verdict =====")
|
||||
print(json.dumps(results["verdict"], indent=2, ensure_ascii=False))
|
||||
OUT.write_text(json.dumps(results, indent=2, ensure_ascii=False))
|
||||
print(f"Saved {OUT}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,240 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Gate v1.1 冻结前小范围稳健性确认(不改 Spring / 不调 soft-score)
|
||||
|
||||
在 Baseline SPRING_LONG 全集上:
|
||||
- 按年份、era 切片
|
||||
- 看 blocked 是否仍主要来自 distribution
|
||||
- kept vs blocked 的 PF 关系是否稳定
|
||||
|
||||
range 只作观察桶,不改交易规则。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
sys.path.insert(0, str(ROOT / "user_data/Chan"))
|
||||
|
||||
from engine.market_state import compute_market_state_8h # noqa: E402
|
||||
from user_data.Chan.scripts.wyckoff_tf_grid import install_offline_markets # noqa: E402
|
||||
|
||||
AUDIT = ROOT / "user_data/Chan/scripts/wyckoff_negative_domain_audit_result.json"
|
||||
OUT = ROOT / "user_data/Chan/scripts/wyckoff_gate_robustness_slices_result.json"
|
||||
PAIR = "BTC/USDT:USDT"
|
||||
CFG = ROOT / "user_data/Chan/config/Wyckoff_BTC_V1_BASELINE.json"
|
||||
|
||||
|
||||
def _pf(ps: list[float]) -> float:
|
||||
wins = [p for p in ps if p > 0]
|
||||
losses = [-p for p in ps if p <= 0]
|
||||
gw, gl = sum(wins), sum(losses)
|
||||
if gl <= 0:
|
||||
return 999.0 if gw > 0 else 0.0
|
||||
return gw / gl
|
||||
|
||||
|
||||
def _stats(ps: list[float]) -> dict[str, Any]:
|
||||
if not ps:
|
||||
return {"n": 0, "pf": 0.0, "sum_pct": 0.0, "winrate": 0.0}
|
||||
return {
|
||||
"n": len(ps),
|
||||
"pf": round(_pf(ps), 3),
|
||||
"sum_pct": round(100.0 * float(np.sum(ps)), 2),
|
||||
"winrate": round(100.0 * sum(1 for p in ps if p > 0) / len(ps), 1),
|
||||
}
|
||||
|
||||
|
||||
def load_annotated_springs() -> list[dict[str, Any]]:
|
||||
"""复用 audit 逻辑,产出逐笔 annotated SPRING。"""
|
||||
from freqtrade.configuration import Configuration
|
||||
from freqtrade.enums import RunMode
|
||||
from freqtrade.optimize.backtesting import Backtesting
|
||||
from freqtrade.persistence import LocalTrade
|
||||
import freqtrade.optimize.optimize_reports.bt_output as bt_output
|
||||
|
||||
bt_output.show_backtest_results = lambda *a, **k: None # type: ignore
|
||||
for mod in list(sys.modules):
|
||||
if "Wyckoff_BTC" in mod:
|
||||
del sys.modules[mod]
|
||||
|
||||
cfg = Configuration.from_files([str(CFG)])
|
||||
cfg.update(
|
||||
{
|
||||
"strategy": "Wyckoff_BTC_V1_BASELINE",
|
||||
"strategy_path": str(ROOT / "user_data/Chan/strategies"),
|
||||
"timerange": "20190901-",
|
||||
"timeframe": "1h",
|
||||
"export": "none",
|
||||
"runmode": RunMode.BACKTEST,
|
||||
"datadir": ROOT / "user_data/data/binance",
|
||||
"user_data_dir": ROOT / "user_data",
|
||||
"enable_protections": False,
|
||||
"fee": 0.0010,
|
||||
"exchange": {
|
||||
**cfg.get("exchange", {}),
|
||||
"name": "binance",
|
||||
"pair_whitelist": [PAIR],
|
||||
},
|
||||
}
|
||||
)
|
||||
bt = Backtesting(cfg)
|
||||
bt.start()
|
||||
|
||||
h8 = pd.read_feather(ROOT / "user_data/data/binance/futures/BTC_USDT_USDT-8h-futures.feather")
|
||||
h8["date"] = pd.to_datetime(h8["date"], utc=True)
|
||||
h8 = compute_market_state_8h(h8).set_index("date").sort_index()
|
||||
|
||||
rows = []
|
||||
for t in LocalTrade.bt_trades:
|
||||
if "SPRING" not in (t.enter_tag or ""):
|
||||
continue
|
||||
ed = pd.Timestamp(t.open_date_utc)
|
||||
if ed.tzinfo is None:
|
||||
ed = ed.tz_localize("UTC")
|
||||
idx = h8.index.get_indexer([ed], method="ffill")[0]
|
||||
if idx < 0:
|
||||
continue
|
||||
st = h8.iloc[idx]
|
||||
state = str(st["market_state"])
|
||||
rows.append(
|
||||
{
|
||||
"entry_date": ed.isoformat(),
|
||||
"year": str(ed.year),
|
||||
"era": "2023plus" if ed >= pd.Timestamp("2023-01-01", tz="UTC") else "pre_2023",
|
||||
"market_state": state,
|
||||
"allow_spring": bool(st["allow_spring"]),
|
||||
"profit_ratio": float(t.close_profit or 0.0),
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def slice_report(rows: list[dict], key: str) -> dict[str, Any]:
|
||||
out: dict[str, Any] = {}
|
||||
groups: dict[str, list[dict]] = defaultdict(list)
|
||||
for r in rows:
|
||||
groups[str(r[key])].append(r)
|
||||
for k, rs in sorted(groups.items()):
|
||||
kept = [x for x in rs if x["allow_spring"]]
|
||||
blocked = [x for x in rs if not x["allow_spring"]]
|
||||
b_by_state: dict[str, list[float]] = defaultdict(list)
|
||||
for x in blocked:
|
||||
b_by_state[x["market_state"]].append(x["profit_ratio"])
|
||||
blocked_states = {s: _stats(ps) for s, ps in b_by_state.items()}
|
||||
dist_n = blocked_states.get("distribution", {}).get("n", 0)
|
||||
blocked_n = len(blocked)
|
||||
out[k] = {
|
||||
"n_total": len(rs),
|
||||
"kept": _stats([x["profit_ratio"] for x in kept]),
|
||||
"blocked": _stats([x["profit_ratio"] for x in blocked]),
|
||||
"blocked_by_state": blocked_states,
|
||||
"blocked_distribution_share": round(dist_n / blocked_n, 3) if blocked_n else None,
|
||||
"blocked_all_bad": (
|
||||
all(s in ("distribution", "markdown", "range") for s in blocked_states)
|
||||
if blocked_n
|
||||
else True
|
||||
),
|
||||
}
|
||||
return out
|
||||
|
||||
|
||||
def main() -> None:
|
||||
logging.getLogger("freqtrade").setLevel(logging.ERROR)
|
||||
install_offline_markets([PAIR])
|
||||
|
||||
print("===== Annotate SPRING_LONG =====", flush=True)
|
||||
rows = load_annotated_springs()
|
||||
print(f" n={len(rows)}", flush=True)
|
||||
|
||||
by_year = slice_report(rows, "year")
|
||||
by_era = slice_report(rows, "era")
|
||||
|
||||
# 稳定性:有 blocked 的切片里,distribution 是否为第一大来源
|
||||
dist_primary = []
|
||||
for label, block in {**{f"year:{k}": v for k, v in by_year.items()}, **{f"era:{k}": v for k, v in by_era.items()}}.items():
|
||||
bn = block["blocked"]["n"]
|
||||
if bn < 2:
|
||||
continue
|
||||
states = block["blocked_by_state"]
|
||||
top = max(states.items(), key=lambda x: x[1]["n"])[0] if states else None
|
||||
dist_primary.append(
|
||||
{
|
||||
"slice": label,
|
||||
"blocked_n": bn,
|
||||
"top_blocked_state": top,
|
||||
"distribution_share": block["blocked_distribution_share"],
|
||||
"blocked_pf": block["blocked"]["pf"],
|
||||
"kept_pf": block["kept"]["pf"],
|
||||
}
|
||||
)
|
||||
|
||||
n_slices = len(dist_primary)
|
||||
n_dist_top = sum(1 for x in dist_primary if x["top_blocked_state"] == "distribution")
|
||||
n_dist_ge_50 = sum(
|
||||
1 for x in dist_primary if (x["distribution_share"] or 0) >= 0.5
|
||||
)
|
||||
|
||||
result = {
|
||||
"n_spring": len(rows),
|
||||
"by_year": by_year,
|
||||
"by_era": by_era,
|
||||
"slice_summaries": dist_primary,
|
||||
"range_observation_only": {
|
||||
"note": "range 不作交易规则;仅观察 blocked 中的占比与 PF",
|
||||
"blocked_range_global": _stats(
|
||||
[r["profit_ratio"] for r in rows if (not r["allow_spring"] and r["market_state"] == "range")]
|
||||
),
|
||||
},
|
||||
"verdict": {
|
||||
"slices_with_blocked_ge_2": n_slices,
|
||||
"distribution_is_top_blocked_state": n_dist_top,
|
||||
"distribution_share_ge_50pct_slices": n_dist_ge_50,
|
||||
"distribution_attribution_stable": (
|
||||
n_slices > 0 and (n_dist_top / n_slices) >= 0.6
|
||||
),
|
||||
"status": (
|
||||
"PASS"
|
||||
if n_slices > 0 and (n_dist_top / n_slices) >= 0.6
|
||||
else "PARTIAL"
|
||||
if n_dist_ge_50 >= max(1, n_slices // 2)
|
||||
else "FAIL"
|
||||
),
|
||||
"note": "PASS = across year/era slices, blocked mass still led by distribution.",
|
||||
},
|
||||
}
|
||||
|
||||
print("\n===== By year (blocked focus) =====", flush=True)
|
||||
for y, b in by_year.items():
|
||||
print(
|
||||
f" {y}: total={b['n_total']} kept_pf={b['kept']['pf']} "
|
||||
f"blocked_n={b['blocked']['n']} blocked_pf={b['blocked']['pf']} "
|
||||
f"dist_share={b['blocked_distribution_share']} states={list(b['blocked_by_state'])}",
|
||||
flush=True,
|
||||
)
|
||||
print("\n===== By era =====", flush=True)
|
||||
for e, b in by_era.items():
|
||||
print(
|
||||
f" {e}: total={b['n_total']} kept_pf={b['kept']['pf']} "
|
||||
f"blocked_n={b['blocked']['n']} blocked_pf={b['blocked']['pf']} "
|
||||
f"dist_share={b['blocked_distribution_share']} states={list(b['blocked_by_state'])}",
|
||||
flush=True,
|
||||
)
|
||||
print("\n===== Verdict =====", flush=True)
|
||||
print(json.dumps(result["verdict"], indent=2, ensure_ascii=False))
|
||||
|
||||
OUT.write_text(json.dumps(result, indent=2, ensure_ascii=False))
|
||||
print(f"\nSaved {OUT}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,239 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Negative-domain audit
|
||||
|
||||
问题:Gate 拦掉的 Spring,是否集中死在 distribution | markdown | range(结构性错误),
|
||||
而不是偶然删掉赚钱样本?
|
||||
|
||||
方法(Spring 冻结,Gate=state_set):
|
||||
1) 跑 Baseline,取出全部 SPRING_LONG 成交
|
||||
2) 用因果 8h market_state 标注入场时状态
|
||||
3) 按 allow_spring 分成 kept vs blocked
|
||||
4) 比较各域 n / PF / winrate / sum%
|
||||
|
||||
判定:
|
||||
- blocked 主要落在 bad domains
|
||||
- blocked 整体 PF << kept(或明显更差)
|
||||
- kept 域仍以 accumulation|markup 为主
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
sys.path.insert(0, str(ROOT / "user_data/Chan"))
|
||||
|
||||
from engine.market_state import compute_market_state_8h # noqa: E402
|
||||
from user_data.Chan.scripts.wyckoff_tf_grid import install_offline_markets # noqa: E402
|
||||
|
||||
OUT = ROOT / "user_data/Chan/scripts/wyckoff_negative_domain_audit_result.json"
|
||||
PAIR = "BTC/USDT:USDT"
|
||||
CFG = ROOT / "user_data/Chan/config/Wyckoff_BTC_V1_BASELINE.json"
|
||||
GOOD = {"accumulation", "markup"}
|
||||
BAD = {"distribution", "markdown", "range"}
|
||||
|
||||
|
||||
def _pf(ps: list[float]) -> float:
|
||||
wins = [p for p in ps if p > 0]
|
||||
losses = [-p for p in ps if p <= 0]
|
||||
gw, gl = sum(wins), sum(losses)
|
||||
if gl <= 0:
|
||||
return 999.0 if gw > 0 else 0.0
|
||||
return gw / gl
|
||||
|
||||
|
||||
def _stats(ps: list[float]) -> dict[str, Any]:
|
||||
if not ps:
|
||||
return {"n": 0, "pf": 0.0, "winrate": 0.0, "sum_pct": 0.0, "avg_pct": 0.0}
|
||||
return {
|
||||
"n": len(ps),
|
||||
"pf": round(_pf(ps), 3),
|
||||
"winrate": round(100.0 * sum(1 for p in ps if p > 0) / len(ps), 1),
|
||||
"sum_pct": round(100.0 * float(np.sum(ps)), 2),
|
||||
"avg_pct": round(100.0 * float(np.mean(ps)), 2),
|
||||
}
|
||||
|
||||
|
||||
def run_baseline_spring_trades() -> list[dict[str, Any]]:
|
||||
from freqtrade.configuration import Configuration
|
||||
from freqtrade.enums import RunMode
|
||||
from freqtrade.optimize.backtesting import Backtesting
|
||||
from freqtrade.persistence import LocalTrade
|
||||
import freqtrade.optimize.optimize_reports.bt_output as bt_output
|
||||
|
||||
bt_output.show_backtest_results = lambda *a, **k: None # type: ignore
|
||||
for mod in list(sys.modules):
|
||||
if "Wyckoff_BTC" in mod:
|
||||
del sys.modules[mod]
|
||||
|
||||
cfg = Configuration.from_files([str(CFG)])
|
||||
cfg.update(
|
||||
{
|
||||
"strategy": "Wyckoff_BTC_V1_BASELINE",
|
||||
"strategy_path": str(ROOT / "user_data/Chan/strategies"),
|
||||
"timerange": "20190901-",
|
||||
"timeframe": "1h",
|
||||
"export": "none",
|
||||
"runmode": RunMode.BACKTEST,
|
||||
"datadir": ROOT / "user_data/data/binance",
|
||||
"user_data_dir": ROOT / "user_data",
|
||||
"enable_protections": False,
|
||||
"fee": 0.0010,
|
||||
"exchange": {
|
||||
**cfg.get("exchange", {}),
|
||||
"name": "binance",
|
||||
"pair_whitelist": [PAIR],
|
||||
},
|
||||
}
|
||||
)
|
||||
bt = Backtesting(cfg)
|
||||
bt.start()
|
||||
rows = []
|
||||
for t in LocalTrade.bt_trades:
|
||||
tag = t.enter_tag or ""
|
||||
if "SPRING" not in tag:
|
||||
continue
|
||||
rows.append(
|
||||
{
|
||||
"entry_date": t.open_date_utc.isoformat() if t.open_date_utc else "",
|
||||
"exit_date": t.close_date_utc.isoformat() if t.close_date_utc else "",
|
||||
"enter_tag": tag,
|
||||
"profit_ratio": float(t.close_profit or 0.0),
|
||||
"era": (
|
||||
"2023plus"
|
||||
if t.open_date_utc and t.open_date_utc >= pd.Timestamp("2023-01-01", tz="UTC")
|
||||
else "pre_2023"
|
||||
),
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def annotate(trades: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
h8 = pd.read_feather(ROOT / "user_data/data/binance/futures/BTC_USDT_USDT-8h-futures.feather")
|
||||
h8["date"] = pd.to_datetime(h8["date"], utc=True)
|
||||
h8 = compute_market_state_8h(h8).set_index("date").sort_index()
|
||||
|
||||
out = []
|
||||
for t in trades:
|
||||
ed = pd.Timestamp(t["entry_date"])
|
||||
if ed.tzinfo is None:
|
||||
ed = ed.tz_localize("UTC")
|
||||
idx = h8.index.get_indexer([ed], method="ffill")[0]
|
||||
if idx < 0:
|
||||
continue
|
||||
row = h8.iloc[idx]
|
||||
state = str(row["market_state"])
|
||||
allowed = bool(row["allow_spring"])
|
||||
rec = {
|
||||
**t,
|
||||
"market_state": state,
|
||||
"allow_spring": allowed,
|
||||
"domain": "good" if state in GOOD else ("bad" if state in BAD else "other"),
|
||||
"accumulation_score": float(row["accumulation_score"]),
|
||||
"markup_score": float(row["markup_score"]),
|
||||
"distribution_score": float(row["distribution_score"]),
|
||||
"markdown_score": float(row["markdown_score"]),
|
||||
"range_score": float(row["range_score"]),
|
||||
}
|
||||
out.append(rec)
|
||||
return out
|
||||
|
||||
|
||||
def bucket(rows: list[dict], key: str) -> dict[str, Any]:
|
||||
g: dict[str, list[float]] = defaultdict(list)
|
||||
for r in rows:
|
||||
g[str(r[key])].append(float(r["profit_ratio"]))
|
||||
return {k: _stats(v) for k, v in sorted(g.items(), key=lambda x: -len(x[1]))}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
logging.getLogger("freqtrade").setLevel(logging.ERROR)
|
||||
install_offline_markets([PAIR])
|
||||
|
||||
print("===== Baseline SPRING_LONG trades =====", flush=True)
|
||||
raw = run_baseline_spring_trades()
|
||||
print(f" spring trades={len(raw)}", flush=True)
|
||||
rows = annotate(raw)
|
||||
kept = [r for r in rows if r["allow_spring"]]
|
||||
blocked = [r for r in rows if not r["allow_spring"]]
|
||||
|
||||
result: dict[str, Any] = {
|
||||
"pair": PAIR,
|
||||
"fee_model": "fee5bps+slip5bps",
|
||||
"n_spring_total": len(rows),
|
||||
"n_kept": len(kept),
|
||||
"n_blocked": len(blocked),
|
||||
"kept": {
|
||||
"overall": _stats([r["profit_ratio"] for r in kept]),
|
||||
"by_state": bucket(kept, "market_state"),
|
||||
"by_era": bucket(kept, "era"),
|
||||
},
|
||||
"blocked": {
|
||||
"overall": _stats([r["profit_ratio"] for r in blocked]),
|
||||
"by_state": bucket(blocked, "market_state"),
|
||||
"by_era": bucket(blocked, "era"),
|
||||
"by_domain": bucket(blocked, "domain"),
|
||||
},
|
||||
"blocked_share_by_state": {},
|
||||
"verdict": {},
|
||||
}
|
||||
|
||||
# blocked 状态占比
|
||||
if blocked:
|
||||
for st, stt in result["blocked"]["by_state"].items():
|
||||
result["blocked_share_by_state"][st] = round(stt["n"] / len(blocked), 3)
|
||||
|
||||
bad_n = sum(result["blocked"]["by_state"].get(s, {}).get("n", 0) for s in BAD)
|
||||
blocked_bad_share = (bad_n / len(blocked)) if blocked else 0.0
|
||||
kept_good_share = 0.0
|
||||
if kept:
|
||||
kg = sum(1 for r in kept if r["market_state"] in GOOD)
|
||||
kept_good_share = kg / len(kept)
|
||||
|
||||
bk = result["blocked"]["overall"]
|
||||
kp = result["kept"]["overall"]
|
||||
result["verdict"] = {
|
||||
"blocked_mostly_bad_domain": blocked_bad_share >= 0.8,
|
||||
"blocked_bad_share": round(blocked_bad_share, 3),
|
||||
"kept_mostly_good_domain": kept_good_share >= 0.95,
|
||||
"kept_good_share": round(kept_good_share, 3),
|
||||
"blocked_pf_worse_than_kept": bk["pf"] < kp["pf"],
|
||||
"blocked_pf": bk["pf"],
|
||||
"kept_pf": kp["pf"],
|
||||
"status": (
|
||||
"PASS"
|
||||
if (
|
||||
blocked_bad_share >= 0.8
|
||||
and kept_good_share >= 0.95
|
||||
and bk["pf"] < kp["pf"]
|
||||
)
|
||||
else "PARTIAL"
|
||||
if (blocked_bad_share >= 0.7 and bk["pf"] <= kp["pf"])
|
||||
else "FAIL"
|
||||
),
|
||||
"note": "PASS = Gate filters structural bad domains, not random sample deletion.",
|
||||
}
|
||||
|
||||
print("\n===== KEPT (allow_spring) =====", flush=True)
|
||||
print(json.dumps(result["kept"], indent=2, ensure_ascii=False))
|
||||
print("\n===== BLOCKED =====", flush=True)
|
||||
print(json.dumps(result["blocked"], indent=2, ensure_ascii=False))
|
||||
print("\n===== Verdict =====", flush=True)
|
||||
print(json.dumps(result["verdict"], indent=2, ensure_ascii=False))
|
||||
|
||||
OUT.write_text(json.dumps(result, indent=2, ensure_ascii=False))
|
||||
print(f"\nSaved {OUT}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,145 @@
|
||||
#!/usr/bin/env python3
|
||||
"""在最优周期 1h/4h/8h 上扫 ATR 与关键参数。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import itertools
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from user_data.Chan.scripts.wyckoff_tf_grid import ( # noqa: E402
|
||||
STRAT_PATH,
|
||||
install_offline_markets,
|
||||
patch_strategy,
|
||||
run_one,
|
||||
)
|
||||
|
||||
# 参数名 -> (正则匹配赋值行前缀, 候选值列表)
|
||||
PARAM_GRID = {
|
||||
"atr_sl_mult": (
|
||||
r'^(\tatr_sl_mult = DecimalParameter\([^\n]*default=)([0-9.]+)',
|
||||
[1.5, 2.0, 2.5, 3.0],
|
||||
),
|
||||
"vol_spike_mult": (
|
||||
r'^(\tvol_spike_mult = DecimalParameter\([^\n]*default=)([0-9.]+)',
|
||||
[1.2, 1.4, 1.8],
|
||||
),
|
||||
"spring_pierce_pct": (
|
||||
r'^(\tspring_pierce_pct = DecimalParameter\([^\n]*default=)([0-9.]+)',
|
||||
[0.002, 0.004, 0.008],
|
||||
),
|
||||
"range_lookback": (
|
||||
r'^(\trange_lookback = IntParameter\([^\n]*default=)([0-9]+)',
|
||||
[18, 24, 36],
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def set_defaults(text: str, values: dict[str, Any]) -> str:
|
||||
for key, (pat, _) in PARAM_GRID.items():
|
||||
val = values[key]
|
||||
text = re.sub(pat, rf"\g<1>{val}", text, count=1, flags=re.M)
|
||||
return text
|
||||
|
||||
|
||||
def main() -> None:
|
||||
logging.getLogger("freqtrade").setLevel(logging.ERROR)
|
||||
timerange = sys.argv[1] if len(sys.argv) > 1 else "20240101-"
|
||||
install_offline_markets()
|
||||
orig = STRAT_PATH.read_text()
|
||||
|
||||
keys = list(PARAM_GRID.keys())
|
||||
combos = list(itertools.product(*[PARAM_GRID[k][1] for k in keys]))
|
||||
# 全组合太多:改为坐标下降式 — 先基线,再逐参扫描
|
||||
base = {k: PARAM_GRID[k][1][len(PARAM_GRID[k][1]) // 2] for k in keys}
|
||||
# 确保与当前文件接近的中心点
|
||||
base.update(
|
||||
{
|
||||
"atr_sl_mult": 2.0,
|
||||
"vol_spike_mult": 1.4,
|
||||
"spring_pierce_pct": 0.004,
|
||||
"range_lookback": 24,
|
||||
}
|
||||
)
|
||||
|
||||
trials = [dict(base)]
|
||||
for k in keys:
|
||||
for v in PARAM_GRID[k][1]:
|
||||
if v == base[k]:
|
||||
continue
|
||||
t = dict(base)
|
||||
t[k] = v
|
||||
trials.append(t)
|
||||
|
||||
rows = []
|
||||
try:
|
||||
patch_strategy("1h", "4h", "8h")
|
||||
for i, vals in enumerate(trials):
|
||||
text = set_defaults(STRAT_PATH.read_text(), vals)
|
||||
STRAT_PATH.write_text(text)
|
||||
label = ",".join(f"{k}={vals[k]}" for k in keys)
|
||||
print(f"[{i+1}/{len(trials)}] {label}", flush=True)
|
||||
try:
|
||||
res = run_one("1h", timerange)
|
||||
res.update(vals)
|
||||
res["label"] = label
|
||||
res["ok"] = True
|
||||
except Exception as e:
|
||||
res = {"ok": False, "error": str(e), "label": label, **vals}
|
||||
rows.append(res)
|
||||
if res.get("ok"):
|
||||
print(
|
||||
f" -> profit={res['profit_pct']:.2f}% trades={res['trades']} "
|
||||
f"dd={res['dd_pct']:.2f}% pf={res['pf']:.2f}",
|
||||
flush=True,
|
||||
)
|
||||
else:
|
||||
print(f" FAILED {res.get('error')}", flush=True)
|
||||
finally:
|
||||
STRAT_PATH.write_text(orig)
|
||||
|
||||
ok = [r for r in rows if r.get("ok")]
|
||||
ok.sort(key=lambda r: (r["profit_pct"], r["pf"]), reverse=True)
|
||||
print("\n========== PARAM RANKING ==========")
|
||||
for r in ok[:10]:
|
||||
print(
|
||||
f"{r['profit_pct']:>7.2f}% pf={r['pf']:.2f} dd={r['dd_pct']:.1f}% "
|
||||
f"n={r['trades']:<3} {r['label']}"
|
||||
)
|
||||
out = ROOT / "user_data/Chan/scripts/wyckoff_param_grid_result.txt"
|
||||
out.write_text(json.dumps({"timerange": timerange, "rows": rows}, indent=2))
|
||||
print(f"\nSaved {out}")
|
||||
if ok:
|
||||
best = ok[0]
|
||||
print("\nBEST params:", {k: best[k] for k in keys})
|
||||
# 写回最优 default
|
||||
text = set_defaults(orig, {k: best[k] for k in keys})
|
||||
# 保持最优周期
|
||||
text2 = text
|
||||
text2 = re.sub(r'^(\ttimeframe = ).*$', r'\g<1>"1h"', text2, count=1, flags=re.M)
|
||||
text2 = re.sub(
|
||||
r'^(\tstructure_timeframe = ).*$', r'\g<1>"4h"', text2, count=1, flags=re.M
|
||||
)
|
||||
text2 = re.sub(
|
||||
r'^(\tbias_timeframe: Optional\[str\] = ).*$',
|
||||
r'\g<1>"8h"',
|
||||
text2,
|
||||
count=1,
|
||||
flags=re.M,
|
||||
)
|
||||
STRAT_PATH.write_text(text2)
|
||||
print("Wrote best defaults into Wyckoff_BTC.py")
|
||||
# 长周期验证
|
||||
print("\nValidate 20230101- ...", flush=True)
|
||||
res = run_one("1h", "20230101-")
|
||||
print(res)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,206 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Wyckoff Phase2 对比:同一数据 / 同一成本 / 同一 WFO / 同一 Regime
|
||||
|
||||
对比:
|
||||
- Wyckoff_BTC_V1_BASELINE (Spring, range off)
|
||||
- Wyckoff_BTC_LPS (LPS continuation, range off)
|
||||
|
||||
统一看 net PF(fee 计入)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from user_data.Chan.scripts.wyckoff_tf_grid import install_offline_markets # noqa: E402
|
||||
|
||||
OUT = ROOT / "user_data/Chan/scripts/wyckoff_phase2_compare_result.json"
|
||||
|
||||
WFO = [
|
||||
("train", "20230101-20250101"),
|
||||
("validate", "20250101-20260101"),
|
||||
("test", "20260101-"),
|
||||
("full", "20230101-"),
|
||||
]
|
||||
|
||||
BRANCHES = [
|
||||
{
|
||||
"name": "Spring_V1",
|
||||
"strategy": "Wyckoff_BTC_V1_BASELINE",
|
||||
"config": ROOT / "user_data/Chan/config/Wyckoff_BTC_V1_BASELINE.json",
|
||||
"target": {"pf": 1.3, "dd": 10.0, "note": "Spring: PF>1.3 DD<10%"},
|
||||
},
|
||||
{
|
||||
"name": "LPS_V2",
|
||||
"strategy": "Wyckoff_BTC_LPS",
|
||||
"config": ROOT / "user_data/Chan/config/Wyckoff_BTC_LPS.json",
|
||||
"target": {"pf": 1.2, "dd": 15.0, "note": "LPS V2: 4h SOS→1h LPS; PF>1.2; ~5-15/yr"},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def run_bt(
|
||||
strategy: str,
|
||||
config_path: Path,
|
||||
timerange: str,
|
||||
*,
|
||||
fee: float = 0.0005,
|
||||
extra_cost: float = 0.0,
|
||||
regime: Optional[str] = None,
|
||||
) -> dict[str, Any]:
|
||||
from freqtrade.configuration import Configuration
|
||||
from freqtrade.enums import RunMode
|
||||
from freqtrade.optimize.backtesting import Backtesting
|
||||
import freqtrade.optimize.optimize_reports.bt_output as bt_output
|
||||
|
||||
bt_output.show_backtest_results = lambda *a, **k: None # type: ignore
|
||||
|
||||
for mod in list(sys.modules):
|
||||
if strategy in mod or "Wyckoff_BTC" in mod:
|
||||
del sys.modules[mod]
|
||||
|
||||
# 可选:临时改 regime_mode(写文件)
|
||||
strat_path = ROOT / "user_data/Chan/strategies" / f"{strategy}.py"
|
||||
orig = None
|
||||
if regime is not None:
|
||||
import re
|
||||
orig = strat_path.read_text()
|
||||
text2, n = re.subn(
|
||||
r'^(\tregime_mode: str = )".*"',
|
||||
rf'\g<1>"{regime}"',
|
||||
orig,
|
||||
count=1,
|
||||
flags=re.M,
|
||||
)
|
||||
if n == 0:
|
||||
raise RuntimeError(f"regime_mode not found in {strategy}")
|
||||
strat_path.write_text(text2)
|
||||
pycache = strat_path.parent / "__pycache__"
|
||||
if pycache.is_dir():
|
||||
for p in pycache.glob(f"{strategy}*.pyc"):
|
||||
p.unlink(missing_ok=True)
|
||||
|
||||
try:
|
||||
config = Configuration.from_files([str(config_path)])
|
||||
config.update(
|
||||
{
|
||||
"strategy": strategy,
|
||||
"strategy_path": str(ROOT / "user_data/Chan/strategies"),
|
||||
"timerange": timerange,
|
||||
"timeframe": "1h",
|
||||
"export": "none",
|
||||
"runmode": RunMode.BACKTEST,
|
||||
"datadir": ROOT / "user_data/data/binance",
|
||||
"user_data_dir": ROOT / "user_data",
|
||||
"enable_protections": False,
|
||||
"fee": fee + extra_cost,
|
||||
}
|
||||
)
|
||||
bt = Backtesting(config)
|
||||
loaded = getattr(bt.strategylist[0], "regime_mode", None)
|
||||
bt.start()
|
||||
st = bt.results["strategy"].get(strategy) or list(bt.results["strategy"].values())[0]
|
||||
profit = st.get("profit_total_pct")
|
||||
if profit is None:
|
||||
profit = float(st.get("profit_total") or 0) * 100
|
||||
return {
|
||||
"profit_pct": float(profit),
|
||||
"trades": int(st.get("total_trades") or 0),
|
||||
"dd_pct": float(st.get("max_drawdown_account") or 0) * 100,
|
||||
"pf": float(st.get("profit_factor") or 0),
|
||||
"winrate": float(st.get("winrate") or 0) * 100,
|
||||
"final": float(st.get("final_balance") or 0),
|
||||
"fee_used": config["fee"],
|
||||
"regime_loaded": loaded,
|
||||
}
|
||||
finally:
|
||||
if orig is not None:
|
||||
strat_path.write_text(orig)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
logging.getLogger("freqtrade").setLevel(logging.ERROR)
|
||||
install_offline_markets()
|
||||
results: dict[str, Any] = {"branches": {}}
|
||||
|
||||
for br in BRANCHES:
|
||||
name = br["name"]
|
||||
print(f"\n===== {name} ({br['strategy']}) =====", flush=True)
|
||||
block: dict[str, Any] = {"wfo": {}, "regimes": {}, "cost_stress": {}, "target": br["target"]}
|
||||
|
||||
print("--- WFO ---", flush=True)
|
||||
for wname, tr in WFO:
|
||||
r = run_bt(br["strategy"], br["config"], tr)
|
||||
block["wfo"][wname] = {"timerange": tr, **r}
|
||||
print(
|
||||
f" {wname:<8} profit={r['profit_pct']:>7.2f}% n={r['trades']:<3} "
|
||||
f"dd={r['dd_pct']:.1f}% pf={r['pf']:.2f} wr={r['winrate']:.1f}%",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
print("--- Regime ---", flush=True)
|
||||
for mode in ["trend", "bull", "bear", "range", "all"]:
|
||||
r = run_bt(br["strategy"], br["config"], "20230101-", regime=mode)
|
||||
block["regimes"][mode] = r
|
||||
print(
|
||||
f" {mode:<6} profit={r['profit_pct']:>7.2f}% n={r['trades']:<3} "
|
||||
f"pf={r['pf']:.2f} (loaded={r['regime_loaded']})",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
print("--- Cost (net PF) ---", flush=True)
|
||||
for label, fee, extra in [
|
||||
("fee_5bps", 0.0005, 0.0),
|
||||
("fee_5bps+slip_5bps", 0.0005, 0.0005),
|
||||
("fee_10bps+slip_10bps", 0.0010, 0.0010),
|
||||
]:
|
||||
r = run_bt(br["strategy"], br["config"], "20230101-", fee=fee, extra_cost=extra)
|
||||
block["cost_stress"][label] = r
|
||||
flag = "OK" if r["pf"] >= br["target"]["pf"] else ("WEAK" if r["pf"] >= 1.0 else "FAIL")
|
||||
print(
|
||||
f" {label:<22} profit={r['profit_pct']:>7.2f}% n={r['trades']:<3} "
|
||||
f"pf={r['pf']:.2f} [{flag}]",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
full = block["wfo"]["full"]
|
||||
mid = block["cost_stress"]["fee_5bps+slip_5bps"]
|
||||
years = 3.6 # ~2023→2026.6
|
||||
tpy = full["trades"] / years if years else 0
|
||||
block["verdict"] = {
|
||||
"full_pf": full["pf"],
|
||||
"full_dd": full["dd_pct"],
|
||||
"trades_per_year": tpy,
|
||||
"net_mid_pf": mid["pf"],
|
||||
"target_pf_ok": mid["pf"] >= br["target"]["pf"],
|
||||
"target_dd_ok": full["dd_pct"] <= br["target"]["dd"],
|
||||
}
|
||||
results["branches"][name] = block
|
||||
print(f"Verdict: {json.dumps(block['verdict'], ensure_ascii=False)}", flush=True)
|
||||
|
||||
# 组合粗估:独立回测不可简单相加;只报告各自频率目标
|
||||
s = results["branches"]["Spring_V1"]["verdict"]
|
||||
l = results["branches"]["LPS_V2"]["verdict"]
|
||||
results["portfolio_note"] = {
|
||||
"spring_tpy": s["trades_per_year"],
|
||||
"lps_tpy": l["trades_per_year"],
|
||||
"sum_tpy_approx": s["trades_per_year"] + l["trades_per_year"],
|
||||
"combined_target_tpy": "10-20",
|
||||
"warning": "频率可近似相加;PF/收益不可相加,需另做组合回测;Spring 冻结勿改",
|
||||
}
|
||||
print("\n===== Portfolio note =====")
|
||||
print(json.dumps(results["portfolio_note"], ensure_ascii=False, indent=2))
|
||||
|
||||
OUT.write_text(json.dumps(results, indent=2, ensure_ascii=False))
|
||||
print(f"\nSaved {OUT}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,113 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
LPS V2 单独 Phase2(不改 Spring、不合并组合)
|
||||
|
||||
同一 WFO / Regime / 成本模型。
|
||||
目标: net PF > 1.2;频率约 5-15/year。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from user_data.Chan.scripts.wyckoff_phase2_compare import ( # noqa: E402
|
||||
BRANCHES,
|
||||
WFO,
|
||||
install_offline_markets,
|
||||
run_bt,
|
||||
)
|
||||
|
||||
OUT = ROOT / "user_data/Chan/scripts/wyckoff_lps_v2_phase2_result.json"
|
||||
COMPARE = ROOT / "user_data/Chan/scripts/wyckoff_phase2_compare_result.json"
|
||||
|
||||
|
||||
def main() -> None:
|
||||
logging.getLogger("freqtrade").setLevel(logging.ERROR)
|
||||
install_offline_markets()
|
||||
br = next(b for b in BRANCHES if b["name"] == "LPS_V2")
|
||||
print(f"===== {br['name']} ({br['strategy']}) — LPS-only Phase2 =====", flush=True)
|
||||
block = {"version": "LPS_V2", "wfo": {}, "regimes": {}, "cost_stress": {}, "target": br["target"]}
|
||||
|
||||
print("--- WFO ---", flush=True)
|
||||
for wname, tr in WFO:
|
||||
r = run_bt(br["strategy"], br["config"], tr)
|
||||
block["wfo"][wname] = {"timerange": tr, **r}
|
||||
print(
|
||||
f" {wname:<8} profit={r['profit_pct']:>7.2f}% n={r['trades']:<3} "
|
||||
f"dd={r['dd_pct']:.1f}% pf={r['pf']:.2f} wr={r['winrate']:.1f}%",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
print("--- Regime ---", flush=True)
|
||||
for mode in ["trend", "bull", "bear", "range", "all"]:
|
||||
r = run_bt(br["strategy"], br["config"], "20230101-", regime=mode)
|
||||
block["regimes"][mode] = r
|
||||
print(
|
||||
f" {mode:<6} profit={r['profit_pct']:>7.2f}% n={r['trades']:<3} "
|
||||
f"pf={r['pf']:.2f} (loaded={r['regime_loaded']})",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
print("--- Cost (net PF) ---", flush=True)
|
||||
for label, fee, extra in [
|
||||
("fee_5bps", 0.0005, 0.0),
|
||||
("fee_5bps+slip_5bps", 0.0005, 0.0005),
|
||||
("fee_10bps+slip_10bps", 0.0010, 0.0010),
|
||||
]:
|
||||
r = run_bt(br["strategy"], br["config"], "20230101-", fee=fee, extra_cost=extra)
|
||||
block["cost_stress"][label] = r
|
||||
flag = "OK" if r["pf"] >= br["target"]["pf"] else ("WEAK" if r["pf"] >= 1.0 else "FAIL")
|
||||
print(
|
||||
f" {label:<22} profit={r['profit_pct']:>7.2f}% n={r['trades']:<3} "
|
||||
f"pf={r['pf']:.2f} [{flag}]",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
full = block["wfo"]["full"]
|
||||
mid = block["cost_stress"]["fee_5bps+slip_5bps"]
|
||||
tpy = full["trades"] / 3.6
|
||||
trend_pf = block["regimes"]["trend"]["pf"]
|
||||
range_pf = block["regimes"]["range"]["pf"]
|
||||
block["verdict"] = {
|
||||
"full_pf": full["pf"],
|
||||
"full_dd": full["dd_pct"],
|
||||
"trades_per_year": tpy,
|
||||
"net_mid_pf": mid["pf"],
|
||||
"target_pf_ok": mid["pf"] >= br["target"]["pf"],
|
||||
"target_dd_ok": full["dd_pct"] <= br["target"]["dd"],
|
||||
"freq_ok": 5.0 <= tpy <= 15.0,
|
||||
"regime_logic_ok": trend_pf >= range_pf, # 趋势应不差于横盘
|
||||
"status": "PASS" if (mid["pf"] >= br["target"]["pf"] and full["dd_pct"] <= br["target"]["dd"]) else "FAIL",
|
||||
"hypothesis": "4h native SOS → 1h LPS",
|
||||
}
|
||||
print("\n===== Verdict =====")
|
||||
print(json.dumps(block["verdict"], ensure_ascii=False, indent=2))
|
||||
|
||||
OUT.write_text(json.dumps(block, indent=2, ensure_ascii=False))
|
||||
# 合并进 compare 结果(保留 Spring,覆盖 LPS)
|
||||
if COMPARE.exists():
|
||||
prev = json.loads(COMPARE.read_text())
|
||||
else:
|
||||
prev = {"branches": {}}
|
||||
prev.setdefault("branches", {})["LPS_V2"] = block
|
||||
# 清理旧 LPS_V1 key 的活跃地位,保留作历史可手动看
|
||||
spring = prev["branches"].get("Spring_V1", {}).get("verdict", {})
|
||||
prev["system_status"] = {
|
||||
"spring": "BASELINE FROZEN / PASS + Limited Evidence",
|
||||
"lps": block["verdict"]["status"],
|
||||
"spring_tpy": spring.get("trades_per_year"),
|
||||
"lps_tpy": tpy,
|
||||
"next": "若 LPS PASS → 组合层;否则 Spring-only",
|
||||
}
|
||||
COMPARE.write_text(json.dumps(prev, indent=2, ensure_ascii=False))
|
||||
print(f"\nSaved {OUT}")
|
||||
print("system_status:", json.dumps(prev["system_status"], ensure_ascii=False))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,185 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Wyckoff Phase 2:鲁棒性验证(固定当前参数,不再扫参)
|
||||
|
||||
1) Walk-Forward:Train 2023-2024 / Validate 2025 / Test 2026
|
||||
2) 市场状态拆分:bull / bear / range(8h EMA200 语境)
|
||||
3) 成本压力:抬高手续费 + 滑点后是否仍 PF>1.3
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from user_data.Chan.scripts.wyckoff_tf_grid import ( # noqa: E402
|
||||
CONFIG_PATH,
|
||||
STRAT_PATH,
|
||||
install_offline_markets,
|
||||
patch_strategy,
|
||||
)
|
||||
|
||||
OUT = ROOT / "user_data/Chan/scripts/wyckoff_phase2_result.json"
|
||||
|
||||
WFO = [
|
||||
("train", "20230101-20250101"),
|
||||
("validate", "20250101-20260101"),
|
||||
("test", "20260101-"),
|
||||
("full", "20230101-"),
|
||||
]
|
||||
|
||||
|
||||
def set_regime(mode: str) -> None:
|
||||
text = STRAT_PATH.read_text()
|
||||
text2, n = re.subn(
|
||||
r'^(\tregime_mode: str = )".*"',
|
||||
rf'\g<1>"{mode}"',
|
||||
text,
|
||||
count=1,
|
||||
flags=re.M,
|
||||
)
|
||||
if n == 0:
|
||||
raise RuntimeError("regime_mode not found in strategy")
|
||||
STRAT_PATH.write_text(text2)
|
||||
# 清掉 bytecode,避免连续切换时读到旧 class 属性
|
||||
pycache = STRAT_PATH.parent / "__pycache__"
|
||||
if pycache.is_dir():
|
||||
for p in pycache.glob("Wyckoff_BTC*.pyc"):
|
||||
p.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def run_bt(
|
||||
timerange: str,
|
||||
*,
|
||||
fee: Optional[float] = None,
|
||||
extra_cost: float = 0.0,
|
||||
regime: Optional[str] = None,
|
||||
) -> dict[str, Any]:
|
||||
from freqtrade.configuration import Configuration
|
||||
from freqtrade.enums import RunMode
|
||||
from freqtrade.optimize.backtesting import Backtesting
|
||||
import freqtrade.optimize.optimize_reports.bt_output as bt_output
|
||||
|
||||
bt_output.show_backtest_results = lambda *a, **k: None # type: ignore
|
||||
|
||||
if regime is not None:
|
||||
set_regime(regime)
|
||||
|
||||
for mod in list(sys.modules):
|
||||
if "Wyckoff_BTC" in mod:
|
||||
del sys.modules[mod]
|
||||
|
||||
config = Configuration.from_files([str(CONFIG_PATH)])
|
||||
config.update(
|
||||
{
|
||||
"strategy": "Wyckoff_BTC",
|
||||
"strategy_path": str(ROOT / "user_data/Chan/strategies"),
|
||||
"timerange": timerange,
|
||||
"timeframe": "1h",
|
||||
"export": "none",
|
||||
"runmode": RunMode.BACKTEST,
|
||||
"datadir": ROOT / "user_data/data/binance",
|
||||
"user_data_dir": ROOT / "user_data",
|
||||
"enable_protections": False,
|
||||
}
|
||||
)
|
||||
base_fee = 0.0005 if fee is None else fee
|
||||
config["fee"] = base_fee + extra_cost
|
||||
|
||||
bt = Backtesting(config)
|
||||
loaded_regime = getattr(bt.strategylist[0], "regime_mode", None)
|
||||
bt.start()
|
||||
st = bt.results["strategy"].get("Wyckoff_BTC") or list(bt.results["strategy"].values())[0]
|
||||
profit = st.get("profit_total_pct")
|
||||
if profit is None:
|
||||
profit = float(st.get("profit_total") or 0) * 100
|
||||
return {
|
||||
"profit_pct": float(profit),
|
||||
"trades": int(st.get("total_trades") or 0),
|
||||
"dd_pct": float(st.get("max_drawdown_account") or 0) * 100,
|
||||
"pf": float(st.get("profit_factor") or 0),
|
||||
"winrate": float(st.get("winrate") or 0) * 100,
|
||||
"final": float(st.get("final_balance") or 0),
|
||||
"fee_used": config["fee"],
|
||||
"regime_loaded": loaded_regime,
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
logging.getLogger("freqtrade").setLevel(logging.ERROR)
|
||||
install_offline_markets()
|
||||
orig = STRAT_PATH.read_text()
|
||||
results: dict[str, Any] = {"wfo": {}, "regimes": {}, "cost_stress": {}}
|
||||
|
||||
try:
|
||||
patch_strategy("1h", "4h", "8h")
|
||||
set_regime("all")
|
||||
|
||||
print("===== 1) Walk-Forward (fixed params, no re-opt) =====")
|
||||
for name, tr in WFO:
|
||||
r = run_bt(tr)
|
||||
results["wfo"][name] = {"timerange": tr, **r}
|
||||
print(
|
||||
f" {name:<8} {tr:<22} profit={r['profit_pct']:>7.2f}% "
|
||||
f"n={r['trades']:<3} dd={r['dd_pct']:.1f}% pf={r['pf']:.2f} wr={r['winrate']:.1f}%",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
print("\n===== 2) Regime split (20230101-) =====")
|
||||
for mode in ["all", "bull", "bear", "range"]:
|
||||
r = run_bt("20230101-", regime=mode)
|
||||
results["regimes"][mode] = r
|
||||
print(
|
||||
f" {mode:<6} profit={r['profit_pct']:>7.2f}% n={r['trades']:<3} "
|
||||
f"dd={r['dd_pct']:.1f}% pf={r['pf']:.2f} wr={r['winrate']:.1f}% "
|
||||
f"(loaded={r.get('regime_loaded')})",
|
||||
flush=True,
|
||||
)
|
||||
set_regime("all")
|
||||
|
||||
print("\n===== 3) Cost stress (20230101-) =====")
|
||||
for label, fee, extra in [
|
||||
("fee_5bps", 0.0005, 0.0),
|
||||
("fee_10bps", 0.0010, 0.0),
|
||||
("fee_5bps+slip_5bps", 0.0005, 0.0005),
|
||||
("fee_10bps+slip_10bps", 0.0010, 0.0010),
|
||||
]:
|
||||
r = run_bt("20230101-", fee=fee, extra_cost=extra)
|
||||
results["cost_stress"][label] = r
|
||||
flag = "OK" if r["pf"] >= 1.3 else ("WEAK" if r["pf"] >= 1.0 else "FAIL")
|
||||
print(
|
||||
f" {label:<22} profit={r['profit_pct']:>7.2f}% n={r['trades']:<3} "
|
||||
f"pf={r['pf']:.2f} [{flag}]",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
wfo = results["wfo"]
|
||||
results["verdict"] = {
|
||||
"validate_profit_ok": wfo["validate"]["profit_pct"] > 0,
|
||||
"validate_pf_ge_1": wfo["validate"]["pf"] >= 1.0,
|
||||
"test_pf_ge_1": wfo["test"]["pf"] >= 1.0,
|
||||
"cost_mid_pf_ge_1_3": results["cost_stress"]["fee_5bps+slip_5bps"]["pf"] >= 1.3,
|
||||
"next": [
|
||||
"若 validate/test 稳定 → paper / 小资金",
|
||||
"若仅 train 好 → 参数过拟合,冻结开发",
|
||||
"可并行加 SOS/LPS 趋势跟随以提高频率",
|
||||
],
|
||||
}
|
||||
print("\n===== Verdict =====")
|
||||
print(json.dumps(results["verdict"], ensure_ascii=False, indent=2))
|
||||
finally:
|
||||
STRAT_PATH.write_text(orig)
|
||||
print("\nRestored strategy file", flush=True)
|
||||
|
||||
OUT.write_text(json.dumps(results, indent=2, ensure_ascii=False))
|
||||
print(f"Saved {OUT}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,261 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Phase3 — Evidence Expansion(不改 Spring 规则)
|
||||
|
||||
目标: 将样本从 N=20 推向 N>=50
|
||||
手段:
|
||||
- 多品种外部验证(本地有数据的 pair)
|
||||
- 分开统计 SPRING_LONG / UTAD_SHORT
|
||||
- 同一净成本模型(fee+slip)
|
||||
- 不引入 LPS、不扫参
|
||||
|
||||
用法:
|
||||
.venv/bin/python user_data/Chan/scripts/wyckoff_phase3_evidence.py
|
||||
|
||||
缺 4h/8h 时从 1h resample(离线,不依赖 API)。
|
||||
BTC 若无 2019 更早数据,脚本会标明 gap,不伪造历史。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
import pandas as pd
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from user_data.Chan.scripts.wyckoff_tf_grid import install_offline_markets # noqa: E402
|
||||
|
||||
DATADIR = ROOT / "user_data/data/binance/futures"
|
||||
STRAT = "Wyckoff_BTC_V1_BASELINE"
|
||||
CONFIG = ROOT / "user_data/Chan/config/Wyckoff_BTC_V1_BASELINE.json"
|
||||
OUT = ROOT / "user_data/Chan/scripts/wyckoff_phase3_evidence_result.json"
|
||||
|
||||
# 候选外部验证(规则冻结;用本地最长可用历史)
|
||||
CANDIDATES = [
|
||||
{"pair": "BTC/USDT:USDT", "file": "BTC_USDT_USDT", "timerange": "20190901-"},
|
||||
{"pair": "ETH/USDT:USDT", "file": "ETH_USDT_USDT", "timerange": "20191101-"},
|
||||
{"pair": "SOL/USDT:USDT", "file": "SOL_USDT_USDT", "timerange": "20200901-"},
|
||||
]
|
||||
|
||||
MIN_1H_BARS = 4000 # ~ema200@8h 需要足够历史;过短 skip
|
||||
|
||||
|
||||
def ensure_tf(file_stub: str, tf: str, source_tf: str = "1h") -> bool:
|
||||
"""从更细周期 resample 生成 tf feather;已存在则跳过。"""
|
||||
out = DATADIR / f"{file_stub}-{tf}-futures.feather"
|
||||
src = DATADIR / f"{file_stub}-{source_tf}-futures.feather"
|
||||
if out.exists():
|
||||
return True
|
||||
if not src.exists():
|
||||
return False
|
||||
df = pd.read_feather(src)
|
||||
df["date"] = pd.to_datetime(df["date"], utc=True)
|
||||
df = df.set_index("date").sort_index()
|
||||
rule = tf.replace("m", "min") if tf.endswith("m") else tf
|
||||
ohlc = df.resample(rule).agg(
|
||||
{"open": "first", "high": "max", "low": "min", "close": "last", "volume": "sum"}
|
||||
).dropna(subset=["open", "close"])
|
||||
ohlc = ohlc.reset_index()
|
||||
ohlc.to_feather(out)
|
||||
print(f" resampled {out.name} n={len(ohlc)}", flush=True)
|
||||
return True
|
||||
|
||||
|
||||
def pair_ready(file_stub: str) -> tuple[bool, str]:
|
||||
p1 = DATADIR / f"{file_stub}-1h-futures.feather"
|
||||
if not p1.exists():
|
||||
return False, "missing 1h"
|
||||
df = pd.read_feather(p1)
|
||||
n = len(df)
|
||||
if n < MIN_1H_BARS:
|
||||
return False, f"1h bars={n} < {MIN_1H_BARS} (insufficient for 8h ema200)"
|
||||
ok4 = ensure_tf(file_stub, "4h")
|
||||
ok8 = ensure_tf(file_stub, "8h")
|
||||
if not (ok4 and ok8):
|
||||
return False, "cannot build 4h/8h"
|
||||
return True, f"1h={n}"
|
||||
|
||||
|
||||
def run_bt(pair: str, timerange: str, fee: float = 0.0005, extra: float = 0.0) -> dict[str, Any]:
|
||||
from freqtrade.configuration import Configuration
|
||||
from freqtrade.enums import RunMode
|
||||
from freqtrade.optimize.backtesting import Backtesting
|
||||
import freqtrade.optimize.optimize_reports.bt_output as bt_output
|
||||
|
||||
bt_output.show_backtest_results = lambda *a, **k: None # type: ignore
|
||||
|
||||
for mod in list(sys.modules):
|
||||
if "Wyckoff_BTC" in mod:
|
||||
del sys.modules[mod]
|
||||
|
||||
config = Configuration.from_files([str(CONFIG)])
|
||||
config.update(
|
||||
{
|
||||
"strategy": STRAT,
|
||||
"strategy_path": str(ROOT / "user_data/Chan/strategies"),
|
||||
"timerange": timerange,
|
||||
"timeframe": "1h",
|
||||
"export": "none",
|
||||
"runmode": RunMode.BACKTEST,
|
||||
"datadir": ROOT / "user_data/data/binance",
|
||||
"user_data_dir": ROOT / "user_data",
|
||||
"enable_protections": False,
|
||||
"fee": fee + extra,
|
||||
"exchange": {
|
||||
**config.get("exchange", {}),
|
||||
"pair_whitelist": [pair],
|
||||
"name": config.get("exchange", {}).get("name", "binance"),
|
||||
},
|
||||
}
|
||||
)
|
||||
bt = Backtesting(config)
|
||||
bt.start()
|
||||
st = bt.results["strategy"].get(STRAT) or list(bt.results["strategy"].values())[0]
|
||||
profit = st.get("profit_total_pct")
|
||||
if profit is None:
|
||||
profit = float(st.get("profit_total") or 0) * 100
|
||||
|
||||
# 按 enter_tag 拆分(freqtrade 可能是 dict 或 list[dict])
|
||||
by_tag: dict[str, dict[str, Any]] = {}
|
||||
trades = st.get("trades") or []
|
||||
tag_stats = st.get("results_per_enter_tag") or {}
|
||||
items = []
|
||||
if isinstance(tag_stats, dict):
|
||||
items = list(tag_stats.items())
|
||||
elif isinstance(tag_stats, list):
|
||||
items = [
|
||||
(x.get("key") or x.get("enter_tag") or x.get("tag") or "unknown", x)
|
||||
for x in tag_stats
|
||||
if isinstance(x, dict)
|
||||
]
|
||||
if items:
|
||||
for tag, info in items:
|
||||
if not isinstance(info, dict):
|
||||
continue
|
||||
by_tag[str(tag)] = {
|
||||
"trades": int(info.get("trades") or info.get("total_trades") or 0),
|
||||
"profit_pct": float(
|
||||
info.get("profit_total_pct")
|
||||
if info.get("profit_total_pct") is not None
|
||||
else (float(info.get("profit_total") or 0) * 100)
|
||||
),
|
||||
"pf": float(info.get("profit_factor") or 0),
|
||||
}
|
||||
elif trades:
|
||||
from collections import defaultdict
|
||||
agg: dict[str, list] = defaultdict(list)
|
||||
for t in trades:
|
||||
tag = t.get("enter_tag") or "unknown"
|
||||
agg[tag].append(float(t.get("profit_ratio") or 0))
|
||||
for tag, profits in agg.items():
|
||||
wins = [p for p in profits if p > 0]
|
||||
losses = [-p for p in profits if p <= 0]
|
||||
gross_win = sum(wins)
|
||||
gross_loss = sum(losses)
|
||||
pf = (gross_win / gross_loss) if gross_loss > 0 else (999.0 if gross_win > 0 else 0.0)
|
||||
by_tag[tag] = {
|
||||
"trades": len(profits),
|
||||
"profit_pct": sum(profits) * 100,
|
||||
"pf": float(pf),
|
||||
}
|
||||
|
||||
return {
|
||||
"pair": pair,
|
||||
"timerange": timerange,
|
||||
"profit_pct": float(profit),
|
||||
"trades": int(st.get("total_trades") or 0),
|
||||
"dd_pct": float(st.get("max_drawdown_account") or 0) * 100,
|
||||
"pf": float(st.get("profit_factor") or 0),
|
||||
"winrate": float(st.get("winrate") or 0) * 100,
|
||||
"fee_used": config["fee"],
|
||||
"by_setup": by_tag,
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
logging.getLogger("freqtrade").setLevel(logging.ERROR)
|
||||
install_offline_markets([c["pair"] for c in CANDIDATES])
|
||||
|
||||
results: dict[str, Any] = {
|
||||
"phase": "Phase3 Evidence Expansion",
|
||||
"strategy": STRAT,
|
||||
"rule": "frozen Spring-only; no LPS; no param change",
|
||||
"pairs": {},
|
||||
"skipped": {},
|
||||
"notes": [],
|
||||
}
|
||||
|
||||
# BTC 历史缺口说明
|
||||
btc_1h = DATADIR / "BTC_USDT_USDT-1h-futures.feather"
|
||||
if btc_1h.exists():
|
||||
d0 = pd.read_feather(btc_1h)["date"].min()
|
||||
results["notes"].append(
|
||||
f"BTC local 1h starts {d0}; 2019-2022 not in datadir — download separately for deeper N"
|
||||
)
|
||||
|
||||
print("===== Phase3: prepare TF data =====", flush=True)
|
||||
run_list = []
|
||||
for c in CANDIDATES:
|
||||
ok, msg = pair_ready(c["file"])
|
||||
if ok:
|
||||
print(f" READY {c['pair']}: {msg}", flush=True)
|
||||
run_list.append(c)
|
||||
else:
|
||||
print(f" SKIP {c['pair']}: {msg}", flush=True)
|
||||
results["skipped"][c["pair"]] = msg
|
||||
|
||||
print("\n===== Phase3: backtests (fee 5bps, then fee+slip) =====", flush=True)
|
||||
total_n = 0
|
||||
spring_n = 0
|
||||
utad_n = 0
|
||||
|
||||
for c in run_list:
|
||||
print(f"\n--- {c['pair']} ---", flush=True)
|
||||
base = run_bt(c["pair"], c["timerange"], fee=0.0005, extra=0.0)
|
||||
mid = run_bt(c["pair"], c["timerange"], fee=0.0005, extra=0.0005)
|
||||
block = {"base_fee": base, "net_mid": mid}
|
||||
results["pairs"][c["pair"]] = block
|
||||
total_n += base["trades"]
|
||||
for tag, info in base.get("by_setup", {}).items():
|
||||
if "SPRING" in tag:
|
||||
spring_n += info["trades"]
|
||||
if "UTAD" in tag:
|
||||
utad_n += info["trades"]
|
||||
print(
|
||||
f" fee5bps profit={base['profit_pct']:.2f}% n={base['trades']} "
|
||||
f"dd={base['dd_pct']:.1f}% pf={base['pf']:.2f}",
|
||||
flush=True,
|
||||
)
|
||||
print(
|
||||
f" net_mid profit={mid['profit_pct']:.2f}% n={mid['trades']} "
|
||||
f"pf={mid['pf']:.2f}",
|
||||
flush=True,
|
||||
)
|
||||
print(f" by_setup {base.get('by_setup')}", flush=True)
|
||||
|
||||
results["aggregate"] = {
|
||||
"pairs_tested": len(run_list),
|
||||
"total_trades": total_n,
|
||||
"spring_long_trades": spring_n,
|
||||
"utad_short_trades": utad_n,
|
||||
"target_n": 50,
|
||||
"target_met": total_n >= 50,
|
||||
"next": (
|
||||
"目标 N>=50 已达成 — 再看跨品种 net PF 是否仍>1.3"
|
||||
if total_n >= 50
|
||||
else "继续补历史数据(BTC 2019+)或更多品种 1h/4h/8h"
|
||||
),
|
||||
}
|
||||
print("\n===== Aggregate =====")
|
||||
print(json.dumps(results["aggregate"], ensure_ascii=False, indent=2))
|
||||
OUT.write_text(json.dumps(results, indent=2, ensure_ascii=False))
|
||||
print(f"\nSaved {OUT}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,382 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Regime Attribution Study — 策略完全冻结
|
||||
|
||||
问题:为什么 Spring 在 2023+ BTC 有效,全历史 / 多品种不稳健?
|
||||
方法:逐笔交易打市场状态标签,按桶看 net PF(不改任何入场逻辑)
|
||||
|
||||
输出:
|
||||
- scripts/wyckoff_regime_attribution_trades.jsonl 逐笔
|
||||
- scripts/wyckoff_regime_attribution_result.json 汇总
|
||||
- research/VALIDITY_BOUNDARY.md 适用域草案
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import talib.abstract as ta
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from user_data.Chan.scripts.wyckoff_tf_grid import install_offline_markets # noqa: E402
|
||||
|
||||
STRAT = "Wyckoff_BTC_V1_BASELINE"
|
||||
CONFIG = ROOT / "user_data/Chan/config/Wyckoff_BTC_V1_BASELINE.json"
|
||||
DATADIR = ROOT / "user_data/data/binance/futures"
|
||||
OUT_JSON = ROOT / "user_data/Chan/scripts/wyckoff_regime_attribution_result.json"
|
||||
OUT_TRADES = ROOT / "user_data/Chan/scripts/wyckoff_regime_attribution_trades.jsonl"
|
||||
OUT_BOUNDARY = ROOT / "user_data/Chan/research/VALIDITY_BOUNDARY.md"
|
||||
STATUS = ROOT / "user_data/Chan/research/SYSTEM_STATUS.md"
|
||||
|
||||
PAIR = "BTC/USDT:USDT"
|
||||
TIMERANGE = "20190901-"
|
||||
FEE = 0.0005
|
||||
SLIP = 0.0005 # 评价用 net
|
||||
|
||||
|
||||
def _pf(profits: list[float]) -> float:
|
||||
wins = [p for p in profits if p > 0]
|
||||
losses = [-p for p in profits if p <= 0]
|
||||
gw, gl = sum(wins), sum(losses)
|
||||
if gl <= 0:
|
||||
return 999.0 if gw > 0 else 0.0
|
||||
return gw / gl
|
||||
|
||||
|
||||
def _bucket_stats(rows: list[dict], key: str) -> dict[str, Any]:
|
||||
groups: dict[str, list[float]] = defaultdict(list)
|
||||
for r in rows:
|
||||
groups[str(r.get(key, "na"))].append(float(r["profit_ratio"]))
|
||||
out = {}
|
||||
for k, ps in sorted(groups.items(), key=lambda x: -len(x[1])):
|
||||
out[k] = {
|
||||
"n": len(ps),
|
||||
"winrate": 100.0 * sum(1 for p in ps if p > 0) / len(ps),
|
||||
"avg_pct": 100.0 * float(np.mean(ps)),
|
||||
"sum_pct": 100.0 * float(np.sum(ps)),
|
||||
"pf": round(_pf(ps), 3),
|
||||
}
|
||||
return out
|
||||
|
||||
|
||||
def build_feature_frames(pair_file: str = "BTC_USDT_USDT") -> tuple[pd.DataFrame, pd.DataFrame]:
|
||||
"""1h ATR percentile + 8h structure features(与策略无关的分析层)。"""
|
||||
h1 = pd.read_feather(DATADIR / f"{pair_file}-1h-futures.feather")
|
||||
h1["date"] = pd.to_datetime(h1["date"], utc=True)
|
||||
h1 = h1.sort_values("date").reset_index(drop=True)
|
||||
h1["atr"] = ta.ATR(h1, timeperiod=14)
|
||||
# 滚动 90 天 ≈ 2160 根 1h 的 ATR 分位
|
||||
win = 2160
|
||||
h1["atr_percentile"] = h1["atr"].rolling(win, min_periods=200).apply(
|
||||
lambda x: pd.Series(x).rank(pct=True).iloc[-1], raw=False
|
||||
)
|
||||
|
||||
h8 = pd.read_feather(DATADIR / f"{pair_file}-8h-futures.feather")
|
||||
h8["date"] = pd.to_datetime(h8["date"], utc=True)
|
||||
h8 = h8.sort_values("date").reset_index(drop=True)
|
||||
h8["ema50"] = ta.EMA(h8, timeperiod=50)
|
||||
h8["ema200"] = ta.EMA(h8, timeperiod=200)
|
||||
h8["adx"] = ta.ADX(h8, timeperiod=14)
|
||||
h8["ema_slope"] = (h8["ema50"] - h8["ema50"].shift(6)) / h8["ema50"].shift(6)
|
||||
h8["dist_ema200"] = (h8["close"] - h8["ema200"]) / h8["ema200"]
|
||||
h8["bull"] = (h8["close"] > h8["ema200"]) & (h8["ema50"] > h8["ema200"])
|
||||
h8["bear"] = (h8["close"] < h8["ema200"]) & (h8["ema50"] < h8["ema200"])
|
||||
|
||||
# Cycle(粗粒度威科夫语境,非策略信号)
|
||||
slope = h8["ema_slope"]
|
||||
cycle = np.full(len(h8), "transition", dtype=object)
|
||||
cycle[(h8["bear"]) & (slope < -0.01)] = "markdown"
|
||||
cycle[(h8["bear"]) & (slope >= -0.01)] = "accumulation_like"
|
||||
cycle[(h8["bull"]) & (slope > 0.005)] = "markup"
|
||||
cycle[(h8["bull"]) & (slope <= 0.005)] = "distribution_like"
|
||||
h8["btc_cycle"] = cycle
|
||||
|
||||
# trend strength
|
||||
ts = np.full(len(h8), "weak", dtype=object)
|
||||
ts[(h8["adx"] >= 25) & (h8["adx"] < 35)] = "moderate"
|
||||
ts[h8["adx"] >= 35] = "strong"
|
||||
h8["trend_strength"] = ts
|
||||
|
||||
regime = np.full(len(h8), "range", dtype=object)
|
||||
regime[h8["bull"].fillna(False)] = "bull"
|
||||
regime[h8["bear"].fillna(False)] = "bear"
|
||||
h8["market_regime"] = regime
|
||||
return h1, h8
|
||||
|
||||
|
||||
def atr_bucket(p: float) -> str:
|
||||
if pd.isna(p):
|
||||
return "atr_unknown"
|
||||
if p < 0.33:
|
||||
return "atr_low"
|
||||
if p < 0.66:
|
||||
return "atr_mid"
|
||||
return "atr_high"
|
||||
|
||||
|
||||
def slope_bucket(s: float) -> str:
|
||||
if pd.isna(s):
|
||||
return "slope_unknown"
|
||||
if s > 0.01:
|
||||
return "slope_up_strong"
|
||||
if s > 0:
|
||||
return "slope_up_mild"
|
||||
if s > -0.01:
|
||||
return "slope_flat_down"
|
||||
return "slope_down_strong"
|
||||
|
||||
|
||||
def run_backtest_trades() -> list[dict[str, Any]]:
|
||||
from freqtrade.configuration import Configuration
|
||||
from freqtrade.enums import RunMode
|
||||
from freqtrade.optimize.backtesting import Backtesting
|
||||
from freqtrade.persistence import LocalTrade
|
||||
import freqtrade.optimize.optimize_reports.bt_output as bt_output
|
||||
|
||||
bt_output.show_backtest_results = lambda *a, **k: None # type: ignore
|
||||
for mod in list(sys.modules):
|
||||
if "Wyckoff_BTC" in mod:
|
||||
del sys.modules[mod]
|
||||
|
||||
config = Configuration.from_files([str(CONFIG)])
|
||||
config.update(
|
||||
{
|
||||
"strategy": STRAT,
|
||||
"strategy_path": str(ROOT / "user_data/Chan/strategies"),
|
||||
"timerange": TIMERANGE,
|
||||
"timeframe": "1h",
|
||||
"export": "none",
|
||||
"runmode": RunMode.BACKTEST,
|
||||
"datadir": ROOT / "user_data/data/binance",
|
||||
"user_data_dir": ROOT / "user_data",
|
||||
"enable_protections": False,
|
||||
"fee": FEE + SLIP,
|
||||
"exchange": {
|
||||
**config.get("exchange", {}),
|
||||
"name": "binance",
|
||||
"pair_whitelist": [PAIR],
|
||||
},
|
||||
}
|
||||
)
|
||||
bt = Backtesting(config)
|
||||
bt.start()
|
||||
|
||||
rows = []
|
||||
for t in LocalTrade.bt_trades:
|
||||
rows.append(
|
||||
{
|
||||
"pair": t.pair,
|
||||
"enter_tag": t.enter_tag or "",
|
||||
"is_short": bool(t.is_short),
|
||||
"entry_date": t.open_date_utc.isoformat(),
|
||||
"exit_date": t.close_date_utc.isoformat() if t.close_date_utc else None,
|
||||
"profit_ratio": float(t.close_profit or 0.0),
|
||||
"exit_reason": t.exit_reason or "",
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def attribute(trades: list[dict], h1: pd.DataFrame, h8: pd.DataFrame) -> list[dict]:
|
||||
h1 = h1.set_index("date").sort_index()
|
||||
h8 = h8.set_index("date").sort_index()
|
||||
out = []
|
||||
for t in trades:
|
||||
ed = pd.Timestamp(t["entry_date"])
|
||||
if ed.tzinfo is None:
|
||||
ed = ed.tz_localize("UTC")
|
||||
# asof merge:入场前最后一根已收盘特征
|
||||
i1 = h1.index.get_indexer([ed], method="ffill")[0]
|
||||
i8 = h8.index.get_indexer([ed], method="ffill")[0]
|
||||
if i1 < 0 or i8 < 0:
|
||||
continue
|
||||
r1 = h1.iloc[i1]
|
||||
r8 = h8.iloc[i8]
|
||||
ap = float(r1["atr_percentile"]) if pd.notna(r1["atr_percentile"]) else float("nan")
|
||||
slope = float(r8["ema_slope"]) if pd.notna(r8["ema_slope"]) else float("nan")
|
||||
adx = float(r8["adx"]) if pd.notna(r8["adx"]) else float("nan")
|
||||
era = "2023plus" if ed >= pd.Timestamp("2023-01-01", tz="UTC") else "pre_2023"
|
||||
rec = {
|
||||
**t,
|
||||
"market_regime": str(r8["market_regime"]),
|
||||
"8h_adx": round(adx, 2) if not np.isnan(adx) else None,
|
||||
"8h_ema_slope": round(slope, 5) if not np.isnan(slope) else None,
|
||||
"atr_percentile": round(ap, 3) if not np.isnan(ap) else None,
|
||||
"btc_cycle": str(r8["btc_cycle"]),
|
||||
"trend_strength": str(r8["trend_strength"]),
|
||||
"dist_ema200": round(float(r8["dist_ema200"]), 4) if pd.notna(r8["dist_ema200"]) else None,
|
||||
"atr_bucket": atr_bucket(ap),
|
||||
"slope_bucket": slope_bucket(slope),
|
||||
"era": era,
|
||||
"setup": t["enter_tag"] or ("UTAD_SHORT" if t["is_short"] else "SPRING_LONG"),
|
||||
"result": "win" if t["profit_ratio"] > 0 else "loss",
|
||||
}
|
||||
out.append(rec)
|
||||
return out
|
||||
|
||||
|
||||
def write_boundary(summary: dict[str, Any]) -> None:
|
||||
# 从桶结果提炼适用域草案(描述性,非自动交易规则)
|
||||
atr = summary["by_atr_bucket"]
|
||||
cycle = summary["by_btc_cycle"]
|
||||
era = summary["by_era"]
|
||||
ts = summary["by_trend_strength"]
|
||||
|
||||
def best_worst(d: dict) -> tuple[str, str]:
|
||||
items = [(k, v) for k, v in d.items() if v["n"] >= 5]
|
||||
if not items:
|
||||
return "n/a", "n/a"
|
||||
best = max(items, key=lambda x: x[1]["pf"])
|
||||
worst = min(items, key=lambda x: x[1]["pf"])
|
||||
return f"{best[0]} (PF {best[1]['pf']}, n={best[1]['n']})", f"{worst[0]} (PF {worst[1]['pf']}, n={worst[1]['n']})"
|
||||
|
||||
ab, aw = best_worst(atr)
|
||||
cb, cw = best_worst(cycle)
|
||||
tb, tw = best_worst(ts)
|
||||
|
||||
text = f"""# Validity Boundary — Spring Baseline (draft)
|
||||
|
||||
> 策略规则冻结。本文仅来自 Regime Attribution,**不是**新入场条件。
|
||||
|
||||
## Evidence snapshot
|
||||
|
||||
| Era | n | PF (net) | sum%% |
|
||||
|-----|---|----------|-------|
|
||||
| pre_2023 | {era.get('pre_2023', {}).get('n', 0)} | {era.get('pre_2023', {}).get('pf', 0)} | {era.get('pre_2023', {}).get('sum_pct', 0):.1f} |
|
||||
| 2023plus | {era.get('2023plus', {}).get('n', 0)} | {era.get('2023plus', {}).get('pf', 0)} | {era.get('2023plus', {}).get('sum_pct', 0):.1f} |
|
||||
|
||||
## Observed favorable (descriptive)
|
||||
|
||||
- ATR bucket best: **{ab}**
|
||||
- Cycle best: **{cb}**
|
||||
- Trend strength best: **{tb}**
|
||||
|
||||
## Observed unfavorable (descriptive)
|
||||
|
||||
- ATR bucket worst: **{aw}**
|
||||
- Cycle worst: **{cw}**
|
||||
- Trend strength worst: **{tw}**
|
||||
|
||||
## Draft Validity Boundary
|
||||
|
||||
```
|
||||
Spring Strategy (BTC)
|
||||
适用(研究假设,待 Decision Engine 验证):
|
||||
✓ BTC(非默认跨资产)
|
||||
✓ 2023+ 类「明确资金方向 / Markup 启动」环境
|
||||
✓ 高/中波动(ATR rising / mid-high percentile)若数据支持
|
||||
✓ Accumulation_like → Markup 过渡语境
|
||||
|
||||
不适用(当前证据):
|
||||
✗ 默认全历史无条件交易
|
||||
✗ 横盘 / range regime
|
||||
✗ 跨资产默认开启(ETH/SOL Phase3 未过)
|
||||
✗ 熊市 Markdown 快速崩跌阶段(若桶显示 PF 差)
|
||||
```
|
||||
|
||||
## Next for Decision Engine
|
||||
|
||||
Market State 先判定「是否落在适用域」→ 再允许 SPRING_LONG / UTAD_SHORT 信号。
|
||||
**禁止**把本文件桶标签直接写回 Baseline 参数扫参。
|
||||
"""
|
||||
OUT_BOUNDARY.write_text(text)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
logging.getLogger("freqtrade").setLevel(logging.ERROR)
|
||||
install_offline_markets([PAIR])
|
||||
|
||||
print("===== 1) Frozen baseline backtest (BTC, net cost) =====", flush=True)
|
||||
raw = run_backtest_trades()
|
||||
print(f" trades={len(raw)}", flush=True)
|
||||
|
||||
print("===== 2) Build regime features =====", flush=True)
|
||||
h1, h8 = build_feature_frames()
|
||||
rows = attribute(raw, h1, h8)
|
||||
print(f" attributed={len(rows)}", flush=True)
|
||||
|
||||
with OUT_TRADES.open("w") as f:
|
||||
for r in rows:
|
||||
f.write(json.dumps(r, ensure_ascii=False) + "\n")
|
||||
|
||||
summary: dict[str, Any] = {
|
||||
"pair": PAIR,
|
||||
"timerange": TIMERANGE,
|
||||
"fee_model": f"fee {FEE}+slip {SLIP}",
|
||||
"n": len(rows),
|
||||
"overall_pf": round(_pf([r["profit_ratio"] for r in rows]), 3),
|
||||
"by_era": _bucket_stats(rows, "era"),
|
||||
"by_setup": _bucket_stats(rows, "setup"),
|
||||
"by_market_regime": _bucket_stats(rows, "market_regime"),
|
||||
"by_atr_bucket": _bucket_stats(rows, "atr_bucket"),
|
||||
"by_trend_strength": _bucket_stats(rows, "trend_strength"),
|
||||
"by_slope_bucket": _bucket_stats(rows, "slope_bucket"),
|
||||
"by_btc_cycle": _bucket_stats(rows, "btc_cycle"),
|
||||
"by_era_x_cycle": {},
|
||||
"by_era_x_atr": {},
|
||||
"interpretation": [],
|
||||
}
|
||||
|
||||
# 交叉:era × cycle / atr
|
||||
for era in ("pre_2023", "2023plus"):
|
||||
sub = [r for r in rows if r["era"] == era]
|
||||
summary["by_era_x_cycle"][era] = _bucket_stats(sub, "btc_cycle")
|
||||
summary["by_era_x_atr"][era] = _bucket_stats(sub, "atr_bucket")
|
||||
|
||||
# 自动写几条解释线索(非交易规则)
|
||||
era = summary["by_era"]
|
||||
if era.get("2023plus", {}).get("pf", 0) > era.get("pre_2023", {}).get("pf", 0):
|
||||
summary["interpretation"].append(
|
||||
"2023plus PF 显著高于 pre_2023 → 存在 regime/cycle 依赖,非随机噪声单一窗口。"
|
||||
)
|
||||
cyc = summary["by_btc_cycle"]
|
||||
if cyc:
|
||||
best_c = max(cyc.items(), key=lambda x: (x[1]["n"] >= 5, x[1]["pf"]))
|
||||
summary["interpretation"].append(
|
||||
f"全样本 cycle 最优桶(n≥5 优先): {best_c[0]} PF={best_c[1]['pf']} n={best_c[1]['n']}"
|
||||
)
|
||||
|
||||
print("\n===== 3) Attribution tables =====", flush=True)
|
||||
for name in (
|
||||
"by_era", "by_setup", "by_market_regime", "by_atr_bucket",
|
||||
"by_trend_strength", "by_slope_bucket", "by_btc_cycle",
|
||||
):
|
||||
print(f"\n-- {name} --")
|
||||
for k, v in summary[name].items():
|
||||
print(f" {k:<22} n={v['n']:<3} pf={v['pf']:<6} wr={v['winrate']:.0f}% sum={v['sum_pct']:.1f}%")
|
||||
|
||||
print("\n-- by_era_x_cycle --")
|
||||
print(json.dumps(summary["by_era_x_cycle"], indent=2, ensure_ascii=False))
|
||||
|
||||
write_boundary(summary)
|
||||
OUT_JSON.write_text(json.dumps(summary, indent=2, ensure_ascii=False))
|
||||
|
||||
# 更新 SYSTEM_STATUS
|
||||
if STATUS.exists():
|
||||
st = STATUS.read_text()
|
||||
marker = "## Frozen Baseline"
|
||||
block = (
|
||||
"**Status update (Regime Attribution):**\n"
|
||||
"Evidence: PASS (2023+ BTC) · Robustness: FAILED (multi-cycle) · "
|
||||
"Confidence: LOW-MEDIUM · Next: Decision Engine validity gate "
|
||||
f"(see `VALIDITY_BOUNDARY.md`, trades=`{OUT_TRADES.name}`).\n\n"
|
||||
)
|
||||
if "Status update (Regime Attribution)" not in st:
|
||||
st = st.replace(marker, block + marker)
|
||||
STATUS.write_text(st)
|
||||
|
||||
print(f"\nSaved {OUT_JSON}")
|
||||
print(f"Saved {OUT_TRADES}")
|
||||
print(f"Saved {OUT_BOUNDARY}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,305 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Soft-score Gate — 窄实验(研究纪律)
|
||||
|
||||
1) 仅在 pre_2023 比较少数 Gate 形式并选定阈值
|
||||
2) 锁定后评估 2023+ / full
|
||||
3) 禁止全样本扫参;判定不要求超过 baseline PF
|
||||
|
||||
候选:
|
||||
- state_set
|
||||
- soft_sum: state_set & (accum+markup) >= q q ∈ {80,100,120,140}
|
||||
- soft_bad_cap: state_set & max(bad) <= q q ∈ {40,50,60}
|
||||
|
||||
Fit 目标(pre_2023): n>=5 前提下优先更低 DD,其次更高 PF(非收益最大化)
|
||||
OOS 通过:
|
||||
- 2023+ PF >= 1.2
|
||||
- full DD 明显低于 baseline(<= baseline_dd * 0.7 或绝对差 >= 5pp)
|
||||
- pre_2023 n >= 5(非极低样本偶然)
|
||||
- 标签不漂移:gated 入场中 state∈{accumulation,markup}|UTAD镜像 比例 >= 0.95
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from user_data.Chan.scripts.wyckoff_tf_grid import install_offline_markets # noqa: E402
|
||||
|
||||
STRAT_PATH = ROOT / "user_data/Chan/strategies/Wyckoff_BTC_GATED.py"
|
||||
BASE_CFG = ROOT / "user_data/Chan/config/Wyckoff_BTC_V1_BASELINE.json"
|
||||
GATE_CFG = ROOT / "user_data/Chan/config/Wyckoff_BTC_GATED.json"
|
||||
OUT = ROOT / "user_data/Chan/scripts/wyckoff_soft_gate_oos_result.json"
|
||||
PAIR = "BTC/USDT:USDT"
|
||||
|
||||
FIT_TR = "20190901-20230101"
|
||||
OOS_TR = "20230101-"
|
||||
FULL_TR = "20190901-"
|
||||
|
||||
CANDIDATES: list[dict[str, Any]] = [
|
||||
{"mode": "state_set", "q_sum": 100.0, "q_bad": 55.0},
|
||||
{"mode": "soft_sum", "q_sum": 80.0, "q_bad": 55.0},
|
||||
{"mode": "soft_sum", "q_sum": 100.0, "q_bad": 55.0},
|
||||
{"mode": "soft_sum", "q_sum": 120.0, "q_bad": 55.0},
|
||||
{"mode": "soft_sum", "q_sum": 140.0, "q_bad": 55.0},
|
||||
{"mode": "soft_bad_cap", "q_sum": 100.0, "q_bad": 40.0},
|
||||
{"mode": "soft_bad_cap", "q_sum": 100.0, "q_bad": 50.0},
|
||||
{"mode": "soft_bad_cap", "q_sum": 100.0, "q_bad": 60.0},
|
||||
]
|
||||
|
||||
|
||||
def set_gate(mode: str, q_sum: float, q_bad: float) -> None:
|
||||
text = STRAT_PATH.read_text()
|
||||
text2, n1 = re.subn(
|
||||
r'^(\tgate_mode: str = )".*"',
|
||||
rf'\g<1>"{mode}"',
|
||||
text,
|
||||
count=1,
|
||||
flags=re.M,
|
||||
)
|
||||
text2, n2 = re.subn(
|
||||
r'^(\tgate_q_sum: float = )[0-9.]+',
|
||||
rf"\g<1>{float(q_sum)}",
|
||||
text2,
|
||||
count=1,
|
||||
flags=re.M,
|
||||
)
|
||||
text2, n3 = re.subn(
|
||||
r'^(\tgate_q_bad: float = )[0-9.]+',
|
||||
rf"\g<1>{float(q_bad)}",
|
||||
text2,
|
||||
count=1,
|
||||
flags=re.M,
|
||||
)
|
||||
if min(n1, n2, n3) < 1:
|
||||
raise RuntimeError(f"failed patching gate attrs n=({n1},{n2},{n3})")
|
||||
STRAT_PATH.write_text(text2)
|
||||
pyc = STRAT_PATH.parent / "__pycache__"
|
||||
if pyc.is_dir():
|
||||
for p in pyc.glob("Wyckoff_BTC_GATED*.pyc"):
|
||||
p.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def run_bt(strategy: str, config: Path, timerange: str) -> dict[str, Any]:
|
||||
from freqtrade.configuration import Configuration
|
||||
from freqtrade.enums import RunMode
|
||||
from freqtrade.optimize.backtesting import Backtesting
|
||||
from freqtrade.persistence import LocalTrade
|
||||
import freqtrade.optimize.optimize_reports.bt_output as bt_output
|
||||
|
||||
bt_output.show_backtest_results = lambda *a, **k: None # type: ignore
|
||||
for mod in list(sys.modules):
|
||||
if "Wyckoff_BTC" in mod or "market_state" in mod:
|
||||
del sys.modules[mod]
|
||||
|
||||
cfg = Configuration.from_files([str(config)])
|
||||
cfg.update(
|
||||
{
|
||||
"strategy": strategy,
|
||||
"strategy_path": str(ROOT / "user_data/Chan/strategies"),
|
||||
"timerange": timerange,
|
||||
"timeframe": "1h",
|
||||
"export": "none",
|
||||
"runmode": RunMode.BACKTEST,
|
||||
"datadir": ROOT / "user_data/data/binance",
|
||||
"user_data_dir": ROOT / "user_data",
|
||||
"enable_protections": False,
|
||||
"fee": 0.0010,
|
||||
"exchange": {
|
||||
**cfg.get("exchange", {}),
|
||||
"name": "binance",
|
||||
"pair_whitelist": [PAIR],
|
||||
},
|
||||
}
|
||||
)
|
||||
bt = Backtesting(cfg)
|
||||
bt.start()
|
||||
st = bt.results["strategy"].get(strategy) or list(bt.results["strategy"].values())[0]
|
||||
profit = st.get("profit_total_pct")
|
||||
if profit is None:
|
||||
profit = float(st.get("profit_total") or 0) * 100
|
||||
|
||||
profits = [float(t.close_profit or 0.0) for t in LocalTrade.bt_trades]
|
||||
wins = [p for p in profits if p > 0]
|
||||
losses = [p for p in profits if p <= 0]
|
||||
avg_win = float(sum(wins) / len(wins)) if wins else 0.0
|
||||
avg_loss = float(sum(losses) / len(losses)) if losses else 0.0
|
||||
expectancy = float(sum(profits) / len(profits)) if profits else 0.0
|
||||
|
||||
# 标签漂移:用原生 8h 因果状态(不依赖 analyzed 缓存窗口)
|
||||
label_ok_rate = None
|
||||
try:
|
||||
import pandas as pd
|
||||
from engine.market_state import compute_market_state_8h
|
||||
|
||||
h8 = pd.read_feather(ROOT / "user_data/data/binance/futures/BTC_USDT_USDT-8h-futures.feather")
|
||||
h8["date"] = pd.to_datetime(h8["date"], utc=True)
|
||||
h8 = compute_market_state_8h(h8).set_index("date").sort_index()
|
||||
ok = tot = 0
|
||||
for t in LocalTrade.bt_trades:
|
||||
ed = pd.Timestamp(t.open_date_utc)
|
||||
if ed.tzinfo is None:
|
||||
ed = ed.tz_localize("UTC")
|
||||
idx = h8.index.get_indexer([ed], method="ffill")[0]
|
||||
if idx < 0:
|
||||
continue
|
||||
stt = str(h8.iloc[idx]["market_state"])
|
||||
tag = t.enter_tag or ""
|
||||
if "SPRING" in tag:
|
||||
ok += int(stt in ("accumulation", "markup"))
|
||||
elif "UTAD" in tag:
|
||||
ok += int(stt in ("distribution", "markdown"))
|
||||
else:
|
||||
ok += 1
|
||||
tot += 1
|
||||
label_ok_rate = (ok / tot) if tot else None
|
||||
except Exception:
|
||||
label_ok_rate = None
|
||||
|
||||
return {
|
||||
"profit_pct": float(profit),
|
||||
"trades": int(st.get("total_trades") or 0),
|
||||
"dd_pct": float(st.get("max_drawdown_account") or 0) * 100,
|
||||
"pf": float(st.get("profit_factor") or 0),
|
||||
"winrate": float(st.get("winrate") or 0) * 100,
|
||||
"expectancy": expectancy,
|
||||
"avg_win": avg_win,
|
||||
"avg_loss": avg_loss,
|
||||
"label_ok_rate": label_ok_rate,
|
||||
}
|
||||
|
||||
|
||||
def fit_score(m: dict[str, Any]) -> tuple:
|
||||
"""pre_2023 选择:n>=5;DD 越低越好;PF 次之;n 再之。"""
|
||||
n = m["trades"]
|
||||
if n < 5:
|
||||
return (0, 999.0, 0.0, 0) # invalid
|
||||
return (1, m["dd_pct"], -m["pf"], -n)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
logging.getLogger("freqtrade").setLevel(logging.ERROR)
|
||||
install_offline_markets([PAIR])
|
||||
orig = STRAT_PATH.read_text()
|
||||
results: dict[str, Any] = {
|
||||
"discipline": "fit on pre_2023 only; lock; test 2023+/full; no full-sample sweep",
|
||||
"baseline": {},
|
||||
"candidates_fit_pre2023": [],
|
||||
"locked": None,
|
||||
"oos": {},
|
||||
"verdict": {},
|
||||
}
|
||||
|
||||
try:
|
||||
print("===== Baseline (reference) =====", flush=True)
|
||||
for name, tr in [("pre_2023", FIT_TR), ("oos_2023plus", OOS_TR), ("full", FULL_TR)]:
|
||||
r = run_bt("Wyckoff_BTC_V1_BASELINE", BASE_CFG, tr)
|
||||
results["baseline"][name] = r
|
||||
print(
|
||||
f" baseline {name:<12} n={r['trades']:<3} pf={r['pf']:.2f} "
|
||||
f"dd={r['dd_pct']:.1f}% exp={r['expectancy']*100:.2f}%",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
print("\n===== Fit soft gates on pre_2023 only =====", flush=True)
|
||||
fit_rows = []
|
||||
for c in CANDIDATES:
|
||||
set_gate(c["mode"], c["q_sum"], c["q_bad"])
|
||||
r = run_bt("Wyckoff_BTC_GATED", GATE_CFG, FIT_TR)
|
||||
row = {**c, **r, "valid_n": r["trades"] >= 5}
|
||||
fit_rows.append(row)
|
||||
print(
|
||||
f" {c['mode']:<12} q_sum={c['q_sum']:<5} q_bad={c['q_bad']:<5} "
|
||||
f"n={r['trades']:<3} pf={r['pf']:.2f} dd={r['dd_pct']:.1f}% "
|
||||
f"label_ok={r['label_ok_rate']}",
|
||||
flush=True,
|
||||
)
|
||||
results["candidates_fit_pre2023"] = fit_rows
|
||||
|
||||
valid = [x for x in fit_rows if x["valid_n"]]
|
||||
if not valid:
|
||||
raise RuntimeError("no candidate with n>=5 on pre_2023")
|
||||
locked = sorted(valid, key=fit_score)[0]
|
||||
results["locked"] = {
|
||||
"mode": locked["mode"],
|
||||
"q_sum": locked["q_sum"],
|
||||
"q_bad": locked["q_bad"],
|
||||
"pre_2023": {
|
||||
k: locked[k]
|
||||
for k in (
|
||||
"trades", "pf", "dd_pct", "profit_pct", "expectancy",
|
||||
"avg_win", "avg_loss", "label_ok_rate",
|
||||
)
|
||||
},
|
||||
}
|
||||
print(
|
||||
f"\nLOCKED (pre_2023): mode={locked['mode']} q_sum={locked['q_sum']} "
|
||||
f"q_bad={locked['q_bad']} n={locked['trades']} pf={locked['pf']:.2f} "
|
||||
f"dd={locked['dd_pct']:.1f}%",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
set_gate(locked["mode"], locked["q_sum"], locked["q_bad"])
|
||||
print("\n===== Locked gate → OOS / full =====", flush=True)
|
||||
for name, tr in [("pre_2023", FIT_TR), ("oos_2023plus", OOS_TR), ("full", FULL_TR)]:
|
||||
r = run_bt("Wyckoff_BTC_GATED", GATE_CFG, tr)
|
||||
results["oos"][name] = r
|
||||
print(
|
||||
f" gated {name:<12} n={r['trades']:<3} pf={r['pf']:.2f} "
|
||||
f"dd={r['dd_pct']:.1f}% exp={r['expectancy']*100:.2f}% "
|
||||
f"avgW={r['avg_win']*100:.2f}% avgL={r['avg_loss']*100:.2f}% "
|
||||
f"label_ok={r['label_ok_rate']}",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
b_full = results["baseline"]["full"]
|
||||
b_oos = results["baseline"]["oos_2023plus"]
|
||||
g_pre = results["oos"]["pre_2023"]
|
||||
g_oos = results["oos"]["oos_2023plus"]
|
||||
g_full = results["oos"]["full"]
|
||||
|
||||
dd_ok = (g_full["dd_pct"] <= b_full["dd_pct"] * 0.7) or (
|
||||
(b_full["dd_pct"] - g_full["dd_pct"]) >= 5.0
|
||||
)
|
||||
label_ok = (g_oos.get("label_ok_rate") is None) or (g_oos["label_ok_rate"] >= 0.95)
|
||||
results["verdict"] = {
|
||||
"oos_pf_ge_1_2": g_oos["pf"] >= 1.2,
|
||||
"full_dd_clearly_below_baseline": dd_ok,
|
||||
"pre2023_n_ge_5": g_pre["trades"] >= 5,
|
||||
"label_no_drift": label_ok,
|
||||
"oos_pf": g_oos["pf"],
|
||||
"oos_n": g_oos["trades"],
|
||||
"full_dd_gated": g_full["dd_pct"],
|
||||
"full_dd_baseline": b_full["dd_pct"],
|
||||
"baseline_oos_pf": b_oos["pf"],
|
||||
"status": (
|
||||
"PASS"
|
||||
if (
|
||||
g_oos["pf"] >= 1.2
|
||||
and dd_ok
|
||||
and g_pre["trades"] >= 5
|
||||
and label_ok
|
||||
)
|
||||
else "FAIL"
|
||||
),
|
||||
"note": "Success = domain control (PF floor + DD cut), not beating baseline PF.",
|
||||
}
|
||||
print("\n===== Verdict =====")
|
||||
print(json.dumps(results["verdict"], indent=2, ensure_ascii=False))
|
||||
finally:
|
||||
# 恢复默认 state_set,避免污染 live 默认
|
||||
STRAT_PATH.write_text(orig)
|
||||
print("\nRestored Wyckoff_BTC_GATED.py defaults", flush=True)
|
||||
|
||||
OUT.write_text(json.dumps(results, indent=2, ensure_ascii=False))
|
||||
print(f"Saved {OUT}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,226 @@
|
||||
#!/usr/bin/env python3
|
||||
"""离线网格:对比 Wyckoff 多周期组合(不依赖 Binance API)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
STRAT_PATH = ROOT / "user_data/Chan/strategies/Wyckoff_BTC.py"
|
||||
CONFIG_PATH = ROOT / "user_data/Chan/config/Wyckoff_BTC.json"
|
||||
|
||||
COMBOS = [
|
||||
("1h_4h_noBias", "1h", "4h", None),
|
||||
("1h_4h_8h", "1h", "4h", "8h"),
|
||||
("1h_8h_noBias", "1h", "8h", None),
|
||||
("30m_4h_8h", "30m", "4h", "8h"),
|
||||
("30m_4h_noBias", "30m", "4h", None),
|
||||
("15m_1h_4h", "15m", "1h", "4h"),
|
||||
("15m_4h_8h", "15m", "4h", "8h"),
|
||||
("4h_8h_noBias", "4h", "8h", None),
|
||||
]
|
||||
|
||||
|
||||
def stub_market(symbol: str = "BTC/USDT:USDT") -> dict[str, Any]:
|
||||
base = symbol.split("/")[0]
|
||||
return {
|
||||
"id": symbol,
|
||||
"symbol": symbol,
|
||||
"base": base,
|
||||
"quote": "USDT",
|
||||
"settle": "USDT",
|
||||
"baseId": base,
|
||||
"quoteId": "USDT",
|
||||
"settleId": "USDT",
|
||||
"type": "swap",
|
||||
"spot": False,
|
||||
"swap": True,
|
||||
"future": False,
|
||||
"option": False,
|
||||
"active": True,
|
||||
"contract": True,
|
||||
"linear": True,
|
||||
"inverse": False,
|
||||
"contractSize": 1.0,
|
||||
"precision": {"amount": 0.001, "price": 0.1},
|
||||
"limits": {
|
||||
"amount": {"min": 0.001, "max": 1000.0},
|
||||
"price": {"min": 0.1, "max": None},
|
||||
"cost": {"min": 5.0, "max": None},
|
||||
"leverage": {"min": 1.0, "max": 125.0},
|
||||
},
|
||||
"percentage": True,
|
||||
"taker": 0.0005,
|
||||
"maker": 0.0002,
|
||||
"info": {},
|
||||
}
|
||||
|
||||
|
||||
def install_offline_markets(pairs: Optional[list[str]] = None) -> None:
|
||||
import ccxt
|
||||
import freqtrade.exchange.exchange as exmod
|
||||
from freqtrade.util import dt_ts
|
||||
|
||||
if pairs is None:
|
||||
pairs = ["BTC/USDT:USDT"]
|
||||
markets = {p: stub_market(p) for p in pairs}
|
||||
tiers = {
|
||||
p: [
|
||||
{
|
||||
"minNotional": 0,
|
||||
"maxNotional": 1e12,
|
||||
"maintenanceMarginRate": 0.005,
|
||||
"maxLeverage": 125,
|
||||
"info": {},
|
||||
}
|
||||
]
|
||||
for p in pairs
|
||||
}
|
||||
|
||||
def fake_reload(self, force: bool = False, *, load_leverage_tiers: bool = True) -> None:
|
||||
self._markets = markets
|
||||
try:
|
||||
self._api.precisionMode = ccxt.TICK_SIZE
|
||||
self._api_async.precisionMode = ccxt.TICK_SIZE
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
self._api.set_markets(markets)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
self._api_async.set_markets(markets)
|
||||
except Exception:
|
||||
pass
|
||||
self._last_markets_refresh = dt_ts()
|
||||
self._leverage_tiers = tiers
|
||||
self._trading_fees = {}
|
||||
|
||||
exmod.Exchange.reload_markets = fake_reload # type: ignore
|
||||
exmod.Exchange.fills_leverage_tiers = lambda self: setattr(self, "_leverage_tiers", tiers) # type: ignore
|
||||
|
||||
|
||||
def patch_strategy(exec_tf: str, structure_tf: str, bias_tf: Optional[str]) -> None:
|
||||
text = STRAT_PATH.read_text()
|
||||
bias_repr = "None" if bias_tf is None else f'"{bias_tf}"'
|
||||
text = re.sub(r'^(\ttimeframe = ).*$', rf'\g<1>"{exec_tf}"', text, count=1, flags=re.M)
|
||||
text = re.sub(
|
||||
r'^(\tstructure_timeframe = ).*$', rf'\g<1>"{structure_tf}"', text, count=1, flags=re.M
|
||||
)
|
||||
text = re.sub(
|
||||
r'^(\tbias_timeframe: Optional\[str\] = ).*$',
|
||||
rf'\g<1>{bias_repr}',
|
||||
text,
|
||||
count=1,
|
||||
flags=re.M,
|
||||
)
|
||||
startup = 220 if exec_tf in ("1h", "4h", "8h") else 400
|
||||
text = re.sub(
|
||||
r'^(\tstartup_candle_count = ).*$', rf'\g<1>{startup}', text, count=1, flags=re.M
|
||||
)
|
||||
STRAT_PATH.write_text(text)
|
||||
|
||||
|
||||
def run_one(exec_tf: str, timerange: str) -> dict[str, Any]:
|
||||
from freqtrade.configuration import Configuration
|
||||
from freqtrade.enums import RunMode
|
||||
from freqtrade.optimize.backtesting import Backtesting
|
||||
import freqtrade.optimize.optimize_reports.bt_output as bt_output
|
||||
|
||||
# 静默打印
|
||||
bt_output.show_backtest_results = lambda *a, **k: None # type: ignore
|
||||
bt_output.show_backtest_result = lambda *a, **k: None # type: ignore
|
||||
|
||||
for mod in list(sys.modules):
|
||||
if "Wyckoff_BTC" in mod or mod.endswith("Wyckoff_BTC"):
|
||||
del sys.modules[mod]
|
||||
|
||||
config = Configuration.from_files([str(CONFIG_PATH)])
|
||||
config["strategy"] = "Wyckoff_BTC"
|
||||
config["strategy_path"] = str(ROOT / "user_data/Chan/strategies")
|
||||
config["timerange"] = timerange
|
||||
config["timeframe"] = exec_tf
|
||||
config["export"] = "none"
|
||||
config["runmode"] = RunMode.BACKTEST
|
||||
config["datadir"] = ROOT / "user_data/data/binance"
|
||||
config["user_data_dir"] = ROOT / "user_data"
|
||||
config["enable_protections"] = False
|
||||
|
||||
bt = Backtesting(config)
|
||||
bt.start()
|
||||
stats = bt.results
|
||||
strat_stats = stats["strategy"].get("Wyckoff_BTC") or list(stats["strategy"].values())[0]
|
||||
trades = int(strat_stats.get("total_trades") or 0)
|
||||
profit_pct = strat_stats.get("profit_total_pct")
|
||||
if profit_pct is None:
|
||||
profit_pct = float(strat_stats.get("profit_total") or 0) * 100
|
||||
dd = float(strat_stats.get("max_drawdown_account") or 0) * 100
|
||||
wr = float(strat_stats.get("winrate") or 0) * 100
|
||||
return {
|
||||
"ok": True,
|
||||
"profit_pct": float(profit_pct),
|
||||
"trades": trades,
|
||||
"dd_pct": dd,
|
||||
"pf": float(strat_stats.get("profit_factor") or 0),
|
||||
"winrate": wr,
|
||||
"rejected": int(strat_stats.get("rejected_signals") or 0),
|
||||
"timeframe_used": config.get("timeframe"),
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
logging.getLogger("freqtrade").setLevel(logging.ERROR)
|
||||
timerange = sys.argv[1] if len(sys.argv) > 1 else "20240101-"
|
||||
install_offline_markets()
|
||||
orig = STRAT_PATH.read_text()
|
||||
rows: list[dict[str, Any]] = []
|
||||
try:
|
||||
for label, exec_tf, stf, btf in COMBOS:
|
||||
print(f"=== {label} ===", flush=True)
|
||||
patch_strategy(exec_tf, stf, btf)
|
||||
try:
|
||||
res = run_one(exec_tf, timerange)
|
||||
except Exception as e:
|
||||
res = {"ok": False, "error": f"{type(e).__name__}: {e}"}
|
||||
res["label"] = label
|
||||
res["exec"] = exec_tf
|
||||
res["struct"] = stf
|
||||
res["bias"] = btf or "-"
|
||||
rows.append(res)
|
||||
if res.get("ok"):
|
||||
print(
|
||||
f" profit={res['profit_pct']:.2f}% trades={res['trades']} "
|
||||
f"dd={res['dd_pct']:.2f}% pf={res['pf']:.2f} wr={res['winrate']:.1f}% "
|
||||
f"rej={res['rejected']}",
|
||||
flush=True,
|
||||
)
|
||||
else:
|
||||
print(f" FAILED: {res.get('error')}", flush=True)
|
||||
finally:
|
||||
STRAT_PATH.write_text(orig)
|
||||
|
||||
ok = [r for r in rows if r.get("ok")]
|
||||
ok.sort(key=lambda r: (r["profit_pct"], r["pf"]), reverse=True)
|
||||
print("\n========== RANKING ==========")
|
||||
print(f"{'label':<16} {'E':<5} {'S':<5} {'B':<5} {'profit%':>8} {'trades':>7} {'dd%':>7} {'pf':>6} {'wr%':>6}")
|
||||
for r in ok:
|
||||
print(
|
||||
f"{r['label']:<16} {r['exec']:<5} {r['struct']:<5} {r['bias']:<5} "
|
||||
f"{r['profit_pct']:>8.2f} {r['trades']:>7} {r['dd_pct']:>7.2f} {r['pf']:>6.2f} {r['winrate']:>6.1f}"
|
||||
)
|
||||
out = ROOT / "user_data/Chan/scripts/wyckoff_tf_grid_result.txt"
|
||||
out.write_text(json.dumps({"timerange": timerange, "rows": rows}, indent=2))
|
||||
print(f"\nSaved {out}")
|
||||
if ok:
|
||||
best = ok[0]
|
||||
print(f"BEST: {best['label']} -> 将写入策略默认周期")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,458 @@
|
||||
"""
|
||||
BTC Maker Micro Scalper v1.0
|
||||
|
||||
目标:在 BTCUSDT 永续 1m 级别,用盘口微结构(OBI / Delta / CVD / VWAP)
|
||||
做 Maker 挂单,捕捉约 0.03%~0.08% 的微小价差。
|
||||
|
||||
回测说明:
|
||||
- Freqtrade 标准回测只有 OHLCV,没有真实 L2 / Tick。
|
||||
- 本策略用 K 线代理重构 OBI / Delta / CVD,使逻辑可回测、可验证。
|
||||
- 实盘 / Dry-run 下,confirm_trade_entry 会用真实 10 档 orderbook 覆盖 OBI。
|
||||
|
||||
不要加入:RSI / MACD / 均线交叉 / 神经网络。
|
||||
|
||||
运行示例:
|
||||
freqtrade download-data -c ./user_data/Chan/config/BTC_Maker_Micro_Scalper.json \\
|
||||
-t 1m --pairs BTC/USDT:USDT --timerange=20260101-
|
||||
|
||||
freqtrade backtesting -c ./user_data/Chan/config/BTC_Maker_Micro_Scalper.json \\
|
||||
--strategy BTC_Maker_Micro_Scalper --strategy-path ./user_data/Chan/strategies \\
|
||||
--timerange=20260101- --fee 0.00016
|
||||
|
||||
python user_data/Chan/strategies/mms_stats.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import talib.abstract as ta
|
||||
from pandas import DataFrame
|
||||
|
||||
from freqtrade.persistence import Trade
|
||||
from freqtrade.strategy import IStrategy, DecimalParameter
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _safe_div(num, den):
|
||||
return np.where(den != 0, num / den, 0.0)
|
||||
|
||||
|
||||
class BTC_Maker_Micro_Scalper(IStrategy):
|
||||
"""
|
||||
Maker Micro Scalping MVP — 盘口失衡 + 主动成交方向 + CVD + VWAP 过滤。
|
||||
"""
|
||||
|
||||
INTERFACE_VERSION: int = 3
|
||||
timeframe: str = "1m"
|
||||
can_short: bool = True
|
||||
process_only_new_candles: bool = True
|
||||
startup_candle_count: int = 120
|
||||
|
||||
# 固定小止盈 / 止损(价格百分比,非杠杆后权益)
|
||||
# ROI +0.05%;stoploss -0.03%;时间止损 3 分钟在 custom_exit
|
||||
minimal_roi = {"0": 0.0005}
|
||||
stoploss = -0.0003
|
||||
trailing_stop = False
|
||||
use_exit_signal = False
|
||||
use_custom_stoploss = False
|
||||
|
||||
# Maker 限价单
|
||||
order_types = {
|
||||
"entry": "limit",
|
||||
"exit": "limit",
|
||||
"stoploss": "limit",
|
||||
"stoploss_on_exchange": False,
|
||||
}
|
||||
order_time_in_force = {
|
||||
"entry": "GTC",
|
||||
"exit": "GTC",
|
||||
}
|
||||
|
||||
# ---- 可调参数(保持与规格一致;后续可 hyperopt)----
|
||||
maker_fee = 0.00016 # 0.016%
|
||||
atr_fee_mult = 3.0 # ATR > fee * 3
|
||||
obi_threshold = 0.15
|
||||
tp_pct = 0.0005 # +0.05%
|
||||
sl_pct = 0.0003 # -0.03%
|
||||
max_hold_minutes = 3
|
||||
stake_pct = 0.005 # 单次 0.5% 账户资金
|
||||
max_leverage = 3.0
|
||||
consecutive_loss_limit = 3
|
||||
pause_minutes = 30
|
||||
vwap_band = 0.001 # ±0.1%
|
||||
ob_levels = 10 # 实盘用 10 档
|
||||
tick_size = 0.1 # BTCUSDT 永续常见最小变动
|
||||
maker_offset_ticks = 1
|
||||
|
||||
# Hyperopt 可选(默认关闭,不改变 v1 逻辑)
|
||||
buy_obi = DecimalParameter(0.10, 0.30, default=0.15, decimals=2, space="buy", optimize=False)
|
||||
|
||||
# 运行时状态:连续亏损熔断
|
||||
_loss_streak: int = 0
|
||||
_pause_until: Optional[datetime] = None
|
||||
_maker_fills: int = 0
|
||||
_total_fills: int = 0
|
||||
|
||||
plot_config = {
|
||||
"main_plot": {
|
||||
"vwap": {"color": "orange"},
|
||||
},
|
||||
"subplots": {
|
||||
"OBI": {"obi": {"color": "blue"}},
|
||||
"Delta": {"delta": {"color": "green"}, "delta_ma": {"color": "gray"}},
|
||||
"CVD": {"cvd": {"color": "purple"}},
|
||||
"ATR_pct": {"atr_pct": {"color": "red"}},
|
||||
},
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# 微结构指标(OHLCV 代理,供回测;实盘 OBI 可被 orderbook 覆盖)
|
||||
# ------------------------------------------------------------------ #
|
||||
def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
|
||||
df = dataframe
|
||||
|
||||
high = df["high"]
|
||||
low = df["low"]
|
||||
close = df["close"]
|
||||
volume = df["volume"].astype(float)
|
||||
|
||||
# ATR(20) 与相对波动
|
||||
df["atr"] = ta.ATR(df, timeperiod=20)
|
||||
df["atr_pct"] = _safe_div(df["atr"], close)
|
||||
# 规格:ATR > 单边手续费 × 3(0.016% × 3 = 0.048%)
|
||||
df["vol_ok"] = df["atr_pct"] > (self.maker_fee * self.atr_fee_mult)
|
||||
|
||||
# ---- Delta / Buy-Sell 分解(蜡烛代理)----
|
||||
# buy_vol ≈ vol * (close-low)/(high-low); sell_vol ≈ vol * (high-close)/(high-low)
|
||||
# 先把 close 夹到 [low, high],避免脏数据让 OBI 越界
|
||||
close_c = close.clip(lower=low, upper=high)
|
||||
hl = (high - low).astype(float)
|
||||
hl_safe = hl.where(hl > 0, np.nan)
|
||||
buy_frac = ((close_c - low) / hl_safe).fillna(0.5).clip(0.0, 1.0)
|
||||
sell_frac = 1.0 - buy_frac
|
||||
buy_vol = volume * buy_frac
|
||||
sell_vol = volume * sell_frac
|
||||
|
||||
df["buy_vol"] = buy_vol
|
||||
df["sell_vol"] = sell_vol
|
||||
df["delta"] = buy_vol - sell_vol
|
||||
|
||||
# 最近约 100 笔成交的代理:用最近 N 根 K 线累计 Delta
|
||||
# 1m 下无法还原真实 100 trades,用 rolling(5) 近似“近期主动方向”
|
||||
df["delta_sum"] = df["delta"].rolling(5, min_periods=1).sum()
|
||||
# “Delta 变化率 > 最近 20 秒平均” → 1m 代理:当前 delta > 近 3 根均值
|
||||
df["delta_ma"] = df["delta"].rolling(3, min_periods=1).mean()
|
||||
df["delta_accel"] = df["delta"] > df["delta_ma"]
|
||||
|
||||
# CVD
|
||||
df["cvd"] = df["delta"].cumsum()
|
||||
# 规格:CVD_now > CVD_20s_ago(1m 用 shift(1))
|
||||
df["cvd_up"] = df["cvd"] > df["cvd"].shift(1)
|
||||
df["cvd_down"] = df["cvd"] < df["cvd"].shift(1)
|
||||
|
||||
# ---- OBI 代理(无 L2 时)----
|
||||
# OBI ≈ (bid_vol - ask_vol)/(bid_vol + ask_vol) ∈ [-1, 1]
|
||||
denom = buy_vol + sell_vol
|
||||
df["obi"] = pd.Series(_safe_div(buy_vol - sell_vol, denom), index=df.index).clip(-1.0, 1.0)
|
||||
|
||||
# ---- VWAP(滚动 60 根 ≈ 1h session 近似;避免无限累计漂移)----
|
||||
tp = (high + low + close) / 3.0
|
||||
window = 60
|
||||
cum_pv = (tp * volume).rolling(window, min_periods=1).sum()
|
||||
cum_v = volume.rolling(window, min_periods=1).sum()
|
||||
df["vwap"] = _safe_div(cum_pv, cum_v)
|
||||
|
||||
df["below_vwap_band"] = close < df["vwap"] * (1.0 + self.vwap_band)
|
||||
df["above_vwap_band"] = close > df["vwap"] * (1.0 - self.vwap_band)
|
||||
|
||||
# 辅助:标记是否满足波动过滤
|
||||
df["fee_atr_floor"] = self.maker_fee * self.atr_fee_mult
|
||||
|
||||
return df
|
||||
|
||||
def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
|
||||
obi_th = float(self.buy_obi.value) if hasattr(self.buy_obi, "value") else self.obi_threshold
|
||||
|
||||
long_cond = (
|
||||
dataframe["vol_ok"]
|
||||
& (dataframe["obi"] > obi_th)
|
||||
& (dataframe["delta_sum"] > 0)
|
||||
& dataframe["delta_accel"]
|
||||
& dataframe["cvd_up"]
|
||||
& dataframe["below_vwap_band"]
|
||||
& (dataframe["volume"] > 0)
|
||||
)
|
||||
short_cond = (
|
||||
dataframe["vol_ok"]
|
||||
& (dataframe["obi"] < -obi_th)
|
||||
& (dataframe["delta_sum"] < 0)
|
||||
& (dataframe["delta"] < dataframe["delta_ma"]) # 空头加速(弱于均值)
|
||||
& dataframe["cvd_down"]
|
||||
& dataframe["above_vwap_band"]
|
||||
& (dataframe["volume"] > 0)
|
||||
)
|
||||
|
||||
dataframe.loc[long_cond, ["enter_long", "enter_tag"]] = (1, "mm_long_obi")
|
||||
dataframe.loc[short_cond, ["enter_short", "enter_tag"]] = (1, "mm_short_obi")
|
||||
return dataframe
|
||||
|
||||
def populate_exit_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
|
||||
# 出场交给 ROI / stoploss / custom_exit(时间止损)
|
||||
dataframe["exit_long"] = 0
|
||||
dataframe["exit_short"] = 0
|
||||
return dataframe
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Maker 报价:Bid+1tick / Ask-1tick
|
||||
# ------------------------------------------------------------------ #
|
||||
def custom_entry_price(
|
||||
self,
|
||||
pair: str,
|
||||
trade: Trade | None,
|
||||
current_time: datetime,
|
||||
proposed_rate: float,
|
||||
entry_tag: str | None,
|
||||
side: str,
|
||||
**kwargs,
|
||||
) -> float:
|
||||
tick = self.tick_size
|
||||
offset = self.maker_offset_ticks * tick
|
||||
|
||||
# 实盘优先用盘口
|
||||
try:
|
||||
if self.dp and self.dp.runmode.value in ("live", "dry_run"):
|
||||
ob = self.dp.orderbook(pair, self.ob_levels)
|
||||
bids = ob.get("bids") or []
|
||||
asks = ob.get("asks") or []
|
||||
if side == "long" and bids:
|
||||
return float(bids[0][0]) + offset
|
||||
if side == "short" and asks:
|
||||
return float(asks[0][0]) - offset
|
||||
except Exception as e:
|
||||
logger.debug("custom_entry_price orderbook fallback: %s", e)
|
||||
|
||||
# 回测:挂在对侧内侧,模拟 Maker(买低挂 / 卖高挂)
|
||||
if side == "long":
|
||||
return proposed_rate - offset
|
||||
return proposed_rate + offset
|
||||
|
||||
def custom_exit_price(
|
||||
self,
|
||||
pair: str,
|
||||
trade: Trade,
|
||||
current_time: datetime,
|
||||
proposed_rate: float,
|
||||
current_profit: float,
|
||||
exit_tag: str | None,
|
||||
**kwargs,
|
||||
) -> float:
|
||||
tick = self.tick_size
|
||||
offset = self.maker_offset_ticks * tick
|
||||
try:
|
||||
if self.dp and self.dp.runmode.value in ("live", "dry_run"):
|
||||
ob = self.dp.orderbook(pair, self.ob_levels)
|
||||
bids = ob.get("bids") or []
|
||||
asks = ob.get("asks") or []
|
||||
if trade.is_short and bids:
|
||||
# 空头平仓 = 买入,挂 bid+1tick
|
||||
return float(bids[0][0]) + offset
|
||||
if (not trade.is_short) and asks:
|
||||
# 多头平仓 = 卖出,挂 ask-1tick
|
||||
return float(asks[0][0]) - offset
|
||||
except Exception as e:
|
||||
logger.debug("custom_exit_price orderbook fallback: %s", e)
|
||||
|
||||
if trade.is_short:
|
||||
return proposed_rate - offset
|
||||
return proposed_rate + offset
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# 风控
|
||||
# ------------------------------------------------------------------ #
|
||||
def leverage(
|
||||
self,
|
||||
pair: str,
|
||||
current_time: datetime,
|
||||
current_rate: float,
|
||||
proposed_leverage: float,
|
||||
max_leverage: float,
|
||||
entry_tag: Optional[str],
|
||||
side: str,
|
||||
**kwargs,
|
||||
) -> float:
|
||||
return min(self.max_leverage, float(max_leverage))
|
||||
|
||||
def custom_stake_amount(
|
||||
self,
|
||||
pair: str,
|
||||
current_time: datetime,
|
||||
current_rate: float,
|
||||
proposed_stake: float,
|
||||
min_stake: float | None,
|
||||
max_stake: float,
|
||||
leverage: float,
|
||||
entry_tag: str | None,
|
||||
side: str,
|
||||
**kwargs,
|
||||
) -> float:
|
||||
# 单次账户资金 0.5%(作为保证金 stake)
|
||||
try:
|
||||
wallets = self.wallets
|
||||
if wallets:
|
||||
free = wallets.get_free(self.config["stake_currency"])
|
||||
stake = free * self.stake_pct
|
||||
if min_stake:
|
||||
stake = max(stake, min_stake)
|
||||
return min(stake, max_stake)
|
||||
except Exception as e:
|
||||
logger.debug("custom_stake_amount fallback: %s", e)
|
||||
return proposed_stake * self.stake_pct if proposed_stake else proposed_stake
|
||||
|
||||
def _paused(self, current_time: datetime) -> bool:
|
||||
if self._pause_until is None:
|
||||
return False
|
||||
now = current_time if current_time.tzinfo else current_time.replace(tzinfo=timezone.utc)
|
||||
until = self._pause_until if self._pause_until.tzinfo else self._pause_until.replace(
|
||||
tzinfo=timezone.utc
|
||||
)
|
||||
return now < until
|
||||
|
||||
@staticmethod
|
||||
def _calc_obi_from_orderbook(ob: dict, levels: int = 10) -> Optional[float]:
|
||||
bids = (ob.get("bids") or [])[:levels]
|
||||
asks = (ob.get("asks") or [])[:levels]
|
||||
if not bids or not asks:
|
||||
return None
|
||||
bid_vol = sum(float(b[1]) for b in bids)
|
||||
ask_vol = sum(float(a[1]) for a in asks)
|
||||
tot = bid_vol + ask_vol
|
||||
if tot <= 0:
|
||||
return None
|
||||
return (bid_vol - ask_vol) / tot
|
||||
|
||||
def confirm_trade_entry(
|
||||
self,
|
||||
pair: str,
|
||||
order_type: str,
|
||||
amount: float,
|
||||
rate: float,
|
||||
time_in_force: str,
|
||||
current_time: datetime,
|
||||
entry_tag: str | None,
|
||||
side: str,
|
||||
**kwargs,
|
||||
) -> bool:
|
||||
if self._paused(current_time):
|
||||
logger.info("Paused until %s — skip entry", self._pause_until)
|
||||
return False
|
||||
|
||||
# 实盘:用真实 10 档 OBI 复核
|
||||
try:
|
||||
if self.dp and self.dp.runmode.value in ("live", "dry_run"):
|
||||
ob = self.dp.orderbook(pair, self.ob_levels)
|
||||
obi = self._calc_obi_from_orderbook(ob, self.ob_levels)
|
||||
if obi is None:
|
||||
return False
|
||||
if side == "long" and obi <= self.obi_threshold:
|
||||
logger.info("Live OBI %.3f <= %.2f, reject long", obi, self.obi_threshold)
|
||||
return False
|
||||
if side == "short" and obi >= -self.obi_threshold:
|
||||
logger.info("Live OBI %.3f >= -%.2f, reject short", obi, self.obi_threshold)
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.warning("confirm_trade_entry orderbook check failed: %s", e)
|
||||
|
||||
return True
|
||||
|
||||
def custom_exit(
|
||||
self,
|
||||
pair: str,
|
||||
trade: Trade,
|
||||
current_time: datetime,
|
||||
current_rate: float,
|
||||
current_profit: float,
|
||||
**kwargs,
|
||||
):
|
||||
# 时间止损:持仓 > 3 分钟
|
||||
open_time = trade.open_date_utc
|
||||
if open_time.tzinfo is None:
|
||||
open_time = open_time.replace(tzinfo=timezone.utc)
|
||||
now = current_time if current_time.tzinfo else current_time.replace(tzinfo=timezone.utc)
|
||||
held = now - open_time
|
||||
if held >= timedelta(minutes=self.max_hold_minutes):
|
||||
return "time_stop_3m"
|
||||
|
||||
# 双保险:显式 TP / SL(ROI/stoploss 也会触发)
|
||||
if current_profit >= self.tp_pct:
|
||||
return "tp_0.05pct"
|
||||
if current_profit <= -self.sl_pct:
|
||||
return "sl_0.03pct"
|
||||
return None
|
||||
|
||||
def order_filled(
|
||||
self,
|
||||
pair: str,
|
||||
trade: Trade,
|
||||
order,
|
||||
current_time: datetime,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
self._total_fills += 1
|
||||
# limit 单视为 Maker
|
||||
otype = getattr(order, "order_type", None) or getattr(order, "ft_order_type", None)
|
||||
if otype and str(otype).lower() == "limit":
|
||||
self._maker_fills += 1
|
||||
|
||||
def confirm_trade_exit(
|
||||
self,
|
||||
pair: str,
|
||||
trade: Trade,
|
||||
order_type: str,
|
||||
amount: float,
|
||||
rate: float,
|
||||
time_in_force: str,
|
||||
exit_reason: str,
|
||||
current_time: datetime,
|
||||
**kwargs,
|
||||
) -> bool:
|
||||
# 用已实现盈亏更新连续亏损(exit 确认时 trade 可能尚未 close,用 rate 估)
|
||||
try:
|
||||
profit = trade.calc_profit_ratio(rate)
|
||||
if profit < 0:
|
||||
self._loss_streak += 1
|
||||
if self._loss_streak >= self.consecutive_loss_limit:
|
||||
self._pause_until = current_time + timedelta(minutes=self.pause_minutes)
|
||||
logger.warning(
|
||||
"Loss streak=%d → pause %d min until %s",
|
||||
self._loss_streak,
|
||||
self.pause_minutes,
|
||||
self._pause_until,
|
||||
)
|
||||
self._loss_streak = 0
|
||||
else:
|
||||
self._loss_streak = 0
|
||||
except Exception as e:
|
||||
logger.debug("confirm_trade_exit streak update: %s", e)
|
||||
return True
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Protections(回测需 --enable-protections)
|
||||
# ------------------------------------------------------------------ #
|
||||
@property
|
||||
def protections(self):
|
||||
return [
|
||||
{
|
||||
"method": "StoplossGuard",
|
||||
"lookback_period_candles": 30,
|
||||
"trade_limit": self.consecutive_loss_limit,
|
||||
"stop_duration_candles": self.pause_minutes,
|
||||
"only_per_pair": True,
|
||||
"only_per_side": False,
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,488 @@
|
||||
"""
|
||||
BTC Maker Scalper v1.1 — Liquidity Providing
|
||||
|
||||
相对 v1.0 的核心变化:
|
||||
- 不再用 OBI/Delta/CVD 预测下一根涨跌(Directional Scalping)
|
||||
- 改为:卖压衰竭 + Bid 吸收 → 提供流动性接单(Liquidity Providing)
|
||||
- 挂单更深:Bid - 0~2 tick(等待被打)
|
||||
- 出场:盘口/价差优势恢复(非固定 0.05% TP)
|
||||
- 禁做市:5m EMA26 斜率过大 或 ATR 异常(单边趋势)
|
||||
|
||||
回测限制(仍然存在,但模型目标不同):
|
||||
- OHLCV 无法完美模拟 Maker 成交时点;本版用更严过滤降频到 ~10-30 笔/天量级做压力测试
|
||||
- 实盘用 orderbook 复核吸收/挂价
|
||||
|
||||
运行:
|
||||
freqtrade backtesting -c ./user_data/Chan/config/BTC_Maker_Micro_Scalper_v11.json \\
|
||||
--strategy BTC_Maker_Micro_Scalper_v11 --strategy-path ./user_data/Chan/strategies \\
|
||||
--timerange=20260701-20260708 --fee 0.00016 --enable-protections
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import talib.abstract as ta
|
||||
from pandas import DataFrame
|
||||
|
||||
from freqtrade.persistence import Trade
|
||||
from freqtrade.strategy import IStrategy
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _safe_div(num, den, fill=0.0):
|
||||
out = np.where((den is not None) & (den != 0), num / den, fill)
|
||||
return out
|
||||
|
||||
|
||||
class BTC_Maker_Micro_Scalper_v11(IStrategy):
|
||||
"""
|
||||
v1.1 Liquidity Providing:卖压衰竭 + 吸收 → Maker 接单;趋势中禁做市。
|
||||
"""
|
||||
|
||||
INTERFACE_VERSION: int = 3
|
||||
timeframe = "1m"
|
||||
can_short = True
|
||||
process_only_new_candles = True
|
||||
startup_candle_count = 200
|
||||
|
||||
# 不用固定小 ROI;出场交给 custom_exit(价差/优势恢复)
|
||||
# 给一个很宽的 ROI 兜底,避免永远不走 ROI 路径也能被时间/恢复逻辑平掉
|
||||
minimal_roi = {"0": 0.01}
|
||||
# 硬止损仍保留,但比 v1 更宽松一点,避免“小止盈大止损”结构;主出场是恢复
|
||||
stoploss = -0.0015 # -0.15% profit_ratio 硬止损(含杠杆后仍需观察)
|
||||
trailing_stop = False
|
||||
use_exit_signal = True
|
||||
exit_profit_only = False
|
||||
use_custom_stoploss = False
|
||||
|
||||
order_types = {
|
||||
"entry": "limit",
|
||||
"exit": "limit",
|
||||
"stoploss": "limit",
|
||||
"stoploss_on_exchange": False,
|
||||
}
|
||||
order_time_in_force = {"entry": "GTC", "exit": "GTC"}
|
||||
|
||||
# ---- 费用 / 风控 ----
|
||||
maker_fee = 0.00016
|
||||
stake_pct = 0.005
|
||||
max_leverage = 2.0 # v1.1 更克制
|
||||
consecutive_loss_limit = 10
|
||||
pause_minutes = 30
|
||||
max_hold_minutes = 5
|
||||
|
||||
# ---- 微结构代理窗口(1m 近似 20s/100trades)----
|
||||
sell_window = 3 # 近端卖量
|
||||
sell_ref_window = 8 # 更长对比窗:必须“先有卖压再衰竭”
|
||||
absorb_lookback = 5
|
||||
min_absorb_ratio = 18.0 # 吸收要足够强(模型阈值,不是 OBI 调参)
|
||||
tick_size = 0.1
|
||||
maker_depth_ticks = 2 # Bid - 2 tick / Ask + 2 tick
|
||||
exhaust_ratio = 0.70 # 近端卖量 < 参考窗 * 70%
|
||||
prior_sell_mult = 1.2 # 衰竭前参考窗卖量须高于更长均量(真有过卖压)
|
||||
|
||||
# ---- 禁做市(趋势)----
|
||||
ema_slope_thr = 0.00018 # 更早禁止单边做市
|
||||
atr_spike_mult = 1.8
|
||||
min_atr_pct = 0.00035
|
||||
|
||||
# 目标退出:相对入场的“优势恢复”幅度(价格)
|
||||
edge_exit_pct = 0.00025
|
||||
adverse_exit_pct = 0.0006
|
||||
cooldown_minutes = 8 # 降频到验收带附近
|
||||
|
||||
ob_levels = 10
|
||||
|
||||
_loss_streak: int = 0
|
||||
_pause_until: Optional[datetime] = None
|
||||
_last_entry_time: Optional[datetime] = None
|
||||
|
||||
plot_config = {
|
||||
"main_plot": {
|
||||
"ema26_1m": {"color": "gray"},
|
||||
},
|
||||
"subplots": {
|
||||
"SellVol": {
|
||||
"sell_vol": {"color": "red"},
|
||||
"sell_vol_ma": {"color": "orange"},
|
||||
},
|
||||
"Absorb": {"absorb_ratio": {"color": "blue"}},
|
||||
"TrendBlock": {"trend_block": {"color": "black"}},
|
||||
},
|
||||
}
|
||||
|
||||
def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
|
||||
df = dataframe.copy()
|
||||
high, low, close, volume = df["high"], df["low"], df["close"], df["volume"].astype(float)
|
||||
|
||||
close_c = close.clip(lower=low, upper=high)
|
||||
hl = (high - low).astype(float)
|
||||
hl_safe = hl.where(hl > 0, np.nan)
|
||||
buy_frac = ((close_c - low) / hl_safe).fillna(0.5).clip(0.0, 1.0)
|
||||
sell_frac = 1.0 - buy_frac
|
||||
buy_vol = volume * buy_frac
|
||||
sell_vol = volume * sell_frac
|
||||
df["buy_vol"] = buy_vol
|
||||
df["sell_vol"] = sell_vol
|
||||
df["delta"] = buy_vol - sell_vol
|
||||
|
||||
# ---- A. 主动卖压衰竭(Long)----
|
||||
# 先有卖压(ref 高),再衰竭(近端下降),且价格不创新低
|
||||
df["sell_vol_ma"] = sell_vol.rolling(self.sell_window, min_periods=1).mean()
|
||||
df["sell_vol_ref"] = sell_vol.rolling(self.sell_ref_window, min_periods=1).mean()
|
||||
sell_baseline = sell_vol.rolling(30, min_periods=10).mean()
|
||||
df["sell_exhaust"] = (
|
||||
(df["sell_vol_ref"] > sell_baseline * self.prior_sell_mult)
|
||||
& (df["sell_vol_ma"] < df["sell_vol_ref"] * self.exhaust_ratio)
|
||||
& (low >= low.rolling(self.sell_ref_window, min_periods=1).min().shift(1))
|
||||
)
|
||||
|
||||
# 主动买压衰竭(Short 对称)
|
||||
df["buy_vol_ma"] = buy_vol.rolling(self.sell_window, min_periods=1).mean()
|
||||
df["buy_vol_ref"] = buy_vol.rolling(self.sell_ref_window, min_periods=1).mean()
|
||||
buy_baseline = buy_vol.rolling(30, min_periods=10).mean()
|
||||
df["buy_exhaust"] = (
|
||||
(df["buy_vol_ref"] > buy_baseline * self.prior_sell_mult)
|
||||
& (df["buy_vol_ma"] < df["buy_vol_ref"] * self.exhaust_ratio)
|
||||
& (high <= high.rolling(self.sell_ref_window, min_periods=1).max().shift(1))
|
||||
)
|
||||
|
||||
# ---- B. Bid 吸收:成交卖量 / 价格跌幅 ----
|
||||
# 价格跌幅用 lookback 内低点相对起点跌幅(百分比,避免除零)
|
||||
px_drop = (close.shift(self.absorb_lookback) - low).clip(lower=0)
|
||||
px_drop_pct = (px_drop / close.shift(self.absorb_lookback)).replace(0, np.nan)
|
||||
sell_sum = sell_vol.rolling(self.absorb_lookback, min_periods=1).sum()
|
||||
# absorb_ratio = 卖量 / (跌幅% * 10000) 标准化到可读量级;跌不动时放大
|
||||
df["absorb_ratio"] = (sell_sum / (px_drop_pct * 10000.0)).replace(
|
||||
[np.inf, -np.inf], np.nan
|
||||
).fillna(0.0)
|
||||
|
||||
# 价格几乎不跌但有大量卖出 → 吸收极强:给高分
|
||||
flat_sell = (px_drop_pct.fillna(0) < 0.00005) & (sell_sum > sell_sum.rolling(20).median())
|
||||
df.loc[flat_sell.fillna(False), "absorb_ratio"] = df.loc[
|
||||
flat_sell.fillna(False), "absorb_ratio"
|
||||
].clip(lower=self.min_absorb_ratio * 1.5)
|
||||
|
||||
# Ask 吸收(Short):买量 / 上涨幅度
|
||||
px_up = (high - close.shift(self.absorb_lookback)).clip(lower=0)
|
||||
px_up_pct = (px_up / close.shift(self.absorb_lookback)).replace(0, np.nan)
|
||||
buy_sum = buy_vol.rolling(self.absorb_lookback, min_periods=1).sum()
|
||||
df["absorb_ratio_ask"] = (buy_sum / (px_up_pct * 10000.0)).replace(
|
||||
[np.inf, -np.inf], np.nan
|
||||
).fillna(0.0)
|
||||
flat_buy = (px_up_pct.fillna(0) < 0.00005) & (buy_sum > buy_sum.rolling(20).median())
|
||||
df.loc[flat_buy.fillna(False), "absorb_ratio_ask"] = df.loc[
|
||||
flat_buy.fillna(False), "absorb_ratio_ask"
|
||||
].clip(lower=self.min_absorb_ratio * 1.5)
|
||||
|
||||
df["bid_absorb"] = df["absorb_ratio"] >= self.min_absorb_ratio
|
||||
df["ask_absorb"] = df["absorb_ratio_ask"] >= self.min_absorb_ratio
|
||||
|
||||
# ---- 波动与 ATR ----
|
||||
df["atr"] = ta.ATR(df, timeperiod=20)
|
||||
df["atr_pct"] = (df["atr"] / close).replace([np.inf, -np.inf], np.nan).fillna(0.0)
|
||||
atr_med = df["atr_pct"].rolling(60, min_periods=20).median()
|
||||
df["atr_spike"] = df["atr_pct"] > (atr_med * self.atr_spike_mult)
|
||||
df["atr_ok"] = (df["atr_pct"] >= self.min_atr_pct) & (~df["atr_spike"])
|
||||
|
||||
# ---- 禁做市:趋势(EMA26 斜率,1m 上 5 根≈5m 变化代理)----
|
||||
df["ema26_1m"] = ta.EMA(df, timeperiod=26)
|
||||
df["ema26_slope"] = (
|
||||
(df["ema26_1m"] - df["ema26_1m"].shift(5)) / close
|
||||
).replace([np.inf, -np.inf], np.nan).fillna(0.0)
|
||||
df["trend_block"] = df["ema26_slope"].abs() > self.ema_slope_thr
|
||||
|
||||
# 微结构“可做市”综合
|
||||
df["mm_regime"] = df["atr_ok"] & (~df["trend_block"])
|
||||
|
||||
# 中价 / 伪价差
|
||||
df["mid"] = (high + low) / 2.0
|
||||
df["range_pct"] = (hl / close).replace([np.inf, -np.inf], np.nan).fillna(0.0)
|
||||
|
||||
return df
|
||||
|
||||
def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
|
||||
df = dataframe
|
||||
|
||||
# Long:卖压衰竭 + Bid 吸收 + 非趋势
|
||||
long_cond = (
|
||||
df["mm_regime"]
|
||||
& df["sell_exhaust"]
|
||||
& df["bid_absorb"]
|
||||
& (df["volume"] > 0)
|
||||
# 额外:近端 delta 不再恶化(卖压减弱)
|
||||
& (df["delta"] > df["delta"].shift(1))
|
||||
)
|
||||
|
||||
# Short:买压衰竭 + Ask 吸收 + 非趋势
|
||||
short_cond = (
|
||||
df["mm_regime"]
|
||||
& df["buy_exhaust"]
|
||||
& df["ask_absorb"]
|
||||
& (df["volume"] > 0)
|
||||
& (df["delta"] < df["delta"].shift(1))
|
||||
)
|
||||
|
||||
df.loc[long_cond, ["enter_long", "enter_tag"]] = (1, "lp_bid_absorb")
|
||||
df.loc[short_cond, ["enter_short", "enter_tag"]] = (1, "lp_ask_absorb")
|
||||
return df
|
||||
|
||||
def populate_exit_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
|
||||
"""信号层只做趋势禁做市强平;主出场交给 custom_exit。"""
|
||||
df = dataframe
|
||||
df["exit_long"] = 0
|
||||
df["exit_short"] = 0
|
||||
df.loc[df["trend_block"], ["exit_long", "exit_tag"]] = (1, "trend_block")
|
||||
df.loc[df["trend_block"], ["exit_short", "exit_tag"]] = (1, "trend_block")
|
||||
return df
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Maker 报价:Bid - depth ticks / Ask + depth ticks
|
||||
# ------------------------------------------------------------------ #
|
||||
def custom_entry_price(
|
||||
self,
|
||||
pair: str,
|
||||
trade: Trade | None,
|
||||
current_time: datetime,
|
||||
proposed_rate: float,
|
||||
entry_tag: str | None,
|
||||
side: str,
|
||||
**kwargs,
|
||||
) -> float:
|
||||
offset = self.maker_depth_ticks * self.tick_size
|
||||
try:
|
||||
if self.dp and self.dp.runmode.value in ("live", "dry_run"):
|
||||
ob = self.dp.orderbook(pair, self.ob_levels)
|
||||
bids = ob.get("bids") or []
|
||||
asks = ob.get("asks") or []
|
||||
if side == "long" and bids:
|
||||
return max(float(bids[0][0]) - offset, self.tick_size)
|
||||
if side == "short" and asks:
|
||||
return float(asks[0][0]) + offset
|
||||
except Exception as e:
|
||||
logger.debug("v11 entry price ob fallback: %s", e)
|
||||
|
||||
# 回测:挂得更深,降低“虚假即时成交”概率(仍不完美)
|
||||
if side == "long":
|
||||
return proposed_rate - offset
|
||||
return proposed_rate + offset
|
||||
|
||||
def custom_exit_price(
|
||||
self,
|
||||
pair: str,
|
||||
trade: Trade,
|
||||
current_time: datetime,
|
||||
proposed_rate: float,
|
||||
current_profit: float,
|
||||
exit_tag: str | None,
|
||||
**kwargs,
|
||||
) -> float:
|
||||
offset = 1 * self.tick_size
|
||||
try:
|
||||
if self.dp and self.dp.runmode.value in ("live", "dry_run"):
|
||||
ob = self.dp.orderbook(pair, self.ob_levels)
|
||||
bids = ob.get("bids") or []
|
||||
asks = ob.get("asks") or []
|
||||
# 出场尽量 Maker:多头卖 Ask-1;空头买 Bid+1
|
||||
if trade.is_short and bids:
|
||||
return float(bids[0][0]) + offset
|
||||
if (not trade.is_short) and asks:
|
||||
return float(asks[0][0]) - offset
|
||||
except Exception as e:
|
||||
logger.debug("v11 exit price ob fallback: %s", e)
|
||||
if trade.is_short:
|
||||
return proposed_rate - offset
|
||||
return proposed_rate + offset
|
||||
|
||||
def leverage(
|
||||
self,
|
||||
pair: str,
|
||||
current_time: datetime,
|
||||
current_rate: float,
|
||||
proposed_leverage: float,
|
||||
max_leverage: float,
|
||||
entry_tag: Optional[str],
|
||||
side: str,
|
||||
**kwargs,
|
||||
) -> float:
|
||||
return min(self.max_leverage, float(max_leverage))
|
||||
|
||||
def custom_stake_amount(
|
||||
self,
|
||||
pair: str,
|
||||
current_time: datetime,
|
||||
current_rate: float,
|
||||
proposed_stake: float,
|
||||
min_stake: float | None,
|
||||
max_stake: float,
|
||||
leverage: float,
|
||||
entry_tag: str | None,
|
||||
side: str,
|
||||
**kwargs,
|
||||
) -> float:
|
||||
try:
|
||||
if self.wallets:
|
||||
free = self.wallets.get_free(self.config["stake_currency"])
|
||||
stake = free * self.stake_pct
|
||||
if min_stake:
|
||||
stake = max(stake, min_stake)
|
||||
return min(stake, max_stake)
|
||||
except Exception:
|
||||
pass
|
||||
return min(proposed_stake * self.stake_pct, max_stake) if proposed_stake else proposed_stake
|
||||
|
||||
def _paused(self, current_time: datetime) -> bool:
|
||||
if self._pause_until is None:
|
||||
return False
|
||||
now = current_time if current_time.tzinfo else current_time.replace(tzinfo=timezone.utc)
|
||||
until = (
|
||||
self._pause_until
|
||||
if self._pause_until.tzinfo
|
||||
else self._pause_until.replace(tzinfo=timezone.utc)
|
||||
)
|
||||
return now < until
|
||||
|
||||
def _in_cooldown(self, current_time: datetime) -> bool:
|
||||
if self._last_entry_time is None:
|
||||
return False
|
||||
now = current_time if current_time.tzinfo else current_time.replace(tzinfo=timezone.utc)
|
||||
last = (
|
||||
self._last_entry_time
|
||||
if self._last_entry_time.tzinfo
|
||||
else self._last_entry_time.replace(tzinfo=timezone.utc)
|
||||
)
|
||||
return (now - last) < timedelta(minutes=self.cooldown_minutes)
|
||||
|
||||
def confirm_trade_entry(
|
||||
self,
|
||||
pair: str,
|
||||
order_type: str,
|
||||
amount: float,
|
||||
rate: float,
|
||||
time_in_force: str,
|
||||
current_time: datetime,
|
||||
entry_tag: str | None,
|
||||
side: str,
|
||||
**kwargs,
|
||||
) -> bool:
|
||||
if self._paused(current_time) or self._in_cooldown(current_time):
|
||||
return False
|
||||
|
||||
# 实盘:趋势禁做市 + 盘口复核(卖一/买一厚度)
|
||||
try:
|
||||
dataframe, _ = self.dp.get_analyzed_dataframe(pair, self.timeframe)
|
||||
if dataframe is not None and len(dataframe):
|
||||
last = dataframe.iloc[-1]
|
||||
if bool(last.get("trend_block", False)) or (not bool(last.get("mm_regime", False))):
|
||||
return False
|
||||
|
||||
if self.dp.runmode.value in ("live", "dry_run"):
|
||||
ob = self.dp.orderbook(pair, self.ob_levels)
|
||||
bids = ob.get("bids") or []
|
||||
asks = ob.get("asks") or []
|
||||
if not bids or not asks:
|
||||
return False
|
||||
# 简单吸收代理:同价位附近挂单厚度
|
||||
bid_vol = sum(float(b[1]) for b in bids[:3])
|
||||
ask_vol = sum(float(a[1]) for a in asks[:3])
|
||||
if side == "long" and bid_vol < ask_vol * 0.8:
|
||||
# Bid 不够厚,吸收叙事弱
|
||||
return False
|
||||
if side == "short" and ask_vol < bid_vol * 0.8:
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.debug("v11 confirm entry: %s", e)
|
||||
|
||||
self._last_entry_time = current_time
|
||||
return True
|
||||
|
||||
def custom_exit(
|
||||
self,
|
||||
pair: str,
|
||||
trade: Trade,
|
||||
current_time: datetime,
|
||||
current_rate: float,
|
||||
current_profit: float,
|
||||
**kwargs,
|
||||
):
|
||||
open_time = trade.open_date_utc
|
||||
if open_time.tzinfo is None:
|
||||
open_time = open_time.replace(tzinfo=timezone.utc)
|
||||
now = current_time if current_time.tzinfo else current_time.replace(tzinfo=timezone.utc)
|
||||
if now - open_time >= timedelta(minutes=self.max_hold_minutes):
|
||||
return "time_stop_5m"
|
||||
|
||||
# 优势恢复出场(替代固定 0.05% TP)
|
||||
# long: 价格相对开仓上涨 edge_exit_pct;short: 下跌 edge_exit_pct
|
||||
# current_profit 已是 stake 利润率(含杠杆),换算成“价格优势”用 open_rate 更稳
|
||||
entry = trade.open_rate
|
||||
if not trade.is_short:
|
||||
edge = (current_rate - entry) / entry
|
||||
if edge >= self.edge_exit_pct:
|
||||
return "spread_edge_restore"
|
||||
if edge <= -self.adverse_exit_pct:
|
||||
return "adverse_move"
|
||||
else:
|
||||
edge = (entry - current_rate) / entry
|
||||
if edge >= self.edge_exit_pct:
|
||||
return "spread_edge_restore"
|
||||
if edge <= -self.adverse_exit_pct:
|
||||
return "adverse_move"
|
||||
|
||||
# 重新进入趋势禁做市 → 立刻撤流动性
|
||||
try:
|
||||
dataframe, _ = self.dp.get_analyzed_dataframe(pair, self.timeframe)
|
||||
if dataframe is not None and len(dataframe):
|
||||
if bool(dataframe.iloc[-1].get("trend_block", False)):
|
||||
return "trend_block_exit"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
def confirm_trade_exit(
|
||||
self,
|
||||
pair: str,
|
||||
trade: Trade,
|
||||
order_type: str,
|
||||
amount: float,
|
||||
rate: float,
|
||||
time_in_force: str,
|
||||
exit_reason: str,
|
||||
current_time: datetime,
|
||||
**kwargs,
|
||||
) -> bool:
|
||||
try:
|
||||
profit = trade.calc_profit_ratio(rate)
|
||||
if profit < 0:
|
||||
self._loss_streak += 1
|
||||
if self._loss_streak >= self.consecutive_loss_limit:
|
||||
self._pause_until = current_time + timedelta(minutes=self.pause_minutes)
|
||||
self._loss_streak = 0
|
||||
else:
|
||||
self._loss_streak = 0
|
||||
except Exception:
|
||||
pass
|
||||
return True
|
||||
|
||||
@property
|
||||
def protections(self):
|
||||
return [
|
||||
{
|
||||
"method": "CooldownPeriod",
|
||||
"stop_duration_candles": int(self.cooldown_minutes),
|
||||
},
|
||||
{
|
||||
"method": "StoplossGuard",
|
||||
"lookback_period_candles": 60,
|
||||
"trade_limit": self.consecutive_loss_limit,
|
||||
"stop_duration_candles": self.pause_minutes,
|
||||
"only_per_pair": True,
|
||||
},
|
||||
]
|
||||
@@ -77,7 +77,7 @@ class ChanLun_BTC_15(IStrategy):
|
||||
trailing_only_offset_is_reached = False
|
||||
|
||||
position_adjustment_enable = True
|
||||
startup_candle_count = 100
|
||||
startup_candle_count = 1000
|
||||
|
||||
time5 = 5
|
||||
time15 = 15
|
||||
@@ -88,21 +88,14 @@ class ChanLun_BTC_15(IStrategy):
|
||||
time5 = 1440
|
||||
last_time = datetime.now()
|
||||
def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
|
||||
tf_df_5 = TF_DF(dataframe, self.time5, '5m')
|
||||
tf_df_15 = TF_DF(dataframe, self.time15, '15m')
|
||||
tf_df_30 = TF_DF(dataframe, self.time30, '30m')
|
||||
tf_df_60 = TF_DF(dataframe, self.time60, '60m')
|
||||
tf_df_4h = TF_DF(dataframe, self.time4h, '4h')
|
||||
tf_df_1d = TF_DF(dataframe, self.time1d, '1d')
|
||||
|
||||
df_5m = resample_to_interval(dataframe, self.time5)
|
||||
df_15m = resample_to_interval(dataframe, self.time15)
|
||||
dataframe = TF_DF.add_indicators(dataframe)
|
||||
df_5m = TF_DF.add_indicators(df_5m)
|
||||
df_15m = TF_DF.add_indicators(df_15m)
|
||||
|
||||
|
||||
dataframe = resampled_merge(dataframe, tf_df_5.dataframe)
|
||||
dataframe = resampled_merge(dataframe, tf_df_15.dataframe)
|
||||
dataframe = resampled_merge(dataframe, tf_df_30.dataframe)
|
||||
dataframe = resampled_merge(dataframe, tf_df_60.dataframe)
|
||||
dataframe = resampled_merge(dataframe, tf_df_4h.dataframe)
|
||||
dataframe = resampled_merge(dataframe, tf_df_1d.dataframe)
|
||||
dataframe = resampled_merge(dataframe, df_5m)
|
||||
dataframe = resampled_merge(dataframe, df_15m)
|
||||
return dataframe
|
||||
|
||||
def custom_entry_price(self, pair: str, trade: Trade | None, current_time: datetime, proposed_rate: float,
|
||||
|
||||
@@ -0,0 +1,434 @@
|
||||
"""
|
||||
MakerEdgeProbe — Freqtrade Dry-run 探针(过渡用)。
|
||||
|
||||
正式 Maker / L2 / Edge 采集已迁移到:
|
||||
nautilus_mm/ (NautilusTrader,独立 .venv)
|
||||
|
||||
本策略仍可用于 Freqtrade 侧对照;新开发请走 nautilus_mm。
|
||||
|
||||
运行 Nautilus:
|
||||
cd nautilus_mm && ./scripts/run_probe.sh
|
||||
|
||||
分析:
|
||||
cd nautilus_mm && ./scripts/analyze.sh
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
|
||||
import numpy as np
|
||||
import talib.abstract as ta
|
||||
from pandas import DataFrame
|
||||
|
||||
from freqtrade.persistence import Trade, Order
|
||||
from freqtrade.strategy import IStrategy
|
||||
|
||||
from maker_edge_logger import MakerEdgeLogger
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MakerEdgeProbe(IStrategy):
|
||||
INTERFACE_VERSION = 3
|
||||
timeframe = "1m"
|
||||
can_short = True
|
||||
process_only_new_candles = False
|
||||
startup_candle_count = 60
|
||||
|
||||
minimal_roi = {"0": 0.01}
|
||||
stoploss = -0.002
|
||||
trailing_stop = False
|
||||
use_exit_signal = False
|
||||
|
||||
order_types = {
|
||||
"entry": "limit",
|
||||
"exit": "limit",
|
||||
"stoploss": "market",
|
||||
"stoploss_on_exchange": False,
|
||||
}
|
||||
order_time_in_force = {"entry": "GTC", "exit": "GTC"}
|
||||
|
||||
tick_size = 0.1
|
||||
quote_depth_ticks = 1
|
||||
max_leverage = 1.0
|
||||
stake_pct = 0.003
|
||||
max_hold_minutes = 5
|
||||
edge_exit_pct = 0.0002
|
||||
adverse_exit_pct = 0.0008
|
||||
cooldown_minutes = 5
|
||||
ob_levels = 10
|
||||
trade_lookback = 100
|
||||
ema_slope_thr = 0.0002
|
||||
book_sample_every_sec = 2.0
|
||||
|
||||
_logger: MakerEdgeLogger | None = None
|
||||
_last_mid: float | None = None
|
||||
_last_book_sample: float = 0.0
|
||||
_last_entry_time: Optional[datetime] = None
|
||||
_recent_high: float = 0.0
|
||||
_recent_low: float = 0.0
|
||||
_pending_quote_id: Optional[str] = None
|
||||
_fill_by_trade: dict[int, str] = {}
|
||||
|
||||
def bot_start(self, **kwargs) -> None:
|
||||
self._logger = MakerEdgeLogger(levels=self.ob_levels)
|
||||
self._fill_by_trade = {}
|
||||
logger.info("MakerEdgeProbe started. log_dir=%s", self._logger.log_dir)
|
||||
|
||||
def _get_logger(self) -> MakerEdgeLogger:
|
||||
if self._logger is None:
|
||||
self._logger = MakerEdgeLogger(levels=self.ob_levels)
|
||||
return self._logger
|
||||
|
||||
def _fetch_trades(self, pair: str) -> list:
|
||||
try:
|
||||
ex = self.dp._exchange
|
||||
if ex is None:
|
||||
return []
|
||||
api = getattr(ex, "_api", None) or getattr(ex, "api", None)
|
||||
if api is None:
|
||||
return []
|
||||
return api.fetch_trades(pair, limit=self.trade_lookback) or []
|
||||
except Exception as e:
|
||||
logger.debug("fetch_trades failed: %s", e)
|
||||
return []
|
||||
|
||||
def _inventory(self) -> float:
|
||||
try:
|
||||
inv = 0.0
|
||||
for t in Trade.get_open_trades():
|
||||
amt = float(t.amount or 0.0)
|
||||
inv += -amt if t.is_short else amt
|
||||
return inv
|
||||
except Exception:
|
||||
return 0.0
|
||||
|
||||
def _market_state(self, pair: str) -> dict:
|
||||
state = {
|
||||
"trend_state": "UNKNOWN",
|
||||
"atr_pct": None,
|
||||
"volatility_regime": "UNKNOWN",
|
||||
"ema_slope": None,
|
||||
}
|
||||
try:
|
||||
df, _ = self.dp.get_analyzed_dataframe(pair, self.timeframe)
|
||||
if df is None or len(df) == 0:
|
||||
return state
|
||||
last = df.iloc[-1]
|
||||
slope = float(last.get("ema_slope") or 0.0)
|
||||
atr_pct = float(last.get("atr_pct") or 0.0)
|
||||
state["ema_slope"] = slope
|
||||
state["atr_pct"] = atr_pct
|
||||
if bool(last.get("trend_block", False)):
|
||||
state["trend_state"] = "TREND_UP" if slope > 0 else "TREND_DOWN"
|
||||
else:
|
||||
state["trend_state"] = "RANGE"
|
||||
# 波动分位代理
|
||||
if "atr_pct" in df.columns:
|
||||
med = float(df["atr_pct"].tail(60).median() or 0)
|
||||
if atr_pct > med * 1.8:
|
||||
state["volatility_regime"] = "HIGH"
|
||||
elif atr_pct < med * 0.7:
|
||||
state["volatility_regime"] = "LOW"
|
||||
else:
|
||||
state["volatility_regime"] = "NORMAL"
|
||||
except Exception:
|
||||
pass
|
||||
return state
|
||||
|
||||
def _snapshot(self, pair: str):
|
||||
ob = self.dp.orderbook(pair, self.ob_levels)
|
||||
trades = self._fetch_trades(pair)
|
||||
snap = MakerEdgeLogger.snapshot_from_orderbook(
|
||||
ob,
|
||||
levels=self.ob_levels,
|
||||
recent_trades=trades,
|
||||
last_mid=self._last_mid,
|
||||
liq_proxy_low=self._recent_low or None,
|
||||
liq_proxy_high=self._recent_high or None,
|
||||
)
|
||||
if snap.mid:
|
||||
self._last_mid = snap.mid
|
||||
return snap
|
||||
|
||||
def bot_loop_start(self, current_time: datetime, **kwargs) -> None:
|
||||
if self.dp.runmode.value not in ("live", "dry_run"):
|
||||
return
|
||||
pair = self.config["exchange"]["pair_whitelist"][0]
|
||||
try:
|
||||
snap = self._snapshot(pair)
|
||||
tick = self.dp.ticker(pair) or {}
|
||||
last = float(tick.get("last") or tick.get("close") or 0.0) or snap.mid
|
||||
|
||||
df, _ = self.dp.get_analyzed_dataframe(pair, self.timeframe)
|
||||
if df is not None and len(df):
|
||||
self._recent_high = float(df.iloc[-1].get("roll_high") or self._recent_high or last)
|
||||
self._recent_low = float(df.iloc[-1].get("roll_low") or self._recent_low or last)
|
||||
|
||||
lg = self._get_logger()
|
||||
now = time.time()
|
||||
# 盘口历史(成交前5s恶化检测依赖此)
|
||||
if now - self._last_book_sample >= self.book_sample_every_sec:
|
||||
self._last_book_sample = now
|
||||
lg.record_book(snap, now=now)
|
||||
|
||||
if last:
|
||||
lg.update_paths(pair, last, now=now)
|
||||
except Exception as e:
|
||||
logger.warning("bot_loop_start probe error: %s", e)
|
||||
|
||||
def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
|
||||
df = dataframe
|
||||
close, high, low = df["close"], df["high"], df["low"]
|
||||
volume = df["volume"].astype(float)
|
||||
|
||||
close_c = close.clip(lower=low, upper=high)
|
||||
hl = (high - low).replace(0, np.nan)
|
||||
buy_frac = ((close_c - low) / hl).fillna(0.5).clip(0, 1)
|
||||
sell_vol = volume * (1.0 - buy_frac)
|
||||
buy_vol = volume * buy_frac
|
||||
df["sell_vol"] = sell_vol
|
||||
df["buy_vol"] = buy_vol
|
||||
df["delta"] = buy_vol - sell_vol
|
||||
|
||||
vol_ma = volume.rolling(20, min_periods=5).mean()
|
||||
df["shock_sell"] = (sell_vol > vol_ma * 3) & (df["delta"] < 0)
|
||||
df["shock_buy"] = (buy_vol > vol_ma * 3) & (df["delta"] > 0)
|
||||
|
||||
drop = (close.shift(3) - low).clip(lower=0) / close.shift(3)
|
||||
up = (high - close.shift(3)).clip(lower=0) / close.shift(3)
|
||||
df["de_sell"] = (sell_vol.rolling(3).sum() / (drop.replace(0, np.nan) * 1e4)).replace(
|
||||
[np.inf, -np.inf], np.nan
|
||||
).fillna(0)
|
||||
df["de_buy"] = (buy_vol.rolling(3).sum() / (up.replace(0, np.nan) * 1e4)).replace(
|
||||
[np.inf, -np.inf], np.nan
|
||||
).fillna(0)
|
||||
|
||||
df["ema26"] = ta.EMA(df, timeperiod=26)
|
||||
df["ema_slope"] = ((df["ema26"] - df["ema26"].shift(5)) / close).fillna(0)
|
||||
df["trend_block"] = df["ema_slope"].abs() > self.ema_slope_thr
|
||||
df["atr"] = ta.ATR(df, timeperiod=20)
|
||||
df["atr_pct"] = (df["atr"] / close).fillna(0)
|
||||
|
||||
s_ma, s_ref = sell_vol.rolling(3).mean(), sell_vol.rolling(8).mean()
|
||||
df["sell_exhaust"] = (s_ma < s_ref * 0.75) & (low >= low.rolling(8).min().shift(1))
|
||||
b_ma, b_ref = buy_vol.rolling(3).mean(), buy_vol.rolling(8).mean()
|
||||
df["buy_exhaust"] = (b_ma < b_ref * 0.75) & (high <= high.rolling(8).max().shift(1))
|
||||
|
||||
df["roll_high"] = high.rolling(60, min_periods=10).max()
|
||||
df["roll_low"] = low.rolling(60, min_periods=10).min()
|
||||
return df
|
||||
|
||||
def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
|
||||
df = dataframe
|
||||
long_c = (
|
||||
(~df["trend_block"])
|
||||
& df["shock_sell"].rolling(5).max().astype(bool)
|
||||
& (df["de_sell"] > 10)
|
||||
& df["sell_exhaust"]
|
||||
)
|
||||
short_c = (
|
||||
(~df["trend_block"])
|
||||
& df["shock_buy"].rolling(5).max().astype(bool)
|
||||
& (df["de_buy"] > 10)
|
||||
& df["buy_exhaust"]
|
||||
)
|
||||
df.loc[long_c, ["enter_long", "enter_tag"]] = (1, "probe_bid_lp")
|
||||
df.loc[short_c, ["enter_short", "enter_tag"]] = (1, "probe_ask_lp")
|
||||
return df
|
||||
|
||||
def populate_exit_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
|
||||
dataframe["exit_long"] = 0
|
||||
dataframe["exit_short"] = 0
|
||||
return dataframe
|
||||
|
||||
def custom_entry_price(
|
||||
self,
|
||||
pair: str,
|
||||
trade: Trade | None,
|
||||
current_time: datetime,
|
||||
proposed_rate: float,
|
||||
entry_tag: str | None,
|
||||
side: str,
|
||||
**kwargs,
|
||||
) -> float:
|
||||
offset = self.quote_depth_ticks * self.tick_size
|
||||
try:
|
||||
snap = self._snapshot(pair)
|
||||
price = snap.best_bid - offset if side == "long" else snap.best_ask + offset
|
||||
state = self._market_state(pair)
|
||||
qid = self._get_logger().create_quote(
|
||||
pair=pair,
|
||||
side="bid" if side == "long" else "ask",
|
||||
quote_price=price,
|
||||
inventory=self._inventory(),
|
||||
snap=snap,
|
||||
reason=entry_tag or "entry",
|
||||
trade_id=trade.id if trade else None,
|
||||
state=state,
|
||||
)
|
||||
self._pending_quote_id = qid
|
||||
return price
|
||||
except Exception as e:
|
||||
logger.debug("custom_entry_price: %s", e)
|
||||
return proposed_rate - offset if side == "long" else proposed_rate + offset
|
||||
|
||||
def confirm_trade_entry(
|
||||
self,
|
||||
pair: str,
|
||||
order_type: str,
|
||||
amount: float,
|
||||
rate: float,
|
||||
time_in_force: str,
|
||||
current_time: datetime,
|
||||
entry_tag: str | None,
|
||||
side: str,
|
||||
**kwargs,
|
||||
) -> bool:
|
||||
if self._last_entry_time:
|
||||
last = self._last_entry_time
|
||||
if last.tzinfo is None:
|
||||
last = last.replace(tzinfo=timezone.utc)
|
||||
now = current_time if current_time.tzinfo else current_time.replace(tzinfo=timezone.utc)
|
||||
if now - last < timedelta(minutes=self.cooldown_minutes):
|
||||
return False
|
||||
try:
|
||||
df, _ = self.dp.get_analyzed_dataframe(pair, self.timeframe)
|
||||
if df is not None and len(df) and bool(df.iloc[-1].get("trend_block", False)):
|
||||
return False
|
||||
snap = self._snapshot(pair)
|
||||
if side == "long" and snap.bid_depth_1 < snap.ask_depth_1 * 0.7:
|
||||
return False
|
||||
if side == "short" and snap.ask_depth_1 < snap.bid_depth_1 * 0.7:
|
||||
return False
|
||||
except Exception:
|
||||
pass
|
||||
self._last_entry_time = current_time
|
||||
return True
|
||||
|
||||
def check_entry_timeout(
|
||||
self, pair: str, trade: Trade, order: Order, current_time: datetime, **kwargs
|
||||
) -> bool:
|
||||
"""超时撤单 → 记录 quote_cancel(坏时间未成交 vs 被动成交的对照)。"""
|
||||
try:
|
||||
snap = self._snapshot(pair)
|
||||
self._get_logger().cancel_quote(
|
||||
quote_id=self._pending_quote_id,
|
||||
trade_id=trade.id,
|
||||
reason="entry_timeout",
|
||||
snap=snap,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug("cancel_quote on timeout: %s", e)
|
||||
# False = 不额外强制取消;交给 unfilledtimeout 配置。若要立刻取消返回 True
|
||||
return False
|
||||
|
||||
def order_filled(
|
||||
self,
|
||||
pair: str,
|
||||
trade: Trade,
|
||||
order: Order,
|
||||
current_time: datetime,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
try:
|
||||
lg = self._get_logger()
|
||||
# 入场成交
|
||||
if order.ft_order_side == trade.entry_side:
|
||||
snap = self._snapshot(pair)
|
||||
side = "short" if trade.is_short else "long"
|
||||
# 粗分 fill_reason:time_to_fill 在 logger 内算;这里标 maker_hit
|
||||
# 若成交前5s盘口已恶化 → toxic_passive 候选
|
||||
det = lg.book_deterioration(side)
|
||||
fill_reason = "toxic_passive" if det.get("pre_5s_deteriorated") else "maker_hit"
|
||||
if self._pending_quote_id:
|
||||
lg.bind_trade(self._pending_quote_id, trade.id)
|
||||
fill_id = lg.log_fill(
|
||||
pair=pair,
|
||||
side=side,
|
||||
fill_price=float(order.safe_price or trade.open_rate),
|
||||
amount=float(order.safe_filled or order.safe_amount or 0),
|
||||
inventory=self._inventory(),
|
||||
snap=snap,
|
||||
order_type=str(getattr(order, "order_type", None) or "limit"),
|
||||
quote_id=self._pending_quote_id,
|
||||
trade_id=trade.id,
|
||||
fill_reason=fill_reason,
|
||||
state=self._market_state(pair),
|
||||
extra={"entry_tag": trade.enter_tag},
|
||||
)
|
||||
self._fill_by_trade[trade.id] = fill_id
|
||||
self._pending_quote_id = None
|
||||
else:
|
||||
# 出场:把 exit_reason 挂到入场 fill,供 H2
|
||||
fill_id = self._fill_by_trade.get(trade.id)
|
||||
reason = trade.exit_reason or getattr(order, "ft_order_tag", None) or "exit"
|
||||
if fill_id:
|
||||
lg.attach_exit_reason(fill_id, str(reason))
|
||||
except Exception as e:
|
||||
logger.warning("order_filled log error: %s", e)
|
||||
|
||||
def custom_exit(
|
||||
self,
|
||||
pair: str,
|
||||
trade: Trade,
|
||||
current_time: datetime,
|
||||
current_rate: float,
|
||||
current_profit: float,
|
||||
**kwargs,
|
||||
):
|
||||
open_time = trade.open_date_utc
|
||||
if open_time.tzinfo is None:
|
||||
open_time = open_time.replace(tzinfo=timezone.utc)
|
||||
now = current_time if current_time.tzinfo else current_time.replace(tzinfo=timezone.utc)
|
||||
if now - open_time >= timedelta(minutes=self.max_hold_minutes):
|
||||
return "probe_time"
|
||||
entry = trade.open_rate
|
||||
edge = (
|
||||
(current_rate - entry) / entry
|
||||
if not trade.is_short
|
||||
else (entry - current_rate) / entry
|
||||
)
|
||||
if edge >= self.edge_exit_pct:
|
||||
return "probe_edge_restore"
|
||||
if edge <= -self.adverse_exit_pct:
|
||||
return "probe_adverse"
|
||||
# 趋势切换 → 撤流动性思维
|
||||
try:
|
||||
st = self._market_state(pair)
|
||||
if st.get("trend_state") in ("TREND_UP", "TREND_DOWN"):
|
||||
# 持仓方向与趋势相反时更危险
|
||||
if (not trade.is_short and st["trend_state"] == "TREND_DOWN") or (
|
||||
trade.is_short and st["trend_state"] == "TREND_UP"
|
||||
):
|
||||
return "probe_trend_cancel"
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
def leverage(
|
||||
self, pair: str, current_time: datetime, current_rate: float,
|
||||
proposed_leverage: float, max_leverage: float, entry_tag: Optional[str],
|
||||
side: str, **kwargs,
|
||||
) -> float:
|
||||
return min(self.max_leverage, float(max_leverage))
|
||||
|
||||
def custom_stake_amount(
|
||||
self, pair: str, current_time: datetime, current_rate: float,
|
||||
proposed_stake: float, min_stake: Optional[float], max_stake: float,
|
||||
leverage: float, entry_tag: Optional[str], side: str, **kwargs,
|
||||
) -> float:
|
||||
try:
|
||||
if self.wallets:
|
||||
free = self.wallets.get_free(self.config["stake_currency"])
|
||||
stake = free * self.stake_pct
|
||||
if min_stake:
|
||||
stake = max(stake, min_stake)
|
||||
return min(stake, max_stake)
|
||||
except Exception:
|
||||
pass
|
||||
return min(proposed_stake * self.stake_pct, max_stake)
|
||||
@@ -0,0 +1,449 @@
|
||||
# --- Do not remove these libs ---
|
||||
from freqtrade.strategy import IStrategy, IntParameter, DecimalParameter, stoploss_from_absolute
|
||||
from freqtrade.persistence import Trade
|
||||
import talib.abstract as ta
|
||||
from pandas import DataFrame
|
||||
import pandas as pd
|
||||
import numpy as np
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# freqtrade trade -c ./user_data/Chan/config/Turtle_BTC.json --strategy Turtle_BTC --strategy-path ./user_data/Chan/strategies
|
||||
# freqtrade backtesting -c ./user_data/Chan/config/Turtle_BTC.json --strategy Turtle_BTC --strategy-path ./user_data/Chan/strategies --timerange=20251201-
|
||||
# freqtrade download-data -c ./user_data/Chan/config/Turtle_BTC.json -t 15m --pairs BTC/USDT:USDT --timerange=20240101-
|
||||
|
||||
|
||||
class Turtle_BTC(IStrategy):
|
||||
"""
|
||||
海龟交易法 (Turtle Trading) - 15m 优化版
|
||||
|
||||
相对经典日线参数,15m 上做了适配:
|
||||
- 通道周期拉长(约 1日 / 2日),降低噪音假突破
|
||||
- EMA200 趋势过滤:只做顺势方向
|
||||
- ADX 过滤:只在有趋势时开仓
|
||||
- 突破用「向上/向下穿越」,避免通道内反复信号
|
||||
- 单单元保证金上限,避免低波动时仓位占满账户
|
||||
- 系统2 优先、系统1 补漏(S1 带赢利跳过过滤)
|
||||
- trade_side 可限制只做多/只做空(默认 short,适配近段下跌市)
|
||||
"""
|
||||
INTERFACE_VERSION = 3
|
||||
timeframe = "15m"
|
||||
can_short = True
|
||||
process_only_new_candles = True
|
||||
# 需覆盖 S2 入场周期 + EMA200
|
||||
startup_candle_count = 250
|
||||
|
||||
minimal_roi = {"0": 100}
|
||||
stoploss = -0.99
|
||||
use_custom_stoploss = True
|
||||
trailing_stop = False
|
||||
use_exit_signal = False
|
||||
exit_profit_only = False
|
||||
ignore_roi_if_entry_signal = True
|
||||
|
||||
position_adjustment_enable = True
|
||||
max_entry_position_adjustment = 3 # 首仓 + 3 加仓 = 4 单元
|
||||
|
||||
# ---- 15m 适配后的默认周期(约 1日 / 2日)----
|
||||
# 96 根 15m ≈ 1 天;192 根 ≈ 2 天
|
||||
entry_period_s1 = IntParameter(48, 144, default=96, space="buy", optimize=True)
|
||||
exit_period_s1 = IntParameter(24, 96, default=48, space="sell", optimize=True)
|
||||
entry_period_s2 = IntParameter(120, 288, default=192, space="buy", optimize=True)
|
||||
exit_period_s2 = IntParameter(48, 144, default=96, space="sell", optimize=True)
|
||||
atr_period = IntParameter(14, 40, default=20, space="buy", optimize=False)
|
||||
stop_atr_mult = DecimalParameter(1.5, 3.5, default=2.0, decimals=1, space="sell", optimize=True)
|
||||
pyramid_atr_mult = DecimalParameter(0.3, 1.0, default=0.5, decimals=1, space="buy", optimize=True)
|
||||
risk_per_unit = DecimalParameter(0.005, 0.02, default=0.01, decimals=3, space="buy", optimize=False)
|
||||
adx_threshold = IntParameter(15, 35, default=20, space="buy", optimize=True)
|
||||
# 单单元保证金占可用资金上限(防止 15m 低波动时打满仓)
|
||||
max_unit_stake_pct = DecimalParameter(0.15, 0.40, default=0.25, decimals=2, space="buy", optimize=False)
|
||||
|
||||
lev = 1.0
|
||||
use_s1_win_skip = True
|
||||
use_system1 = True
|
||||
use_system2 = True
|
||||
# 趋势 / 强度过滤
|
||||
use_ema_filter = True
|
||||
use_adx_filter = True
|
||||
# None=双向;可用 "long" / "short" 限制单边(勿用单段行情曲线拟合)
|
||||
trade_side: Optional[str] = None
|
||||
ema_period = 200
|
||||
|
||||
def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
|
||||
ep1 = int(self.entry_period_s1.value)
|
||||
xp1 = int(self.exit_period_s1.value)
|
||||
ep2 = int(self.entry_period_s2.value)
|
||||
xp2 = int(self.exit_period_s2.value)
|
||||
atr_n = int(self.atr_period.value)
|
||||
|
||||
dataframe["atr"] = ta.ATR(dataframe, timeperiod=atr_n)
|
||||
dataframe["n"] = dataframe["atr"]
|
||||
dataframe["ema_trend"] = ta.EMA(dataframe, timeperiod=self.ema_period)
|
||||
dataframe["adx"] = ta.ADX(dataframe, timeperiod=14)
|
||||
dataframe["volume_ma"] = ta.SMA(dataframe, timeperiod=20, price="volume")
|
||||
|
||||
# 唐奇安通道(shift 1 防 lookahead)
|
||||
dataframe["dc_high_s1"] = dataframe["high"].rolling(ep1).max().shift(1)
|
||||
dataframe["dc_low_s1"] = dataframe["low"].rolling(ep1).min().shift(1)
|
||||
dataframe["dc_exit_high_s1"] = dataframe["high"].rolling(xp1).max().shift(1)
|
||||
dataframe["dc_exit_low_s1"] = dataframe["low"].rolling(xp1).min().shift(1)
|
||||
|
||||
dataframe["dc_high_s2"] = dataframe["high"].rolling(ep2).max().shift(1)
|
||||
dataframe["dc_low_s2"] = dataframe["low"].rolling(ep2).min().shift(1)
|
||||
dataframe["dc_exit_high_s2"] = dataframe["high"].rolling(xp2).max().shift(1)
|
||||
dataframe["dc_exit_low_s2"] = dataframe["low"].rolling(xp2).min().shift(1)
|
||||
|
||||
# 穿越突破(只在刚突破那根触发)
|
||||
dataframe["break_up_s1"] = (
|
||||
(dataframe["close"] > dataframe["dc_high_s1"])
|
||||
& (dataframe["close"].shift(1) <= dataframe["dc_high_s1"].shift(1))
|
||||
)
|
||||
dataframe["break_dn_s1"] = (
|
||||
(dataframe["close"] < dataframe["dc_low_s1"])
|
||||
& (dataframe["close"].shift(1) >= dataframe["dc_low_s1"].shift(1))
|
||||
)
|
||||
dataframe["break_up_s2"] = (
|
||||
(dataframe["close"] > dataframe["dc_high_s2"])
|
||||
& (dataframe["close"].shift(1) <= dataframe["dc_high_s2"].shift(1))
|
||||
)
|
||||
dataframe["break_dn_s2"] = (
|
||||
(dataframe["close"] < dataframe["dc_low_s2"])
|
||||
& (dataframe["close"].shift(1) >= dataframe["dc_low_s2"].shift(1))
|
||||
)
|
||||
|
||||
# 顺势过滤:价格相对 EMA200
|
||||
dataframe["trend_long"] = dataframe["close"] > dataframe["ema_trend"]
|
||||
dataframe["trend_short"] = dataframe["close"] < dataframe["ema_trend"]
|
||||
dataframe["adx_ok"] = dataframe["adx"] >= float(self.adx_threshold.value)
|
||||
dataframe["vol_ok"] = dataframe["volume"] > dataframe["volume_ma"] * 0.8
|
||||
|
||||
if self.use_s1_win_skip:
|
||||
dataframe["skip_s1_long"] = self._s1_skip_mask(
|
||||
dataframe, long=True, exit_col="dc_exit_low_s1"
|
||||
)
|
||||
dataframe["skip_s1_short"] = self._s1_skip_mask(
|
||||
dataframe, long=False, exit_col="dc_exit_high_s1"
|
||||
)
|
||||
else:
|
||||
dataframe["skip_s1_long"] = False
|
||||
dataframe["skip_s1_short"] = False
|
||||
|
||||
return dataframe
|
||||
|
||||
@staticmethod
|
||||
def _s1_skip_mask(dataframe: DataFrame, long: bool, exit_col: str) -> pd.Series:
|
||||
"""系统1:上次同向突破盈利则跳过下一次。"""
|
||||
n = len(dataframe)
|
||||
skip = np.zeros(n, dtype=bool)
|
||||
in_trade = False
|
||||
entry_price = 0.0
|
||||
last_was_win = False
|
||||
closes = dataframe["close"].to_numpy()
|
||||
breaks = (dataframe["break_up_s1"] if long else dataframe["break_dn_s1"]).fillna(False).to_numpy()
|
||||
exits = dataframe[exit_col].to_numpy()
|
||||
|
||||
for i in range(n):
|
||||
if np.isnan(exits[i]) or np.isnan(closes[i]):
|
||||
continue
|
||||
if in_trade:
|
||||
hit_exit = closes[i] < exits[i] if long else closes[i] > exits[i]
|
||||
if hit_exit:
|
||||
pnl = (closes[i] - entry_price) if long else (entry_price - closes[i])
|
||||
last_was_win = pnl > 0
|
||||
in_trade = False
|
||||
elif breaks[i]:
|
||||
if last_was_win:
|
||||
skip[i] = True
|
||||
last_was_win = False
|
||||
else:
|
||||
in_trade = True
|
||||
entry_price = closes[i]
|
||||
return pd.Series(skip, index=dataframe.index)
|
||||
|
||||
def _entry_filters(self, dataframe: DataFrame, long: bool) -> pd.Series:
|
||||
base = (
|
||||
(dataframe["volume"] > 0)
|
||||
& dataframe["atr"].notna()
|
||||
& (dataframe["atr"] > 0)
|
||||
& dataframe["vol_ok"]
|
||||
)
|
||||
if self.use_ema_filter:
|
||||
base &= dataframe["trend_long"] if long else dataframe["trend_short"]
|
||||
if self.use_adx_filter:
|
||||
base &= dataframe["adx_ok"]
|
||||
return base
|
||||
|
||||
def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
|
||||
dataframe["enter_long"] = 0
|
||||
dataframe["enter_short"] = 0
|
||||
dataframe["enter_tag"] = ""
|
||||
|
||||
allow_long = self.trade_side in (None, "long")
|
||||
allow_short = self.trade_side in (None, "short")
|
||||
long_base = self._entry_filters(dataframe, long=True) if allow_long else False
|
||||
short_base = self._entry_filters(dataframe, long=False) if allow_short else False
|
||||
|
||||
# 系统2优先(更稳),系统1补漏
|
||||
if self.use_system2:
|
||||
if allow_long:
|
||||
long_s2 = long_base & dataframe["break_up_s2"]
|
||||
dataframe.loc[long_s2, ["enter_long", "enter_tag"]] = (1, "turtle_s2_long")
|
||||
if allow_short:
|
||||
short_s2 = short_base & dataframe["break_dn_s2"]
|
||||
dataframe.loc[short_s2, ["enter_short", "enter_tag"]] = (1, "turtle_s2_short")
|
||||
|
||||
if self.use_system1:
|
||||
if allow_long:
|
||||
long_s1 = (
|
||||
long_base & dataframe["break_up_s1"]
|
||||
& (~dataframe["skip_s1_long"])
|
||||
& (dataframe["enter_long"] != 1)
|
||||
)
|
||||
dataframe.loc[long_s1, ["enter_long", "enter_tag"]] = (1, "turtle_s1_long")
|
||||
if allow_short:
|
||||
short_s1 = (
|
||||
short_base & dataframe["break_dn_s1"]
|
||||
& (~dataframe["skip_s1_short"])
|
||||
& (dataframe["enter_short"] != 1)
|
||||
)
|
||||
dataframe.loc[short_s1, ["enter_short", "enter_tag"]] = (1, "turtle_s1_short")
|
||||
|
||||
return dataframe
|
||||
|
||||
def populate_exit_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
|
||||
dataframe["exit_long"] = 0
|
||||
dataframe["exit_short"] = 0
|
||||
return dataframe
|
||||
|
||||
def custom_exit(
|
||||
self,
|
||||
pair: str,
|
||||
trade: Trade,
|
||||
current_time: datetime,
|
||||
current_rate: float,
|
||||
current_profit: float,
|
||||
**kwargs,
|
||||
) -> Optional[str]:
|
||||
"""按入场系统使用对应退出通道;用 close 与 current_rate 双确认。"""
|
||||
dataframe, _ = self.dp.get_analyzed_dataframe(pair, self.timeframe)
|
||||
if dataframe.empty:
|
||||
return None
|
||||
last = dataframe.iloc[-1]
|
||||
tag = trade.enter_tag or ""
|
||||
price = min(float(last["close"]), current_rate) if not trade.is_short else max(float(last["close"]), current_rate)
|
||||
|
||||
if trade.is_short:
|
||||
if "s1" in tag and price > float(last["dc_exit_high_s1"]):
|
||||
return "turtle_s1_exit"
|
||||
if "s2" in tag and price > float(last["dc_exit_high_s2"]):
|
||||
return "turtle_s2_exit"
|
||||
else:
|
||||
if "s1" in tag and price < float(last["dc_exit_low_s1"]):
|
||||
return "turtle_s1_exit"
|
||||
if "s2" in tag and price < float(last["dc_exit_low_s2"]):
|
||||
return "turtle_s2_exit"
|
||||
return None
|
||||
|
||||
def custom_stake_amount(
|
||||
self,
|
||||
pair: str,
|
||||
current_time: datetime,
|
||||
current_rate: float,
|
||||
proposed_stake: float,
|
||||
min_stake: Optional[float],
|
||||
max_stake: float,
|
||||
leverage: float,
|
||||
entry_tag: Optional[str],
|
||||
side: str,
|
||||
**kwargs,
|
||||
) -> float:
|
||||
dataframe, _ = self.dp.get_analyzed_dataframe(pair, self.timeframe)
|
||||
if dataframe.empty:
|
||||
return proposed_stake
|
||||
|
||||
last = dataframe.iloc[-1]
|
||||
atr = float(last["atr"]) if pd.notna(last["atr"]) else 0.0
|
||||
if atr <= 0 or current_rate <= 0:
|
||||
return proposed_stake
|
||||
|
||||
wallets = self.wallets
|
||||
available = wallets.get_total(self.config["stake_currency"]) if wallets else max_stake
|
||||
risk_amount = available * float(self.risk_per_unit.value)
|
||||
stop_dist = float(self.stop_atr_mult.value) * atr
|
||||
notional = risk_amount * current_rate / stop_dist
|
||||
stake = notional / max(leverage, 1.0)
|
||||
|
||||
# 单单元上限,避免低波动打满仓
|
||||
stake = min(stake, available * float(self.max_unit_stake_pct.value))
|
||||
|
||||
if min_stake is not None:
|
||||
stake = max(stake, min_stake)
|
||||
stake = min(stake, max_stake)
|
||||
return stake
|
||||
|
||||
def adjust_trade_position(
|
||||
self,
|
||||
trade: Trade,
|
||||
current_time: datetime,
|
||||
current_rate: float,
|
||||
current_profit: float,
|
||||
min_stake: Optional[float],
|
||||
max_stake: float,
|
||||
current_entry_rate: float,
|
||||
current_exit_rate: float,
|
||||
current_entry_profit: float,
|
||||
current_exit_profit: float,
|
||||
**kwargs,
|
||||
):
|
||||
"""每朝有利方向 0.5N 加仓,最多 4 单元;有挂单时不加。"""
|
||||
if trade.has_open_orders:
|
||||
return None
|
||||
if trade.nr_of_successful_entries >= (1 + self.max_entry_position_adjustment):
|
||||
return None
|
||||
|
||||
dataframe, _ = self.dp.get_analyzed_dataframe(trade.pair, self.timeframe)
|
||||
if dataframe.empty:
|
||||
return None
|
||||
last = dataframe.iloc[-1]
|
||||
atr = float(last["atr"]) if pd.notna(last["atr"]) else 0.0
|
||||
if atr <= 0:
|
||||
return None
|
||||
|
||||
entry_n = trade.get_custom_data("entry_n")
|
||||
if entry_n is None:
|
||||
entry_n = atr
|
||||
trade.set_custom_data("entry_n", entry_n)
|
||||
|
||||
last_entry_price = trade.get_custom_data("last_entry_price")
|
||||
if last_entry_price is None:
|
||||
last_entry_price = trade.open_rate
|
||||
trade.set_custom_data("last_entry_price", last_entry_price)
|
||||
|
||||
# 已规划的下一单元序号(从第 2 单元起)
|
||||
next_unit = trade.nr_of_successful_entries + 1
|
||||
step = float(self.pyramid_atr_mult.value) * float(entry_n)
|
||||
# 相对首仓(或记录的单元锚定价)计算阈值,避免 after_fill 用均价漂移
|
||||
anchor = float(trade.get_custom_data("unit1_price") or trade.open_rate)
|
||||
# 第 n 单元触发价 = 首仓 ± (n-1)*0.5N
|
||||
offset = (next_unit - 1) * step
|
||||
|
||||
if trade.is_short:
|
||||
trigger = anchor - offset
|
||||
if current_rate > trigger:
|
||||
return None
|
||||
else:
|
||||
trigger = anchor + offset
|
||||
if current_rate < trigger:
|
||||
return None
|
||||
|
||||
stake = self.custom_stake_amount(
|
||||
pair=trade.pair,
|
||||
current_time=current_time,
|
||||
current_rate=current_rate,
|
||||
proposed_stake=max_stake,
|
||||
min_stake=min_stake,
|
||||
max_stake=max_stake,
|
||||
leverage=trade.leverage,
|
||||
entry_tag=trade.enter_tag,
|
||||
side="short" if trade.is_short else "long",
|
||||
)
|
||||
if stake <= 0:
|
||||
return None
|
||||
|
||||
return stake, f"turtle_pyramid_{next_unit}"
|
||||
|
||||
def custom_stoploss(
|
||||
self,
|
||||
pair: str,
|
||||
trade: Trade,
|
||||
current_time: datetime,
|
||||
current_rate: float,
|
||||
current_profit: float,
|
||||
after_fill: bool,
|
||||
**kwargs,
|
||||
) -> Optional[float]:
|
||||
"""
|
||||
止损 = 最近一单元入场价 ± 2N。
|
||||
加仓后整体移到新单元的 2N(海龟原版)。
|
||||
"""
|
||||
dataframe, _ = self.dp.get_analyzed_dataframe(pair, self.timeframe)
|
||||
if dataframe.empty:
|
||||
return None
|
||||
|
||||
last = dataframe.iloc[-1]
|
||||
atr = float(last["atr"]) if pd.notna(last["atr"]) else 0.0
|
||||
|
||||
if after_fill:
|
||||
filled = trade.nr_of_successful_entries
|
||||
if filled <= 1:
|
||||
trade.set_custom_data("unit1_price", current_rate)
|
||||
trade.set_custom_data("last_entry_price", current_rate)
|
||||
if atr > 0:
|
||||
trade.set_custom_data("entry_n", atr)
|
||||
else:
|
||||
# 加仓:用本次成交价作为最新单元锚点
|
||||
trade.set_custom_data("last_entry_price", current_rate)
|
||||
|
||||
entry_n = trade.get_custom_data("entry_n")
|
||||
n = float(entry_n) if entry_n is not None else atr
|
||||
if n <= 0:
|
||||
return None
|
||||
|
||||
last_entry = trade.get_custom_data("last_entry_price") or trade.open_rate
|
||||
mult = float(self.stop_atr_mult.value)
|
||||
|
||||
if trade.is_short:
|
||||
stop_price = float(last_entry) + mult * n
|
||||
else:
|
||||
stop_price = float(last_entry) - mult * n
|
||||
|
||||
sl = stoploss_from_absolute(
|
||||
stop_price, current_rate, is_short=trade.is_short, leverage=trade.leverage
|
||||
)
|
||||
# 0 表示止损已在价格不利侧之外,保持不变
|
||||
return sl if sl > 0 else None
|
||||
|
||||
def confirm_trade_entry(
|
||||
self,
|
||||
pair: str,
|
||||
order_type: str,
|
||||
amount: float,
|
||||
rate: float,
|
||||
time_in_force: str,
|
||||
current_time: datetime,
|
||||
entry_tag: Optional[str],
|
||||
side: str,
|
||||
**kwargs,
|
||||
) -> bool:
|
||||
dataframe, _ = self.dp.get_analyzed_dataframe(pair, self.timeframe)
|
||||
if dataframe.empty:
|
||||
return False
|
||||
row = dataframe.iloc[-1]
|
||||
if pd.isna(row["atr"]) or row["atr"] <= 0:
|
||||
return False
|
||||
if self.trade_side is not None and side != self.trade_side:
|
||||
return False
|
||||
if self.use_ema_filter:
|
||||
if side == "long" and not bool(row["trend_long"]):
|
||||
return False
|
||||
if side == "short" and not bool(row["trend_short"]):
|
||||
return False
|
||||
if self.use_adx_filter and not bool(row["adx_ok"]):
|
||||
return False
|
||||
return True
|
||||
|
||||
def leverage(
|
||||
self,
|
||||
pair: str,
|
||||
current_time: datetime,
|
||||
current_rate: float,
|
||||
proposed_leverage: float,
|
||||
max_leverage: float,
|
||||
entry_tag: Optional[str],
|
||||
side: str,
|
||||
**kwargs,
|
||||
) -> float:
|
||||
return min(self.lev, max_leverage)
|
||||
@@ -0,0 +1,368 @@
|
||||
# --- Do not remove these libs ---
|
||||
"""
|
||||
Wyckoff BTC V1.0 BASELINE — FROZEN
|
||||
|
||||
Status: BASELINE FROZEN (live alias of V1_BASELINE)
|
||||
Evidence: PASS (+ Limited Evidence, N=20)
|
||||
Cost Adjusted: PASS (net PF 1.45 @ fee+slip 5bps)
|
||||
Risk: small sample — 目标积累 N>=50 再谈规模
|
||||
|
||||
Branch A: Spring Reversal
|
||||
8h bias + 4h structure + 1h Spring/UTAD
|
||||
Range disabled(regime_mode=trend)
|
||||
ATR + 结构止损
|
||||
setup_type: SPRING / UTAD
|
||||
|
||||
证据: user_data/Chan/scripts/wyckoff_v1_baseline_phase2.json
|
||||
LPS 是独立 Setup 研究,禁止并入本文件调参。
|
||||
"""
|
||||
from freqtrade.strategy import (
|
||||
IStrategy, IntParameter, DecimalParameter, CategoricalParameter,
|
||||
merge_informative_pair, stoploss_from_open, stoploss_from_absolute,
|
||||
)
|
||||
from freqtrade.persistence import Trade
|
||||
import talib.abstract as ta
|
||||
from pandas import DataFrame
|
||||
import pandas as pd
|
||||
import numpy as np
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# freqtrade backtesting -c ./user_data/Chan/config/Wyckoff_BTC.json \
|
||||
# --strategy Wyckoff_BTC --strategy-path ./user_data/Chan/strategies --timerange=20230101-
|
||||
|
||||
|
||||
class Wyckoff_BTC(IStrategy):
|
||||
"""Live alias of V1_BASELINE — 改规则请复制新文件,勿直接改 Baseline。"""
|
||||
INTERFACE_VERSION = 3
|
||||
STRATEGY_VERSION = "V1.0_SPRING"
|
||||
SETUP_FAMILY = "SPRING"
|
||||
|
||||
timeframe = "1h"
|
||||
structure_timeframe = "4h"
|
||||
bias_timeframe: Optional[str] = "8h"
|
||||
use_bias_filter = True
|
||||
# trend = bull|bear only(Range disabled — 理论一致性约束,非调参)
|
||||
regime_mode: str = "trend"
|
||||
|
||||
can_short = True
|
||||
process_only_new_candles = True
|
||||
startup_candle_count = 220
|
||||
|
||||
minimal_roi = {
|
||||
"0": 0.10,
|
||||
"1440": 0.05,
|
||||
"4320": 0.025,
|
||||
"10080": 0,
|
||||
}
|
||||
stoploss = -0.10
|
||||
use_custom_stoploss = True
|
||||
trailing_stop = True
|
||||
trailing_stop_positive = 0.02
|
||||
trailing_stop_positive_offset = 0.04
|
||||
trailing_only_offset_is_reached = True
|
||||
use_exit_signal = True
|
||||
exit_profit_only = False
|
||||
|
||||
# ---- 冻结默认值(optimize=False)----
|
||||
range_lookback = IntParameter(12, 48, default=24, space="buy", optimize=False)
|
||||
spring_pierce_pct = DecimalParameter(0.001, 0.012, default=0.004, decimals=3, space="buy", optimize=False)
|
||||
vol_spike_mult = DecimalParameter(1.1, 2.5, default=1.8, decimals=1, space="buy", optimize=False)
|
||||
adx_min = IntParameter(10, 28, default=14, space="buy", optimize=False)
|
||||
tr_pos_long_max = DecimalParameter(0.35, 0.55, default=0.45, decimals=2, space="buy", optimize=False)
|
||||
tr_pos_short_min = DecimalParameter(0.45, 0.65, default=0.55, decimals=2, space="buy", optimize=False)
|
||||
atr_sl_mult = DecimalParameter(1.2, 3.5, default=1.5, decimals=1, space="sell", optimize=False)
|
||||
atr_sl_min = DecimalParameter(0.012, 0.04, default=0.018, decimals=3, space="sell", optimize=False)
|
||||
atr_sl_max = DecimalParameter(0.05, 0.12, default=0.08, decimals=2, space="sell", optimize=False)
|
||||
time_stop_hours = IntParameter(48, 240, default=120, space="sell", optimize=False)
|
||||
|
||||
# Branch A:仅 Spring / UTAD
|
||||
use_spring_sig = CategoricalParameter([True, False], default=True, space="buy", optimize=False)
|
||||
use_utad_sig = CategoricalParameter([True, False], default=True, space="buy", optimize=False)
|
||||
use_sos_sig = CategoricalParameter([True, False], default=False, space="buy", optimize=False)
|
||||
use_sow_sig = CategoricalParameter([True, False], default=False, space="buy", optimize=False)
|
||||
|
||||
lev = 1.0
|
||||
|
||||
def informative_pairs(self):
|
||||
pairs = self.dp.current_whitelist() if self.dp else []
|
||||
tfs = {self.structure_timeframe}
|
||||
if self.bias_timeframe and self.use_bias_filter:
|
||||
tfs.add(self.bias_timeframe)
|
||||
return [(pair, tf) for pair in pairs for tf in tfs]
|
||||
|
||||
def _add_wyckoff_structure(self, df: DataFrame) -> DataFrame:
|
||||
lb = int(self.range_lookback.value)
|
||||
|
||||
df["atr"] = ta.ATR(df, timeperiod=14)
|
||||
df["ema50"] = ta.EMA(df, timeperiod=50)
|
||||
df["ema200"] = ta.EMA(df, timeperiod=200)
|
||||
df["adx"] = ta.ADX(df, timeperiod=14)
|
||||
df["rsi"] = ta.RSI(df, timeperiod=14)
|
||||
df["volume_ma"] = ta.SMA(df, timeperiod=20, price="volume")
|
||||
|
||||
df["tr_high"] = df["high"].rolling(lb).max()
|
||||
df["tr_low"] = df["low"].rolling(lb).min()
|
||||
df["tr_mid"] = (df["tr_high"] + df["tr_low"]) / 2.0
|
||||
df["tr_width"] = (df["tr_high"] - df["tr_low"]) / df["tr_mid"].replace(0, np.nan)
|
||||
df["tr_width_ma"] = df["tr_width"].rolling(lb).mean()
|
||||
|
||||
rng = (df["tr_high"] - df["tr_low"]).replace(0, np.nan)
|
||||
df["tr_pos"] = (df["close"] - df["tr_low"]) / rng
|
||||
|
||||
df["in_range"] = (df["tr_width"] < df["tr_width_ma"] * 1.35) & (df["adx"] < 28)
|
||||
df["ema50_slope"] = df["ema50"] - df["ema50"].shift(8)
|
||||
df["prior_down"] = df["ema50_slope"].shift(lb) < 0
|
||||
df["prior_up"] = df["ema50_slope"].shift(lb) > 0
|
||||
|
||||
down_bar = df["close"] < df["open"]
|
||||
up_bar = df["close"] > df["open"]
|
||||
vol_down = np.where(down_bar, df["volume"], np.nan)
|
||||
vol_up = np.where(up_bar, df["volume"], np.nan)
|
||||
df["vol_down_ma"] = pd.Series(vol_down, index=df.index).rolling(10, min_periods=3).mean()
|
||||
df["vol_up_ma"] = pd.Series(vol_up, index=df.index).rolling(10, min_periods=3).mean()
|
||||
df["effort_absorb"] = (
|
||||
df["vol_down_ma"].notna()
|
||||
& df["vol_up_ma"].notna()
|
||||
& (df["vol_up_ma"] > df["vol_down_ma"] * 1.05)
|
||||
)
|
||||
|
||||
df["accum_ctx"] = (
|
||||
df["in_range"]
|
||||
& (df["prior_down"] | (df["close"] < df["ema50"]))
|
||||
& (df["tr_pos"] < float(self.tr_pos_long_max.value))
|
||||
)
|
||||
df["distrib_ctx"] = (
|
||||
df["in_range"]
|
||||
& (df["prior_up"] | (df["close"] > df["ema50"]))
|
||||
& (df["tr_pos"] > float(self.tr_pos_short_min.value))
|
||||
)
|
||||
df["bull_bias"] = (df["close"] > df["ema200"]) & (df["ema50"] > df["ema200"])
|
||||
df["bear_bias"] = (df["close"] < df["ema200"]) & (df["ema50"] < df["ema200"])
|
||||
df["vol_spike"] = df["volume"] > df["volume_ma"] * float(self.vol_spike_mult.value)
|
||||
return df
|
||||
|
||||
def _merge_tf(self, dataframe: DataFrame, pair: str, tf: str) -> DataFrame:
|
||||
inf = self.dp.get_pair_dataframe(pair=pair, timeframe=tf)
|
||||
inf = self._add_wyckoff_structure(inf)
|
||||
keep = [
|
||||
"date", "atr", "ema50", "ema200", "adx", "rsi",
|
||||
"tr_high", "tr_low", "tr_mid", "tr_width", "tr_pos",
|
||||
"in_range", "accum_ctx", "distrib_ctx",
|
||||
"vol_spike", "effort_absorb", "prior_down", "prior_up",
|
||||
"bull_bias", "bear_bias",
|
||||
]
|
||||
inf = inf[[c for c in keep if c in inf.columns]].copy()
|
||||
return merge_informative_pair(dataframe, inf, self.timeframe, tf, ffill=True)
|
||||
|
||||
def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
|
||||
pair = metadata["pair"]
|
||||
stf = self.structure_timeframe
|
||||
dataframe = self._merge_tf(dataframe, pair, stf)
|
||||
|
||||
btf = self.bias_timeframe
|
||||
if btf and self.use_bias_filter and btf != stf:
|
||||
dataframe = self._merge_tf(dataframe, pair, btf)
|
||||
|
||||
ss = f"_{stf}"
|
||||
dataframe["atr"] = ta.ATR(dataframe, timeperiod=14)
|
||||
dataframe["ema21"] = ta.EMA(dataframe, timeperiod=21)
|
||||
dataframe["ema50"] = ta.EMA(dataframe, timeperiod=50)
|
||||
dataframe["rsi"] = ta.RSI(dataframe, timeperiod=14)
|
||||
dataframe["volume_ma"] = ta.SMA(dataframe, timeperiod=20, price="volume")
|
||||
dataframe["vol_ok"] = dataframe["volume"] > dataframe["volume_ma"] * float(self.vol_spike_mult.value)
|
||||
|
||||
tr_high = dataframe[f"tr_high{ss}"]
|
||||
tr_low = dataframe[f"tr_low{ss}"]
|
||||
pierce = float(self.spring_pierce_pct.value)
|
||||
|
||||
accum_soft = (
|
||||
dataframe[f"accum_ctx{ss}"].fillna(False).astype(bool)
|
||||
| (
|
||||
dataframe[f"in_range{ss}"].fillna(False).astype(bool)
|
||||
& dataframe[f"prior_down{ss}"].fillna(False).astype(bool)
|
||||
& (dataframe[f"tr_pos{ss}"] < float(self.tr_pos_long_max.value))
|
||||
)
|
||||
)
|
||||
distrib_soft = (
|
||||
dataframe[f"distrib_ctx{ss}"].fillna(False).astype(bool)
|
||||
| (
|
||||
dataframe[f"in_range{ss}"].fillna(False).astype(bool)
|
||||
& dataframe[f"prior_up{ss}"].fillna(False).astype(bool)
|
||||
& (dataframe[f"tr_pos{ss}"] > float(self.tr_pos_short_min.value))
|
||||
)
|
||||
)
|
||||
|
||||
if btf and self.use_bias_filter:
|
||||
bs = f"_{btf}" if btf != stf else ss
|
||||
if f"bear_bias{bs}" in dataframe.columns:
|
||||
dataframe["bias_long_ok"] = ~dataframe[f"bear_bias{bs}"].fillna(False).astype(bool)
|
||||
dataframe["bias_short_ok"] = ~dataframe[f"bull_bias{bs}"].fillna(False).astype(bool)
|
||||
else:
|
||||
dataframe["bias_long_ok"] = True
|
||||
dataframe["bias_short_ok"] = True
|
||||
else:
|
||||
dataframe["bias_long_ok"] = True
|
||||
dataframe["bias_short_ok"] = True
|
||||
|
||||
vol_mild = dataframe["volume"] > dataframe["volume_ma"] * max(1.1, float(self.vol_spike_mult.value) * 0.85)
|
||||
|
||||
dataframe["spring"] = (
|
||||
tr_low.notna()
|
||||
& (dataframe["low"] < tr_low * (1.0 - pierce))
|
||||
& (dataframe["close"] > tr_low)
|
||||
& (dataframe["close"] > dataframe["open"])
|
||||
& accum_soft
|
||||
& vol_mild
|
||||
& (dataframe["rsi"] < 58)
|
||||
& dataframe["bias_long_ok"]
|
||||
)
|
||||
dataframe["utad"] = (
|
||||
tr_high.notna()
|
||||
& (dataframe["high"] > tr_high * (1.0 + pierce))
|
||||
& (dataframe["close"] < tr_high)
|
||||
& (dataframe["close"] < dataframe["open"])
|
||||
& distrib_soft
|
||||
& vol_mild
|
||||
& (dataframe["rsi"] > 42)
|
||||
& dataframe["bias_short_ok"]
|
||||
)
|
||||
# 基线不进 SOS/SOW;保留列供 exit 参考
|
||||
dataframe["sos"] = False
|
||||
dataframe["sow"] = False
|
||||
|
||||
for col in ["spring", "utad", "sos", "sow", "vol_ok", "bias_long_ok", "bias_short_ok"]:
|
||||
dataframe[col] = dataframe[col].fillna(False).astype(bool)
|
||||
dataframe["setup_type"] = ""
|
||||
dataframe.loc[dataframe["spring"], "setup_type"] = "SPRING_LONG"
|
||||
dataframe.loc[dataframe["utad"], "setup_type"] = "UTAD_SHORT"
|
||||
return dataframe
|
||||
|
||||
def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
|
||||
dataframe["enter_long"] = 0
|
||||
dataframe["enter_short"] = 0
|
||||
dataframe["enter_tag"] = ""
|
||||
|
||||
vol_ok = dataframe["volume"] > 0
|
||||
|
||||
# 分开标签:禁止把 SPRING / UTAD 混成同一统计桶
|
||||
if bool(self.use_spring_sig.value):
|
||||
cond = vol_ok & dataframe["spring"]
|
||||
dataframe.loc[cond, ["enter_long", "enter_tag"]] = (1, "SPRING_LONG")
|
||||
|
||||
if bool(self.use_utad_sig.value):
|
||||
cond = vol_ok & dataframe["utad"]
|
||||
dataframe.loc[cond, ["enter_short", "enter_tag"]] = (1, "UTAD_SHORT")
|
||||
|
||||
self._apply_regime_filter(dataframe)
|
||||
return dataframe
|
||||
|
||||
def _apply_regime_filter(self, dataframe: DataFrame) -> None:
|
||||
rm = getattr(self, "regime_mode", "all")
|
||||
if rm == "all" or not self.bias_timeframe:
|
||||
return
|
||||
bs = f"_{self.bias_timeframe}"
|
||||
bc, ec = f"bull_bias{bs}", f"bear_bias{bs}"
|
||||
if bc not in dataframe.columns or ec not in dataframe.columns:
|
||||
return
|
||||
bull = dataframe[bc].fillna(False).astype(bool)
|
||||
bear = dataframe[ec].fillna(False).astype(bool)
|
||||
both = bull & bear
|
||||
bull, bear = bull & ~both, bear & ~both
|
||||
range_m = (~bull) & (~bear)
|
||||
if rm == "bull":
|
||||
mask = ~bull
|
||||
elif rm == "bear":
|
||||
mask = ~bear
|
||||
elif rm == "range":
|
||||
mask = ~range_m
|
||||
elif rm == "trend":
|
||||
mask = range_m # Range disabled
|
||||
else:
|
||||
return
|
||||
dataframe.loc[mask, ["enter_long", "enter_short"]] = (0, 0)
|
||||
dataframe.loc[mask, "enter_tag"] = ""
|
||||
|
||||
def populate_exit_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
|
||||
dataframe["exit_long"] = 0
|
||||
dataframe["exit_short"] = 0
|
||||
dataframe["exit_tag"] = ""
|
||||
ss = f"_{self.structure_timeframe}"
|
||||
|
||||
exit_long = dataframe["utad"] | (
|
||||
dataframe[f"distrib_ctx{ss}"].fillna(False).astype(bool)
|
||||
& (dataframe["close"] < dataframe["ema21"])
|
||||
& (dataframe["rsi"] < 45)
|
||||
)
|
||||
exit_short = dataframe["spring"] | (
|
||||
dataframe[f"accum_ctx{ss}"].fillna(False).astype(bool)
|
||||
& (dataframe["close"] > dataframe["ema21"])
|
||||
& (dataframe["rsi"] > 55)
|
||||
)
|
||||
dataframe.loc[exit_long, ["exit_long", "exit_tag"]] = (1, "wyckoff_phase_flip")
|
||||
dataframe.loc[exit_short, ["exit_short", "exit_tag"]] = (1, "wyckoff_phase_flip")
|
||||
return dataframe
|
||||
|
||||
def custom_stoploss(
|
||||
self, pair: str, trade: Trade, current_time: datetime,
|
||||
current_rate: float, current_profit: float, after_fill: bool, **kwargs,
|
||||
) -> Optional[float]:
|
||||
dataframe, _ = self.dp.get_analyzed_dataframe(pair, self.timeframe)
|
||||
if dataframe.empty:
|
||||
return None
|
||||
last = dataframe.iloc[-1]
|
||||
atr = float(last["atr"]) if pd.notna(last["atr"]) else 0.0
|
||||
if atr <= 0 or trade.open_rate <= 0:
|
||||
return None
|
||||
|
||||
atr_dist = float(self.atr_sl_mult.value) * atr
|
||||
tag = trade.enter_tag or ""
|
||||
buffer = atr * 0.15
|
||||
|
||||
if after_fill and trade.get_custom_data("struct_stop") is None:
|
||||
if trade.is_short:
|
||||
trade.set_custom_data("struct_stop", float(last["high"]) + buffer)
|
||||
else:
|
||||
trade.set_custom_data("struct_stop", float(last["low"]) - buffer)
|
||||
|
||||
struct = trade.get_custom_data("struct_stop")
|
||||
if trade.is_short:
|
||||
atr_stop = trade.open_rate + atr_dist
|
||||
stop_price = min(atr_stop, float(struct)) if struct is not None else atr_stop
|
||||
else:
|
||||
atr_stop = trade.open_rate - atr_dist
|
||||
stop_price = max(atr_stop, float(struct)) if struct is not None else atr_stop
|
||||
|
||||
raw = abs(trade.open_rate - stop_price) / trade.open_rate
|
||||
raw = min(max(raw, float(self.atr_sl_min.value)), float(self.atr_sl_max.value))
|
||||
if struct is not None and tag in (
|
||||
"SPRING_LONG", "UTAD_SHORT", "SPRING", "UTAD", "wyckoff_spring", "wyckoff_utad",
|
||||
):
|
||||
sl = stoploss_from_absolute(
|
||||
stop_price, current_rate, is_short=trade.is_short, leverage=trade.leverage
|
||||
)
|
||||
return sl if sl and sl > 0 else None
|
||||
return stoploss_from_open(
|
||||
-raw, current_profit, is_short=trade.is_short, leverage=trade.leverage
|
||||
) or None
|
||||
|
||||
def custom_exit(
|
||||
self, pair: str, trade: Trade, current_time: datetime,
|
||||
current_rate: float, current_profit: float, **kwargs,
|
||||
) -> Optional[str]:
|
||||
hours = (current_time - trade.open_date_utc).total_seconds() / 3600
|
||||
if hours > float(self.time_stop_hours.value) and current_profit < 0:
|
||||
return "wyckoff_time_stop"
|
||||
if hours > float(self.time_stop_hours.value) * 2:
|
||||
return "wyckoff_time_stop_max"
|
||||
return None
|
||||
|
||||
def leverage(
|
||||
self, pair: str, current_time: datetime, current_rate: float,
|
||||
proposed_leverage: float, max_leverage: float, entry_tag: Optional[str],
|
||||
side: str, **kwargs,
|
||||
) -> float:
|
||||
return min(self.lev, max_leverage)
|
||||
@@ -0,0 +1,197 @@
|
||||
# --- Do not remove these libs ---
|
||||
"""
|
||||
Wyckoff BTC — Market-State Gated Spring(Decision Layer)
|
||||
|
||||
Spring = V1_BASELINE(FROZEN)
|
||||
Gate v1.1 = LOCKED default Decision rule:
|
||||
market_state in {accumulation, markup} -> allow Spring
|
||||
else -> block
|
||||
|
||||
Soft-score 不进默认规则。勿改 Spring;勿全样本扫 Gate。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from pandas import DataFrame
|
||||
import pandas as pd
|
||||
|
||||
_CHAN = Path(__file__).resolve().parents[1]
|
||||
if str(_CHAN) not in sys.path:
|
||||
sys.path.insert(0, str(_CHAN))
|
||||
|
||||
from engine.market_state import apply_decision_gate, compute_market_state_8h # noqa: E402
|
||||
from freqtrade.strategy import merge_informative_pair # noqa: E402
|
||||
|
||||
from Wyckoff_BTC_V1_BASELINE import Wyckoff_BTC_V1_BASELINE # noqa: E402
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Wyckoff_BTC_GATED(Wyckoff_BTC_V1_BASELINE):
|
||||
"""Baseline Spring + causal Market State Gate。"""
|
||||
|
||||
STRATEGY_VERSION = "GATED_V1_1_LOCKED"
|
||||
SETUP_FAMILY = "SPRING_GATED"
|
||||
|
||||
# LOCKED default — 研究脚本可临时改写,跑完必须恢复
|
||||
gate_mode: str = "state_set"
|
||||
gate_q_sum: float = 100.0
|
||||
gate_q_bad: float = 55.0
|
||||
decision_log_enabled: bool = True
|
||||
decision_log_path: str = str(_CHAN / "logs" / "wyckoff_decision_events.jsonl")
|
||||
|
||||
def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
|
||||
dataframe = super().populate_indicators(dataframe, metadata)
|
||||
pair = metadata["pair"]
|
||||
btf = self.bias_timeframe or "8h"
|
||||
|
||||
raw8 = self.dp.get_pair_dataframe(pair=pair, timeframe=btf)
|
||||
st8 = compute_market_state_8h(raw8)
|
||||
# 覆盖默认门闩为当前 class 配置(可能已被脚本锁定)
|
||||
st8 = apply_decision_gate(
|
||||
st8,
|
||||
mode=str(self.gate_mode),
|
||||
q_sum=float(self.gate_q_sum),
|
||||
q_bad=float(self.gate_q_bad),
|
||||
)
|
||||
keep = [
|
||||
"date",
|
||||
"accumulation_score",
|
||||
"markup_score",
|
||||
"distribution_score",
|
||||
"markdown_score",
|
||||
"range_score",
|
||||
"market_state",
|
||||
"allow_spring",
|
||||
"allow_utad",
|
||||
"ema_slope",
|
||||
"dist_ema200",
|
||||
]
|
||||
st8 = st8[[c for c in keep if c in st8.columns]].copy()
|
||||
dataframe = merge_informative_pair(dataframe, st8, self.timeframe, btf, ffill=True)
|
||||
return dataframe
|
||||
|
||||
def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
|
||||
dataframe = super().populate_entry_trend(dataframe, metadata)
|
||||
|
||||
bs = f"_{self.bias_timeframe or '8h'}"
|
||||
allow_s = dataframe.get(f"allow_spring{bs}")
|
||||
allow_u = dataframe.get(f"allow_utad{bs}")
|
||||
if allow_s is None or allow_u is None:
|
||||
return dataframe
|
||||
|
||||
allow_s = allow_s.fillna(False).astype(bool)
|
||||
allow_u = allow_u.fillna(False).astype(bool)
|
||||
|
||||
block_long = (dataframe["enter_long"] == 1) & (~allow_s)
|
||||
block_short = (dataframe["enter_short"] == 1) & (~allow_u)
|
||||
self._log_decision_events(dataframe, metadata, allow_s, allow_u, bs)
|
||||
dataframe.loc[block_long, ["enter_long", "enter_tag"]] = (0, "")
|
||||
dataframe.loc[block_short, ["enter_short", "enter_tag"]] = (0, "")
|
||||
return dataframe
|
||||
|
||||
def _decision_log_active(self) -> bool:
|
||||
if not bool(getattr(self, "decision_log_enabled", True)):
|
||||
return False
|
||||
config = getattr(self, "config", {}) or {}
|
||||
runmode = config.get("runmode")
|
||||
runmode_value = getattr(runmode, "value", str(runmode) if runmode is not None else "")
|
||||
if runmode_value:
|
||||
return runmode_value == "dry_run"
|
||||
return bool(config.get("dry_run", False))
|
||||
|
||||
def _log_decision_events(
|
||||
self,
|
||||
dataframe: DataFrame,
|
||||
metadata: dict,
|
||||
allow_s: pd.Series,
|
||||
allow_u: pd.Series,
|
||||
bias_suffix: str,
|
||||
) -> None:
|
||||
if not self._decision_log_active():
|
||||
return
|
||||
|
||||
pair = metadata.get("pair", "")
|
||||
long_candidates = dataframe["enter_long"] == 1
|
||||
short_candidates = dataframe["enter_short"] == 1
|
||||
if not bool(long_candidates.any() or short_candidates.any()):
|
||||
return
|
||||
|
||||
seen = getattr(self, "_decision_log_seen", None)
|
||||
if seen is None:
|
||||
seen = set()
|
||||
self._decision_log_seen = seen
|
||||
|
||||
events = []
|
||||
for idx in dataframe.index[long_candidates]:
|
||||
events.append(self._decision_event(dataframe.loc[idx], pair, "SPRING_LONG", bool(allow_s.loc[idx]), bias_suffix))
|
||||
for idx in dataframe.index[short_candidates]:
|
||||
events.append(self._decision_event(dataframe.loc[idx], pair, "UTAD_SHORT", bool(allow_u.loc[idx]), bias_suffix))
|
||||
|
||||
path = Path(str(getattr(self, "decision_log_path", ""))).expanduser()
|
||||
try:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with path.open("a", encoding="utf-8") as handle:
|
||||
for event in events:
|
||||
key = (
|
||||
event["timestamp"],
|
||||
event["pair"],
|
||||
event["signal_type"],
|
||||
event["gate_version"],
|
||||
)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
handle.write(json.dumps(event, ensure_ascii=False, sort_keys=True) + "\n")
|
||||
except OSError as exc:
|
||||
logger.warning("Decision log write failed: %s", exc)
|
||||
|
||||
def _decision_event(self, row: pd.Series, pair: str, signal_type: str, allow: bool, bias_suffix: str) -> dict:
|
||||
state_col = f"market_state{bias_suffix}"
|
||||
bias_time_col = f"date{bias_suffix}"
|
||||
state = self._json_value(row.get(state_col))
|
||||
bias_bar_time = self._json_value(row.get(bias_time_col))
|
||||
state_missing = state in (None, "", "missing")
|
||||
block_reason = "" if allow else ("state_missing" if state_missing else "not_in_allow_set")
|
||||
|
||||
event = {
|
||||
"timestamp": self._json_value(row.get("date")),
|
||||
"pair": pair,
|
||||
"signal_type": signal_type,
|
||||
"market_state": state if not state_missing else "missing",
|
||||
"allow": bool(allow),
|
||||
"gate_version": self.STRATEGY_VERSION,
|
||||
"baseline_signal": signal_type,
|
||||
"block_reason": block_reason,
|
||||
"bias_bar_time": bias_bar_time,
|
||||
"would_enter": True,
|
||||
"order_sent": bool(allow),
|
||||
}
|
||||
for score in [
|
||||
"accumulation_score",
|
||||
"markup_score",
|
||||
"distribution_score",
|
||||
"markdown_score",
|
||||
"range_score",
|
||||
]:
|
||||
event[score] = self._json_value(row.get(f"{score}{bias_suffix}"))
|
||||
return event
|
||||
|
||||
@staticmethod
|
||||
def _json_value(value):
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
if pd.isna(value):
|
||||
return None
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
if hasattr(value, "isoformat"):
|
||||
return value.isoformat()
|
||||
if hasattr(value, "item"):
|
||||
return value.item()
|
||||
return value
|
||||
@@ -0,0 +1,42 @@
|
||||
# --- Do not remove these libs ---
|
||||
"""
|
||||
ARCHIVED — LPS 研究分支已冻结,禁止用于 dry-run / 生产。
|
||||
|
||||
见:
|
||||
user_data/Chan/research/SYSTEM_STATUS.md
|
||||
user_data/Chan/research/lps_v1_failed/REJECT.md
|
||||
user_data/Chan/research/lps_v1_1_failed/REJECT.md
|
||||
user_data/Chan/research/lps_v2_failed/REJECT.md
|
||||
user_data/Chan/research/lps_v2_failed/Wyckoff_BTC_LPS_V2.py
|
||||
|
||||
Baseline: Wyckoff_BTC_V1_BASELINE(Spring-only)
|
||||
"""
|
||||
from freqtrade.strategy import IStrategy
|
||||
from pandas import DataFrame
|
||||
|
||||
|
||||
class Wyckoff_BTC_LPS(IStrategy):
|
||||
"""Stub: LPS archived. Use Wyckoff_BTC_V1_BASELINE."""
|
||||
INTERFACE_VERSION = 3
|
||||
STRATEGY_VERSION = "ARCHIVED"
|
||||
timeframe = "1h"
|
||||
can_short = True
|
||||
startup_candle_count = 20
|
||||
minimal_roi = {"0": 1}
|
||||
stoploss = -0.99
|
||||
|
||||
def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
|
||||
raise RuntimeError(
|
||||
"LPS research archived (V1/V1.1/V2 all REJECTED). "
|
||||
"Use Wyckoff_BTC_V1_BASELINE. See user_data/Chan/research/"
|
||||
)
|
||||
|
||||
def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
|
||||
dataframe["enter_long"] = 0
|
||||
dataframe["enter_short"] = 0
|
||||
return dataframe
|
||||
|
||||
def populate_exit_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
|
||||
dataframe["exit_long"] = 0
|
||||
dataframe["exit_short"] = 0
|
||||
return dataframe
|
||||
@@ -0,0 +1,368 @@
|
||||
# --- Do not remove these libs ---
|
||||
"""
|
||||
Wyckoff BTC V1.0 BASELINE — FROZEN
|
||||
|
||||
Status: BASELINE FROZEN
|
||||
Evidence: PASS (+ Limited Evidence, N=20)
|
||||
Cost Adjusted: PASS (net PF 1.45 @ fee+slip 5bps)
|
||||
Risk: small sample — 目标积累 N>=50 再谈规模
|
||||
|
||||
Branch A: Spring Reversal
|
||||
8h bias + 4h structure + 1h Spring/UTAD
|
||||
Range disabled(regime_mode=trend)
|
||||
ATR + 结构止损
|
||||
setup_type: SPRING / UTAD
|
||||
|
||||
证据: user_data/Chan/scripts/wyckoff_v1_baseline_phase2.json
|
||||
LPS 是独立 Setup 研究,禁止并入本文件调参。
|
||||
"""
|
||||
from freqtrade.strategy import (
|
||||
IStrategy, IntParameter, DecimalParameter, CategoricalParameter,
|
||||
merge_informative_pair, stoploss_from_open, stoploss_from_absolute,
|
||||
)
|
||||
from freqtrade.persistence import Trade
|
||||
import talib.abstract as ta
|
||||
from pandas import DataFrame
|
||||
import pandas as pd
|
||||
import numpy as np
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# freqtrade backtesting -c ./user_data/Chan/config/Wyckoff_BTC_V1_BASELINE.json \
|
||||
# --strategy Wyckoff_BTC_V1_BASELINE --strategy-path ./user_data/Chan/strategies --timerange=20230101-
|
||||
|
||||
|
||||
class Wyckoff_BTC_V1_BASELINE(IStrategy):
|
||||
"""冻结基线:Spring 反转。禁止继续调参;对比实验请用独立分支。"""
|
||||
INTERFACE_VERSION = 3
|
||||
STRATEGY_VERSION = "V1.0_BASELINE"
|
||||
SETUP_FAMILY = "SPRING"
|
||||
|
||||
timeframe = "1h"
|
||||
structure_timeframe = "4h"
|
||||
bias_timeframe: Optional[str] = "8h"
|
||||
use_bias_filter = True
|
||||
# trend = bull|bear only(Range disabled — 理论一致性约束,非调参)
|
||||
regime_mode: str = "trend"
|
||||
|
||||
can_short = True
|
||||
process_only_new_candles = True
|
||||
startup_candle_count = 220
|
||||
|
||||
minimal_roi = {
|
||||
"0": 0.10,
|
||||
"1440": 0.05,
|
||||
"4320": 0.025,
|
||||
"10080": 0,
|
||||
}
|
||||
stoploss = -0.10
|
||||
use_custom_stoploss = True
|
||||
trailing_stop = True
|
||||
trailing_stop_positive = 0.02
|
||||
trailing_stop_positive_offset = 0.04
|
||||
trailing_only_offset_is_reached = True
|
||||
use_exit_signal = True
|
||||
exit_profit_only = False
|
||||
|
||||
# ---- 冻结默认值(optimize=False)----
|
||||
range_lookback = IntParameter(12, 48, default=24, space="buy", optimize=False)
|
||||
spring_pierce_pct = DecimalParameter(0.001, 0.012, default=0.004, decimals=3, space="buy", optimize=False)
|
||||
vol_spike_mult = DecimalParameter(1.1, 2.5, default=1.8, decimals=1, space="buy", optimize=False)
|
||||
adx_min = IntParameter(10, 28, default=14, space="buy", optimize=False)
|
||||
tr_pos_long_max = DecimalParameter(0.35, 0.55, default=0.45, decimals=2, space="buy", optimize=False)
|
||||
tr_pos_short_min = DecimalParameter(0.45, 0.65, default=0.55, decimals=2, space="buy", optimize=False)
|
||||
atr_sl_mult = DecimalParameter(1.2, 3.5, default=1.5, decimals=1, space="sell", optimize=False)
|
||||
atr_sl_min = DecimalParameter(0.012, 0.04, default=0.018, decimals=3, space="sell", optimize=False)
|
||||
atr_sl_max = DecimalParameter(0.05, 0.12, default=0.08, decimals=2, space="sell", optimize=False)
|
||||
time_stop_hours = IntParameter(48, 240, default=120, space="sell", optimize=False)
|
||||
|
||||
# Branch A:仅 Spring / UTAD
|
||||
use_spring_sig = CategoricalParameter([True, False], default=True, space="buy", optimize=False)
|
||||
use_utad_sig = CategoricalParameter([True, False], default=True, space="buy", optimize=False)
|
||||
use_sos_sig = CategoricalParameter([True, False], default=False, space="buy", optimize=False)
|
||||
use_sow_sig = CategoricalParameter([True, False], default=False, space="buy", optimize=False)
|
||||
|
||||
lev = 1.0
|
||||
|
||||
def informative_pairs(self):
|
||||
pairs = self.dp.current_whitelist() if self.dp else []
|
||||
tfs = {self.structure_timeframe}
|
||||
if self.bias_timeframe and self.use_bias_filter:
|
||||
tfs.add(self.bias_timeframe)
|
||||
return [(pair, tf) for pair in pairs for tf in tfs]
|
||||
|
||||
def _add_wyckoff_structure(self, df: DataFrame) -> DataFrame:
|
||||
lb = int(self.range_lookback.value)
|
||||
|
||||
df["atr"] = ta.ATR(df, timeperiod=14)
|
||||
df["ema50"] = ta.EMA(df, timeperiod=50)
|
||||
df["ema200"] = ta.EMA(df, timeperiod=200)
|
||||
df["adx"] = ta.ADX(df, timeperiod=14)
|
||||
df["rsi"] = ta.RSI(df, timeperiod=14)
|
||||
df["volume_ma"] = ta.SMA(df, timeperiod=20, price="volume")
|
||||
|
||||
df["tr_high"] = df["high"].rolling(lb).max()
|
||||
df["tr_low"] = df["low"].rolling(lb).min()
|
||||
df["tr_mid"] = (df["tr_high"] + df["tr_low"]) / 2.0
|
||||
df["tr_width"] = (df["tr_high"] - df["tr_low"]) / df["tr_mid"].replace(0, np.nan)
|
||||
df["tr_width_ma"] = df["tr_width"].rolling(lb).mean()
|
||||
|
||||
rng = (df["tr_high"] - df["tr_low"]).replace(0, np.nan)
|
||||
df["tr_pos"] = (df["close"] - df["tr_low"]) / rng
|
||||
|
||||
df["in_range"] = (df["tr_width"] < df["tr_width_ma"] * 1.35) & (df["adx"] < 28)
|
||||
df["ema50_slope"] = df["ema50"] - df["ema50"].shift(8)
|
||||
df["prior_down"] = df["ema50_slope"].shift(lb) < 0
|
||||
df["prior_up"] = df["ema50_slope"].shift(lb) > 0
|
||||
|
||||
down_bar = df["close"] < df["open"]
|
||||
up_bar = df["close"] > df["open"]
|
||||
vol_down = np.where(down_bar, df["volume"], np.nan)
|
||||
vol_up = np.where(up_bar, df["volume"], np.nan)
|
||||
df["vol_down_ma"] = pd.Series(vol_down, index=df.index).rolling(10, min_periods=3).mean()
|
||||
df["vol_up_ma"] = pd.Series(vol_up, index=df.index).rolling(10, min_periods=3).mean()
|
||||
df["effort_absorb"] = (
|
||||
df["vol_down_ma"].notna()
|
||||
& df["vol_up_ma"].notna()
|
||||
& (df["vol_up_ma"] > df["vol_down_ma"] * 1.05)
|
||||
)
|
||||
|
||||
df["accum_ctx"] = (
|
||||
df["in_range"]
|
||||
& (df["prior_down"] | (df["close"] < df["ema50"]))
|
||||
& (df["tr_pos"] < float(self.tr_pos_long_max.value))
|
||||
)
|
||||
df["distrib_ctx"] = (
|
||||
df["in_range"]
|
||||
& (df["prior_up"] | (df["close"] > df["ema50"]))
|
||||
& (df["tr_pos"] > float(self.tr_pos_short_min.value))
|
||||
)
|
||||
df["bull_bias"] = (df["close"] > df["ema200"]) & (df["ema50"] > df["ema200"])
|
||||
df["bear_bias"] = (df["close"] < df["ema200"]) & (df["ema50"] < df["ema200"])
|
||||
df["vol_spike"] = df["volume"] > df["volume_ma"] * float(self.vol_spike_mult.value)
|
||||
return df
|
||||
|
||||
def _merge_tf(self, dataframe: DataFrame, pair: str, tf: str) -> DataFrame:
|
||||
inf = self.dp.get_pair_dataframe(pair=pair, timeframe=tf)
|
||||
inf = self._add_wyckoff_structure(inf)
|
||||
keep = [
|
||||
"date", "atr", "ema50", "ema200", "adx", "rsi",
|
||||
"tr_high", "tr_low", "tr_mid", "tr_width", "tr_pos",
|
||||
"in_range", "accum_ctx", "distrib_ctx",
|
||||
"vol_spike", "effort_absorb", "prior_down", "prior_up",
|
||||
"bull_bias", "bear_bias",
|
||||
]
|
||||
inf = inf[[c for c in keep if c in inf.columns]].copy()
|
||||
return merge_informative_pair(dataframe, inf, self.timeframe, tf, ffill=True)
|
||||
|
||||
def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
|
||||
pair = metadata["pair"]
|
||||
stf = self.structure_timeframe
|
||||
dataframe = self._merge_tf(dataframe, pair, stf)
|
||||
|
||||
btf = self.bias_timeframe
|
||||
if btf and self.use_bias_filter and btf != stf:
|
||||
dataframe = self._merge_tf(dataframe, pair, btf)
|
||||
|
||||
ss = f"_{stf}"
|
||||
dataframe["atr"] = ta.ATR(dataframe, timeperiod=14)
|
||||
dataframe["ema21"] = ta.EMA(dataframe, timeperiod=21)
|
||||
dataframe["ema50"] = ta.EMA(dataframe, timeperiod=50)
|
||||
dataframe["rsi"] = ta.RSI(dataframe, timeperiod=14)
|
||||
dataframe["volume_ma"] = ta.SMA(dataframe, timeperiod=20, price="volume")
|
||||
dataframe["vol_ok"] = dataframe["volume"] > dataframe["volume_ma"] * float(self.vol_spike_mult.value)
|
||||
|
||||
tr_high = dataframe[f"tr_high{ss}"]
|
||||
tr_low = dataframe[f"tr_low{ss}"]
|
||||
pierce = float(self.spring_pierce_pct.value)
|
||||
|
||||
accum_soft = (
|
||||
dataframe[f"accum_ctx{ss}"].fillna(False).astype(bool)
|
||||
| (
|
||||
dataframe[f"in_range{ss}"].fillna(False).astype(bool)
|
||||
& dataframe[f"prior_down{ss}"].fillna(False).astype(bool)
|
||||
& (dataframe[f"tr_pos{ss}"] < float(self.tr_pos_long_max.value))
|
||||
)
|
||||
)
|
||||
distrib_soft = (
|
||||
dataframe[f"distrib_ctx{ss}"].fillna(False).astype(bool)
|
||||
| (
|
||||
dataframe[f"in_range{ss}"].fillna(False).astype(bool)
|
||||
& dataframe[f"prior_up{ss}"].fillna(False).astype(bool)
|
||||
& (dataframe[f"tr_pos{ss}"] > float(self.tr_pos_short_min.value))
|
||||
)
|
||||
)
|
||||
|
||||
if btf and self.use_bias_filter:
|
||||
bs = f"_{btf}" if btf != stf else ss
|
||||
if f"bear_bias{bs}" in dataframe.columns:
|
||||
dataframe["bias_long_ok"] = ~dataframe[f"bear_bias{bs}"].fillna(False).astype(bool)
|
||||
dataframe["bias_short_ok"] = ~dataframe[f"bull_bias{bs}"].fillna(False).astype(bool)
|
||||
else:
|
||||
dataframe["bias_long_ok"] = True
|
||||
dataframe["bias_short_ok"] = True
|
||||
else:
|
||||
dataframe["bias_long_ok"] = True
|
||||
dataframe["bias_short_ok"] = True
|
||||
|
||||
vol_mild = dataframe["volume"] > dataframe["volume_ma"] * max(1.1, float(self.vol_spike_mult.value) * 0.85)
|
||||
|
||||
dataframe["spring"] = (
|
||||
tr_low.notna()
|
||||
& (dataframe["low"] < tr_low * (1.0 - pierce))
|
||||
& (dataframe["close"] > tr_low)
|
||||
& (dataframe["close"] > dataframe["open"])
|
||||
& accum_soft
|
||||
& vol_mild
|
||||
& (dataframe["rsi"] < 58)
|
||||
& dataframe["bias_long_ok"]
|
||||
)
|
||||
dataframe["utad"] = (
|
||||
tr_high.notna()
|
||||
& (dataframe["high"] > tr_high * (1.0 + pierce))
|
||||
& (dataframe["close"] < tr_high)
|
||||
& (dataframe["close"] < dataframe["open"])
|
||||
& distrib_soft
|
||||
& vol_mild
|
||||
& (dataframe["rsi"] > 42)
|
||||
& dataframe["bias_short_ok"]
|
||||
)
|
||||
# 基线不进 SOS/SOW;保留列供 exit 参考
|
||||
dataframe["sos"] = False
|
||||
dataframe["sow"] = False
|
||||
|
||||
for col in ["spring", "utad", "sos", "sow", "vol_ok", "bias_long_ok", "bias_short_ok"]:
|
||||
dataframe[col] = dataframe[col].fillna(False).astype(bool)
|
||||
dataframe["setup_type"] = ""
|
||||
dataframe.loc[dataframe["spring"], "setup_type"] = "SPRING_LONG"
|
||||
dataframe.loc[dataframe["utad"], "setup_type"] = "UTAD_SHORT"
|
||||
return dataframe
|
||||
|
||||
def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
|
||||
dataframe["enter_long"] = 0
|
||||
dataframe["enter_short"] = 0
|
||||
dataframe["enter_tag"] = ""
|
||||
|
||||
vol_ok = dataframe["volume"] > 0
|
||||
|
||||
# 分开标签:禁止把 SPRING / UTAD 混成同一统计桶
|
||||
if bool(self.use_spring_sig.value):
|
||||
cond = vol_ok & dataframe["spring"]
|
||||
dataframe.loc[cond, ["enter_long", "enter_tag"]] = (1, "SPRING_LONG")
|
||||
|
||||
if bool(self.use_utad_sig.value):
|
||||
cond = vol_ok & dataframe["utad"]
|
||||
dataframe.loc[cond, ["enter_short", "enter_tag"]] = (1, "UTAD_SHORT")
|
||||
|
||||
self._apply_regime_filter(dataframe)
|
||||
return dataframe
|
||||
|
||||
def _apply_regime_filter(self, dataframe: DataFrame) -> None:
|
||||
rm = getattr(self, "regime_mode", "all")
|
||||
if rm == "all" or not self.bias_timeframe:
|
||||
return
|
||||
bs = f"_{self.bias_timeframe}"
|
||||
bc, ec = f"bull_bias{bs}", f"bear_bias{bs}"
|
||||
if bc not in dataframe.columns or ec not in dataframe.columns:
|
||||
return
|
||||
bull = dataframe[bc].fillna(False).astype(bool)
|
||||
bear = dataframe[ec].fillna(False).astype(bool)
|
||||
both = bull & bear
|
||||
bull, bear = bull & ~both, bear & ~both
|
||||
range_m = (~bull) & (~bear)
|
||||
if rm == "bull":
|
||||
mask = ~bull
|
||||
elif rm == "bear":
|
||||
mask = ~bear
|
||||
elif rm == "range":
|
||||
mask = ~range_m
|
||||
elif rm == "trend":
|
||||
mask = range_m # Range disabled
|
||||
else:
|
||||
return
|
||||
dataframe.loc[mask, ["enter_long", "enter_short"]] = (0, 0)
|
||||
dataframe.loc[mask, "enter_tag"] = ""
|
||||
|
||||
def populate_exit_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
|
||||
dataframe["exit_long"] = 0
|
||||
dataframe["exit_short"] = 0
|
||||
dataframe["exit_tag"] = ""
|
||||
ss = f"_{self.structure_timeframe}"
|
||||
|
||||
exit_long = dataframe["utad"] | (
|
||||
dataframe[f"distrib_ctx{ss}"].fillna(False).astype(bool)
|
||||
& (dataframe["close"] < dataframe["ema21"])
|
||||
& (dataframe["rsi"] < 45)
|
||||
)
|
||||
exit_short = dataframe["spring"] | (
|
||||
dataframe[f"accum_ctx{ss}"].fillna(False).astype(bool)
|
||||
& (dataframe["close"] > dataframe["ema21"])
|
||||
& (dataframe["rsi"] > 55)
|
||||
)
|
||||
dataframe.loc[exit_long, ["exit_long", "exit_tag"]] = (1, "wyckoff_phase_flip")
|
||||
dataframe.loc[exit_short, ["exit_short", "exit_tag"]] = (1, "wyckoff_phase_flip")
|
||||
return dataframe
|
||||
|
||||
def custom_stoploss(
|
||||
self, pair: str, trade: Trade, current_time: datetime,
|
||||
current_rate: float, current_profit: float, after_fill: bool, **kwargs,
|
||||
) -> Optional[float]:
|
||||
dataframe, _ = self.dp.get_analyzed_dataframe(pair, self.timeframe)
|
||||
if dataframe.empty:
|
||||
return None
|
||||
last = dataframe.iloc[-1]
|
||||
atr = float(last["atr"]) if pd.notna(last["atr"]) else 0.0
|
||||
if atr <= 0 or trade.open_rate <= 0:
|
||||
return None
|
||||
|
||||
atr_dist = float(self.atr_sl_mult.value) * atr
|
||||
tag = trade.enter_tag or ""
|
||||
buffer = atr * 0.15
|
||||
|
||||
if after_fill and trade.get_custom_data("struct_stop") is None:
|
||||
if trade.is_short:
|
||||
trade.set_custom_data("struct_stop", float(last["high"]) + buffer)
|
||||
else:
|
||||
trade.set_custom_data("struct_stop", float(last["low"]) - buffer)
|
||||
|
||||
struct = trade.get_custom_data("struct_stop")
|
||||
if trade.is_short:
|
||||
atr_stop = trade.open_rate + atr_dist
|
||||
stop_price = min(atr_stop, float(struct)) if struct is not None else atr_stop
|
||||
else:
|
||||
atr_stop = trade.open_rate - atr_dist
|
||||
stop_price = max(atr_stop, float(struct)) if struct is not None else atr_stop
|
||||
|
||||
raw = abs(trade.open_rate - stop_price) / trade.open_rate
|
||||
raw = min(max(raw, float(self.atr_sl_min.value)), float(self.atr_sl_max.value))
|
||||
if struct is not None and tag in (
|
||||
"SPRING_LONG", "UTAD_SHORT", "SPRING", "UTAD", "wyckoff_spring", "wyckoff_utad",
|
||||
):
|
||||
sl = stoploss_from_absolute(
|
||||
stop_price, current_rate, is_short=trade.is_short, leverage=trade.leverage
|
||||
)
|
||||
return sl if sl and sl > 0 else None
|
||||
return stoploss_from_open(
|
||||
-raw, current_profit, is_short=trade.is_short, leverage=trade.leverage
|
||||
) or None
|
||||
|
||||
def custom_exit(
|
||||
self, pair: str, trade: Trade, current_time: datetime,
|
||||
current_rate: float, current_profit: float, **kwargs,
|
||||
) -> Optional[str]:
|
||||
hours = (current_time - trade.open_date_utc).total_seconds() / 3600
|
||||
if hours > float(self.time_stop_hours.value) and current_profit < 0:
|
||||
return "wyckoff_time_stop"
|
||||
if hours > float(self.time_stop_hours.value) * 2:
|
||||
return "wyckoff_time_stop_max"
|
||||
return None
|
||||
|
||||
def leverage(
|
||||
self, pair: str, current_time: datetime, current_rate: float,
|
||||
proposed_leverage: float, max_leverage: float, entry_tag: Optional[str],
|
||||
side: str, **kwargs,
|
||||
) -> float:
|
||||
return min(self.lev, max_leverage)
|
||||
@@ -0,0 +1,321 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Maker Edge Report v0.1
|
||||
|
||||
章节:
|
||||
1. Fill Quality
|
||||
2. Adverse Selection
|
||||
3. MAE/MFE (Price + Time)
|
||||
4. State Attribution
|
||||
5. Spread Capture / Quote Lifecycle
|
||||
|
||||
假设:
|
||||
H1: P(ret_30s 有利) > 50%
|
||||
H2: restore exit 优于 all fills
|
||||
H3: 亏损集中在某类状态 → 应撤单而非止损
|
||||
|
||||
用法:
|
||||
python user_data/Chan/strategies/analyze_maker_edge.py
|
||||
python user_data/Chan/strategies/analyze_maker_edge.py --report
|
||||
python user_data/Chan/strategies/analyze_maker_edge.py --min-fills 500
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
|
||||
def load_events(log_dir: Path) -> pd.DataFrame:
|
||||
rows = []
|
||||
files = sorted(log_dir.glob("*.jsonl"))
|
||||
if not files:
|
||||
raise FileNotFoundError(f"No jsonl in {log_dir}")
|
||||
for f in files:
|
||||
for line in f.read_text(encoding="utf-8").splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
rows.append(json.loads(line))
|
||||
return pd.DataFrame(rows)
|
||||
|
||||
|
||||
def _fav_ret(side: pd.Series, fill: pd.Series, px: pd.Series) -> pd.Series:
|
||||
"""多头:价格涨为正;空头:价格跌为正。"""
|
||||
raw = (px - fill) / fill
|
||||
return np.where(side == "long", raw, -raw)
|
||||
|
||||
|
||||
def report(df: pd.DataFrame, min_fills: int = 500, out_path: Path | None = None) -> None:
|
||||
fills = df[df["event"] == "fill"].copy() if "event" in df.columns else pd.DataFrame()
|
||||
paths = df[df["event"] == "fill_path"].copy() if "event" in df.columns else pd.DataFrame()
|
||||
created = df[df["event"] == "quote_created"].copy() if "event" in df.columns else pd.DataFrame()
|
||||
canceled = df[df["event"] == "quote_canceled"].copy() if "event" in df.columns else pd.DataFrame()
|
||||
qfilled = df[df["event"] == "quote_filled"].copy() if "event" in df.columns else pd.DataFrame()
|
||||
exits = df[df["event"] == "fill_exit"].copy() if "event" in df.columns else pd.DataFrame()
|
||||
|
||||
lines: list[str] = []
|
||||
|
||||
def p(s: str = ""):
|
||||
lines.append(s)
|
||||
print(s)
|
||||
|
||||
p("=" * 72)
|
||||
p("Maker Edge Report v0.1")
|
||||
p("=" * 72)
|
||||
p(f"quote_created : {len(created)}")
|
||||
p(f"quote_canceled: {len(canceled)}")
|
||||
p(f"quote_filled : {len(qfilled)}")
|
||||
p(f"fills : {len(fills)}")
|
||||
p(f"fill_paths : {len(paths)} (需成交后≥5m)")
|
||||
p(f"target fills : ≥{min_fills} [{'OK' if len(fills) >= min_fills else 'COLLECTING'}]")
|
||||
|
||||
if fills.empty:
|
||||
p("\n尚无 fill。先跑 Dry-run 探针。")
|
||||
return
|
||||
|
||||
# merge exit_reason onto paths
|
||||
if not exits.empty and not paths.empty and "fill_id" in exits.columns:
|
||||
er = exits.drop_duplicates("fill_id").set_index("fill_id")["exit_reason"]
|
||||
if "exit_reason" not in paths.columns or paths["exit_reason"].isna().all():
|
||||
paths = paths.merge(er.rename("exit_reason_x"), left_on="fill_id", right_index=True, how="left")
|
||||
if "exit_reason" not in paths.columns:
|
||||
paths["exit_reason"] = paths.get("exit_reason_x")
|
||||
else:
|
||||
paths["exit_reason"] = paths["exit_reason"].fillna(paths.get("exit_reason_x"))
|
||||
|
||||
# merge fill meta into paths
|
||||
if not paths.empty:
|
||||
cols = [
|
||||
c
|
||||
for c in [
|
||||
"side",
|
||||
"fill_price",
|
||||
"fill_reason",
|
||||
"time_to_fill",
|
||||
"trend_state",
|
||||
"volatility_regime",
|
||||
"pre_5s_deteriorated",
|
||||
"obi",
|
||||
"trade_imbalance",
|
||||
"spread",
|
||||
"entry_tag",
|
||||
]
|
||||
if c in fills.columns
|
||||
]
|
||||
if cols and "fill_id" in fills.columns:
|
||||
meta = fills.drop_duplicates("fill_id")[["fill_id"] + cols]
|
||||
paths = paths.merge(meta, on="fill_id", how="left", suffixes=("", "_f"))
|
||||
|
||||
# -------------------- 1. Fill Quality --------------------
|
||||
p("\n" + "-" * 72)
|
||||
p("1. Fill Quality")
|
||||
p("-" * 72)
|
||||
if "time_to_fill" in fills.columns:
|
||||
ttf = fills["time_to_fill"].dropna()
|
||||
if len(ttf):
|
||||
p(
|
||||
f"time_to_fill mean={ttf.mean():.1f}s median={ttf.median():.1f}s "
|
||||
f"p90={ttf.quantile(0.9):.1f}s"
|
||||
)
|
||||
fast = fills[fills["time_to_fill"].fillna(1e9) <= 10]
|
||||
slow = fills[fills["time_to_fill"].fillna(0) > 30]
|
||||
p(f"fast fills (≤10s): {len(fast)} slow fills (>30s): {len(slow)}")
|
||||
if "fill_reason" in fills.columns:
|
||||
p("fill_reason: " + str(fills["fill_reason"].value_counts().to_dict()))
|
||||
if "pre_5s_deteriorated" in fills.columns:
|
||||
det = fills["pre_5s_deteriorated"].fillna(False).astype(bool)
|
||||
p(f"pre_5s book deteriorated: {det.mean()*100:.1f}% of fills")
|
||||
|
||||
n_created = max(len(created), 1)
|
||||
p(f"fill rate (filled/created): {len(qfilled)/n_created*100:.1f}%")
|
||||
if len(canceled):
|
||||
p(f"cancel rate: {len(canceled)/n_created*100:.1f}%")
|
||||
|
||||
# -------------------- 2. Adverse Selection --------------------
|
||||
p("\n" + "-" * 72)
|
||||
p("2. Adverse Selection (fill 后收益分布)")
|
||||
p("-" * 72)
|
||||
if paths.empty:
|
||||
p("等待 fill_path 完成(成交后 ≥5 分钟)…")
|
||||
else:
|
||||
side = paths["side"] if "side" in paths.columns else paths.get("side_f")
|
||||
fp = paths["fill_price"]
|
||||
for label, col in [
|
||||
("10s", "after_10s_price"),
|
||||
("30s", "after_30s_price"),
|
||||
("1m", "after_1m_price"),
|
||||
("5m", "after_5m_price"),
|
||||
]:
|
||||
if col not in paths.columns:
|
||||
continue
|
||||
fav = pd.Series(_fav_ret(side, fp, paths[col]), index=paths.index)
|
||||
p(
|
||||
f" +{label:3s} mean={fav.mean()*100:+.4f}% "
|
||||
f"median={fav.median()*100:+.4f}% "
|
||||
f"P(fav)={ (fav>0).mean()*100:.1f}% n={fav.notna().sum()}"
|
||||
)
|
||||
# toxic: 10s 立刻不利
|
||||
if "after_10s_price" in paths.columns:
|
||||
fav10 = pd.Series(_fav_ret(side, fp, paths["after_10s_price"]), index=paths.index)
|
||||
p(f" toxic@10s (fav<0): { (fav10<0).mean()*100:.1f}% → 接毒比例")
|
||||
|
||||
# -------------------- 3. MAE / MFE --------------------
|
||||
p("\n" + "-" * 72)
|
||||
p("3. MAE / MFE (Price + Time)")
|
||||
p("-" * 72)
|
||||
if not paths.empty:
|
||||
if "price_mae" in paths.columns:
|
||||
p(
|
||||
f"Price MAE mean={paths['price_mae'].mean():+.2f} "
|
||||
f"Price MFE mean={paths['price_mfe'].mean():+.2f}"
|
||||
)
|
||||
for h in ["10s", "30s", "1m", "5m"]:
|
||||
mae_c, mfe_c = f"mae_{h}", f"mfe_{h}"
|
||||
if mae_c in paths.columns and mfe_c in paths.columns:
|
||||
p(
|
||||
f" Time@{h:3s} MAE={paths[mae_c].mean()*100:+.4f}% "
|
||||
f"MFE={paths[mfe_c].mean()*100:+.4f}%"
|
||||
)
|
||||
if "mae_5m" in paths.columns and "mfe_5m" in paths.columns:
|
||||
ratio = paths["mfe_5m"].mean() / abs(paths["mae_5m"].mean()) if paths["mae_5m"].mean() != 0 else np.nan
|
||||
p(f" MFE/|MAE| @5m = {ratio:.2f}")
|
||||
|
||||
# -------------------- 4. State Attribution --------------------
|
||||
p("\n" + "-" * 72)
|
||||
p("4. State Attribution (亏损集中在哪?)")
|
||||
p("-" * 72)
|
||||
if not paths.empty and "after_5m_price" in paths.columns:
|
||||
side = paths["side"] if "side" in paths.columns else paths.get("side_f")
|
||||
fav5 = pd.Series(_fav_ret(side, paths["fill_price"], paths["after_5m_price"]), index=paths.index)
|
||||
paths = paths.copy()
|
||||
paths["_fav5"] = fav5
|
||||
paths["_loss"] = fav5 < 0
|
||||
loss_rate = float(paths["_loss"].mean())
|
||||
p(f"overall loss@5m: {loss_rate*100:.1f}%")
|
||||
|
||||
for col in ["trend_state", "volatility_regime", "fill_reason", "pre_5s_deteriorated"]:
|
||||
c = col if col in paths.columns else (col + "_f" if col + "_f" in paths.columns else None)
|
||||
if not c:
|
||||
continue
|
||||
p(f"\n by {c}:")
|
||||
g = paths.groupby(c).agg(
|
||||
n=("_fav5", "count"),
|
||||
loss_rate=("_loss", "mean"),
|
||||
mean_ret=("_fav5", "mean"),
|
||||
)
|
||||
for idx, row in g.iterrows():
|
||||
p(
|
||||
f" {idx}: n={int(row['n'])} loss={row['loss_rate']*100:.1f}% "
|
||||
f"E[ret]={row['mean_ret']*100:+.4f}%"
|
||||
)
|
||||
|
||||
# -------------------- 5. Spread Capture / Lifecycle --------------------
|
||||
p("\n" + "-" * 72)
|
||||
p("5. Spread Capture / Quote Lifecycle")
|
||||
p("-" * 72)
|
||||
if "spread" in fills.columns and fills["spread"].notna().any():
|
||||
mid = (fills.get("bid_price", 0) + fills.get("ask_price", 0)) / 2
|
||||
# 简化:相对价差
|
||||
p(f"spread at fill mean={fills['spread'].mean():.4f} ({(fills['spread']/fills['fill_price']).mean()*100:.5f}%)")
|
||||
if not paths.empty and "after_30s_price" in paths.columns:
|
||||
side = paths["side"] if "side" in paths.columns else paths.get("side_f")
|
||||
fav30 = pd.Series(_fav_ret(side, paths["fill_price"], paths["after_30s_price"]), index=paths.index)
|
||||
p(f"mean edge@30s (proxy spread capture): {fav30.mean()*100:+.4f}%")
|
||||
|
||||
# -------------------- Hypotheses --------------------
|
||||
p("\n" + "-" * 72)
|
||||
p("Hypotheses")
|
||||
p("-" * 72)
|
||||
|
||||
# H1
|
||||
h1 = None
|
||||
if not paths.empty and "after_30s_price" in paths.columns:
|
||||
side = paths["side"] if "side" in paths.columns else paths.get("side_f")
|
||||
fav30 = pd.Series(_fav_ret(side, paths["fill_price"], paths["after_30s_price"]), index=paths.index)
|
||||
h1 = float((fav30 > 0).mean())
|
||||
p(f"H1 P(fav@30s)>50%: {h1*100:.1f}% [{'PASS' if h1>0.5 else 'FAIL'}]")
|
||||
else:
|
||||
p("H1: insufficient fill_path with after_30s")
|
||||
|
||||
# H2 restore vs all
|
||||
if not paths.empty and "after_5m_price" in paths.columns:
|
||||
side = paths["side"] if "side" in paths.columns else paths.get("side_f")
|
||||
fav5 = pd.Series(_fav_ret(side, paths["fill_price"], paths["after_5m_price"]), index=paths.index)
|
||||
er_col = "exit_reason" if "exit_reason" in paths.columns else None
|
||||
if er_col and paths[er_col].notna().any():
|
||||
restore_mask = paths[er_col].astype(str).str.contains("restore", case=False, na=False)
|
||||
if restore_mask.any():
|
||||
r_all = float(fav5.mean())
|
||||
r_res = float(fav5[restore_mask].mean())
|
||||
verdict = (
|
||||
"PASS"
|
||||
if r_res > r_all + 1e-12
|
||||
else ("INCONCLUSIVE" if abs(r_res - r_all) < 1e-12 else "FAIL")
|
||||
)
|
||||
p(
|
||||
f"H2 restore vs all @5m: restore={r_res*100:+.4f}% all={r_all*100:+.4f}% "
|
||||
f"[{verdict}] n_restore={int(restore_mask.sum())}"
|
||||
)
|
||||
else:
|
||||
p("H2: no restore exits tagged yet")
|
||||
else:
|
||||
p("H2: exit_reason not linked yet (need closed trades)")
|
||||
else:
|
||||
p("H2: waiting for paths")
|
||||
|
||||
# H3 concentrated losses
|
||||
if not paths.empty and "_loss" in paths.columns and paths["_loss"].any():
|
||||
losses = paths[paths["_loss"]]
|
||||
for col in ["trend_state", "volatility_regime", "fill_reason"]:
|
||||
c = col if col in losses.columns else None
|
||||
if c and losses[c].notna().any():
|
||||
top = losses[c].value_counts(normalize=True).head(1)
|
||||
if len(top):
|
||||
k, v = top.index[0], float(top.iloc[0])
|
||||
p(f"H3 loss concentration: {v*100:.1f}% of losses in {c}={k} "
|
||||
f"[{'ACTION: cancel in this state' if v>=0.5 else 'diffuse'}]")
|
||||
else:
|
||||
p("H3: need completed paths with losses")
|
||||
|
||||
p("\n" + "=" * 72)
|
||||
p("Next: accumulate ≥500 fills (ideal 1000) before designing quote model / v1.2.")
|
||||
p("=" * 72)
|
||||
|
||||
if out_path:
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
out_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
print(f"\nReport saved: {out_path}")
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument(
|
||||
"--dir",
|
||||
type=str,
|
||||
default=str(Path(__file__).resolve().parents[2] / "logs" / "maker_edge"),
|
||||
)
|
||||
ap.add_argument("--min-fills", type=int, default=500)
|
||||
ap.add_argument("--report", action="store_true", help="also write markdown/txt report")
|
||||
args = ap.parse_args()
|
||||
log_dir = Path(args.dir)
|
||||
if not log_dir.exists():
|
||||
print(f"日志目录不存在: {log_dir}")
|
||||
return
|
||||
try:
|
||||
df = load_events(log_dir)
|
||||
except FileNotFoundError as e:
|
||||
print(e)
|
||||
return
|
||||
out = None
|
||||
if args.report:
|
||||
out = Path(__file__).resolve().parents[2] / "logs" / "maker_edge" / "Maker_Edge_Report_v0.1.txt"
|
||||
report(df, min_fills=args.min_fills, out_path=out)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,566 @@
|
||||
"""
|
||||
Maker Edge 事件记录器(Dry-run / Live)— Execution Reality Layer
|
||||
|
||||
事件:
|
||||
- quote_created / quote_canceled / quote_filled (报价生命周期)
|
||||
- book_tick (可选心跳,用于成交前5s盘口)
|
||||
- fill (成交瞬间 + 盘口状态)
|
||||
- fill_path (10s/30s/1m/5m + Price/Time MAE/MFE)
|
||||
|
||||
输出:user_data/logs/maker_edge/YYYYMMDD.jsonl
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
from collections import deque
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _utc_now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _iso(ts: datetime | float | None = None) -> str:
|
||||
if ts is None:
|
||||
t = _utc_now()
|
||||
elif isinstance(ts, (int, float)):
|
||||
t = datetime.fromtimestamp(ts, tz=timezone.utc)
|
||||
else:
|
||||
t = ts if ts.tzinfo else ts.replace(tzinfo=timezone.utc)
|
||||
return t.isoformat()
|
||||
|
||||
|
||||
@dataclass
|
||||
class MicroSnapshot:
|
||||
best_bid: float = 0.0
|
||||
best_ask: float = 0.0
|
||||
mid: float = 0.0
|
||||
spread: float = 0.0
|
||||
bid_depth_1: float = 0.0
|
||||
ask_depth_1: float = 0.0
|
||||
bid_depth_5: float = 0.0
|
||||
ask_depth_5: float = 0.0
|
||||
bid_depth: float = 0.0 # top-N
|
||||
ask_depth: float = 0.0
|
||||
obi: float = 0.0
|
||||
delta: float = 0.0
|
||||
trade_imbalance: float = 0.0 # (buy-sell)/(buy+sell) on recent trades
|
||||
delta_efficiency: float = 0.0
|
||||
liquidation_distance: float = 0.0
|
||||
|
||||
def to_book_fields(self) -> dict[str, float]:
|
||||
return {
|
||||
"bid_price": self.best_bid,
|
||||
"ask_price": self.best_ask,
|
||||
"mid": self.mid,
|
||||
"spread": self.spread,
|
||||
"bid_depth_1": self.bid_depth_1,
|
||||
"ask_depth_1": self.ask_depth_1,
|
||||
"bid_depth_5": self.bid_depth_5,
|
||||
"ask_depth_5": self.ask_depth_5,
|
||||
"bid_depth": self.bid_depth,
|
||||
"ask_depth": self.ask_depth,
|
||||
"obi": self.obi,
|
||||
"delta": self.delta,
|
||||
"trade_imbalance": self.trade_imbalance,
|
||||
"delta_efficiency": self.delta_efficiency,
|
||||
"liquidation_distance": self.liquidation_distance,
|
||||
# 兼容旧字段
|
||||
"buy1_depth": self.bid_depth_1,
|
||||
"sell1_depth": self.ask_depth_1,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class ActiveQuote:
|
||||
quote_id: str
|
||||
pair: str
|
||||
side: str # bid / ask
|
||||
quote_price: float
|
||||
created_ts: float
|
||||
reason: str = ""
|
||||
trade_id: Optional[int] = None
|
||||
status: str = "open" # open / filled / canceled
|
||||
|
||||
|
||||
@dataclass
|
||||
class PendingFillPath:
|
||||
fill_id: str
|
||||
pair: str
|
||||
side: str
|
||||
fill_price: float
|
||||
fill_ts: float
|
||||
quote_id: Optional[str] = None
|
||||
exit_reason: Optional[str] = None
|
||||
# horizon prices
|
||||
after_10s_price: Optional[float] = None
|
||||
after_30s_price: Optional[float] = None
|
||||
after_1m_price: Optional[float] = None
|
||||
after_5m_price: Optional[float] = None
|
||||
# running extrema
|
||||
min_price: float = 0.0
|
||||
max_price: float = 0.0
|
||||
# time-MAE: worst adverse excursion seen by each horizon (signed, adverse negative for long)
|
||||
mae_10s: Optional[float] = None
|
||||
mae_30s: Optional[float] = None
|
||||
mae_1m: Optional[float] = None
|
||||
mae_5m: Optional[float] = None
|
||||
mfe_10s: Optional[float] = None
|
||||
mfe_30s: Optional[float] = None
|
||||
mfe_1m: Optional[float] = None
|
||||
mfe_5m: Optional[float] = None
|
||||
done: bool = False
|
||||
|
||||
def __post_init__(self):
|
||||
self.min_price = self.fill_price
|
||||
self.max_price = self.fill_price
|
||||
|
||||
def signed_excursions(self) -> tuple[float, float]:
|
||||
"""Return (mae, mfe) at current min/max. mae<=0 adverse, mfe>=0 favorable."""
|
||||
if self.side == "long":
|
||||
mae = (self.min_price - self.fill_price) / self.fill_price
|
||||
mfe = (self.max_price - self.fill_price) / self.fill_price
|
||||
else:
|
||||
mae = (self.fill_price - self.max_price) / self.fill_price
|
||||
mfe = (self.fill_price - self.min_price) / self.fill_price
|
||||
return mae, mfe
|
||||
|
||||
|
||||
class MakerEdgeLogger:
|
||||
def __init__(
|
||||
self,
|
||||
log_dir: str | Path | None = None,
|
||||
levels: int = 10,
|
||||
book_history_sec: float = 30.0,
|
||||
):
|
||||
root = Path(__file__).resolve().parents[2]
|
||||
self.log_dir = Path(log_dir) if log_dir else root / "logs" / "maker_edge"
|
||||
self.log_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.levels = levels
|
||||
self.book_history_sec = book_history_sec
|
||||
self._pending: dict[str, PendingFillPath] = {}
|
||||
self._quotes: dict[str, ActiveQuote] = {} # quote_id -> ActiveQuote
|
||||
self._quotes_by_trade: dict[int, str] = {} # trade_id -> quote_id
|
||||
self._book_hist: deque[tuple[float, MicroSnapshot]] = deque(maxlen=2000)
|
||||
|
||||
def _file(self) -> Path:
|
||||
return self.log_dir / f"{_utc_now().strftime('%Y%m%d')}.jsonl"
|
||||
|
||||
def write(self, event: dict[str, Any]) -> None:
|
||||
event.setdefault("ts", _iso())
|
||||
event.setdefault("ts_epoch", time.time())
|
||||
with self._file().open("a", encoding="utf-8") as f:
|
||||
f.write(json.dumps(event, ensure_ascii=False, default=str) + "\n")
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Snapshot
|
||||
# ------------------------------------------------------------------ #
|
||||
@staticmethod
|
||||
def snapshot_from_orderbook(
|
||||
ob: dict,
|
||||
levels: int = 10,
|
||||
recent_trades: list | None = None,
|
||||
last_mid: float | None = None,
|
||||
liq_proxy_low: float | None = None,
|
||||
liq_proxy_high: float | None = None,
|
||||
) -> MicroSnapshot:
|
||||
bids = (ob.get("bids") or [])[:levels]
|
||||
asks = (ob.get("asks") or [])[:levels]
|
||||
if not bids or not asks:
|
||||
return MicroSnapshot()
|
||||
|
||||
best_bid = float(bids[0][0])
|
||||
best_ask = float(asks[0][0])
|
||||
mid = (best_bid + best_ask) / 2.0
|
||||
spread = best_ask - best_bid
|
||||
|
||||
def depth(levels_side, n):
|
||||
return sum(float(x[1]) for x in levels_side[:n])
|
||||
|
||||
bid_depth_1 = depth(bids, 1)
|
||||
ask_depth_1 = depth(asks, 1)
|
||||
bid_depth_5 = depth(bids, 5)
|
||||
ask_depth_5 = depth(asks, 5)
|
||||
bid_depth = depth(bids, levels)
|
||||
ask_depth = depth(asks, levels)
|
||||
tot = bid_depth + ask_depth
|
||||
obi = ((bid_depth - ask_depth) / tot) if tot > 0 else 0.0
|
||||
|
||||
buy_v = sell_v = 0.0
|
||||
if recent_trades:
|
||||
for t in recent_trades:
|
||||
amt = float(t.get("amount") or t.get("qty") or 0.0)
|
||||
side = (t.get("side") or "").lower()
|
||||
if side in ("buy", "b"):
|
||||
buy_v += amt
|
||||
elif side in ("sell", "s"):
|
||||
sell_v += amt
|
||||
delta = buy_v - sell_v
|
||||
timb_den = buy_v + sell_v
|
||||
trade_imbalance = ((buy_v - sell_v) / timb_den) if timb_den > 0 else 0.0
|
||||
|
||||
de = 0.0
|
||||
if last_mid and mid and abs(delta) > 1e-12:
|
||||
de = ((mid - last_mid) / last_mid) / delta
|
||||
|
||||
liq_dist = 0.0
|
||||
if liq_proxy_low and liq_proxy_high and mid:
|
||||
rng = liq_proxy_high - liq_proxy_low
|
||||
if rng > 0:
|
||||
liq_dist = ((mid - liq_proxy_low) / rng) * 2 - 1
|
||||
|
||||
return MicroSnapshot(
|
||||
best_bid=best_bid,
|
||||
best_ask=best_ask,
|
||||
mid=mid,
|
||||
spread=spread,
|
||||
bid_depth_1=bid_depth_1,
|
||||
ask_depth_1=ask_depth_1,
|
||||
bid_depth_5=bid_depth_5,
|
||||
ask_depth_5=ask_depth_5,
|
||||
bid_depth=bid_depth,
|
||||
ask_depth=ask_depth,
|
||||
obi=obi,
|
||||
delta=delta,
|
||||
trade_imbalance=trade_imbalance,
|
||||
delta_efficiency=de,
|
||||
liquidation_distance=liq_dist,
|
||||
)
|
||||
|
||||
def record_book(self, snap: MicroSnapshot, now: float | None = None) -> None:
|
||||
now = now or time.time()
|
||||
self._book_hist.append((now, snap))
|
||||
# trim old
|
||||
cutoff = now - self.book_history_sec
|
||||
while self._book_hist and self._book_hist[0][0] < cutoff:
|
||||
self._book_hist.popleft()
|
||||
|
||||
def book_at(self, target_ts: float) -> Optional[MicroSnapshot]:
|
||||
"""取最接近 target_ts 的历史盘口(用于成交前5s)。"""
|
||||
if not self._book_hist:
|
||||
return None
|
||||
best = min(self._book_hist, key=lambda x: abs(x[0] - target_ts))
|
||||
return best[1]
|
||||
|
||||
def book_deterioration(self, side: str, now: float | None = None, lookback: float = 5.0) -> dict:
|
||||
"""
|
||||
成交前 lookback 秒盘口是否恶化。
|
||||
long: bid_depth 下降 / ask_depth 上升 / mid 下跌 → 恶化
|
||||
"""
|
||||
now = now or time.time()
|
||||
cur = self.book_at(now)
|
||||
past = self.book_at(now - lookback)
|
||||
if not cur or not past or past.mid <= 0:
|
||||
return {"book_ok": False}
|
||||
mid_chg = (cur.mid - past.mid) / past.mid
|
||||
bid5_chg = (cur.bid_depth_5 - past.bid_depth_5) / past.bid_depth_5 if past.bid_depth_5 else 0.0
|
||||
ask5_chg = (cur.ask_depth_5 - past.ask_depth_5) / past.ask_depth_5 if past.ask_depth_5 else 0.0
|
||||
obi_chg = cur.obi - past.obi
|
||||
if side == "long":
|
||||
deteriorated = (mid_chg < -0.00005) or (bid5_chg < -0.15) or (obi_chg < -0.1)
|
||||
else:
|
||||
deteriorated = (mid_chg > 0.00005) or (ask5_chg < -0.15) or (obi_chg > 0.1)
|
||||
return {
|
||||
"book_ok": True,
|
||||
"pre_5s_mid_chg": mid_chg,
|
||||
"pre_5s_bid_depth_5_chg": bid5_chg,
|
||||
"pre_5s_ask_depth_5_chg": ask5_chg,
|
||||
"pre_5s_obi_chg": obi_chg,
|
||||
"pre_5s_deteriorated": bool(deteriorated),
|
||||
"pre_5s_bid_depth_1": past.bid_depth_1,
|
||||
"pre_5s_ask_depth_1": past.ask_depth_1,
|
||||
"pre_5s_bid_depth_5": past.bid_depth_5,
|
||||
"pre_5s_ask_depth_5": past.ask_depth_5,
|
||||
"pre_5s_obi": past.obi,
|
||||
"pre_5s_spread": past.spread,
|
||||
"pre_5s_trade_imbalance": past.trade_imbalance,
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Quote lifecycle
|
||||
# ------------------------------------------------------------------ #
|
||||
def create_quote(
|
||||
self,
|
||||
pair: str,
|
||||
side: str,
|
||||
quote_price: float,
|
||||
inventory: float,
|
||||
snap: MicroSnapshot,
|
||||
reason: str = "",
|
||||
trade_id: Optional[int] = None,
|
||||
state: dict | None = None,
|
||||
) -> str:
|
||||
qid = uuid.uuid4().hex[:16]
|
||||
now = time.time()
|
||||
q = ActiveQuote(
|
||||
quote_id=qid,
|
||||
pair=pair,
|
||||
side=side,
|
||||
quote_price=quote_price,
|
||||
created_ts=now,
|
||||
reason=reason,
|
||||
trade_id=trade_id,
|
||||
status="open",
|
||||
)
|
||||
self._quotes[qid] = q
|
||||
if trade_id is not None:
|
||||
self._quotes_by_trade[trade_id] = qid
|
||||
|
||||
ev = {
|
||||
"event": "quote_created",
|
||||
"quote_id": qid,
|
||||
"pair": pair,
|
||||
"side": side,
|
||||
"quote_price": quote_price,
|
||||
"quote_created_time": _iso(now),
|
||||
"quote_created_epoch": now,
|
||||
"inventory": inventory,
|
||||
"reason": reason,
|
||||
"trade_id": trade_id,
|
||||
"status": "open",
|
||||
"filled": False,
|
||||
}
|
||||
ev.update(snap.to_book_fields())
|
||||
if state:
|
||||
ev.update(state)
|
||||
self.write(ev)
|
||||
return qid
|
||||
|
||||
def cancel_quote(
|
||||
self,
|
||||
quote_id: str | None = None,
|
||||
trade_id: Optional[int] = None,
|
||||
reason: str = "timeout",
|
||||
snap: MicroSnapshot | None = None,
|
||||
) -> None:
|
||||
q = None
|
||||
if quote_id and quote_id in self._quotes:
|
||||
q = self._quotes[quote_id]
|
||||
elif trade_id is not None and trade_id in self._quotes_by_trade:
|
||||
q = self._quotes.get(self._quotes_by_trade[trade_id])
|
||||
if q is None or q.status != "open":
|
||||
return
|
||||
|
||||
now = time.time()
|
||||
q.status = "canceled"
|
||||
ev = {
|
||||
"event": "quote_canceled",
|
||||
"quote_id": q.quote_id,
|
||||
"pair": q.pair,
|
||||
"side": q.side,
|
||||
"quote_price": q.quote_price,
|
||||
"quote_created_time": _iso(q.created_ts),
|
||||
"quote_cancel_time": _iso(now),
|
||||
"quote_cancel_epoch": now,
|
||||
"time_alive_sec": now - q.created_ts,
|
||||
"cancel_reason": reason,
|
||||
"filled": False,
|
||||
"status": "canceled",
|
||||
"trade_id": q.trade_id,
|
||||
}
|
||||
if snap:
|
||||
ev.update(snap.to_book_fields())
|
||||
self.write(ev)
|
||||
|
||||
def bind_trade(self, quote_id: str, trade_id: int) -> None:
|
||||
if quote_id in self._quotes:
|
||||
self._quotes[quote_id].trade_id = trade_id
|
||||
self._quotes_by_trade[trade_id] = quote_id
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Fill + path
|
||||
# ------------------------------------------------------------------ #
|
||||
def log_fill(
|
||||
self,
|
||||
pair: str,
|
||||
side: str,
|
||||
fill_price: float,
|
||||
amount: float,
|
||||
inventory: float,
|
||||
snap: MicroSnapshot,
|
||||
order_type: str = "limit",
|
||||
quote_id: str | None = None,
|
||||
trade_id: Optional[int] = None,
|
||||
fill_reason: str = "maker_hit",
|
||||
state: dict | None = None,
|
||||
extra: dict | None = None,
|
||||
) -> str:
|
||||
now = time.time()
|
||||
fill_id = uuid.uuid4().hex[:16]
|
||||
|
||||
# resolve quote lifecycle
|
||||
q: Optional[ActiveQuote] = None
|
||||
if quote_id and quote_id in self._quotes:
|
||||
q = self._quotes[quote_id]
|
||||
elif trade_id is not None and trade_id in self._quotes_by_trade:
|
||||
q = self._quotes.get(self._quotes_by_trade[trade_id])
|
||||
|
||||
time_to_fill = None
|
||||
quote_created_time = None
|
||||
quote_price = fill_price
|
||||
if q is not None:
|
||||
q.status = "filled"
|
||||
time_to_fill = now - q.created_ts
|
||||
quote_created_time = _iso(q.created_ts)
|
||||
quote_price = q.quote_price
|
||||
quote_id = q.quote_id
|
||||
|
||||
det = self.book_deterioration(side, now=now, lookback=5.0)
|
||||
|
||||
ev = {
|
||||
"event": "fill",
|
||||
"fill_id": fill_id,
|
||||
"quote_id": quote_id,
|
||||
"pair": pair,
|
||||
"side": side,
|
||||
"fill_price": fill_price,
|
||||
"quote_price": quote_price,
|
||||
"amount": amount,
|
||||
"inventory": inventory,
|
||||
"order_type": order_type,
|
||||
"fill_reason": fill_reason,
|
||||
"quote_created_time": quote_created_time,
|
||||
"quote_fill_time": _iso(now),
|
||||
"time_to_fill": time_to_fill,
|
||||
"trade_id": trade_id,
|
||||
"filled": True,
|
||||
}
|
||||
ev.update(snap.to_book_fields())
|
||||
ev.update(det)
|
||||
if state:
|
||||
ev.update(state)
|
||||
if extra:
|
||||
ev.update(extra)
|
||||
self.write(ev)
|
||||
|
||||
# also emit quote_filled lifecycle event
|
||||
if q is not None:
|
||||
self.write(
|
||||
{
|
||||
"event": "quote_filled",
|
||||
"quote_id": q.quote_id,
|
||||
"fill_id": fill_id,
|
||||
"pair": pair,
|
||||
"side": q.side,
|
||||
"quote_price": q.quote_price,
|
||||
"quote_created_time": _iso(q.created_ts),
|
||||
"quote_fill_time": _iso(now),
|
||||
"time_to_fill": time_to_fill,
|
||||
"fill_reason": fill_reason,
|
||||
"filled": True,
|
||||
"status": "filled",
|
||||
"trade_id": trade_id,
|
||||
**snap.to_book_fields(),
|
||||
**det,
|
||||
}
|
||||
)
|
||||
|
||||
self._pending[fill_id] = PendingFillPath(
|
||||
fill_id=fill_id,
|
||||
pair=pair,
|
||||
side=side,
|
||||
fill_price=fill_price,
|
||||
fill_ts=now,
|
||||
quote_id=quote_id,
|
||||
)
|
||||
return fill_id
|
||||
|
||||
def attach_exit_reason(self, fill_id: str, exit_reason: str) -> None:
|
||||
if fill_id in self._pending:
|
||||
self._pending[fill_id].exit_reason = exit_reason
|
||||
# also write lightweight annotation
|
||||
self.write(
|
||||
{
|
||||
"event": "fill_exit",
|
||||
"fill_id": fill_id,
|
||||
"exit_reason": exit_reason,
|
||||
}
|
||||
)
|
||||
|
||||
def update_paths(self, pair: str, last_price: float, now: float | None = None) -> None:
|
||||
now = now or time.time()
|
||||
finished = []
|
||||
for fid, p in self._pending.items():
|
||||
if p.pair != pair or p.done:
|
||||
continue
|
||||
p.min_price = min(p.min_price, last_price)
|
||||
p.max_price = max(p.max_price, last_price)
|
||||
mae, mfe = p.signed_excursions()
|
||||
age = now - p.fill_ts
|
||||
|
||||
def mark(horizon_attr_price, horizon_mae, horizon_mfe, sec, price_val):
|
||||
if getattr(p, horizon_attr_price) is None and age >= sec:
|
||||
setattr(p, horizon_attr_price, price_val)
|
||||
setattr(p, horizon_mae, mae)
|
||||
setattr(p, horizon_mfe, mfe)
|
||||
|
||||
mark("after_10s_price", "mae_10s", "mfe_10s", 10, last_price)
|
||||
mark("after_30s_price", "mae_30s", "mfe_30s", 30, last_price)
|
||||
mark("after_1m_price", "mae_1m", "mfe_1m", 60, last_price)
|
||||
|
||||
if p.after_5m_price is None and age >= 300:
|
||||
p.after_5m_price = last_price
|
||||
p.mae_5m = mae
|
||||
p.mfe_5m = mfe
|
||||
p.done = True
|
||||
# Price MAE absolute
|
||||
if p.side == "long":
|
||||
price_mae = p.min_price - p.fill_price
|
||||
price_mfe = p.max_price - p.fill_price
|
||||
else:
|
||||
price_mae = p.fill_price - p.max_price # negative if adverse up
|
||||
price_mfe = p.fill_price - p.min_price
|
||||
|
||||
self.write(
|
||||
{
|
||||
"event": "fill_path",
|
||||
"fill_id": p.fill_id,
|
||||
"quote_id": p.quote_id,
|
||||
"pair": p.pair,
|
||||
"side": p.side,
|
||||
"fill_price": p.fill_price,
|
||||
"exit_reason": p.exit_reason,
|
||||
"after_10s_price": p.after_10s_price,
|
||||
"after_30s_price": p.after_30s_price,
|
||||
"after_1m_price": p.after_1m_price,
|
||||
"after_5m_price": p.after_5m_price,
|
||||
"min_price": p.min_price,
|
||||
"max_price": p.max_price,
|
||||
# percent
|
||||
"mae_10s": p.mae_10s,
|
||||
"mae_30s": p.mae_30s,
|
||||
"mae_1m": p.mae_1m,
|
||||
"mae_5m": p.mae_5m,
|
||||
"mfe_10s": p.mfe_10s,
|
||||
"mfe_30s": p.mfe_30s,
|
||||
"mfe_1m": p.mfe_1m,
|
||||
"mfe_5m": p.mfe_5m,
|
||||
# absolute price
|
||||
"price_mae": price_mae,
|
||||
"price_mfe": price_mfe,
|
||||
"price_mae_pct": mae,
|
||||
"price_mfe_pct": mfe,
|
||||
}
|
||||
)
|
||||
finished.append(fid)
|
||||
|
||||
for fid in finished:
|
||||
self._pending.pop(fid, None)
|
||||
|
||||
@property
|
||||
def pending_count(self) -> int:
|
||||
return len(self._pending)
|
||||
|
||||
# 兼容旧 API
|
||||
def log_quote(self, *args, **kwargs):
|
||||
"""Deprecated wrapper → create_quote for live quotes; heartbeat uses book only."""
|
||||
return self.create_quote(*args, **kwargs)
|
||||
@@ -0,0 +1,246 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
BTC Maker Micro Scalper — 回测结果统计
|
||||
|
||||
重点指标:Net Expectancy(不是胜率)
|
||||
E = 胜率×平均盈利 - 失败率×平均亏损 - 手续费 - 滑点
|
||||
|
||||
用法:
|
||||
python user_data/Chan/strategies/mms_stats.py
|
||||
python user_data/Chan/strategies/mms_stats.py --file user_data/backtest_results/xxx.zip
|
||||
python user_data/Chan/strategies/mms_stats.py --slippage 0.00005
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
|
||||
def _latest_backtest(results_dir: Path) -> Path | None:
|
||||
zips = sorted(results_dir.glob("backtest-result-*.zip"), key=lambda p: p.stat().st_mtime)
|
||||
return zips[-1] if zips else None
|
||||
|
||||
|
||||
def _load_trades(path: Path) -> tuple[pd.DataFrame, dict[str, Any]]:
|
||||
meta: dict[str, Any] = {}
|
||||
if path.suffix == ".zip":
|
||||
with zipfile.ZipFile(path, "r") as zf:
|
||||
names = zf.namelist()
|
||||
# prefer meta + trades json inside zip
|
||||
trade_name = next((n for n in names if n.endswith(".json") and "meta" not in n), None)
|
||||
meta_name = next((n for n in names if n.endswith(".meta.json")), None)
|
||||
if meta_name:
|
||||
meta = json.loads(zf.read(meta_name))
|
||||
if not trade_name:
|
||||
raise FileNotFoundError(f"No trades json in {path}")
|
||||
payload = json.loads(zf.read(trade_name))
|
||||
else:
|
||||
payload = json.loads(path.read_text())
|
||||
|
||||
# Freqtrade formats: {"strategy": {"BTC_...": {"trades": [...]}}}
|
||||
# or flat list / {"trades": [...]}
|
||||
trades = None
|
||||
if isinstance(payload, list):
|
||||
trades = payload
|
||||
elif isinstance(payload, dict):
|
||||
if "trades" in payload:
|
||||
trades = payload["trades"]
|
||||
elif isinstance(payload.get("strategy"), dict):
|
||||
# freqtrade zip: {"strategy": {"BTC_Maker_Micro_Scalper": {"trades": [...]}}}
|
||||
for name, v in payload["strategy"].items():
|
||||
if isinstance(v, dict) and "trades" in v:
|
||||
trades = v["trades"]
|
||||
meta.setdefault("strategy", name)
|
||||
break
|
||||
if trades is None:
|
||||
for _k, v in payload.items():
|
||||
if isinstance(v, dict) and "trades" in v:
|
||||
trades = v["trades"]
|
||||
meta.setdefault("strategy", _k)
|
||||
break
|
||||
if trades is None:
|
||||
raise ValueError(f"Cannot parse trades from {path}")
|
||||
|
||||
df = pd.DataFrame(trades)
|
||||
return df, meta
|
||||
|
||||
|
||||
def summarize(df: pd.DataFrame, fee_rate: float = 0.00016, slippage: float = 0.0) -> dict[str, Any]:
|
||||
if df.empty:
|
||||
return {"error": "no trades"}
|
||||
|
||||
# profit_ratio is net of fees in freqtrade; also keep absolute
|
||||
profit_col = "profit_ratio" if "profit_ratio" in df.columns else "close_profit"
|
||||
profits = df[profit_col].astype(float)
|
||||
|
||||
wins = profits[profits > 0]
|
||||
losses = profits[profits <= 0]
|
||||
n = len(profits)
|
||||
win_rate = len(wins) / n if n else 0.0
|
||||
loss_rate = 1.0 - win_rate
|
||||
avg_win = float(wins.mean()) if len(wins) else 0.0
|
||||
avg_loss = float(losses.mean()) if len(losses) else 0.0 # negative or 0
|
||||
avg_loss_abs = abs(avg_loss)
|
||||
|
||||
gross_profit = float(wins.sum()) if len(wins) else 0.0
|
||||
gross_loss = float((-losses).sum()) if len(losses) else 0.0
|
||||
profit_factor = (gross_profit / gross_loss) if gross_loss > 0 else float("inf")
|
||||
|
||||
# 手续费:freqtrade 的 profit 已扣费;这里单独估算双边 maker 占比
|
||||
# 每笔双边 fee ≈ 2 * fee_rate(相对名义)
|
||||
fee_per_trade = 2.0 * fee_rate
|
||||
total_fee_est = n * fee_per_trade
|
||||
# 滑点假设(每边)
|
||||
slip_per_trade = 2.0 * slippage
|
||||
total_slip_est = n * slip_per_trade
|
||||
|
||||
# Net Expectancy(每笔期望,比率)
|
||||
# E = WR*avg_win - LR*avg_loss_abs - fee - slip
|
||||
expectancy = win_rate * avg_win - loss_rate * avg_loss_abs - fee_per_trade - slip_per_trade
|
||||
|
||||
# 注意:若 profit_ratio 已含手续费,上式 fee 会双重扣除。
|
||||
# 提供两个版本:
|
||||
# 1) E_raw:用毛期望再减 fee/slip(假设 profit 含 fee → 用 E_from_net)
|
||||
# 2) E_from_net:直接用已实现平均利润(已含 fee)再减额外滑点假设
|
||||
e_from_net = float(profits.mean()) - slip_per_trade
|
||||
|
||||
# 最大回撤(权益曲线,相对)
|
||||
equity = (1.0 + profits).cumprod()
|
||||
peak = equity.cummax()
|
||||
dd = (equity - peak) / peak
|
||||
max_dd = float(dd.min()) if len(dd) else 0.0
|
||||
|
||||
# 持仓时间
|
||||
hold_min = None
|
||||
if "open_date" in df.columns and "close_date" in df.columns:
|
||||
od = pd.to_datetime(df["open_date"], utc=True)
|
||||
cd = pd.to_datetime(df["close_date"], utc=True)
|
||||
hold_min = float(((cd - od).dt.total_seconds() / 60.0).mean())
|
||||
|
||||
# Maker 成交率:若有 order_type / is_short 等字段无法直接得,默认限价策略按 100% 标注
|
||||
maker_rate = 1.0
|
||||
if "exit_reason" in df.columns:
|
||||
# 无法精确时保持 1.0;实盘可从策略 _maker_fills 导出
|
||||
pass
|
||||
|
||||
# 手续费占毛利
|
||||
fee_share = None
|
||||
if "fee_open" in df.columns and "fee_close" in df.columns:
|
||||
fees = df["fee_open"].astype(float).fillna(0) + df["fee_close"].astype(float).fillna(0)
|
||||
abs_pnl = df.get("profit_abs", profits).astype(float).abs().sum()
|
||||
fee_share = float(fees.sum() / abs_pnl) if abs_pnl else None
|
||||
total_fee_est = float(fees.sum())
|
||||
|
||||
return {
|
||||
"total_trades": n,
|
||||
"win_rate": win_rate,
|
||||
"avg_win": avg_win,
|
||||
"avg_loss": avg_loss,
|
||||
"profit_factor": profit_factor,
|
||||
"max_drawdown": max_dd,
|
||||
"fee_est_total_ratio_units": total_fee_est,
|
||||
"fee_share_of_abs_pnl": fee_share,
|
||||
"maker_fill_rate_assumed": maker_rate,
|
||||
"avg_hold_minutes": hold_min,
|
||||
"net_expectancy_from_realized": e_from_net,
|
||||
"net_expectancy_formula_rebuild": expectancy,
|
||||
"total_profit_ratio_sum": float(profits.sum()),
|
||||
"avg_profit": float(profits.mean()),
|
||||
"slippage_assumed_per_side": slippage,
|
||||
"note": (
|
||||
"优先看 net_expectancy_from_realized(已含 freqtrade 手续费)。"
|
||||
"net_expectancy_formula_rebuild 会再减一遍 fee,仅作分解参考。"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def print_report(stats: dict[str, Any], source: str) -> None:
|
||||
print("=" * 60)
|
||||
print("BTC Maker Micro Scalper — Backtest Stats")
|
||||
print(f"source: {source}")
|
||||
print("=" * 60)
|
||||
if "error" in stats:
|
||||
print(stats["error"])
|
||||
return
|
||||
|
||||
def pct(x):
|
||||
return f"{x * 100:.4f}%" if x is not None else "n/a"
|
||||
|
||||
print(f"总交易次数 : {stats['total_trades']}")
|
||||
print(f"胜率 : {pct(stats['win_rate'])} (勿作为主指标)")
|
||||
print(f"平均盈利 : {pct(stats['avg_win'])}")
|
||||
print(f"平均亏损 : {pct(stats['avg_loss'])}")
|
||||
print(f"Profit Factor : {stats['profit_factor']:.4f}")
|
||||
print(f"最大回撤 : {pct(stats['max_drawdown'])}")
|
||||
print(f"手续费占比(abs pnl) : {stats['fee_share_of_abs_pnl']}")
|
||||
print(f"Maker成交率(假设) : {pct(stats['maker_fill_rate_assumed'])}")
|
||||
print(f"平均持仓时间(分钟) : {stats['avg_hold_minutes']}")
|
||||
print("-" * 60)
|
||||
print(f"Net Expectancy/笔 : {pct(stats['net_expectancy_from_realized'])} ★主指标")
|
||||
print(f"公式重建 E(参考) : {pct(stats['net_expectancy_formula_rebuild'])}")
|
||||
print(f"累计收益(比率和) : {pct(stats['total_profit_ratio_sum'])}")
|
||||
print(f"平均单笔 : {pct(stats['avg_profit'])}")
|
||||
print("-" * 60)
|
||||
print(stats["note"])
|
||||
print("=" * 60)
|
||||
|
||||
|
||||
def export_equity_csv(df: pd.DataFrame, out: Path) -> None:
|
||||
if df.empty or "profit_ratio" not in df.columns:
|
||||
return
|
||||
profits = df["profit_ratio"].astype(float)
|
||||
equity = (1.0 + profits).cumprod()
|
||||
out_df = pd.DataFrame({
|
||||
"close_date": df.get("close_date"),
|
||||
"profit_ratio": profits,
|
||||
"equity": equity,
|
||||
})
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
out_df.to_csv(out, index=False)
|
||||
print(f"净收益曲线已导出: {out}")
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--file", type=str, default=None, help="backtest zip/json path")
|
||||
ap.add_argument("--slippage", type=float, default=0.0, help="per-side slippage ratio")
|
||||
ap.add_argument("--fee", type=float, default=0.00016, help="per-side maker fee ratio")
|
||||
ap.add_argument(
|
||||
"--equity-out",
|
||||
type=str,
|
||||
default="user_data/plot/mms_equity.csv",
|
||||
help="equity curve csv",
|
||||
)
|
||||
args = ap.parse_args()
|
||||
|
||||
root = Path(__file__).resolve().parents[3] # freqtrade root
|
||||
results_dir = root / "user_data" / "backtest_results"
|
||||
|
||||
path = Path(args.file) if args.file else _latest_backtest(results_dir)
|
||||
if path is None or not path.exists():
|
||||
print("未找到回测结果。请先运行 backtesting,或用 --file 指定。")
|
||||
print(
|
||||
"示例:\n"
|
||||
" freqtrade backtesting -c ./user_data/Chan/config/BTC_Maker_Micro_Scalper.json \\\n"
|
||||
" --strategy BTC_Maker_Micro_Scalper --strategy-path ./user_data/Chan/strategies \\\n"
|
||||
" --timerange=20260101- --fee 0.00016 --enable-protections"
|
||||
)
|
||||
return
|
||||
|
||||
df, meta = _load_trades(path)
|
||||
stats = summarize(df, fee_rate=args.fee, slippage=args.slippage)
|
||||
print_report(stats, str(path))
|
||||
if meta:
|
||||
print(f"meta keys: {list(meta.keys())[:8]}")
|
||||
export_equity_csv(df, root / args.equity_out)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+201
-25
@@ -2,9 +2,103 @@
|
||||
from flask import Blueprint, jsonify, request
|
||||
from services.runtime import * # noqa: F403
|
||||
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__)
|
||||
|
||||
_WYCKOFF_EMPTY = {
|
||||
'trading_range': None,
|
||||
'bias': 'unknown',
|
||||
'phases': [],
|
||||
'events': [],
|
||||
'volume_profile': {'bins': [], 'poc': None, 'vah': None, 'val': None, 'bin_count': 0},
|
||||
'volume_confirm': {'avg_volume': 0.0, 'event_checks': {}},
|
||||
'cycles': [],
|
||||
'live': None,
|
||||
'lifecycle': 'UNKNOWN',
|
||||
}
|
||||
|
||||
|
||||
def _localize_wyckoff_payload(w, client_tz):
|
||||
"""把威科夫时间统一成客户端时区 ISO,便于与主图对齐。"""
|
||||
if not w:
|
||||
return w
|
||||
|
||||
def _loc_tr(tr):
|
||||
if not tr:
|
||||
return
|
||||
tr['start_time'] = format_time_safely(tr.get('start_time'), client_tz) or tr.get('start_time')
|
||||
tr['end_time'] = format_time_safely(tr.get('end_time'), client_tz) or tr.get('end_time')
|
||||
|
||||
def _loc_cycle(c):
|
||||
if not c:
|
||||
return
|
||||
per = c.get('period') or {}
|
||||
per['start_time'] = format_time_safely(per.get('start_time'), client_tz) or per.get('start_time')
|
||||
per['end_time'] = format_time_safely(per.get('end_time'), client_tz) or per.get('end_time')
|
||||
c['period'] = per
|
||||
_loc_tr(c.get('trading_range'))
|
||||
for ph in c.get('phases') or []:
|
||||
ph['start_time'] = format_time_safely(ph.get('start_time'), client_tz) or ph.get('start_time')
|
||||
ph['end_time'] = format_time_safely(ph.get('end_time'), client_tz) or ph.get('end_time')
|
||||
for ev in c.get('events') or []:
|
||||
ev['time'] = format_time_safely(ev.get('time'), client_tz) or ev.get('time')
|
||||
|
||||
_loc_tr(w.get('trading_range'))
|
||||
for ph in w.get('phases') or []:
|
||||
ph['start_time'] = format_time_safely(ph.get('start_time'), client_tz) or ph.get('start_time')
|
||||
ph['end_time'] = format_time_safely(ph.get('end_time'), client_tz) or ph.get('end_time')
|
||||
for ev in w.get('events') or []:
|
||||
ev['time'] = format_time_safely(ev.get('time'), client_tz) or ev.get('time')
|
||||
for c in w.get('cycles') or []:
|
||||
_loc_cycle(c)
|
||||
return w
|
||||
|
||||
|
||||
def _compute_wyckoff_from_df(df, tf, vp_bins, client_tz=None, range_start_time=None, prefer_start_time=None):
|
||||
"""直接用该周期已有 DataFrame(与缠论同一份)。
|
||||
搜索窗口 = 整段数据;箱体在窗内评分选取(近优分取更长),
|
||||
次/次次可用 prefer_start_time 对齐主箱起点。
|
||||
"""
|
||||
from chanlun.analysis.wyckoff import analyze_wyckoff
|
||||
|
||||
try:
|
||||
if df is None or len(df) < 30:
|
||||
empty = dict(_WYCKOFF_EMPTY)
|
||||
empty['volume_profile'] = dict(_WYCKOFF_EMPTY['volume_profile'])
|
||||
empty['volume_confirm'] = dict(_WYCKOFF_EMPTY['volume_confirm'])
|
||||
empty['timeframe'] = tf
|
||||
return empty
|
||||
lookback = len(df)
|
||||
min_bars = max(24, min(80, lookback // 12))
|
||||
out = analyze_wyckoff(
|
||||
df,
|
||||
lookback=lookback,
|
||||
vp_bins=vp_bins,
|
||||
min_bars=min_bars,
|
||||
range_start_time=range_start_time,
|
||||
prefer_start_time=prefer_start_time,
|
||||
)
|
||||
out['timeframe'] = tf
|
||||
out['lookback'] = lookback
|
||||
out['min_bars'] = min_bars
|
||||
if client_tz is not None:
|
||||
_localize_wyckoff_payload(out, client_tz)
|
||||
return out
|
||||
except Exception as e:
|
||||
print(f"Wyckoff 分析出错 ({tf}): {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
empty = dict(_WYCKOFF_EMPTY)
|
||||
empty['volume_profile'] = dict(_WYCKOFF_EMPTY['volume_profile'])
|
||||
empty['volume_confirm'] = dict(_WYCKOFF_EMPTY['volume_confirm'])
|
||||
empty['timeframe'] = tf
|
||||
empty['error'] = str(e)
|
||||
return empty
|
||||
|
||||
|
||||
@bp.route('/api/analyze')
|
||||
def analyze():
|
||||
"""分析接口"""
|
||||
@@ -25,6 +119,9 @@ def analyze():
|
||||
# 获取分形元素时间周期与次次周期
|
||||
element_timeframe = request.args.get('element_timeframe')
|
||||
sub_sub_timeframe = request.args.get('sub_sub_timeframe')
|
||||
# 供文末三周期威科夫复用(避免重复拉数)
|
||||
element_df_for_wyckoff = None
|
||||
sub_sub_df_for_wyckoff = None
|
||||
|
||||
# 获取是否只需要分形元素数据的参数
|
||||
elements_only_param = request.args.get('elements_only')
|
||||
@@ -249,6 +346,7 @@ def analyze():
|
||||
if element_df is not None and len(element_df) > 0:
|
||||
# 添加小周期技术指标(包括布林带)
|
||||
element_df = add_indicators(element_df)
|
||||
element_df_for_wyckoff = element_df
|
||||
|
||||
# 对小周期数据进行缠论分析
|
||||
element_analysis = analyze_chan(element_df, symbol, element_timeframe)
|
||||
@@ -427,6 +525,7 @@ def analyze():
|
||||
sub_sub_df = get_kl_data(symbol, sub_sub_timeframe, start_time=start_time, end_time=end_time)
|
||||
if sub_sub_df is not None and len(sub_sub_df) > 0:
|
||||
sub_sub_df = add_indicators(sub_sub_df)
|
||||
sub_sub_df_for_wyckoff = sub_sub_df
|
||||
sub_sub_analysis = analyze_chan(sub_sub_df, symbol, sub_sub_timeframe)
|
||||
result['sub_sub_timeframe'] = sub_sub_timeframe
|
||||
result['sub_sub_kline_data'] = clean_dataframe_for_json(sub_sub_df).to_dict('records')
|
||||
@@ -656,33 +755,110 @@ def analyze():
|
||||
else:
|
||||
result['structure_zones'] = []
|
||||
|
||||
# 威科夫分析 —— 按需:include_wyckoff=1,且须有主周期分析(非 elements_only)
|
||||
include_wyckoff_param = request.args.get('include_wyckoff', '')
|
||||
include_wyckoff = str(include_wyckoff_param).lower() in ('1', 'true', 'yes')
|
||||
# 威科夫:主 / 次 / 次次各算一份(非 elements_only);前端开关只控制绘制
|
||||
# include_wyckoff=0 可显式跳过;缺省与其它真值均计算
|
||||
include_wyckoff_param = request.args.get('include_wyckoff', '1')
|
||||
include_wyckoff = str(include_wyckoff_param).lower() not in ('0', 'false', 'no')
|
||||
if include_wyckoff and not elements_only:
|
||||
try:
|
||||
from chanlun.analysis.wyckoff import analyze_wyckoff
|
||||
wyckoff_lookback = int(request.args.get('wyckoff_lookback', 120))
|
||||
# ECR-004:默认/上限 24 bins(A+C)
|
||||
wyckoff_bins = int(request.args.get('wyckoff_vp_bins', 24))
|
||||
result['wyckoff'] = analyze_wyckoff(
|
||||
df,
|
||||
lookback=max(40, min(wyckoff_lookback, 500)),
|
||||
vp_bins=max(10, min(wyckoff_bins, 24)),
|
||||
# 主周期先算;次/次次只同步 active=cycles[0] 的 start(WYCKOFF-MULTI-CYCLE-001)
|
||||
wyckoff_bins = max(10, min(int(request.args.get('wyckoff_vp_bins', 24)), 24))
|
||||
result['wyckoff'] = _compute_wyckoff_from_df(df, timeframe, wyckoff_bins, client_tz=None)
|
||||
main_w = result.get('wyckoff') or {}
|
||||
cycles = main_w.get('cycles') or []
|
||||
# active 唯一来源 cycles[0];禁止 cycles[-1]
|
||||
active = cycles[0] if cycles else None
|
||||
prefer_start = None
|
||||
if active:
|
||||
prefer_start = ((active.get('trading_range') or {}).get('start_time')
|
||||
or (active.get('period') or {}).get('start_time'))
|
||||
elif main_w.get('trading_range'):
|
||||
prefer_start = main_w['trading_range'].get('start_time')
|
||||
if client_tz is not None:
|
||||
_localize_wyckoff_payload(result['wyckoff'], client_tz)
|
||||
if element_timeframe:
|
||||
result['element_wyckoff'] = _compute_wyckoff_from_df(
|
||||
element_df_for_wyckoff, element_timeframe, wyckoff_bins, client_tz,
|
||||
prefer_start_time=prefer_start,
|
||||
)
|
||||
if sub_sub_timeframe:
|
||||
result['sub_sub_wyckoff'] = _compute_wyckoff_from_df(
|
||||
sub_sub_df_for_wyckoff, sub_sub_timeframe, wyckoff_bins, client_tz,
|
||||
prefer_start_time=prefer_start,
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"Wyckoff 分析出错: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
result['wyckoff'] = {
|
||||
'trading_range': None,
|
||||
'bias': 'unknown',
|
||||
'phases': [],
|
||||
'events': [],
|
||||
'volume_profile': {'bins': [], 'poc': None, 'vah': None, 'val': None, 'bin_count': 0},
|
||||
'volume_confirm': {'avg_volume': 0.0, 'event_checks': {}},
|
||||
'error': str(e),
|
||||
}
|
||||
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
def _serialize_kl_tail(df, limit: int):
|
||||
"""只序列化最近 limit 根,供自动刷新增量合并。"""
|
||||
if df is None or getattr(df, "empty", True):
|
||||
return []
|
||||
tail = df.tail(limit)
|
||||
clean = clean_dataframe_for_json(tail)
|
||||
records = clean.to_dict("records")
|
||||
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)
|
||||
|
||||
|
||||
+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 services.runtime import * # noqa: F403
|
||||
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.symbols import bp as symbols_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:
|
||||
@@ -23,6 +24,12 @@ def create_app() -> Flask:
|
||||
app.register_blueprint(analyze_bp)
|
||||
app.register_blueprint(symbols_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
|
||||
|
||||
|
||||
|
||||
@@ -70,36 +70,43 @@ def build_timeframe_labels(timeframes):
|
||||
return labels
|
||||
|
||||
|
||||
def _adjacent_smaller(timeframe_keys, ceiling_tf):
|
||||
"""取排序列表中严格小于 ceiling 的相邻周期。"""
|
||||
if not timeframe_keys:
|
||||
return ceiling_tf
|
||||
try:
|
||||
idx = timeframe_keys.index(ceiling_tf)
|
||||
return timeframe_keys[idx - 1] if idx > 0 else timeframe_keys[0]
|
||||
except ValueError:
|
||||
return timeframe_keys[0]
|
||||
|
||||
|
||||
def _prefer_smaller(candidates, labels_ordered, ceiling_tf, timeframe_keys):
|
||||
"""从候选中选第一个存在且严格小于 ceiling 的周期,否则回退相邻更小。"""
|
||||
ceil_m = timeframe_to_minutes(ceiling_tf)
|
||||
for tf in candidates:
|
||||
m = timeframe_to_minutes(tf)
|
||||
if tf in labels_ordered and m is not None and ceil_m is not None and m < ceil_m:
|
||||
return tf
|
||||
return _adjacent_smaller(timeframe_keys, ceiling_tf)
|
||||
|
||||
|
||||
def compute_timeframe_defaults(labels_ordered):
|
||||
"""
|
||||
根据已排序的「周期 → 中文标签」映射,计算主 / 次 / 次次周期默认值。
|
||||
默认偏好:主 4h、次 1h、次次 15m。
|
||||
labels_ordered: OrderedDict 或按插入顺序排列的 dict。
|
||||
"""
|
||||
if not labels_ordered:
|
||||
labels_ordered = DEFAULT_TIMEFRAME_LABELS.copy()
|
||||
timeframe_keys = list(labels_ordered.keys())
|
||||
preferred_main = next((tf for tf in ['5m', '15m', '1h'] if tf in labels_ordered), None)
|
||||
preferred_main = next((tf for tf in ['4h', '1h', '15m'] if tf in labels_ordered), None)
|
||||
default_main = preferred_main or (timeframe_keys[0] if timeframe_keys else '1m')
|
||||
if default_main not in labels_ordered and timeframe_keys:
|
||||
default_main = timeframe_keys[0]
|
||||
|
||||
if timeframe_keys:
|
||||
try:
|
||||
idx = timeframe_keys.index(default_main)
|
||||
default_element = timeframe_keys[idx - 1] if idx > 0 else timeframe_keys[0]
|
||||
except ValueError:
|
||||
default_element = timeframe_keys[0]
|
||||
else:
|
||||
default_element = default_main
|
||||
|
||||
if timeframe_keys:
|
||||
try:
|
||||
idx_el = timeframe_keys.index(default_element)
|
||||
default_sub_sub = timeframe_keys[idx_el - 1] if idx_el > 0 else timeframe_keys[0]
|
||||
except ValueError:
|
||||
default_sub_sub = timeframe_keys[0]
|
||||
else:
|
||||
default_sub_sub = default_element
|
||||
default_element = _prefer_smaller(['1h', '15m'], labels_ordered, default_main, timeframe_keys)
|
||||
default_sub_sub = _prefer_smaller(['15m', '5m'], labels_ordered, default_element, timeframe_keys)
|
||||
|
||||
return default_main, default_element, default_sub_sub, timeframe_keys
|
||||
|
||||
|
||||
@@ -9,18 +9,19 @@ function updateChartDisplay() {
|
||||
_lastKlinePeriod = curPeriod;
|
||||
|
||||
// 保存当前的可见范围(周期切换时不保留,避免范围越界)
|
||||
if (!periodChanged && tvWidget && tvWidget.mainChart) {
|
||||
try {
|
||||
window._pendingRestoreView = captureChartViewState(tvWidget.mainChart);
|
||||
} catch (e) {
|
||||
window._pendingRestoreView = null;
|
||||
if (!periodChanged) {
|
||||
if (typeof reinitTradingViewPreservingViewport === 'function') {
|
||||
reinitTradingViewPreservingViewport();
|
||||
} else {
|
||||
initTradingView($('#symbol').val(), $('#timeframe').val());
|
||||
}
|
||||
} else {
|
||||
window._pendingRestoreView = null;
|
||||
window._preserveViewBarCount = 0;
|
||||
initTradingView($('#symbol').val(), $('#timeframe').val());
|
||||
}
|
||||
|
||||
console.log('更新图表显示');
|
||||
|
||||
// 重新初始化图表(initTradingView 内部会在最终同步时读取 _pendingRestoreView)
|
||||
initTradingView($('#symbol').val(), $('#timeframe').val());
|
||||
}
|
||||
}
|
||||
// 确保所有时间处理都使用UTC时间,包括表格数据显示
|
||||
|
||||
+131
-33
@@ -1,5 +1,7 @@
|
||||
/* chart_sync.js — split from chart.js */
|
||||
function updateTradingViewData() {
|
||||
function updateTradingViewData(options) {
|
||||
options = options || {};
|
||||
const tailOnly = !!options.tailOnly;
|
||||
try {
|
||||
console.log('增量更新图表数据');
|
||||
|
||||
@@ -9,11 +11,34 @@ function updateTradingViewData() {
|
||||
return;
|
||||
}
|
||||
|
||||
// 保存当前的可视范围
|
||||
// 优先用请求前冻结的视窗;否则现场拍(自动刷新短间隔 delta≈0,两种都稳)
|
||||
const frozen = window._preserveViewOnRefresh;
|
||||
const oldBarCount = window._preserveViewBarCount || 0;
|
||||
let savedScrollPosition = null;
|
||||
let savedVisibleRange = null;
|
||||
let savedLogicalRange = null;
|
||||
if (tvWidget.mainChart) {
|
||||
tvWidget.state.visibleRange = tvWidget.mainChart.timeScale().getVisibleRange();
|
||||
tvWidget.state.logicalRange = tvWidget.mainChart.timeScale().getVisibleLogicalRange();
|
||||
const ts = tvWidget.mainChart.timeScale();
|
||||
if (frozen) {
|
||||
savedVisibleRange = frozen.visibleRange;
|
||||
savedLogicalRange = frozen.logicalRange;
|
||||
savedScrollPosition = (typeof frozen.scrollPosition === 'number') ? frozen.scrollPosition : null;
|
||||
} else {
|
||||
try { savedVisibleRange = ts.getVisibleRange(); } catch (e) {}
|
||||
try { savedLogicalRange = ts.getVisibleLogicalRange(); } catch (e) {}
|
||||
try {
|
||||
savedScrollPosition = ts.scrollPosition ? ts.scrollPosition() : null;
|
||||
} catch (e) {}
|
||||
}
|
||||
if (tvWidget.state) {
|
||||
tvWidget.state.visibleRange = savedVisibleRange;
|
||||
tvWidget.state.logicalRange = savedLogicalRange;
|
||||
}
|
||||
}
|
||||
window._preserveViewOnRefresh = null;
|
||||
window._preserveViewBarCount = 0;
|
||||
// setData 会触发 timeRange 回调;期间禁止 sync 写回 state(否则会把已跳回左侧的视窗当成「要恢复的目标」)
|
||||
window._preserveViewDuringUpdate = true;
|
||||
|
||||
// 检查是否显示原始K线
|
||||
const showOriginalKline = $('#showOriginalKline').is(':checked');
|
||||
@@ -71,11 +96,31 @@ 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 firstBarTime = newBarCount > 0 ? candles[0].time : null;
|
||||
const lastBarTime = newBarCount > 0 ? candles[newBarCount - 1].time : null;
|
||||
const clampedVisibleRange = clampVisibleRangeToBarTimes(savedVisibleRange, firstBarTime, lastBarTime);
|
||||
|
||||
// 更新主系列数据(根据klineType)
|
||||
const klineType = ($('#klineType').val() || (showOriginalKline ? 'candlestick' : 'line'));
|
||||
if (klineType === 'candlestick' && tvWidget.series.candleSeries) {
|
||||
tvWidget.series.candleSeries.setData(candles);
|
||||
applySeriesDataTail(tvWidget.series.candleSeries, candles, tailOnly);
|
||||
} else if (klineType === 'renko' && tvWidget.series.renkoSeries) {
|
||||
const bricks = buildRenkoFromCandles(candles);
|
||||
tvWidget.series.renkoSeries.setData(bricks);
|
||||
@@ -83,26 +128,30 @@ function updateTradingViewData() {
|
||||
const hk = buildHeikinFromCandles(candles);
|
||||
tvWidget.series.heikinSeries.setData(hk);
|
||||
} else if (klineType === 'bar' && tvWidget.series.barSeries) {
|
||||
tvWidget.series.barSeries.setData(candles);
|
||||
applySeriesDataTail(tvWidget.series.barSeries, candles, tailOnly);
|
||||
} else if (klineType === 'line' && tvWidget.series.lineSeries) {
|
||||
const lineData = candles.map(c => ({ time: c.time, value: c.close }));
|
||||
tvWidget.series.lineSeries.setData(lineData);
|
||||
applySeriesDataTail(tvWidget.series.lineSeries, lineData, tailOnly);
|
||||
} else if (klineType === 'area' && tvWidget.series.areaSeries) {
|
||||
const areaData = candles.map(c => ({ time: c.time, value: c.close }));
|
||||
tvWidget.series.areaSeries.setData(areaData);
|
||||
applySeriesDataTail(tvWidget.series.areaSeries, areaData, tailOnly);
|
||||
} else if (klineType === 'baseline' && tvWidget.series.baselineSeries) {
|
||||
const baseData = candles.map(c => ({ time: c.time, value: c.close }));
|
||||
tvWidget.series.baselineSeries.setData(baseData);
|
||||
applySeriesDataTail(tvWidget.series.baselineSeries, baseData, tailOnly);
|
||||
} else if (klineType === 'klc' && tvWidget.series.klcSeries) {
|
||||
const klcCandles = buildKLCFromAnalysis(currentData);
|
||||
tvWidget.series.klcSeries.setData(klcCandles);
|
||||
if (tailOnly) {
|
||||
applySeriesDataTail(tvWidget.series.klcSeries, klcCandles, true);
|
||||
} else {
|
||||
tvWidget.series.klcSeries.setData(klcCandles);
|
||||
}
|
||||
}
|
||||
|
||||
// 更新均线数据
|
||||
addMovingAveragesToChart(candles);
|
||||
|
||||
// 更新布林带数据
|
||||
addBollingerBandsToChart(candles);
|
||||
// 尾部刷新不重算均线/布林带(removeSeries 会触发视窗跳动)
|
||||
if (!tailOnly) {
|
||||
addMovingAveragesToChart(candles);
|
||||
addBollingerBandsToChart(candles);
|
||||
}
|
||||
|
||||
// 更新成交量数据
|
||||
let volumes = [];
|
||||
@@ -136,9 +185,11 @@ function updateTradingViewData() {
|
||||
}
|
||||
|
||||
if (tvWidget.series.volumeSeries) {
|
||||
tvWidget.series.volumeSeries.setData(volumes);
|
||||
applySeriesDataTail(tvWidget.series.volumeSeries, volumes, tailOnly);
|
||||
}
|
||||
|
||||
// 尾部刷新不重拉 ATR/MACD(setData 会触发视窗跳到最右)
|
||||
if (!tailOnly) {
|
||||
// 更新ATR数据
|
||||
if (tvWidget.series.atrLineSeries) {
|
||||
const atrData = [];
|
||||
@@ -262,6 +313,7 @@ function updateTradingViewData() {
|
||||
console.warn('更新ChanMACD标注失败:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 不再调用 redrawFractalElements():它会全量 initTradingView,
|
||||
// 与增量更新叠加会导致图表反复重建、内存暴涨。
|
||||
@@ -270,27 +322,73 @@ function updateTradingViewData() {
|
||||
// 更新EMA52显示
|
||||
updateEMA52Display(currentData);
|
||||
|
||||
// 恢复之前的可视范围 - 优先使用visibleRange以确保时间轴对齐
|
||||
// 与自动刷新一致:增量更新绝不碰 barSpacing(缩放本来就留在图表实例上)。
|
||||
// 一写 barSpacing,LWC 会按右边缘重锚 → 放大往右、缩小往左。
|
||||
// 这里只在 setData 之后把位置扳回刷新前的 logical / time 窗口。
|
||||
if (tvWidget.mainChart) {
|
||||
if (tvWidget.state.visibleRange) {
|
||||
console.log('🔄 恢复可见范围:', tvWidget.state.visibleRange);
|
||||
tvWidget.mainChart.timeScale().setVisibleRange(tvWidget.state.visibleRange);
|
||||
if (tvWidget.volumeChart) tvWidget.volumeChart.timeScale().setVisibleRange(tvWidget.state.visibleRange);
|
||||
if (tvWidget.atrChart) tvWidget.atrChart.timeScale().setVisibleRange(tvWidget.state.visibleRange);
|
||||
if (tvWidget.macdChart) tvWidget.macdChart.timeScale().setVisibleRange(tvWidget.state.visibleRange);
|
||||
if (tvWidget.chanMacdChart) tvWidget.chanMacdChart.timeScale().setVisibleRange(tvWidget.state.visibleRange);
|
||||
} else if (tvWidget.state.logicalRange) {
|
||||
console.log('🔄 恢复逻辑范围:', tvWidget.state.logicalRange);
|
||||
tvWidget.mainChart.timeScale().setVisibleLogicalRange(tvWidget.state.logicalRange);
|
||||
if (tvWidget.volumeChart) tvWidget.volumeChart.timeScale().setVisibleLogicalRange(tvWidget.state.logicalRange);
|
||||
if (tvWidget.atrChart) tvWidget.atrChart.timeScale().setVisibleLogicalRange(tvWidget.state.logicalRange);
|
||||
if (tvWidget.macdChart) tvWidget.macdChart.timeScale().setVisibleLogicalRange(tvWidget.state.logicalRange);
|
||||
if (tvWidget.chanMacdChart) tvWidget.chanMacdChart.timeScale().setVisibleLogicalRange(tvWidget.state.logicalRange);
|
||||
}
|
||||
const charts = [
|
||||
tvWidget.mainChart,
|
||||
tvWidget.volumeChart,
|
||||
tvWidget.atrChart,
|
||||
tvWidget.macdChart,
|
||||
tvWidget.chanMacdChart
|
||||
].filter(Boolean);
|
||||
|
||||
const vr = clampedVisibleRange || savedVisibleRange;
|
||||
const viewSnap = {
|
||||
logicalRange: savedLogicalRange,
|
||||
visibleRange: vr,
|
||||
scrollPosition: savedScrollPosition
|
||||
};
|
||||
|
||||
const applyPosition = function (tag) {
|
||||
if (typeof restoreChartViewState !== 'function') return;
|
||||
restoreChartViewState(charts, viewSnap, {
|
||||
incremental: true,
|
||||
skipBarSpacing: true,
|
||||
oldBarCount: oldBarCount,
|
||||
newBarCount: newBarCount,
|
||||
firstBarTime: firstBarTime,
|
||||
lastBarTime: lastBarTime
|
||||
});
|
||||
if (tag) console.log('🔄 恢复位置' + tag);
|
||||
};
|
||||
|
||||
const finishPreserve = function () {
|
||||
window._preserveViewDuringUpdate = false;
|
||||
if (tvWidget.mainChart && tvWidget.state) {
|
||||
try {
|
||||
const ts = tvWidget.mainChart.timeScale();
|
||||
tvWidget.state.logicalRange = ts.getVisibleLogicalRange();
|
||||
tvWidget.state.visibleRange = ts.getVisibleRange();
|
||||
} catch (e) {}
|
||||
}
|
||||
};
|
||||
|
||||
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);
|
||||
setTimeout(function () {
|
||||
applyPosition('@150');
|
||||
bumpFxVert();
|
||||
finishPreserve();
|
||||
}, 150);
|
||||
} else {
|
||||
window._preserveViewDuringUpdate = false;
|
||||
}
|
||||
|
||||
console.log('增量更新图表完成');
|
||||
} catch (e) {
|
||||
window._preserveViewDuringUpdate = false;
|
||||
console.error('增量更新图表错误,回退到完全重绘:', e);
|
||||
// 出错时回退到完全重绘
|
||||
initTradingView($('#symbol').val(), $('#timeframe').val());
|
||||
@@ -316,7 +414,7 @@ function bindSyncEvents(mainChartContainer, volumeChartContainer, atrChartContai
|
||||
|
||||
// 同步图表的时间范围
|
||||
function syncCharts(sourceChart, sourceContainer) {
|
||||
if (syncInProgress) return;
|
||||
if (syncInProgress || window._preserveViewDuringUpdate) return;
|
||||
|
||||
syncInProgress = true;
|
||||
|
||||
|
||||
+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,271 @@
|
||||
/* 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] : []);
|
||||
const restoreOpts = function () {
|
||||
const firstT = candles && candles.length ? candles[0].time : null;
|
||||
const lastT = candles && candles.length ? candles[candles.length - 1].time : null;
|
||||
return {
|
||||
firstBarTime: firstT,
|
||||
lastBarTime: lastT,
|
||||
oldBarCount: window._preserveViewBarCount || 0,
|
||||
newBarCount: totalBars
|
||||
};
|
||||
};
|
||||
if (hasPendingRestoreView && pendingView) {
|
||||
restoreChartViewState(allChartsNow, pendingView, restoreOpts());
|
||||
} 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();
|
||||
}
|
||||
}
|
||||
|
||||
// 立即同步其他图表到主图表的范围
|
||||
setTimeout(() => {
|
||||
if (pendingView) {
|
||||
restoreChartViewState(allChartsNow, pendingView, restoreOpts());
|
||||
const logRange = mainChart.timeScale().getVisibleLogicalRange();
|
||||
if (logRange) {
|
||||
volumeChart.timeScale().setVisibleLogicalRange(logRange);
|
||||
atrChart.timeScale().setVisibleLogicalRange(logRange);
|
||||
if (showMacd && macdChart) {
|
||||
macdChart.timeScale().setVisibleLogicalRange(logRange);
|
||||
}
|
||||
if (showMacd && chanMacdChart) {
|
||||
chanMacdChart.timeScale().setVisibleLogicalRange(logRange);
|
||||
}
|
||||
}
|
||||
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();
|
||||
|
||||
// 绑定同步事件
|
||||
if (hasPendingRestoreView && pendingView) {
|
||||
window._preserveViewDuringUpdate = true;
|
||||
}
|
||||
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 || pendingView;
|
||||
window._pendingRestoreView = null;
|
||||
|
||||
if (pending) {
|
||||
const firstT = candles && candles.length ? candles[0].time : null;
|
||||
const lastT = candles && candles.length ? candles[candles.length - 1].time : null;
|
||||
console.log('📌 恢复图表视图:', JSON.stringify(pending));
|
||||
restoreChartViewState(allCharts, pending, {
|
||||
firstBarTime: firstT,
|
||||
lastBarTime: lastT,
|
||||
oldBarCount: window._preserveViewBarCount || 0,
|
||||
newBarCount: totalBars
|
||||
});
|
||||
window._preserveViewBarCount = 0;
|
||||
} 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) {}
|
||||
});
|
||||
}
|
||||
}
|
||||
window._preserveViewDuringUpdate = false;
|
||||
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,48 @@
|
||||
/* 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) {
|
||||
chartRoot.innerHTML = '';
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('disposeTradingViewCharts 失败(可忽略):', e);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,504 @@
|
||||
/* 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'));
|
||||
|
||||
// 创建成交量图表 - 只显示底部的时间轴
|
||||
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;
|
||||
}
|
||||
+454
-109
@@ -1,10 +1,69 @@
|
||||
/* chart_view.js — split from chart.js */
|
||||
function updateChart(options) {
|
||||
options = options || {};
|
||||
// 只显示旋转加载图标
|
||||
$('#refreshLoadingSpinner').show();
|
||||
|
||||
// 获取参数
|
||||
/* chart_view.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;
|
||||
}
|
||||
|
||||
/** 实时基线是否过旧(仅自动刷新用来决定 recent vs 全量 live) */
|
||||
function isLiveBaselineStale(timeframe) {
|
||||
if (!currentData || !Array.isArray(currentData.kline_data) || !currentData.kline_data.length) {
|
||||
return true;
|
||||
}
|
||||
const last = currentData.kline_data[currentData.kline_data.length - 1];
|
||||
let lastMs;
|
||||
if (last.timestamp != null && last.timestamp !== '') {
|
||||
lastMs = Number(last.timestamp);
|
||||
} else {
|
||||
lastMs = new Date(last.date).getTime();
|
||||
}
|
||||
if (Number.isNaN(lastMs)) return true;
|
||||
const tfMs = (window.timeframeToMs && window.timeframeToMs(timeframe)) || (15 * 60 * 1000);
|
||||
return (Date.now() - lastMs) > tfMs * 3;
|
||||
}
|
||||
|
||||
/** 兼容旧名 */
|
||||
function isChartBaselineStale(timeframe) {
|
||||
return isLiveBaselineStale(timeframe);
|
||||
}
|
||||
|
||||
function readChartFormContext() {
|
||||
const dataSource = $('#dataSource').val() || 'crypto';
|
||||
let symbol;
|
||||
if (dataSource === 'crypto') {
|
||||
@@ -12,98 +71,295 @@ function updateChart(options) {
|
||||
} else {
|
||||
symbol = $('#astockSymbol').val() || '000001';
|
||||
}
|
||||
|
||||
const timeframe = $('#timeframe').val() || window.DEFAULT_MAIN_TIMEFRAME || '5m';
|
||||
const timezone = $('#timezone').val() || 'Asia/Shanghai';
|
||||
const elementTimeframe = $('#elementTimeframe').val() || window.DEFAULT_ELEMENT_TIMEFRAME || '1m';
|
||||
const subSubTimeframe = $('#subSubTimeframe').val() || '';
|
||||
|
||||
// 确保时区参数有效
|
||||
console.log('更新图表使用时区:', timezone, 'reason:', options.reason || (options.fromAutoRefresh ? 'auto' : 'manual'));
|
||||
console.log('数据源:', dataSource, '交易对/股票:', symbol);
|
||||
|
||||
// 如果symbol为空,不发送请求
|
||||
if (!symbol) {
|
||||
return {
|
||||
dataSource: dataSource,
|
||||
symbol: symbol,
|
||||
timeframe: $('#timeframe').val() || window.DEFAULT_MAIN_TIMEFRAME || '4h',
|
||||
timezone: $('#timezone').val() || 'Asia/Shanghai',
|
||||
elementTimeframe: $('#elementTimeframe').val() || window.DEFAULT_ELEMENT_TIMEFRAME || '1m',
|
||||
subSubTimeframe: $('#subSubTimeframe').val() || '',
|
||||
startTimeMs: $('#start_time').val() ? new Date($('#start_time').val()).getTime() : null,
|
||||
endTimeMs: $('#end_time').val() ? new Date($('#end_time').val()).getTime() : null
|
||||
};
|
||||
}
|
||||
|
||||
/** 当前展示用 K 线序列根数(主/小/次次周期与 freeze 逻辑一致) */
|
||||
function getActiveKlineBarCount(data) {
|
||||
data = data || (typeof currentData !== 'undefined' ? currentData : null);
|
||||
if (!data) return 0;
|
||||
const series = ($('#subSubPeriodKline').is(':checked') && data.sub_sub_kline_data) ||
|
||||
($('#elementPeriodKline').is(':checked') && data.element_kline_data) ||
|
||||
data.kline_data;
|
||||
return Array.isArray(series) ? series.length : 0;
|
||||
}
|
||||
|
||||
/** 全量 init 前拍快照(周期切换等;不含 _preserveViewOnRefresh) */
|
||||
function snapshotPendingChartViewport() {
|
||||
if (!tvWidget || !tvWidget.mainChart || typeof captureChartViewState !== 'function') {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
window._pendingRestoreView = captureChartViewState(tvWidget.mainChart);
|
||||
window._preserveViewBarCount = getActiveKlineBarCount();
|
||||
} catch (e) {
|
||||
window._pendingRestoreView = null;
|
||||
window._preserveViewBarCount = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/** 本地重绘(开关缠论元素 / K 线类型等):先冻结视窗再 init */
|
||||
function reinitTradingViewPreservingViewport() {
|
||||
snapshotPendingChartViewport();
|
||||
initTradingView($('#symbol').val(), $('#timeframe').val());
|
||||
}
|
||||
|
||||
/** 全量 init 前确保 _pendingRestoreView 与 bar 数齐全(refreshChart 等路径) */
|
||||
function ensurePendingChartViewportBeforeInit() {
|
||||
if (window._preserveViewOnRefresh) {
|
||||
window._pendingRestoreView = window._preserveViewOnRefresh;
|
||||
if (!window._preserveViewBarCount) {
|
||||
window._preserveViewBarCount = getActiveKlineBarCount();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!window._pendingRestoreView && tvWidget && tvWidget.mainChart) {
|
||||
snapshotPendingChartViewport();
|
||||
return;
|
||||
}
|
||||
if (window._pendingRestoreView && !window._preserveViewBarCount) {
|
||||
window._preserveViewBarCount = getActiveKlineBarCount();
|
||||
}
|
||||
}
|
||||
|
||||
function freezeChartViewportBeforeRequest() {
|
||||
try {
|
||||
if (tvWidget && tvWidget.mainChart) {
|
||||
const snap = captureChartViewState(tvWidget.mainChart);
|
||||
window._preserveViewOnRefresh = snap;
|
||||
// 全量 initTradingView 只认 _pendingRestoreView,须与增量冻结同步
|
||||
window._pendingRestoreView = snap;
|
||||
window._preserveViewBarCount = getActiveKlineBarCount();
|
||||
console.log('📌 刷新前冻结视窗 bars=', window._preserveViewBarCount, snap);
|
||||
}
|
||||
} catch (e) {
|
||||
window._preserveViewOnRefresh = null;
|
||||
window._pendingRestoreView = null;
|
||||
window._preserveViewBarCount = 0;
|
||||
}
|
||||
}
|
||||
|
||||
function abortInFlightChartRequest() {
|
||||
if (window._analyzeXhr && window._analyzeXhr.readyState !== 4) {
|
||||
try { window._analyzeXhr.abort(); } catch (e) {}
|
||||
}
|
||||
}
|
||||
|
||||
function applyAnalyzeSuccess(data, symbol, options) {
|
||||
options = options || {};
|
||||
const prevSymbol = (currentData && currentData.symbol) || window._lastChartSymbol || '';
|
||||
if (currentData) {
|
||||
delete currentData.original_kline_data;
|
||||
delete currentData.original_macd;
|
||||
}
|
||||
currentData = data;
|
||||
window._lastChartSymbol = symbol;
|
||||
window._lastFullAnalyzeAt = Date.now();
|
||||
|
||||
const ready = !!(tvWidget && tvWidget.state && tvWidget.state.isInitialized && tvWidget.mainChart);
|
||||
const structureZonesOn = $('#showMainStructureZone').is(':checked');
|
||||
const symbolChanged = !!(prevSymbol && prevSymbol !== symbol);
|
||||
let wantIncremental = options.incremental !== undefined ? !!options.incremental : ready;
|
||||
if (structureZonesOn || options.forceFullRebuild || symbolChanged || options.incremental === false) {
|
||||
wantIncremental = false;
|
||||
}
|
||||
refreshChart(data, { incremental: wantIncremental });
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动分析(按钮 / 首屏 / 切换参数)
|
||||
* - 严格使用表单 start_time / end_time
|
||||
* - 只走 /api/analyze,不做 recent 合并,不改结束时间为现在
|
||||
*/
|
||||
function analyzeChart(options) {
|
||||
options = options || {};
|
||||
$('#refreshLoadingSpinner').show();
|
||||
|
||||
const ctx = readChartFormContext();
|
||||
console.log('手动分析:', ctx.symbol, ctx.timeframe, 'range', ctx.startTimeMs, '→', ctx.endTimeMs, options.reason || '');
|
||||
|
||||
if (!ctx.symbol) {
|
||||
console.error('交易对/股票代码不能为空');
|
||||
$('#refreshLoadingSpinner').hide();
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`更新图表: symbol=${symbol}, timeframe=${timeframe}, elementTimeframe=${elementTimeframe}, timezone=${timezone}`);
|
||||
|
||||
// 获取开始和结束时间(如果已设置)
|
||||
let startTimeMs = null;
|
||||
let endTimeMs = null;
|
||||
|
||||
if ($('#start_time').val()) {
|
||||
startTimeMs = new Date($('#start_time').val()).getTime();
|
||||
}
|
||||
|
||||
if ($('#end_time').val()) {
|
||||
endTimeMs = new Date($('#end_time').val()).getTime();
|
||||
}
|
||||
|
||||
// 自动刷新:取消进行中的上一请求,避免响应堆积
|
||||
if (options.fromAutoRefresh && window._analyzeXhr && window._analyzeXhr.readyState !== 4) {
|
||||
try { window._analyzeXhr.abort(); } catch (e) {}
|
||||
}
|
||||
|
||||
// 发送请求
|
||||
const requestId = ++lastRequestId; // 标记本次请求
|
||||
abortInFlightChartRequest();
|
||||
freezeChartViewportBeforeRequest();
|
||||
const requestId = ++lastRequestId;
|
||||
|
||||
window._analyzeXhr = $.ajax({
|
||||
url: '/api/analyze',
|
||||
data: {
|
||||
symbol: symbol,
|
||||
timeframe: timeframe,
|
||||
timezone: timezone,
|
||||
element_timeframe: elementTimeframe,
|
||||
sub_sub_timeframe: subSubTimeframe || undefined,
|
||||
start_time: startTimeMs,
|
||||
end_time: endTimeMs,
|
||||
symbol: ctx.symbol,
|
||||
timeframe: ctx.timeframe,
|
||||
timezone: ctx.timezone,
|
||||
element_timeframe: ctx.elementTimeframe,
|
||||
sub_sub_timeframe: ctx.subSubTimeframe || undefined,
|
||||
start_time: ctx.startTimeMs,
|
||||
end_time: ctx.endTimeMs,
|
||||
elements_only: false,
|
||||
zone_kl_lines: parseInt($('#zoneKlLines').val()) || 1000,
|
||||
include_structure_zones: $('#showMainStructureZone').is(':checked') ? 1 : 0,
|
||||
include_wyckoff: $('#showWyckoff').is(':checked') ? 1 : 0
|
||||
include_wyckoff: 0
|
||||
},
|
||||
success: function(data) {
|
||||
// 隐藏加载图标
|
||||
$('#refreshLoadingSpinner').hide();
|
||||
|
||||
// 忽略过期响应
|
||||
if (requestId !== lastRequestId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 保存当前数据
|
||||
if (currentData) {
|
||||
// 覆盖前断开旧引用,帮助GC尽快回收
|
||||
delete currentData.original_kline_data;
|
||||
delete currentData.original_macd;
|
||||
}
|
||||
currentData = data;
|
||||
|
||||
refreshChart(data, {
|
||||
incremental: options.incremental !== undefined
|
||||
? !!options.incremental
|
||||
: !!options.fromAutoRefresh
|
||||
if (requestId !== lastRequestId) return;
|
||||
applyAnalyzeSuccess(data, ctx.symbol, {
|
||||
incremental: options.incremental !== undefined ? options.incremental : false,
|
||||
forceFullRebuild: true
|
||||
});
|
||||
},
|
||||
error: function(jqXHR, textStatus, errorThrown) {
|
||||
// 隐藏加载图标
|
||||
$('#refreshLoadingSpinner').hide();
|
||||
if (textStatus === 'abort') {
|
||||
return;
|
||||
}
|
||||
|
||||
// 显示错误信息
|
||||
console.error('加载数据失败:', errorThrown);
|
||||
// 自动刷新失败不弹窗打扰
|
||||
if (!options.fromAutoRefresh) {
|
||||
alert('加载数据失败: ' + (jqXHR.responseJSON?.error || errorThrown));
|
||||
}
|
||||
if (textStatus === 'abort') return;
|
||||
console.error('分析失败:', errorThrown);
|
||||
alert('加载数据失败: ' + (jqXHR.responseJSON?.error || errorThrown));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 自动刷新(仅启用自动刷新时)
|
||||
* - 结束时间固定为当前时间(调用方先 updateEndTimeToNow)
|
||||
* - mode=recent:/api/klines/recent 增量合并(不重算缠论)
|
||||
* - mode=full:/api/analyze 全量 live(start 用表单,end=现在)
|
||||
*/
|
||||
function autoRefreshChart(options) {
|
||||
options = options || {};
|
||||
const mode = options.mode === 'full' ? 'full' : 'recent';
|
||||
$('#refreshLoadingSpinner').show();
|
||||
|
||||
const ctx = readChartFormContext();
|
||||
console.log('自动刷新:', mode, ctx.symbol, ctx.timeframe, 'end=', ctx.endTimeMs);
|
||||
|
||||
if (!ctx.symbol) {
|
||||
$('#refreshLoadingSpinner').hide();
|
||||
return;
|
||||
}
|
||||
|
||||
abortInFlightChartRequest();
|
||||
freezeChartViewportBeforeRequest();
|
||||
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 || '';
|
||||
const canRecent = !!(
|
||||
mode === 'recent' &&
|
||||
chartsReady &&
|
||||
hasBaseline &&
|
||||
baselineSymbol &&
|
||||
baselineSymbol === ctx.symbol &&
|
||||
!isLiveBaselineStale(ctx.timeframe)
|
||||
);
|
||||
|
||||
if (canRecent) {
|
||||
console.log('自动刷新 → /api/klines/recent limit=2');
|
||||
window._analyzeXhr = $.ajax({
|
||||
url: '/api/klines/recent',
|
||||
data: {
|
||||
symbol: ctx.symbol,
|
||||
timeframe: ctx.timeframe,
|
||||
limit: 2,
|
||||
element_timeframe: ctx.elementTimeframe || undefined,
|
||||
sub_sub_timeframe: ctx.subSubTimeframe || undefined
|
||||
},
|
||||
success: function(partial) {
|
||||
$('#refreshLoadingSpinner').hide();
|
||||
if (requestId !== lastRequestId) return;
|
||||
if (!partial || !Array.isArray(partial.kline_data)) {
|
||||
console.warn('recent 无效,改走 live 全量');
|
||||
autoRefreshChart({ mode: 'full', 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 失败,改走 live 全量:', errorThrown);
|
||||
autoRefreshChart({ mode: 'full', reason: 'recent-error-fallback' });
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('自动刷新 → /api/analyze (live)');
|
||||
window._analyzeXhr = $.ajax({
|
||||
url: '/api/analyze',
|
||||
data: {
|
||||
symbol: ctx.symbol,
|
||||
timeframe: ctx.timeframe,
|
||||
timezone: ctx.timezone,
|
||||
element_timeframe: ctx.elementTimeframe,
|
||||
sub_sub_timeframe: ctx.subSubTimeframe || undefined,
|
||||
start_time: ctx.startTimeMs,
|
||||
end_time: ctx.endTimeMs,
|
||||
elements_only: false,
|
||||
zone_kl_lines: parseInt($('#zoneKlLines').val()) || 1000,
|
||||
include_structure_zones: $('#showMainStructureZone').is(':checked') ? 1 : 0,
|
||||
include_wyckoff: 0
|
||||
},
|
||||
success: function(data) {
|
||||
$('#refreshLoadingSpinner').hide();
|
||||
if (requestId !== lastRequestId) return;
|
||||
applyAnalyzeSuccess(data, ctx.symbol, {
|
||||
incremental: options.incremental === true,
|
||||
forceFullRebuild: options.forceFullRebuild !== false && options.incremental !== true
|
||||
});
|
||||
},
|
||||
error: function(jqXHR, textStatus, errorThrown) {
|
||||
$('#refreshLoadingSpinner').hide();
|
||||
if (textStatus === 'abort') return;
|
||||
console.error('自动刷新分析失败:', errorThrown);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** 兼容旧调用:手动走 analyzeChart;fromAutoRefresh 转 autoRefreshChart */
|
||||
function updateChart(options) {
|
||||
options = options || {};
|
||||
if (options.fromAutoRefresh) {
|
||||
console.warn('updateChart(fromAutoRefresh) 已废弃,请改用 autoRefreshChart');
|
||||
autoRefreshChart({
|
||||
mode: options.fullAnalyze ? 'full' : 'recent',
|
||||
incremental: options.incremental,
|
||||
forceFullRebuild: options.incremental === false,
|
||||
reason: options.reason
|
||||
});
|
||||
return;
|
||||
}
|
||||
analyzeChart(options);
|
||||
}
|
||||
|
||||
function captureChartViewState(chart) {
|
||||
if (!chart || !chart.timeScale) return null;
|
||||
const ts = chart.timeScale();
|
||||
@@ -117,49 +373,138 @@ function captureChartViewState(chart) {
|
||||
};
|
||||
}
|
||||
|
||||
function restoreChartViewState(charts, viewState) {
|
||||
/** 将可见时间窗口限制在真实 K 线范围内,避免 to 落在右侧空白区导致锚到最右 */
|
||||
function clampVisibleRangeToBarTimes(vr, firstTime, lastTime) {
|
||||
if (!vr || vr.from === undefined || vr.to === undefined) return vr;
|
||||
if (firstTime == null || lastTime == null) return vr;
|
||||
const f = Number(firstTime);
|
||||
const l = Number(lastTime);
|
||||
if (!isFinite(f) || !isFinite(l)) return vr;
|
||||
let from = Number(vr.from);
|
||||
let to = Number(vr.to);
|
||||
const span = Math.max(1, to - from);
|
||||
if (to > l) {
|
||||
to = l;
|
||||
from = to - span;
|
||||
}
|
||||
if (from < f) {
|
||||
from = f;
|
||||
to = from + span;
|
||||
}
|
||||
return { from: from, to: to };
|
||||
}
|
||||
|
||||
/** 尾部合并时用 update 代替 setData,避免 LWC 重置滚动位置 */
|
||||
function applySeriesDataTail(series, points, tailOnly) {
|
||||
if (!series || typeof series.setData !== 'function' || !Array.isArray(points) || !points.length) {
|
||||
return;
|
||||
}
|
||||
if (tailOnly && typeof series.update === 'function' && points.length > 2) {
|
||||
points.slice(-4).forEach(function (p) {
|
||||
try { series.update(p); } catch (e) {}
|
||||
});
|
||||
return;
|
||||
}
|
||||
series.setData(points);
|
||||
}
|
||||
|
||||
function restoreChartViewState(charts, viewState, options) {
|
||||
if (!viewState || !Array.isArray(charts) || charts.length === 0) return;
|
||||
options = options || {};
|
||||
const validCharts = charts.filter(c => c && c.timeScale);
|
||||
if (validCharts.length === 0) return;
|
||||
const mainChart = validCharts[0];
|
||||
const incremental = !!options.incremental;
|
||||
|
||||
validCharts.forEach(c => {
|
||||
const oldBarCount = options.oldBarCount || window._preserveViewBarCount || 0;
|
||||
const newBarCount = options.newBarCount || 0;
|
||||
const barDelta = (oldBarCount > 0 && newBarCount > 0) ? (newBarCount - oldBarCount) : 0;
|
||||
|
||||
const applyLogical = function (lr) {
|
||||
if (!lr || lr.from === undefined || lr.to === undefined) return false;
|
||||
let from = lr.from;
|
||||
let to = lr.to;
|
||||
if (newBarCount > 0) {
|
||||
const span = Math.max(1, to - from);
|
||||
const maxTo = newBarCount - 1 + 8;
|
||||
if (to > maxTo) {
|
||||
to = maxTo;
|
||||
from = to - span;
|
||||
}
|
||||
if (from < -8) {
|
||||
from = -8;
|
||||
to = from + span;
|
||||
}
|
||||
lr = { from: from, to: to };
|
||||
}
|
||||
let ok = false;
|
||||
validCharts.forEach(c => {
|
||||
try {
|
||||
c.timeScale().setVisibleLogicalRange(lr);
|
||||
ok = true;
|
||||
} catch (e) {}
|
||||
});
|
||||
return ok;
|
||||
};
|
||||
|
||||
const applyVisible = function () {
|
||||
if (!viewState.visibleRange ||
|
||||
viewState.visibleRange.from === undefined ||
|
||||
viewState.visibleRange.to === undefined) {
|
||||
return false;
|
||||
}
|
||||
let vr = viewState.visibleRange;
|
||||
if (options.firstBarTime != null && options.lastBarTime != null) {
|
||||
vr = clampVisibleRangeToBarTimes(vr, options.firstBarTime, options.lastBarTime);
|
||||
}
|
||||
let ok = false;
|
||||
validCharts.forEach(c => {
|
||||
try {
|
||||
c.timeScale().setVisibleRange(vr);
|
||||
ok = true;
|
||||
} catch (e) {}
|
||||
});
|
||||
return ok;
|
||||
};
|
||||
|
||||
const applyScroll = function () {
|
||||
if (typeof viewState.scrollPosition !== 'number') return false;
|
||||
try {
|
||||
const optionsPatch = {};
|
||||
if (typeof viewState.barSpacing === 'number') optionsPatch.barSpacing = viewState.barSpacing;
|
||||
if (typeof viewState.rightOffset === 'number') optionsPatch.rightOffset = viewState.rightOffset;
|
||||
if (Object.keys(optionsPatch).length) {
|
||||
c.timeScale().applyOptions(optionsPatch);
|
||||
const pos = viewState.scrollPosition + barDelta;
|
||||
mainChart.timeScale().scrollToPosition(pos, false);
|
||||
const lrNow = mainChart.timeScale().getVisibleLogicalRange();
|
||||
if (lrNow) {
|
||||
validCharts.forEach(c => {
|
||||
try { c.timeScale().setVisibleLogicalRange(lrNow); } catch (e) {}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
} catch (e) {}
|
||||
});
|
||||
return false;
|
||||
};
|
||||
|
||||
let restored = false;
|
||||
const restorePosition = function () {
|
||||
if (incremental) {
|
||||
// 尾部增量:scroll+barDelta 最稳;全量重建勿先 scroll(中间段会锚到最右)
|
||||
if (applyScroll()) return true;
|
||||
if (applyLogical(viewState.logicalRange)) return true;
|
||||
return applyVisible();
|
||||
}
|
||||
// 全量重建:logical → clamped time → scroll
|
||||
if (applyLogical(viewState.logicalRange)) return true;
|
||||
if (applyVisible()) return true;
|
||||
return applyScroll();
|
||||
};
|
||||
|
||||
// 优先按逻辑范围恢复(对新数据更稳健)
|
||||
if (viewState.logicalRange && viewState.logicalRange.from !== undefined && viewState.logicalRange.to !== undefined) {
|
||||
restorePosition();
|
||||
|
||||
// barSpacing 写在位置之后会按右缘重锚,故放最后并再扳一次位置
|
||||
if (!options.skipBarSpacing && typeof viewState.barSpacing === 'number') {
|
||||
validCharts.forEach(c => {
|
||||
try {
|
||||
c.timeScale().setVisibleLogicalRange(viewState.logicalRange);
|
||||
restored = true;
|
||||
c.timeScale().applyOptions({ barSpacing: viewState.barSpacing });
|
||||
} catch (e) {}
|
||||
});
|
||||
}
|
||||
|
||||
// 逻辑范围失败时,回退到时间可见范围
|
||||
if (!restored && viewState.visibleRange && viewState.visibleRange.from !== undefined && viewState.visibleRange.to !== undefined) {
|
||||
validCharts.forEach(c => {
|
||||
try {
|
||||
c.timeScale().setVisibleRange(viewState.visibleRange);
|
||||
restored = true;
|
||||
} catch (e) {}
|
||||
});
|
||||
}
|
||||
|
||||
// 最后回退到滚动位置
|
||||
if (!restored && typeof viewState.scrollPosition === 'number') {
|
||||
validCharts.forEach(c => {
|
||||
try { c.timeScale().scrollToPosition(viewState.scrollPosition, false); } catch (e) {}
|
||||
});
|
||||
restorePosition();
|
||||
}
|
||||
}
|
||||
// 初始化图表
|
||||
|
||||
@@ -37,7 +37,7 @@ function saveMacdConfig() {
|
||||
data: JSON.stringify({ fast: fast, slow: slow, signal: signal }),
|
||||
success: function() {
|
||||
hideMacdConfig();
|
||||
updateChart();
|
||||
analyzeChart({ reason: 'macd-config-saved' });
|
||||
},
|
||||
error: function() {
|
||||
alert('保存MACD参数失败');
|
||||
@@ -85,34 +85,14 @@ $(document).on('change', '#showMainBiZs', function() {
|
||||
$(document).on('change', '#showMainStructureZone', function() {
|
||||
const on = $('#showMainStructureZone').is(':checked');
|
||||
console.log('结构区切换为:', on);
|
||||
// 勾选后才向服务器请求多周期结构区数据;取消勾选仅重绘,不重复拉取
|
||||
// 勾选后才向服务器请求多周期结构区数据;结构区叠层只在全量 init 里绘制,必须 incremental:false
|
||||
if (on) {
|
||||
updateChart();
|
||||
analyzeChart({ incremental: false, reason: 'structure-zone-on' });
|
||||
} else {
|
||||
updateChartDisplay();
|
||||
}
|
||||
});
|
||||
|
||||
// 威科夫主开关:勾选才请求;子项仅本地重绘
|
||||
function syncWyckoffSubControls() {
|
||||
const on = $('#showWyckoff').is(':checked');
|
||||
$('#showWyckoffRange, #showWyckoffPhases, #showWyckoffEvents, #showWyckoffVP').prop('disabled', !on);
|
||||
}
|
||||
$(document).on('change', '#showWyckoff', function() {
|
||||
const on = $('#showWyckoff').is(':checked');
|
||||
syncWyckoffSubControls();
|
||||
console.log('威科夫切换为:', on);
|
||||
if (on) {
|
||||
updateChart();
|
||||
} else {
|
||||
updateChartDisplay();
|
||||
}
|
||||
});
|
||||
$(document).on('change', '#showWyckoffRange, #showWyckoffPhases, #showWyckoffEvents, #showWyckoffVP', function() {
|
||||
updateChartDisplay();
|
||||
});
|
||||
$(function() { syncWyckoffSubControls(); });
|
||||
|
||||
// 添加趋势显示复选框变更事件(主/元素),变更后刷新主图
|
||||
$('#showMainTrend').change(function() {
|
||||
updateChartDisplay();
|
||||
|
||||
+66
-58
@@ -1,4 +1,6 @@
|
||||
/* ui.js */
|
||||
|
||||
|
||||
function loadSymbols() {
|
||||
$.get('/api/symbols', function(data) {
|
||||
if (Array.isArray(data)) {
|
||||
@@ -24,14 +26,15 @@ function loadSymbols() {
|
||||
});
|
||||
}
|
||||
|
||||
// 设置默认时间范围
|
||||
// 设置默认时间范围:最近 1 个月
|
||||
function setDefaultTimeRange() {
|
||||
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
|
||||
$('#end_time').val(formatDatetimeLocal(now));
|
||||
$('#start_time').val(formatDatetimeLocal(oneDayAgo));
|
||||
$('#start_time').val(formatDatetimeLocal(start));
|
||||
}
|
||||
// 格式化日期为datetime-local输入框格式
|
||||
function formatDatetimeLocal(date) {
|
||||
@@ -90,7 +93,7 @@ $(document).ready(function() {
|
||||
startAStockStatusUpdater();
|
||||
// A 股:metadata 完成后再拉数(下方不再重复 updateChart)
|
||||
setTimeout(function() {
|
||||
updateChart();
|
||||
analyzeChart({ reason: 'astock-init' });
|
||||
}, 300);
|
||||
});
|
||||
}
|
||||
@@ -129,10 +132,10 @@ $(document).ready(function() {
|
||||
// 尝试加载更多交易对
|
||||
loadSymbols();
|
||||
|
||||
// 初始化图表:默认加密货币延迟拉取;若首屏为 A 股则在 chart_metadata 完成后再 updateChart
|
||||
// 初始化图表:默认加密货币延迟拉取;若首屏为 A 股则在 chart_metadata 完成后再 analyze
|
||||
if (initialDataSource !== 'a_stock') {
|
||||
setTimeout(function() {
|
||||
updateChart();
|
||||
analyzeChart({ reason: 'crypto-init' });
|
||||
}, 500);
|
||||
}
|
||||
|
||||
@@ -244,6 +247,9 @@ $(document).ready(function() {
|
||||
let autoRefreshTimer = null;
|
||||
let nextRefreshTime = null;
|
||||
let autoRefreshTick = 0;
|
||||
/** 自动刷新时,缠论全量重算间隔(毫秒);时间戳见 window._lastFullAnalyzeAt */
|
||||
const AUTO_FULL_ANALYZE_MS = 60 * 1000;
|
||||
|
||||
// 初始化自动刷新功能
|
||||
function initAutoRefresh() {
|
||||
// 监听自动刷新勾选框变化
|
||||
@@ -270,10 +276,10 @@ function startAutoRefresh() {
|
||||
stopAutoRefresh();
|
||||
|
||||
// 获取刷新频率(分钟)
|
||||
const interval = parseFloat($('#refreshInterval').val()) || 5;
|
||||
const interval = parseFloat($('#refreshInterval').val()) || (5 / 60);
|
||||
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);
|
||||
@@ -281,19 +287,35 @@ function startAutoRefresh() {
|
||||
|
||||
// 启动定时器
|
||||
autoRefreshTick = 0;
|
||||
|
||||
// 进入实时模式:结束时间=现在,立刻走自动刷新全量(视窗由 autoRefreshChart 内 freeze 冻结)
|
||||
updateEndTimeToNow();
|
||||
autoRefreshChart({
|
||||
mode: 'full',
|
||||
incremental: false,
|
||||
forceFullRebuild: true,
|
||||
reason: 'auto-refresh-start'
|
||||
});
|
||||
|
||||
autoRefreshTimer = setInterval(function() {
|
||||
// 更新结束时间为当前时间
|
||||
// 自动刷新专用:结束时间推进到现在
|
||||
updateEndTimeToNow();
|
||||
|
||||
// 多数周期增量更新;每隔若干次全量重建以刷新笔/段/中枢(dispose 已防泄漏)
|
||||
autoRefreshTick += 1;
|
||||
const fullRebuild = (autoRefreshTick % 6) === 0;
|
||||
updateChart({
|
||||
fromAutoRefresh: true,
|
||||
incremental: !fullRebuild
|
||||
});
|
||||
const now = Date.now();
|
||||
const lastFull = window._lastFullAnalyzeAt || 0;
|
||||
const tf = $('#timeframe').val() || '4h';
|
||||
const needFull = !lastFull || (now - lastFull >= AUTO_FULL_ANALYZE_MS) ||
|
||||
(typeof isLiveBaselineStale === 'function' && isLiveBaselineStale(tf));
|
||||
|
||||
if (needFull) {
|
||||
console.log('自动刷新 tick → live 全量');
|
||||
autoRefreshChart({ mode: 'full', incremental: false, forceFullRebuild: true });
|
||||
} else {
|
||||
console.log('自动刷新 tick → recent 尾部');
|
||||
autoRefreshChart({ mode: 'recent' });
|
||||
}
|
||||
|
||||
// 更新下次刷新时间
|
||||
nextRefreshTime = new Date(Date.now() + intervalMs);
|
||||
updateNextRefreshTimeDisplay();
|
||||
}, intervalMs);
|
||||
@@ -400,10 +422,6 @@ function mapTimeframeToInterval(timeframe) {
|
||||
function redrawFractalElements() {
|
||||
if (!tvWidget || !tvWidget.mainChart) return;
|
||||
|
||||
const mainChart = tvWidget.mainChart;
|
||||
const logicalRange = mainChart.timeScale().getVisibleLogicalRange();
|
||||
const visibleRange = mainChart.timeScale().getVisibleRange();
|
||||
|
||||
// 确保使用主周期的K线和MACD数据
|
||||
if (currentData.original_kline_data) {
|
||||
currentData.kline_data = currentData.original_kline_data;
|
||||
@@ -411,29 +429,14 @@ function redrawFractalElements() {
|
||||
if (currentData.original_macd) {
|
||||
currentData.macd = currentData.original_macd;
|
||||
}
|
||||
// 清除冗余引用,帮助GC回收
|
||||
delete currentData.original_kline_data;
|
||||
delete currentData.original_macd;
|
||||
|
||||
initTradingView($('#symbol').val(), $('#timeframe').val());
|
||||
|
||||
setTimeout(() => {
|
||||
if (tvWidget && tvWidget.mainChart) {
|
||||
if (logicalRange) {
|
||||
tvWidget.mainChart.timeScale().setVisibleLogicalRange(logicalRange);
|
||||
if (tvWidget.volumeChart) tvWidget.volumeChart.timeScale().setVisibleLogicalRange(logicalRange);
|
||||
if (tvWidget.atrChart) tvWidget.atrChart.timeScale().setVisibleLogicalRange(logicalRange);
|
||||
if (tvWidget.macdChart) tvWidget.macdChart.timeScale().setVisibleLogicalRange(logicalRange);
|
||||
if (tvWidget.chanMacdChart) tvWidget.chanMacdChart.timeScale().setVisibleLogicalRange(logicalRange);
|
||||
} else if (visibleRange) {
|
||||
tvWidget.mainChart.timeScale().setVisibleRange(visibleRange);
|
||||
if (tvWidget.volumeChart) tvWidget.volumeChart.timeScale().setVisibleRange(visibleRange);
|
||||
if (tvWidget.atrChart) tvWidget.atrChart.timeScale().setVisibleRange(visibleRange);
|
||||
if (tvWidget.macdChart) tvWidget.macdChart.timeScale().setVisibleRange(visibleRange);
|
||||
if (tvWidget.chanMacdChart) tvWidget.chanMacdChart.timeScale().setVisibleRange(visibleRange);
|
||||
}
|
||||
}
|
||||
}, 200);
|
||||
if (typeof reinitTradingViewPreservingViewport === 'function') {
|
||||
reinitTradingViewPreservingViewport();
|
||||
} else {
|
||||
initTradingView($('#symbol').val(), $('#timeframe').val());
|
||||
}
|
||||
}
|
||||
// 只更新分形元素(笔、线段、中枢)的表格数据
|
||||
function updateFractalTables() {
|
||||
@@ -535,15 +538,12 @@ function refreshChart(data, options) {
|
||||
// 自动刷新:增量更新,避免每次销毁/重建 Lightweight Charts
|
||||
if (preferIncremental && chartsReady) {
|
||||
try {
|
||||
if (tvWidget.mainChart) {
|
||||
try {
|
||||
window._pendingRestoreView = captureChartViewState(tvWidget.mainChart);
|
||||
} catch (e) {
|
||||
window._pendingRestoreView = null;
|
||||
}
|
||||
// 视窗已在请求发出时 freezeChartViewportBeforeRequest 冻结,勿在此重拍(会弄错 barCount)
|
||||
updateTradingViewData({ tailOnly: !!options.skipTables });
|
||||
// recent-tail 刷新结构未变,跳过表格重绘以提速
|
||||
if (!options.skipTables) {
|
||||
updateTables(data);
|
||||
}
|
||||
updateTradingViewData();
|
||||
updateTables(data);
|
||||
if (currentData && currentData.ema52_dict) {
|
||||
updateEMA52Display(currentData);
|
||||
}
|
||||
@@ -553,9 +553,18 @@ function refreshChart(data, options) {
|
||||
}
|
||||
}
|
||||
|
||||
// 保存当前缩放(barSpacing)和滚动位置(scrollPosition)到 window
|
||||
// tvWidget 会在 initTradingView 内被重建,所以必须存到 window 上
|
||||
if (tvWidget && tvWidget.mainChart) {
|
||||
// 全量重建:优先用请求前冻结的视窗(分析按钮在请求发出时已 capture)
|
||||
if (typeof ensurePendingChartViewportBeforeInit === 'function') {
|
||||
ensurePendingChartViewportBeforeInit();
|
||||
if (window._preserveViewOnRefresh) {
|
||||
console.log('📌 全量重建:使用请求前冻结视窗');
|
||||
} else if (window._pendingRestoreView) {
|
||||
console.log('📌 使用已保存图表视图');
|
||||
}
|
||||
} else if (window._preserveViewOnRefresh) {
|
||||
window._pendingRestoreView = window._preserveViewOnRefresh;
|
||||
console.log('📌 全量重建:使用请求前冻结视窗');
|
||||
} else if (!window._pendingRestoreView && tvWidget && tvWidget.mainChart) {
|
||||
try {
|
||||
window._pendingRestoreView = captureChartViewState(tvWidget.mainChart);
|
||||
console.log('📌 保存图表视图:', JSON.stringify(window._pendingRestoreView));
|
||||
@@ -563,6 +572,8 @@ function refreshChart(data, options) {
|
||||
console.warn('保存图表视图失败:', e);
|
||||
window._pendingRestoreView = null;
|
||||
}
|
||||
} else if (window._pendingRestoreView) {
|
||||
console.log('📌 使用已保存图表视图:', JSON.stringify(window._pendingRestoreView));
|
||||
}
|
||||
|
||||
initTradingView($('#symbol').val(), $('#timeframe').val());
|
||||
@@ -596,14 +607,14 @@ $('#showElementMacdDiv').change(function() {
|
||||
refreshChartOnly();
|
||||
});
|
||||
|
||||
// 绑定分型类型显示开关
|
||||
// 绑定分型类型显示开关(与笔一致:全量重建,避免增量路径标记未对齐)
|
||||
$('#showKlcFxType').change(function() {
|
||||
refreshChartOnly();
|
||||
updateChartDisplay();
|
||||
});
|
||||
|
||||
// 绑定小周期分型显示开关
|
||||
$('#showElementKlcFxType').change(function() {
|
||||
refreshChart(currentData);
|
||||
updateChartDisplay();
|
||||
});
|
||||
|
||||
|
||||
@@ -616,10 +627,7 @@ $('#showElementBollinger').change(function() {
|
||||
updateChartDisplay();
|
||||
});
|
||||
|
||||
// 绑定K线周期切换
|
||||
$('input[name="klinePeriod"]').change(function() {
|
||||
refreshChart(currentData);
|
||||
});
|
||||
// K线周期切换由 macd_ui.js 统一走 updateChartDisplay(勿再绑 refreshChart,会重复且易漏对齐)
|
||||
|
||||
// 绑定主图U显示开关
|
||||
$('#toggleUOnMain').change(function() {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user