Author SHA1 Message Date
jackyu66git 7e16edd2c9 fix: pipeline MACD 参数统一为标准 12/26/9(与 web/交易所一致) 2026-09-12 02:14:20 +08:00
jackyu66gitandCursor 5c10e35b76 refactor(web): 移除主图威科夫选项与叠层
去掉区间/阶段/时间/VP 开关、Cycle 摘要面板及绘制逻辑;分析请求默认 include_wyckoff=0。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-26 01:27:51 +08:00
jackyu66gitandCursor 8c165f11cd fix(web): 未完成笔/线段终点对齐图表最新 K 线
各周期使用对应 kline 数据,终点时间 snap 到 candles,优先使用分析 end_price。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-26 00:40:41 +08:00
jackyu66gitandCursor 97e77847d0 fix(web): 开关缠论元素保留视窗;分周期 Trend 涨跌配色
本地重绘统一冻结视窗;次/次次周期 Trend 上涨下跌使用独立颜色。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 23:56:55 +08:00
jackyu66gitandCursor 90499533fb fix(web): 分析/自动刷新后保留 K 线视窗位置
拆分手动分析与自动刷新拉数路径;全量重建用 logical 优先恢复视窗,
增量 recent 用 scroll+barDelta;避免 barSpacing 重锚与重复冻结导致往右跳。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 23:38:20 +08:00
jackyu66gitandCursor 8ee11317d3 fix(web): 自动刷新保留 K 线视窗;威科夫与图表增量更新
自动刷新改用 tail update 与 scrollToPosition 恢复视窗,避免 setData 后跳到最右;拆分 chart_tv 模块并扩展 analyze/recent API。同步威科夫分析、pipeline 增量构建及相关策略与配置。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 22:57:43 +08:00
105 changed files with 21109 additions and 5115 deletions
+3 -2
View File
@@ -1,6 +1,7 @@
"""威科夫分析(启发式):交易区间 / 阶段 / 事件 / Volume Profile。"""
"""威科夫分析(启发式):交易区间 / 阶段 / 事件 / Volume Profile / Live"""
from __future__ import annotations
from .engine import analyze_wyckoff
from .live import execution_signal_from_wyckoff
__all__ = ["analyze_wyckoff"]
__all__ = ["analyze_wyckoff", "execution_signal_from_wyckoff"]
+145 -29
View File
@@ -1,12 +1,18 @@
"""威科夫分析入口"""
"""威科夫分析入口Cycle → Phase → Event → VP + LiveMULTI-CYCLE / LIVE-STRUCTURE)。
range.py 只产 TradingRangeConfirmed 走 events.pyLive 走 live.py。
cycles[0]=ACTIVE;禁止 cycles[-1] 取 active。
Execution 只消费 Confirmed(见 live.execution_signal_from_wyckoff)。
"""
from __future__ import annotations
from typing import Any, Dict, Optional
from typing import Any, Dict, List, Optional
import pandas as pd
from .events import build_phases, detect_bias_and_events
from .range import detect_trading_range
from .live import analyze_live_structure
from .range import detect_trading_ranges
from .volume_profile import compute_volume_profile
@@ -21,31 +27,55 @@ def _fmt_time(v) -> Optional[str]:
return str(v)
def analyze_wyckoff(df: pd.DataFrame, lookback: int = 120, vp_bins: int = 50) -> Dict[str, Any]:
"""
对主周期 OHLCV DataFrame 做威科夫启发式分析。
需要列: open, high, low, close, volume;建议有 date 或 timestamp。
"""
empty = {
def _empty(vp_bins: int) -> Dict[str, Any]:
return {
"cycles": [],
"trading_range": None,
"bias": "unknown",
"phases": [],
"events": [],
"volume_profile": {"bins": [], "poc": None, "vah": None, "val": None, "bin_count": vp_bins},
"volume_confirm": {"avg_volume": 0.0, "event_checks": {}},
"live": None,
}
if df is None or len(df) < 30:
return empty
if not all(c in df.columns for c in ("open", "high", "low", "close")):
return empty
work = df.copy()
if "volume" not in work.columns:
work["volume"] = 1.0
tr = detect_trading_range(work, lookback=lookback)
if tr is None:
return empty
def _confidence_for_confirmed(
tr: Dict[str, Any],
phases: List[Dict[str, Any]],
events: List[Dict[str, Any]],
) -> Dict[str, float]:
range_c = float(tr.get("range_confidence") or 0.5)
labels = {p.get("phase") for p in phases}
phase_c = 0.35
if "A" in labels and "B" in labels:
phase_c += 0.15
if "C" in labels:
phase_c += 0.2
if "D" in labels or "E" in labels:
phase_c += 0.15
phase_c = min(0.95, phase_c)
types = {e.get("type") for e in events}
event_c = 0.25
for t in ("Spring", "UTAD", "SOS", "SOW", "LPS", "LPSY"):
if t in types:
event_c += 0.12
event_c = min(0.95, event_c)
overall = 0.4 * range_c + 0.3 * phase_c + 0.3 * event_c
return {
"range": round(range_c, 3),
"phase": round(phase_c, 3),
"event": round(event_c, 3),
"overall": round(overall, 3),
}
def _build_cycle(
work: pd.DataFrame,
tr: Dict[str, Any],
cycle_id: int,
vp_bins: int,
) -> Dict[str, Any]:
bias, events, volume_confirm = detect_bias_and_events(work, tr)
phases = build_phases(work, tr, bias, events)
vp = compute_volume_profile(
@@ -54,27 +84,113 @@ def analyze_wyckoff(df: pd.DataFrame, lookback: int = 120, vp_bins: int = 50) ->
int(tr["abs_end_idx"]),
bin_count=vp_bins,
)
trading_range = {
"start_time": _fmt_time(tr.get("start_time")),
"end_time": _fmt_time(tr.get("end_time")),
"high": float(tr["high"]),
"low": float(tr["low"]),
"mid": float(tr["mid"]),
"active": bool(tr.get("active", True)),
"bars": int(tr.get("bars", 0)),
}
for ev in events:
ev["time"] = _fmt_time(ev.get("time"))
for ph in phases:
ph["start_time"] = _fmt_time(ph.get("start_time"))
ph["end_time"] = _fmt_time(ph.get("end_time"))
is_active = cycle_id == 0
trading_range = {
"start_time": _fmt_time(tr.get("start_time")),
"end_time": _fmt_time(tr.get("end_time")),
"high": float(tr["high"]),
"low": float(tr["low"]),
"mid": float(tr["mid"]),
"active": bool(is_active),
"bars": int(tr.get("bars", 0)),
}
conf = _confidence_for_confirmed(tr, phases, events)
# Live 层:仅 ACTIVE 周期做推演;历史周期归档为 COMPLETED
if is_active:
live = analyze_live_structure(
work, tr, confirmed_events=events, confirmed_phases=phases, bias=bias,
)
lifecycle = live.get("lifecycle") or "FORMING"
else:
live = None
lifecycle = "COMPLETED"
return {
"id": int(cycle_id),
"role": "latest" if is_active else "historical",
# MULTI-CYCLE:时间线角色
"status": "ACTIVE" if is_active else "HISTORICAL",
# LIVE-STRUCTURE:生命周期
"lifecycle": lifecycle,
"direction": "latest" if is_active else "historical",
"period": {
"start_time": _fmt_time(tr.get("start_time")),
"end_time": _fmt_time(tr.get("end_time")),
"bars": int(tr.get("bars", 0)),
},
"confidence": conf,
"trading_range": trading_range,
"bias": bias,
# 兼容旧读法:顶层 phases/events = confirmed
"phases": phases,
"events": events,
"confirmed": {
"phases": phases,
"events": events,
"volume_confirm": volume_confirm,
},
"live": live,
"volume_profile": vp,
"volume_confirm": volume_confirm,
}
def analyze_wyckoff(
df: pd.DataFrame,
lookback: int = 120,
vp_bins: int = 50,
min_bars: int = 24,
atr_mult: float = 1.2,
range_start_time=None,
prefer_start_time=None,
max_cycles: int = 8,
) -> Dict[str, Any]:
"""
多周期威科夫分析。
cycles[0] = ACTIVE;顶层 phases/events 只镜像 Confirmed。
顶层 live 镜像 cycles[0].live。
"""
empty = _empty(vp_bins)
if df is None or len(df) < 30:
return empty
if not all(c in df.columns for c in ("open", "high", "low", "close")):
return empty
work = df.copy()
if "volume" not in work.columns:
work["volume"] = 1.0
trs = detect_trading_ranges(
work,
lookback=lookback,
min_bars=max(8, int(min_bars)),
atr_mult=atr_mult,
max_cycles=max(1, min(8, int(max_cycles))),
prefer_start_time=prefer_start_time,
range_start_time=range_start_time,
)
if not trs:
return empty
cycles: List[Dict[str, Any]] = []
for i, tr in enumerate(trs):
cycles.append(_build_cycle(work, tr, cycle_id=i, vp_bins=vp_bins))
active = cycles[0]
return {
"cycles": cycles,
"trading_range": active["trading_range"],
"bias": active["bias"],
"phases": active["confirmed"]["phases"],
"events": active["confirmed"]["events"],
"volume_profile": active["volume_profile"],
"volume_confirm": active["volume_confirm"],
"live": active.get("live"),
"lifecycle": active.get("lifecycle"),
}
+157 -35
View File
@@ -1,7 +1,7 @@
"""威科夫阶段与事件(启发式)。"""
from __future__ import annotations
from typing import Any, Dict, List, Tuple
from typing import Any, Dict, List, Optional, Tuple
import numpy as np
import pandas as pd
@@ -29,6 +29,9 @@ def detect_bias_and_events(
) -> Tuple[str, List[Dict[str, Any]], Dict[str, Any]]:
"""
返回 bias、events、volume_confirm。
Spring/UTAD 相对「结构高低」判定:取区间内次低/次高(剔除单根极值),
避免箱体把假破低点吃进 lo 后永远刺不破、从而无 C 阶段。
"""
hi = float(tr["high"])
lo = float(tr["low"])
@@ -38,6 +41,24 @@ def detect_bias_and_events(
e = int(tr["abs_end_idx"])
events: List[Dict[str, Any]] = []
# 结构边界:用次低/次高作假破参照(至少 8 根才启用)
seg = df.iloc[s : e + 1]
event_lo, event_hi = lo, hi
if len(seg) >= 8:
lows = seg["low"].astype(float)
highs = seg["high"].astype(float)
# nsmallest(2) 的较大者 = 次低;nlargest(2) 的较小者 = 次高
event_lo = float(lows.nsmallest(min(2, len(lows))).iloc[-1])
event_hi = float(highs.nlargest(min(2, len(highs))).iloc[-1])
# 勿比公布箱沿更「松」:结构带应在箱内
event_lo = max(event_lo, lo)
event_hi = min(event_hi, hi)
# 若次低仍等于极值(多根同价),略抬参照便于识别收回
if abs(event_lo - lo) < 1e-12:
event_lo = lo + max(tol * 0.35, (hi - lo) * 0.02)
if abs(event_hi - hi) < 1e-12:
event_hi = hi - max(tol * 0.35, (hi - lo) * 0.02)
# 扫描区间内及之后(含 tail_reserve
scan_end = int(tr.get("abs_scan_end_idx", min(len(df) - 1, e + 15)))
scan_end = min(len(df) - 1, max(scan_end, e))
@@ -57,8 +78,8 @@ def detect_bias_and_events(
avg_v = _avg_vol(df, i)
ratio = vol / avg_v if avg_v else 0.0
# Spring: pierce below low then close back above low
if spring is None and low < lo - tol * 0.5 and close >= lo - tol * 0.2:
# Spring: pierce below structural support then close back
if spring is None and low < event_lo - tol * 0.35 and close >= event_lo - tol * 0.35:
vol_ok = ratio <= 1.35 or (i + 1 <= scan_end and float(df.iloc[min(i + 1, scan_end)]["volume"]) / avg_v < 1.2)
spring = {
"type": "Spring",
@@ -70,8 +91,8 @@ def detect_bias_and_events(
"idx": i,
}
# UTAD: pierce above high then close back below
if utad is None and high > hi + tol * 0.5 and close <= hi + tol * 0.2:
# UTAD: pierce above structural resistance then close back
if utad is None and high > event_hi + tol * 0.35 and close <= event_hi + tol * 0.35:
vol_ok = ratio >= 0.8
utad = {
"type": "UTAD",
@@ -154,11 +175,15 @@ def detect_bias_and_events(
}
break
# 冲突清理:已判定吸筹且有 SOS 时,丢弃更早的 UTAD(避免阶段/图面误导)
# 派发且有 SOW 时,丢弃更晚才合理的 Spring 假信号同理在偏置后再滤
keep = []
for ev in (spring, sos, lps, utad, sod, lpsy):
if ev:
events.append({k: v for k, v in ev.items() if k != "idx"})
if not ev:
continue
keep.append(ev)
# bias
# bias(先算)
last_c = float(df["close"].iloc[-1])
bias = "unknown"
if sos and (not sod or int(sos.get("idx", 0)) >= int(sod.get("idx", 0))):
@@ -174,6 +199,16 @@ def detect_bias_and_events(
else:
bias = "distribution"
filtered = []
for ev in keep:
if bias == "accumulation" and ev["type"] == "UTAD" and sos and int(ev["idx"]) <= int(sos["idx"]):
continue
if bias == "distribution" and ev["type"] == "Spring" and sod and int(ev["idx"]) <= int(sod["idx"]):
continue
filtered.append(ev)
events = [{k: v for k, v in ev.items() if k != "idx"} for ev in filtered]
avg_volume = float(df["volume"].astype(float).iloc[max(0, e - 20) : e + 1].mean()) if "volume" in df.columns else 0.0
volume_confirm = {
"avg_volume": avg_volume,
@@ -189,59 +224,146 @@ def build_phases(
events: List[Dict[str, Any]],
min_bars: int = 3,
) -> List[Dict[str, Any]]:
"""按时间切分 A–E 粗阶段;保证非重叠且每段至少 min_bars 根(空间不足则截断尾部阶段)。"""
"""
按威科夫事件锚点切分 A–E(启发式)。
吸筹:A停止 → B筑底 → C测试(Spring) → D拉升(SOS…LPS) → E离开
派发:A停止 → B筑顶 → C测试(UTAD) → D派发(SOW…LPSY) → E离开
无 Spring/UTAD 时:若已有 SOS/SOW,用突破前末次沿带测试补 C;仍无则省略 C。
"""
s = int(tr["abs_start_idx"])
e = int(tr["abs_end_idx"])
hi = float(tr["high"])
lo = float(tr["low"])
n_last = len(df) - 1
min_span = max(2, min_bars - 1)
range_len = max(1, e - s)
event_idx = {}
for ev in events:
t = ev.get("time")
for i in range(s, min(len(df), e + 20)):
def _match_idx(t) -> Optional[int]:
if t is None:
return None
lo = max(0, s - 2)
hi = min(len(df), e + 40)
for i in range(lo, hi):
if _bar_time(df, i) == t:
event_idx[ev["type"]] = i
return i
try:
tt = pd.Timestamp(t)
sample = None
if "date" in df.columns and len(df):
sample = df["date"].iloc[min(s, n_last)]
if sample is not None and getattr(sample, "tzinfo", None) is not None and tt.tzinfo is None:
tt = tt.tz_localize(sample.tzinfo)
for i in range(lo, hi):
bt = _bar_time(df, i)
try:
if abs((pd.Timestamp(bt) - tt).total_seconds()) <= 1:
return i
except Exception:
continue
except Exception:
pass
return None
event_idx: Dict[str, int] = {}
for ev in events:
idx = _match_idx(ev.get("time"))
if idx is not None:
event_idx[str(ev.get("type"))] = idx
accum = bias != "distribution"
if accum:
c_ev = event_idx.get("Spring")
d_ev = event_idx.get("SOS")
d_tail = event_idx.get("LPS") or d_ev
else:
c_ev = event_idx.get("UTAD")
d_ev = event_idx.get("SOW")
d_tail = event_idx.get("LPSY") or d_ev
# 有 D 无明确测试事件时:用突破前最后一次触及下/上沿作为 C(次级测试)
if c_ev is None and d_ev is not None:
band = lo + (hi - lo) * 0.28 if accum else hi - (hi - lo) * 0.28
for i in range(int(d_ev) - 1, s + 1, -1):
row = df.iloc[i]
if accum and float(row["low"]) <= band:
c_ev = i
break
if not accum and float(row["high"]) >= band:
c_ev = i
break
a_end = s + max(min_bars, (e - s) // 5)
c_anchor = event_idx.get("Spring") or event_idx.get("UTAD") or (s + (e - s) // 2)
d_anchor = event_idx.get("SOS") or event_idx.get("SOW") or e
def _lab(phase: str) -> str:
if bias == "distribution":
m = {"A": "A停止上涨", "B": "B筑顶", "C": "C测试", "D": "D派发", "E": "E下跌"}
else:
if accum:
m = {"A": "A停止下跌", "B": "B筑底", "C": "C测试", "D": "D拉升", "E": "E离开"}
else:
m = {"A": "A停止上涨", "B": "B筑顶", "C": "C测试", "D": "D派发", "E": "E离开"}
return m.get(phase, phase)
# 理想切点(随后再强制非重叠 + 最小跨度)
raw = [
("A", s, a_end),
("B", a_end, c_anchor),
("C", c_anchor, d_anchor),
("D", d_anchor, min(n_last, d_anchor + max(min_bars, (e - s) // 6))),
("E", min(n_last, d_anchor + max(min_bars, (e - s) // 6)), min(n_last, max(e, d_anchor + max(min_bars * 2, 8)))),
]
a_end = s + max(min_bars, range_len // 5)
c_start = c_end = None
if c_ev is not None:
c_start = max(s, int(c_ev) - 1)
c_end = min(n_last, int(c_ev) + 1)
if d_ev is not None:
d_start = int(d_ev)
d_end = min(n_last, max(int(d_tail or d_ev), d_start) + max(min_bars, range_len // 8))
if d_tail is not None:
d_end = max(d_end, min(n_last, int(d_tail) + 1))
else:
d_start = d_end = None
if c_start is not None:
b_end = max(a_end + 1, c_start)
elif d_start is not None:
b_end = max(a_end + 1, d_start)
else:
b_end = max(a_end + 1, e)
if d_end is not None:
e_start = min(n_last, d_end)
e_end = n_last
else:
e_start = e_end = None
raw = [("A", s, a_end), ("B", a_end, b_end)]
if c_start is not None and c_end is not None:
raw.append(("C", c_start, c_end))
if d_start is not None and d_end is not None:
raw.append(("D", d_start, d_end))
if e_start is not None and e_end is not None and e_end > e_start:
raw.append(("E", e_start, e_end))
phases: List[Dict[str, Any]] = []
cursor = s
for phase, _a, _b in raw:
if cursor >= n_last:
break
a = max(int(_a), cursor)
b = int(max(_b, a + min_span))
b = int(max(int(_b), a))
need = 1 if phase == "C" else min_span
if b < a + need:
b = min(n_last, a + need)
b = int(np.clip(b, a, n_last))
if b - a < min_span:
# 尾部空间不足:并入上一段终点并停止新增
if phases:
phases[-1]["end_time"] = _bar_time(df, n_last)
break
if b < a:
continue
if phases and phases[-1].get("_a") == a and phases[-1].get("_b") == b:
continue
phases.append(
{
"phase": phase,
"label": _lab(phase),
"start_time": _bar_time(df, a),
"end_time": _bar_time(df, b),
"_a": a,
"_b": b,
}
)
cursor = b
for p in phases:
p.pop("_a", None)
p.pop("_b", None)
return phases
+258
View File
@@ -0,0 +1,258 @@
"""威科夫 Live / Developing 层(WYCKOFF-LIVE-STRUCTURE-001)。
独立于 Confirmed Engine:不修改 events 确认条件,不写入 confirmed.events。
Execution 不得消费本模块输出。
"""
from __future__ import annotations
from typing import Any, Dict, List, Optional, Set
import numpy as np
import pandas as pd
def _avg_vol(df: pd.DataFrame, i: int, win: int = 20) -> float:
a = max(0, i - win + 1)
v = df["volume"].astype(float).iloc[a : i + 1]
m = float(v.mean()) if len(v) else 0.0
return m if m > 0 else 1.0
def _empty_live() -> Dict[str, Any]:
return {
"lifecycle": "UNKNOWN",
"range_formation": None,
"phase_candidate": None,
"event_candidates": [],
"next_expected": None,
"confidence": {
"cycle": 0.0,
"phase": 0.0,
"event": 0.0,
"structure": 0.0,
"volume": 0.0,
"overall": 0.0,
},
"note": "",
}
def analyze_live_structure(
df: pd.DataFrame,
tr: Optional[Dict[str, Any]],
confirmed_events: Optional[List[Dict[str, Any]]] = None,
confirmed_phases: Optional[List[Dict[str, Any]]] = None,
bias: str = "unknown",
) -> Dict[str, Any]:
"""
基于当前 TradingRange 与已确认事件,推演 Live candidates。
confirmed_* 只读,用于避免重复提示已确认事件,不修改之。
"""
out = _empty_live()
if df is None or len(df) < 20 or tr is None:
out["note"] = "insufficient structure"
return out
confirmed_events = confirmed_events or []
confirmed_phases = confirmed_phases or []
confirmed_types: Set[str] = {str(e.get("type")) for e in confirmed_events if e.get("type")}
s = int(tr["abs_start_idx"])
e = int(tr["abs_end_idx"])
scan_end = int(tr.get("abs_scan_end_idx", len(df) - 1))
scan_end = min(len(df) - 1, max(scan_end, e))
hi = float(tr["high"])
lo = float(tr["low"])
mid = float(tr["mid"])
tol = float(tr.get("tol") or (hi - lo) * 0.05)
atr = float(tr.get("atr") or max((hi - lo) * 0.2, 1e-9))
seg = df.iloc[s : e + 1]
if len(seg) < 8:
out["note"] = "range too short"
return out
# —— Range Formation(横盘 / 波动收敛)——
closes = seg["close"].astype(float)
highs = seg["high"].astype(float)
lows = seg["low"].astype(float)
vols = seg["volume"].astype(float) if "volume" in seg.columns else pd.Series([1.0] * len(seg))
half = max(4, len(seg) // 2)
vol_early = float(np.std(closes.iloc[:half])) if half > 1 else 0.0
vol_late = float(np.std(closes.iloc[-half:])) if half > 1 else 0.0
width = hi - lo
width_atr = width / atr if atr > 0 else 99.0
converging = vol_early > 1e-12 and vol_late < vol_early * 0.85
range_ok = 1.2 <= width_atr <= 10.0 and len(seg) >= 16
structure_score = 0.35
if range_ok:
structure_score += 0.25
if converging:
structure_score += 0.2
if width_atr <= 6.0:
structure_score += 0.1
structure_score = float(min(0.95, structure_score))
out["range_formation"] = {
"potential_trading_range": bool(range_ok),
"converging": bool(converging),
"width_atr": round(width_atr, 3),
"bars": int(len(seg)),
}
# —— 最近 K 形态(Phase C / Event candidates)——
i = scan_end
row = df.iloc[i]
o = float(row["open"])
h = float(row["high"])
l = float(row["low"])
c = float(row["close"])
rng = max(h - l, 1e-9)
lower_wick = min(o, c) - l
upper_wick = h - max(o, c)
avg_v = _avg_vol(df, i)
vol = float(row["volume"]) if "volume" in df.columns else avg_v
vol_ratio = vol / avg_v if avg_v else 1.0
volume_score = float(np.clip(1.1 - abs(vol_ratio - 1.0) * 0.35, 0.2, 0.95))
phase_candidate = None
phase_conf = 0.0
# Phase C:测低 + 下影 + 缩量(吸筹语境)
near_lo = l <= lo + tol * 1.2
test_low = l < mid and lower_wick >= rng * 0.35
vol_contract = vol_ratio <= 1.05
if bias != "distribution" and near_lo and test_low and vol_contract:
phase_candidate = "C"
phase_conf = 0.55 + (0.1 if lower_wick >= rng * 0.5 else 0) + (0.08 if vol_ratio < 0.9 else 0)
# Phase D 候选:价格在箱上半、有上破意图但未确认 SOS
elif c >= mid and (h >= hi - tol or c > hi - tol * 0.5):
phase_candidate = "D"
phase_conf = 0.5 + (0.1 if c > mid else 0)
elif c < mid and (l <= lo + tol):
phase_candidate = "B"
phase_conf = 0.45
# 已有 confirmed phase 时,candidate 取「下一阶段」提示,不覆盖事实
confirmed_phase_set = {str(p.get("phase")) for p in confirmed_phases}
if "E" in confirmed_phase_set:
phase_candidate = phase_candidate or "E"
phase_conf = max(phase_conf, 0.7)
elif "D" in confirmed_phase_set and phase_candidate is None:
phase_candidate = "D"
phase_conf = max(phase_conf, 0.65)
out["phase_candidate"] = phase_candidate
phase_conf = float(min(0.92, phase_conf))
# —— Event candidates(仅 Spring / SOS / LPS / UTAD)——
candidates: List[Dict[str, Any]] = []
def _add(typ: str, conf: float, note: str) -> None:
if typ in confirmed_types:
return # 已确认则不再作为 candidate
candidates.append(
{
"type": typ,
"confidence": round(float(min(0.9, conf)), 3),
"confirmed": False,
"note": note,
}
)
# Spring candidate:刺破或贴近下沿,收盘收回,但未达 Confirmed 规则(或不在 confirmed
pierce_lo = l < lo - tol * 0.15
close_back = c >= lo - tol * 0.5
if pierce_lo and close_back:
_add("Spring", 0.5 + (0.12 if vol_ratio <= 1.2 else 0) + (0.08 if close_back else 0), "假破下沿收回(未确认)")
elif l <= lo + tol * 0.35 and close_back and lower_wick >= rng * 0.4:
_add("Spring", 0.45 + (0.1 if vol_contract else 0), "测下沿长下影(未确认)")
# UTAD candidate
pierce_hi = h > hi + tol * 0.15
close_back_dn = c <= hi + tol * 0.5
if pierce_hi and close_back_dn:
_add("UTAD", 0.5 + (0.1 if vol_ratio >= 0.9 else 0), "假破上沿跌回(未确认)")
# SOS candidate:接近/轻破上沿,量能一般,未确认
if c > hi - tol * 0.4 or h >= hi:
sos_conf = 0.48 + (0.12 if c > hi else 0) + (0.1 if vol_ratio >= 1.05 else 0)
_add("SOS", sos_conf, "上破/逼近箱顶(未确认)")
# LPS candidate:站上 mid/上沿带后回踩
if c >= mid and l >= mid - tol * 1.5 and l > lo + (hi - lo) * 0.25:
_add("LPS", 0.46 + (0.1 if vol_ratio <= 1.0 else 0), "箱内上沿带回踩(未确认)")
candidates.sort(key=lambda x: x["confidence"], reverse=True)
out["event_candidates"] = candidates[:4]
event_score = float(candidates[0]["confidence"]) if candidates else 0.25
# next_expected(简规则)
next_exp = None
if "Spring" in confirmed_types and "SOS" not in confirmed_types:
next_exp = "SOS"
elif "SOS" in confirmed_types and "LPS" not in confirmed_types:
next_exp = "LPS"
elif "UTAD" in confirmed_types and "SOW" not in confirmed_types:
next_exp = "SOW"
elif any(c["type"] == "Spring" for c in candidates):
next_exp = "Test"
elif any(c["type"] == "SOS" for c in candidates):
next_exp = "LPS"
out["next_expected"] = next_exp
# —— lifecycle ——
key_confirmed = confirmed_types & {"Spring", "SOS", "UTAD", "SOW", "LPS", "LPSY"}
if key_confirmed:
lifecycle = "CONFIRMED"
elif range_ok or phase_candidate or candidates:
lifecycle = "FORMING"
else:
lifecycle = "UNKNOWN"
out["lifecycle"] = lifecycle
cycle_c = structure_score
overall = 0.35 * cycle_c + 0.25 * phase_conf + 0.25 * event_score + 0.15 * volume_score
out["confidence"] = {
"cycle": round(cycle_c, 3),
"phase": round(phase_conf, 3),
"event": round(event_score, 3),
"structure": round(structure_score, 3),
"volume": round(volume_score, 3),
"overall": round(float(overall), 3),
}
parts = []
if out["range_formation"]["potential_trading_range"]:
parts.append("Potential TR")
if phase_candidate:
parts.append(f"Phase {phase_candidate} candidate")
if candidates:
parts.append(f"{candidates[0]['type']} candidate")
out["note"] = "; ".join(parts) if parts else "observing"
return out
def execution_signal_from_wyckoff(payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""
Execution 边界:只允许 Confirmed。
返回 source='confirmed' 的信号描述;Live-only 时返回 None。
"""
if not payload:
return None
cycles = payload.get("cycles") or []
active = cycles[0] if cycles else None
events = []
if active and isinstance(active.get("confirmed"), dict):
events = list(active["confirmed"].get("events") or [])
if not events:
# 兼容旧顶层 events(均为 confirmed 镜像)
events = list(payload.get("events") or [])
if not events:
return None
last = events[-1]
return {
"source": "confirmed",
"type": last.get("type"),
"time": last.get("time"),
"lifecycle": (active or {}).get("lifecycle") or "CONFIRMED",
}
+371 -57
View File
@@ -1,11 +1,18 @@
"""交易区间检测:ATR 容差下按评分选取近期震荡箱。"""
"""交易区间检测:仅负责 TradingRange(起止/高低/结构分)。
WYCKOFF-MULTI-CYCLE-001Phase/Event/VP 不得进入本模块。
过滤顺序固定:detect → quality → trend → overlap(<0.2) → accept → mask。
"""
from __future__ import annotations
from typing import Any, Dict, Optional
from typing import Any, Dict, List, Optional, Tuple
import numpy as np
import pandas as pd
MAX_CYCLES = 8
OVERLAP_RATIO_MAX = 0.2
def _atr(df: pd.DataFrame, period: int = 14) -> pd.Series:
high = df["high"].astype(float)
@@ -23,6 +30,15 @@ def _atr(df: pd.DataFrame, period: int = 14) -> pd.Series:
return tr.rolling(period, min_periods=max(3, period // 2)).mean()
def _robust_width(seg: pd.DataFrame) -> float:
"""用 90/10 分位估宽,避免单根影线把长窗卡死。"""
h = seg["high"].astype(float)
l = seg["low"].astype(float)
if len(seg) < 6:
return float(h.max() - l.min())
return float(np.nanpercentile(h, 90) - np.nanpercentile(l, 10))
def _score_segment(
length: int,
near_hi: int,
@@ -31,27 +47,161 @@ def _score_segment(
width: float,
atr: float,
) -> float:
"""触边密度 + 箱内比例 − 相对宽度;弱奖励长度以免只追最长"""
touch_density = (near_hi + near_lo) / float(max(length, 1))
"""结构质量分(非 Phase/Event"""
touch = min(near_hi, 6) + min(near_lo, 6)
width_pen = (width / atr) if atr > 0 else width
return touch_density * 50.0 + float(inside) * 30.0 - width_pen * 3.0 + min(length / 40.0, 2.0)
return float(touch) * 4.0 + float(inside) * 25.0 - width_pen * 3.0 + min(length / 40.0, 2.0)
def detect_trading_range(
def _time_col(df: pd.DataFrame) -> Optional[str]:
if "date" in df.columns:
return "date"
if "timestamp" in df.columns:
return "timestamp"
return None
def _bar_index_at_or_after(work: pd.DataFrame, ts: Any) -> Optional[int]:
col = _time_col(work)
if col is None or ts is None:
return None
try:
target = pd.Timestamp(ts)
except Exception:
return None
series = pd.to_datetime(work[col], utc=True, errors="coerce")
if target.tzinfo is None:
target = target.tz_localize("UTC")
else:
target = target.tz_convert("UTC")
if series.isna().all():
return None
ge = series >= target
if ge.any():
return int(np.flatnonzero(ge.to_numpy())[0])
return 0
def _pack_range(
work: pd.DataFrame,
df: pd.DataFrame,
lookback: int = 120,
start_i: int,
end_i: int,
hi: float,
lo: float,
tol: float,
last_atr: float,
score: float,
n: int,
window_offset: int = 0,
) -> Dict[str, Any]:
"""组装 TradingRange(仅结构字段)。"""
mid = (hi + lo) / 2.0
last_c = float(work["close"].iloc[min(end_i, len(work) - 1)])
price_in_box = (lo - tol * 1.5) <= last_c <= (hi + tol * 1.5)
bars = int(end_i - start_i + 1)
# 结构置信:归一化 score(启发式)
range_conf = float(np.clip(score / 55.0, 0.05, 0.99))
best = {
"start_idx": int(start_i),
"end_idx": int(end_i),
"high": float(hi),
"low": float(lo),
"mid": float(mid),
"active": bool(price_in_box),
"atr": float(last_atr),
"tol": float(tol),
"bars": bars,
"score": float(score),
"quality": float(score),
"range_confidence": range_conf,
}
def _ts(row) -> Any:
col = _time_col(work)
if col and pd.notna(row[col]):
return row[col]
return None
best["start_time"] = _ts(work.iloc[best["start_idx"]])
best["end_time"] = _ts(work.iloc[best["end_idx"]])
# window_offsetslice 相对父 DataFrame 的起点;勿用 len(df)-len(work)
offset = int(window_offset)
best["abs_start_idx"] = offset + best["start_idx"]
best["abs_end_idx"] = offset + best["end_idx"]
best["abs_scan_end_idx"] = offset + n - 1
return best
def _overlap_ratio(a0: int, a1: int, b0: int, b1: int) -> float:
"""两闭区间重叠长度 / 较短区间长度。"""
lo = max(a0, b0)
hi = min(a1, b1)
if hi < lo:
return 0.0
overlap = hi - lo + 1
shorter = min(a1 - a0 + 1, b1 - b0 + 1)
if shorter <= 0:
return 0.0
return float(overlap) / float(shorter)
def _passes_quality(tr: Dict[str, Any], min_bars: int) -> bool:
if tr is None:
return False
if int(tr.get("bars") or 0) < max(8, min_bars // 2):
return False
if float(tr.get("score") or 0) < 12.0:
return False
hi = float(tr["high"])
lo = float(tr["low"])
atr = float(tr.get("atr") or 0) or 1.0
if (hi - lo) / atr > 12.0:
return False
return True
def _passes_trend_filter(work: pd.DataFrame, tr: Dict[str, Any]) -> bool:
"""趋势污染:定向位移过大则非震荡箱。"""
s = int(tr["start_idx"])
e = int(tr["end_idx"])
seg = work.iloc[s : e + 1]
if len(seg) < 8:
return False
c0 = float(seg["close"].iloc[0])
c1 = float(seg["close"].iloc[-1])
atr = float(tr.get("atr") or 0) or 1.0
drift = abs(c1 - c0) / atr
# 相对箱宽:漂移占箱宽过大 → 趋势
width = max(float(tr["high"]) - float(tr["low"]), atr)
drift_frac = abs(c1 - c0) / width
if drift > 6.0 and drift_frac > 0.55:
return False
return True
def _detect_in_window(
df: pd.DataFrame,
win_start: int,
win_end: int,
min_bars: int = 24,
atr_mult: float = 1.2,
tail_reserve: int = 12,
prefer_start_time: Any = None,
range_start_time: Any = None,
) -> Optional[Dict[str, Any]]:
"""
最近 lookback 根内寻找高低点波动受控的连续段作为交易区间
尾部预留 tail_reserve 根用于事件(Spring/SOS),不参与箱体边界计算
在硬门槛之上按评分取最优段(非仅最长窗口)。
df[win_start:win_end+1] 内检测单个 TradingRange
只返回箱体结构,不含 Phase/Event/VP
"""
if df is None or len(df) < min_bars + 5:
if df is None or win_end < win_start:
return None
work = df.tail(lookback).reset_index(drop=True)
slice_df = df.iloc[win_start : win_end + 1].reset_index(drop=True)
lookback = len(slice_df)
if lookback < min_bars + 5:
return None
work = slice_df
n = len(work)
reserve = min(tail_reserve, max(0, n - min_bars - 2))
core_end = n - reserve if reserve > 0 else n
@@ -68,61 +218,225 @@ def detect_trading_range(
if not np.isfinite(last_atr) or last_atr <= 0:
last_atr = float(core["close"].iloc[-1]) * 0.01
best = None
best_score = float("-inf")
eff_atr_mult = float(atr_mult)
if lookback >= 280:
eff_atr_mult = atr_mult * 1.7
elif lookback >= 160:
eff_atr_mult = atr_mult * 1.3
width_factor = 3.8 + min(2.2, max(0.0, (lookback - 80) / 100.0))
max_width = last_atr * eff_atr_mult * width_factor
tol = last_atr * eff_atr_mult * 0.35
prefer_i = None
if prefer_start_time is not None:
prefer_i = _bar_index_at_or_after(work, prefer_start_time)
if range_start_time is not None:
start_i = _bar_index_at_or_after(work, range_start_time)
if start_i is not None and start_i <= core_end - 8:
seg = work.iloc[start_i:core_end]
hi = float(seg["high"].max())
lo = float(seg["low"].min())
rw = _robust_width(seg)
if 0 < rw <= max_width * 1.15:
near_hi = int((seg["high"] >= hi - tol).sum())
near_lo = int((seg["low"] <= lo + tol).sum())
inside = float(((seg["close"] >= lo - tol) & (seg["close"] <= hi + tol)).mean())
if near_hi >= 2 and near_lo >= 2 and inside >= 0.70:
score = _score_segment(len(seg), near_hi, near_lo, inside, rw, last_atr)
return _pack_range(
work, df, start_i, core_end - 1, hi, lo, tol, last_atr, score, n,
window_offset=win_start,
)
eff_min_bars = max(8, int(min_bars))
cn = len(core)
for length in range(min(cn, lookback), min_bars - 1, -4):
seg = core.iloc[-length:]
max_bars = min(cn, max(eff_min_bars * 2, min(96, max(eff_min_bars + 8, int(cn * 0.5)))))
cands: List[Tuple[float, int, int, int, float, float, float]] = []
def _try_seg(start_i: int, end_i: int, prefer_boost: float = 0.0) -> None:
if end_i - start_i + 1 < eff_min_bars:
return
if start_i < 0 or end_i >= cn or start_i > end_i:
return
seg = work.iloc[start_i : end_i + 1]
hi = float(seg["high"].max())
lo = float(seg["low"].min())
width = hi - lo
if width <= 0 or width > last_atr * atr_mult * 3.5:
continue
tol = last_atr * atr_mult * 0.35
rw = _robust_width(seg)
if rw <= 0 or rw > max_width:
return
raw_w = hi - lo
if raw_w > max_width * 1.35:
return
near_hi = int((seg["high"] >= hi - tol).sum())
near_lo = int((seg["low"] <= lo + tol).sum())
if near_hi < 2 or near_lo < 2:
continue
return
inside = float(((seg["close"] >= lo - tol) & (seg["close"] <= hi + tol)).mean())
if inside < 0.75:
continue
score = _score_segment(length, near_hi, near_lo, inside, width, last_atr)
if score <= best_score:
continue
if inside < 0.72:
return
length = end_i - start_i + 1
score = _score_segment(length, near_hi, near_lo, inside, rw, last_atr) + prefer_boost
cands.append((score, length, start_i, end_i, hi, lo, rw))
for length in range(min(cn, max_bars), eff_min_bars - 1, -4):
start_i = cn - length
end_i = cn - 1
mid = (hi + lo) / 2.0
last_c = float(work["close"].iloc[-1])
active = (lo - tol * 1.5) <= last_c <= (hi + tol * 1.5)
best_score = score
best = {
"start_idx": int(start_i),
"end_idx": int(end_i),
"high": hi,
"low": lo,
"mid": mid,
"active": bool(active),
"atr": last_atr,
"tol": tol,
"bars": int(length),
"score": float(score),
}
boost = 0.0
if prefer_i is not None:
dist = abs(start_i - int(prefer_i))
if dist <= 6:
boost = 10.0
elif dist <= 14:
boost = 4.0
elif start_i > int(prefer_i) + 16:
boost = -10.0
_try_seg(start_i, cn - 1, boost)
if best is None:
if prefer_i is not None:
pi = int(prefer_i)
if 0 <= pi < cn:
align_max = min(cn, max(max_bars, int(cn * 0.65)))
alen = cn - pi
if eff_min_bars <= alen <= align_max:
_try_seg(pi, cn - 1, prefer_boost=18.0)
elif alen > align_max:
start_i = max(0, cn - align_max)
if start_i > pi:
start_i = pi
end_i = min(cn - 1, pi + align_max - 1)
else:
end_i = cn - 1
_try_seg(start_i, end_i, prefer_boost=12.0)
if not cands:
return None
def _ts(row) -> Any:
if "date" in work.columns and pd.notna(row["date"]):
return row["date"]
if "timestamp" in work.columns:
return row["timestamp"]
return None
cands.sort(key=lambda x: x[0], reverse=True)
best_score = cands[0][0]
band = max(4.0, abs(best_score) * 0.10)
near = [c for c in cands if c[0] >= best_score - band]
chosen = max(near, key=lambda x: (x[1], x[0]))
score, _length, start_i, end_i, hi, lo, _rw = chosen
return _pack_range(work, df, start_i, end_i, hi, lo, tol, last_atr, score, n, window_offset=win_start)
best["start_time"] = _ts(work.iloc[best["start_idx"]])
# 区间时间结束取 core 末,事件可落在其后
best["end_time"] = _ts(work.iloc[best["end_idx"]])
def detect_trading_ranges(
df: pd.DataFrame,
lookback: Optional[int] = None,
min_bars: int = 24,
atr_mult: float = 1.2,
tail_reserve: int = 12,
max_cycles: int = MAX_CYCLES,
prefer_start_time: Any = None,
range_start_time: Any = None,
) -> List[Dict[str, Any]]:
"""
倒序切多段 TradingRange(近→远)。
过滤顺序:detect → quality → trend → overlap → accept → mask。
返回列表已按时间倒序,调用方将 [0] 标为 ACTIVE。
"""
if df is None or len(df) < min_bars + 5:
return []
lb = int(lookback) if lookback is not None else len(df)
work = df.tail(lb).reset_index(drop=True)
n = len(work)
occupied: List[Dict[str, Any]] = []
accepted: List[Dict[str, Any]] = []
# 搜索右端从 n-1 往左收缩;每接受一段后右端移到该段 start 之前
search_end = n - 1
prefer = prefer_start_time
hard_start = range_start_time
while len(accepted) < max(1, int(max_cycles)) and search_end >= min_bars + 4:
# 在剩余历史内从右往左试多个右边界,避免历史箱必须贴住 search_end
# (否则中间趋势会挡住更早的真实箱)
cand = None
step = max(4, min(12, (search_end - min_bars) // 10 or 4))
for end_try in range(search_end, min_bars + 4, -step):
trial = _detect_in_window(
work,
0,
end_try,
min_bars=min_bars,
atr_mult=atr_mult,
tail_reserve=tail_reserve,
prefer_start_time=prefer if len(accepted) == 0 and end_try == search_end else None,
range_start_time=hard_start if len(accepted) == 0 and end_try == search_end else None,
)
# 1) detect
if trial is None:
continue
# 2) quality
if not _passes_quality(trial, min_bars):
continue
# 3) trend contamination
if not _passes_trend_filter(work, trial):
continue
# 4) overlap with accepted
a0, a1 = int(trial["abs_start_idx"]), int(trial["abs_end_idx"])
overlap_bad = False
for occ in occupied:
ratio = _overlap_ratio(a0, a1, int(occ["start"]), int(occ["end"]))
if ratio >= OVERLAP_RATIO_MAX:
overlap_bad = True
break
if overlap_bad:
continue
# 取最靠右的合格箱(倒序第一段)
cand = trial
break
if cand is None:
break
# 5) accept
accepted.append(cand)
a0, a1 = int(cand["abs_start_idx"]), int(cand["abs_end_idx"])
# 6) mask
occupied.append(
{
"start": a0,
"end": max(a1, int(cand.get("abs_scan_end_idx", a1))),
"quality": float(cand.get("quality") or 0),
"high": float(cand["high"]),
"low": float(cand["low"]),
}
)
# 下一轮只在更早窗口搜
search_end = int(cand["abs_start_idx"]) - 1
hard_start = None
prefer = None
# abs_* 目前相对 work;若 df 比 work 长需加 offset
offset = len(df) - len(work)
best["abs_start_idx"] = offset + best["start_idx"]
best["abs_end_idx"] = offset + best["end_idx"]
best["abs_scan_end_idx"] = offset + n - 1
return best
if offset:
for tr in accepted:
tr["abs_start_idx"] = int(tr["abs_start_idx"]) + offset
tr["abs_end_idx"] = int(tr["abs_end_idx"]) + offset
tr["abs_scan_end_idx"] = int(tr["abs_scan_end_idx"]) + offset
return accepted
def detect_trading_range(
df: pd.DataFrame,
lookback: int = 120,
min_bars: int = 24,
atr_mult: float = 1.2,
tail_reserve: int = 12,
range_start_time: Any = None,
prefer_start_time: Any = None,
) -> Optional[Dict[str, Any]]:
"""兼容旧接口:返回倒序列表中的第一段(ACTIVE 候选)。"""
ranges = detect_trading_ranges(
df,
lookback=lookback,
min_bars=min_bars,
atr_mult=atr_mult,
tail_reserve=tail_reserve,
max_cycles=1,
prefer_start_time=prefer_start_time,
range_start_time=range_start_time,
)
return ranges[0] if ranges else None
+171
View File
@@ -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)
+2 -2
View File
@@ -55,8 +55,8 @@ class IndicatorsBuilderMixin:
return None
def add_indicators(self, df):
fast = 26
slow = 52
fast = 12
slow = 26
period = 9
macd = ta.MACD(df, fastperiod=fast, slowperiod=slow, signalperiod=period)
bb365 = ta.BBANDS(df, timeperiod=365, nbdevup=3.0, nbdevdn=3.0, matype=0)
+40 -36
View File
@@ -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)
+11 -6
View File
@@ -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)
+9
View File
@@ -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):
+4 -1
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
from __future__ import annotations
+141
View File
@@ -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()
+98
View File
@@ -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
}
}
+98
View File
@@ -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
}
}
+9 -2
View File
@@ -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"
],
+91
View File
@@ -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
}
}
+86
View File
@@ -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
}
}
+86
View File
@@ -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
}
}
+86
View File
@@ -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
}
}
+86
View File
@@ -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
}
}
+86
View File
@@ -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
}
}
+5
View File
@@ -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"]
+342
View File
@@ -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]
+248
View File
@@ -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)
+102
View File
@@ -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,
},
)
+195
View File
@@ -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},
},
},
)
+154
View File
@@ -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)
+149
View File
@@ -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 SCARST 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,
},
)
+206
View File
@@ -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,
)
+363
View File
@@ -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 []
+78
View File
@@ -0,0 +1,78 @@
"""Phase Engine — Phase AE 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,
},
)
+181
View File
@@ -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
+78
View File
@@ -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,
},
)
+3
View File
@@ -0,0 +1,3 @@
from crypto_wyckoff.rules.registry import rule_registry
__all__ = ["rule_registry"]
+33
View File
@@ -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."""
+126
View File
@@ -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(),
]
+254
View File
@@ -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(),
]
+163
View File
@@ -0,0 +1,163 @@
"""Phase AE 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()]
+39
View File
@@ -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()
+127
View File
@@ -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()
+35
View File
@@ -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", []),
},
)
+236
View File
@@ -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()
+51
View File
@@ -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 → BTC1000PEPE/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}
+4
View File
@@ -0,0 +1,4 @@
"""Wyckoff Screener engine version — bump when rules change."""
WYCKOFF_ENGINE_VERSION = "v1.0.0"
ARCHITECTURE_VERSION = "1.0"
+9
View File
@@ -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",
]
+155
View File
@@ -0,0 +1,155 @@
"""
Market State Engine v1 因果可计算无未来函数
仅使用截至当前 8h K 线已收盘信息
EMA50/200ADXEMA slope价格相对 MA200 距离
输出 0100 分数 + 主导状态标签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+非急跌斜率 → accumulationbull+正斜率 → 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+markdownbad = 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)
+155
View File
@@ -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** 数据(无 lookaheadmerge 后读的是上一根已完成 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 timeUTC |
| `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 DDkept / 账户) | 应相对 ungated 历史继续偏低 |
| range share among blocked | 仅观察;上升不自动改规则 |
### 2023+ OOS 参考阈值(研究窗,非调参目标)
| | Gated(研究) | 解读 |
|--|---------------|------|
| PF | ~1.34baseline ~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. 不允许事项(硬禁)
- 不调 SpringTF / ATR / stoploss / entry 形态)
- 不调 soft-score,不把 soft-score 接回默认路径
- 不全样本扫 Gate 阈值 / 状态集合
- 不因短期 dry-run PF 调规则
- 不因 `range` 小样本表现把 range 升格为交易域
- 不默认合并 ETH/SOL 进生产路径
- 不复活 LPS 分支
违反任一条 = 退出 dry-run,回到研究流程(需新证据包)。
---
## 6. Go / No-Godry-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`
+63
View File
@@ -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+ / 202425 中 range 占比上升 → 保持 **观察标签**,不升格为交易规则。
## Do not
- 调 Spring / soft-score / Gate 阈值
- 因 range 小样本正 PF 开放 range 交易
- 复活 LPS / 默认跨资产
+85
View File
@@ -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 stackLOCKED
```
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 blockeddistribution share **87.5%**blocked PF 0.37
- 多数年份 blocked 以 distribution 为首
- 2023+ blockeddistribution + range 并存;range **仅观察**,不改规则
- 不因 2023+ blocked 弱正 PF 或 range n=4 回滚 Gate
**Primary invalidation domain = distribution(稳定)**
**range = observation bucket only**
+19
View File
@@ -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 disabledregime_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 onlyRange 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
}
}
+14
View File
@@ -0,0 +1,14 @@
# LPS V1.1 — REJECTED
## Hypothesis
在 V1 上收紧:严格 8h bias + 吸筹前置窗口 + 每事件首次回踩
## Result
- Full: **-1.50%**, n=**2**, 全亏
- 过滤方向正确,但过度收缩 → 无统计意义
## Reject reason
无法同时满足「理论纯度」与「可交易样本」。确认问题在事件定义,继续收紧无意义。
+15
View File
@@ -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。
定义错误,不是参数问题。
+43
View File
@@ -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=trendRange 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"
}
}
+221
View File
@@ -0,0 +1,221 @@
#!/usr/bin/env python3
"""
Market State Gate OOS Baseline vs GatedSpring 冻结
比较:
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()
+240
View File
@@ -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()
+239
View File
@@ -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()
+145
View File
@@ -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()
+206
View File
@@ -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 PFfee 计入
"""
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()
+113
View File
@@ -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()
+185
View File
@@ -0,0 +1,185 @@
#!/usr/bin/env python3
"""
Wyckoff Phase 2鲁棒性验证固定当前参数不再扫参
1) Walk-ForwardTrain 2023-2024 / Validate 2025 / Test 2026
2) 市场状态拆分bull / bear / range8h 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()
+261
View File
@@ -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()
+382
View File
@@ -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()
+305
View File
@@ -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>=5DD 越低越好;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()
+226
View File
@@ -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()
+458
View File
@@ -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 > 单边手续费 × 30.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_ago1m 用 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 / SLROI/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,
}
]
+488
View File
@@ -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_pctshort: 下跌 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,
},
]
+8 -15
View File
@@ -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,
+434
View File
@@ -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_reasontime_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)
+449
View File
@@ -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)
+368
View File
@@ -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 disabledregime_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 onlyRange 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)
+197
View File
@@ -0,0 +1,197 @@
# --- Do not remove these libs ---
"""
Wyckoff BTC Market-State Gated SpringDecision Layer
Spring = V1_BASELINEFROZEN
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
+42
View File
@@ -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_BASELINESpring-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
+368
View File
@@ -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 disabledregime_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 onlyRange 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)
+321
View File
@@ -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()
+566
View File
@@ -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)
+246
View File
@@ -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
View File
@@ -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 binsA+C
wyckoff_bins = int(request.args.get('wyckoff_vp_bins', 24))
result['wyckoff'] = analyze_wyckoff(
df,
lookback=max(40, min(wyckoff_lookback, 500)),
vp_bins=max(10, min(wyckoff_bins, 24)),
# 主周期先算;次/次次只同步 active=cycles[0] 的 startWYCKOFF-MULTI-CYCLE-001
wyckoff_bins = max(10, min(int(request.args.get('wyckoff_vp_bins', 24)), 24))
result['wyckoff'] = _compute_wyckoff_from_df(df, timeframe, wyckoff_bins, client_tz=None)
main_w = result.get('wyckoff') or {}
cycles = main_w.get('cycles') or []
# active 唯一来源 cycles[0];禁止 cycles[-1]
active = cycles[0] if cycles else None
prefer_start = None
if active:
prefer_start = ((active.get('trading_range') or {}).get('start_time')
or (active.get('period') or {}).get('start_time'))
elif main_w.get('trading_range'):
prefer_start = main_w['trading_range'].get('start_time')
if client_tz is not None:
_localize_wyckoff_payload(result['wyckoff'], client_tz)
if element_timeframe:
result['element_wyckoff'] = _compute_wyckoff_from_df(
element_df_for_wyckoff, element_timeframe, wyckoff_bins, client_tz,
prefer_start_time=prefer_start,
)
if sub_sub_timeframe:
result['sub_sub_wyckoff'] = _compute_wyckoff_from_df(
sub_sub_df_for_wyckoff, sub_sub_timeframe, wyckoff_bins, client_tz,
prefer_start_time=prefer_start,
)
except Exception as e:
print(f"Wyckoff 分析出错: {e}")
import traceback
traceback.print_exc()
result['wyckoff'] = {
'trading_range': None,
'bias': 'unknown',
'phases': [],
'events': [],
'volume_profile': {'bins': [], 'poc': None, 'vah': None, 'val': None, 'bin_count': 0},
'volume_confirm': {'avg_volume': 0.0, 'event_checks': {}},
'error': str(e),
}
return jsonify(result)
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
View File
@@ -1,5 +1,5 @@
"""页面路由。"""
from flask import Blueprint, render_template, send_from_directory
from flask import Blueprint, jsonify, render_template, request, send_from_directory
from config import DATA_SERVICE_URL, DATA_SERVICE_WS_URL
from services.runtime import * # noqa: F403
from services import runtime as R
+236
View File
@@ -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
+7
View File
@@ -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
+25 -18
View File
@@ -70,36 +70,43 @@ def build_timeframe_labels(timeframes):
return labels
def _adjacent_smaller(timeframe_keys, ceiling_tf):
"""取排序列表中严格小于 ceiling 的相邻周期。"""
if not timeframe_keys:
return ceiling_tf
try:
idx = timeframe_keys.index(ceiling_tf)
return timeframe_keys[idx - 1] if idx > 0 else timeframe_keys[0]
except ValueError:
return timeframe_keys[0]
def _prefer_smaller(candidates, labels_ordered, ceiling_tf, timeframe_keys):
"""从候选中选第一个存在且严格小于 ceiling 的周期,否则回退相邻更小。"""
ceil_m = timeframe_to_minutes(ceiling_tf)
for tf in candidates:
m = timeframe_to_minutes(tf)
if tf in labels_ordered and m is not None and ceil_m is not None and m < ceil_m:
return tf
return _adjacent_smaller(timeframe_keys, ceiling_tf)
def compute_timeframe_defaults(labels_ordered):
"""
根据已排序的周期 中文标签映射计算主 / / 次次周期默认值
默认偏好 4h 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 -8
View File
@@ -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
View File
@@ -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;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+271
View File
@@ -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);
}
}
+564
View File
@@ -0,0 +1,564 @@
/* chart_tv_indicators.js — volume / ATR / ChanMACD */
function chartTvRenderIndicators(ctx) {
var symbol = ctx.symbol;
var timeframe = ctx.timeframe;
var symbolConfig = ctx.symbolConfig;
var useSubSubPeriod = ctx.useSubSubPeriod;
var useElementPeriod = ctx.useElementPeriod;
var klinePeriodLabel = ctx.klinePeriodLabel;
var candles = ctx.candles;
var klineDataSource = ctx.klineDataSource;
var container = ctx.container;
var showMacd = ctx.showMacd;
var showOriginalKline = ctx.showOriginalKline;
var mainChartContainer = ctx.mainChartContainer;
var volumeChartContainer = ctx.volumeChartContainer;
var atrChartContainer = ctx.atrChartContainer;
var macdChartContainer = ctx.macdChartContainer;
var chanMacdChartContainer = ctx.chanMacdChartContainer;
var mainChart = ctx.mainChart;
var volumeChart = ctx.volumeChart;
var atrChart = ctx.atrChart;
var macdChart = ctx.macdChart;
var chanMacdChart = ctx.chanMacdChart;
var createChartOptions = ctx.createChartOptions;
// 转换成交量数据 - 与K线周期一致
let volumes = [];
const volumeDataSource = useSubSubPeriod ? (currentData.sub_sub_kline_data || []) : (useElementPeriod ? currentData.element_kline_data : currentData.kline_data);
console.log('成交量数据源选择:', klinePeriodLabel);
console.log('成交量数据长度:', volumeDataSource.length);
if (volumeDataSource && Array.isArray(volumeDataSource)) {
volumes = volumeDataSource.map(kline => {
// 使用与K线和MACD完全相同的时间戳计算方式
const timestamp = Math.floor(new Date(kline.date).getTime() / 1000);
return {
time: timestamp,
value: parseFloat(kline.volume),
color: parseFloat(kline.close) >= parseFloat(kline.open) ? 'rgba(40, 167, 69, 0.5)' : 'rgba(220, 53, 69, 0.5)',
};
});
console.log('处理后的成交量数据点数:', volumes.length);
}
// 添加成交量图表
const volumeSeries = volumeChart.addHistogramSeries({
color: '#26a69a',
priceFormat: {
type: 'volume',
},
title: '成交量',
});
volumeSeries.setData(volumes);
tvWidget.series.volumeSeries = volumeSeries;
// 添加ATR图表
const atrLineSeries = atrChart.addLineSeries({
color: '#FF9800',
lineWidth: 2,
title: 'ATR',
lastValueVisible: false,
priceLineVisible: false,
});
// 准备ATR数据
const atrData = [];
const atrKlineDataSource = useSubSubPeriod ? (currentData.sub_sub_kline_data || []) : (useElementPeriod ? currentData.element_kline_data : currentData.kline_data);
const atrDataSource = useSubSubPeriod ? (currentData.sub_sub_atr || currentData.atr) : (useElementPeriod ? (currentData.element_atr || currentData.atr) : currentData.atr);
console.log('ATR数据源选择:', klinePeriodLabel);
console.log('ATR数据长度:', atrDataSource ? atrDataSource.length : 0);
console.log('K线数据长度:', atrKlineDataSource ? atrKlineDataSource.length : 0);
if (atrDataSource && Array.isArray(atrDataSource) && atrKlineDataSource && Array.isArray(atrKlineDataSource)) {
// 关键修复:为每个K线时间点都创建ATR数据点,包括没有ATR值的前期数据
for (let i = 0; i < atrKlineDataSource.length; i++) {
const kline = atrKlineDataSource[i];
const timestamp = Math.floor(new Date(kline.date).getTime() / 1000);
// 为每个时间点都添加数据以保持时间轴对齐,但ATR为0时不显示
if (atrDataSource[i] !== undefined) {
if (atrDataSource[i] > 0) {
// ATR有效值,正常显示
atrData.push({
time: timestamp,
value: atrDataSource[i]
});
} else {
// ATR为0,添加时间点但不显示线条(使用undefined作为value
atrData.push({
time: timestamp,
value: undefined
});
}
}
}
console.log('处理后的ATR数据点数:', atrData.length);
console.log('ATR数据样本:', atrData.slice(0, 5));
}
console.log('处理后的ATR数据点数:', atrData.length);
atrLineSeries.setData(atrData);
tvWidget.series.atrLineSeries = atrLineSeries;
// 旧 MACD 图已移除,不再绘制(保留占位但彻底禁用)
if (FEATURES.legacyMacd && showMacd && currentData.macd && currentData.kline_data && Array.isArray(currentData.kline_data)) {
// 创建MACD线
const macdLineSeries = macdChart.addLineSeries({
color: '#2962FF',
lineWidth: 1,
title: 'MACD',
lastValueVisible: false, // 禁用最后值标签,防止遮挡
priceLineVisible: false, // 禁用价格线
});
// 创建信号线
const signalLineSeries = macdChart.addLineSeries({
color: '#FF6B6B',
lineWidth: 1,
title: 'Signal',
lastValueVisible: false, // 禁用最后值标签,防止遮挡
priceLineVisible: false, // 禁用价格线
});
// 创建直方图
const histogramSeries = macdChart.addHistogramSeries({
color: '#26a69a',
title: 'Histogram',
priceFormat: {
type: 'price',
precision: 4,
},
});
// 提取MACD数据 - 使用和K线数据相同的时间处理逻辑
const macdData = [];
const signalData = [];
const histogramData = [];
// 使用与K线数据相同的数据源来确保时间对齐
const klineDataSource = useSubSubPeriod ? (currentData.sub_sub_kline_data || []) : (useElementPeriod ? currentData.element_kline_data : currentData.kline_data);
const macdDataSource = useElementPeriod ?
(currentData.element_macd || currentData.macd) : // 如果有次周期MACD数据则使用,否则使用主周期
currentData.macd; // 主周期使用主周期MACD数据
console.log('MACD数据源选择:', useElementPeriod ? '次周期' : '主周期');
console.log('K线数据长度:', klineDataSource.length);
console.log('MACD数据:', macdDataSource);
for (let i = 0; i < klineDataSource.length; i++) {
const kline = klineDataSource[i];
// 使用与K线完全相同的时间戳计算方式
const timestamp = Math.floor(new Date(kline.date).getTime() / 1000);
if (macdDataSource && macdDataSource.macd && macdDataSource.macd[i] !== undefined) {
macdData.push({
time: timestamp,
value: macdDataSource.macd[i]
});
signalData.push({
time: timestamp,
value: macdDataSource.signal[i]
});
// 设置直方图颜色
const histValue = macdDataSource.histogram[i];
histogramData.push({
time: timestamp,
value: histValue,
color: histValue >= 0 ? 'rgba(40, 167, 69, 0.5)' : 'rgba(220, 53, 69, 0.5)'
});
}
}
console.log('处理后的MACD数据点数:', macdData.length);
macdLineSeries.setData(macdData);
signalLineSeries.setData(signalData);
histogramSeries.setData(histogramData);
tvWidget.series.macdLineSeries = macdLineSeries;
tvWidget.series.signalLineSeries = signalLineSeries;
tvWidget.series.histogramSeries = histogramSeries;
}
// 添加ChanMACD图表
console.log('ChanMACD图表创建条件检查:', {
showMacd: showMacd,
chanMacdChart: !!chanMacdChart,
hasMacd: !!currentData.macd,
hasKlineData: !!currentData.kline_data,
isArray: Array.isArray(currentData.kline_data)
});
// 在创建 ChanMACD 前,确保一次性同步 U 显示开关到全局(默认不显示)
if (typeof window.showUOnMain === 'undefined') {
window.showUOnMain = $('#toggleUOnMain').is(':checked');
}
if (typeof window.showUOnElement === 'undefined') {
window.showUOnElement = $('#toggleUOnElement').is(':checked');
}
if (typeof window.showUOnSubSub === 'undefined') {
window.showUOnSubSub = $('#toggleUOnSubSub').is(':checked');
}
if (showMacd && chanMacdChart && ((useSubSubPeriod && currentData.sub_sub_macd) || (useElementPeriod && currentData.element_macd) || currentData.macd) && (useSubSubPeriod ? (currentData.sub_sub_kline_data || []) : (useElementPeriod ? currentData.element_kline_data : currentData.kline_data))) {
console.log('✅ 开始创建 ChanMACD 系列');
// 创建ChanMACD线系列
const chanMacdLineSeries = chanMacdChart.addLineSeries({
color: '#2962FF',
lineWidth: 1,
title: 'ChanMACD',
lastValueVisible: false,
priceLineVisible: false,
});
// 创建ChanMACD信号线系列
const chanMacdSignalSeries = chanMacdChart.addLineSeries({
color: '#FF6B6B',
lineWidth: 1,
title: 'ChanSignal',
lastValueVisible: false,
priceLineVisible: false,
});
// 创建ChanMACD柱状图系列
const chanMacdHistSeries = chanMacdChart.addHistogramSeries({
color: '#26a69a',
title: 'ChanHistogram',
priceFormat: {
type: 'price',
precision: 4,
},
});
// 设置ChanMACD图表的字体大小
chanMacdChart.applyOptions({
layout: {
fontSize: 10, // 设置更小的字体大小
},
rightPriceScale: {
fontSize: 10, // 设置右侧价格轴的字体大小
},
timeScale: {
fontSize: 10, // 设置时间轴的字体大小
},
});
// 使用与主图一致的数据源(小周期开启时使用小周期MACD与K线)
const klineDataSource = useSubSubPeriod ? (currentData.sub_sub_kline_data || []) : (useElementPeriod ? currentData.element_kline_data : currentData.kline_data);
const macdDataSource = useSubSubPeriod ? (currentData.sub_sub_macd || currentData.macd) : (useElementPeriod ? (currentData.element_macd || currentData.macd) : currentData.macd);
// 准备ChanMACD数据
const chanMacdData = [];
const chanSignalData = [];
const chanHistData = [];
console.log('ChanMACD数据源检查:', {
klineDataSourceLength: klineDataSource.length,
macdDataSource: !!macdDataSource,
macdLength: macdDataSource ? macdDataSource.macd.length : 0
});
console.log('ChanMACD数据源检查:', {
klineDataSourceLength: klineDataSource.length,
macdDataSource: !!macdDataSource,
macdLength: macdDataSource ? macdDataSource.macd.length : 0
});
for (let i = 0; i < klineDataSource.length; i++) {
const kline = klineDataSource[i];
if (kline && kline.date &&
i < macdDataSource.macd.length &&
macdDataSource.macd[i] !== null && macdDataSource.macd[i] !== undefined) {
// 使用与K线完全相同的时间戳计算方式
const timestamp = Math.floor(new Date(kline.date).getTime() / 1000);
chanMacdData.push({
time: timestamp,
value: macdDataSource.macd[i]
});
chanSignalData.push({
time: timestamp,
value: macdDataSource.signal[i]
});
chanHistData.push({
time: timestamp,
value: macdDataSource.histogram[i],
color: macdDataSource.histogram[i] >= 0 ? 'rgba(40, 167, 69, 0.5)' : 'rgba(220, 53, 69, 0.5)'
});
}
}
console.log('ChanMACD数据处理完成:', {
chanMacdDataLength: chanMacdData.length,
chanSignalDataLength: chanSignalData.length,
chanHistDataLength: chanHistData.length,
sampleData: chanMacdData.length > 0 ? chanMacdData[0] : null
});
// 设置ChanMACD数据
console.log('ChanMACD数据长度:', chanMacdData.length, chanSignalData.length, chanHistData.length);
if (chanMacdData.length > 0) {
chanMacdLineSeries.setData(chanMacdData);
chanMacdSignalSeries.setData(chanSignalData);
chanMacdHistSeries.setData(chanHistData);
console.log('✅ ChanMACD数据设置成功');
} else {
console.warn('⚠️ ChanMACD数据为空,无法设置数据');
}
// 保存到tvWidget
tvWidget.series.chanMacdLineSeries = chanMacdLineSeries;
tvWidget.series.chanMacdSignalSeries = chanMacdSignalSeries;
tvWidget.series.chanMacdHistSeries = chanMacdHistSeries;
console.log('✅ ChanMACD图表系列已保存到tvWidget');
// 添加ChanMACD分析标注
// 根据主/次周期开关与各自的"显示U"独立控制
const cm = useSubSubPeriod ? (currentData.sub_sub_chan_macd || currentData.chan_macd) : (useElementPeriod ? (currentData.element_chan_macd || currentData.chan_macd) : currentData.chan_macd);
// 默认不显示,必须用户勾选对应复选框
const allowU = useSubSubPeriod ? !!window.showUOnSubSub : (useElementPeriod ? !!window.showUOnElement : !!window.showUOnMain);
if (cm && allowU) {
console.log('添加ChanMACD分析标注:', {
segListLength: cm.seg_list ? cm.seg_list.length : 0,
unittfListLength: cm.unittf_list ? cm.unittf_list.length : 0,
histsetListLength: cm.histset_list ? cm.histset_list.length : 0
});
// 详细检查段数据
if (cm.seg_list && cm.seg_list.length > 0) {
console.log('段数据详情:', cm.seg_list.slice(0, 3)); // 显示前3个段
} else {
console.log('⚠️ 段数据为空或不存在');
}
addAllChanMacdMarkers(
cm.seg_list || [],
cm.unittf_list || [],
cm.histset_list || [],
{
high_position_list: cm.high_position_list || [],
high_empty_list: cm.high_empty_list || [],
low_position_list: cm.low_position_list || [],
low_empty_list: cm.low_empty_list || [],
return_zero_list: cm.return_zero_list || [],
cross0_up_list: cm.cross0_up_list || [],
cross0_down_list: cm.cross0_down_list || []
}
);
// 同时从主/次周期的 klu_list 提取 SD/CD 标记,分别使用不同样式
try {
const mainCm = currentData.chan_macd || {};
const elementCm = currentData.element_chan_macd || {};
const mainMarkers = [];
const elementMarkers = [];
// 基于时间构建 MACD 值映射,便于按时间快速获取对应的 MACD 值
const buildMacdTimeMap = (macdObj, klineArr) => {
const map = new Map();
if (!macdObj || !klineArr || !Array.isArray(klineArr)) return map;
for (let i = 0; i < klineArr.length; i++) {
const k = klineArr[i];
if (!k || !k.date) continue;
const t = Math.floor(new Date(k.date).getTime() / 1000);
const val = (macdObj.macd && macdObj.macd[i] !== undefined && macdObj.macd[i] !== null) ? macdObj.macd[i] : null;
map.set(t, val);
}
return map;
};
const mainMacdMap = buildMacdTimeMap(currentData.macd, currentData.kline_data);
const elementMacdMap = buildMacdTimeMap(
(currentData.element_macd || currentData.macd),
(currentData.element_kline_data || currentData.kline_data)
);
// 主周期 U 标记(蓝/橙,与原样式一致)
if (window.showUOnMain && Array.isArray(mainCm.klu_list)) {
mainCm.klu_list.forEach((item) => {
if (!item || !item.time) return;
const ts = Math.floor(new Date(item.time).getTime() / 1000);
if (isNaN(ts)) return;
if (Number(item.separate_div) > 0) {
const macdVal = mainMacdMap.get(ts);
const posSd = (macdVal > 0) ? 'aboveBar' : (macdVal < 0) ? 'belowBar' : 'aboveBar';
mainMarkers.push({ time: ts, position: posSd, color: '#03a9f4', shape: 'arrowUp', text: `SD${Number(item.separate_div)}`, size: 0.6 });
}
if (item.continue_div === true) {
const macdVal = mainMacdMap.get(ts);
const posCd = (macdVal > 0) ? 'aboveBar' : (macdVal < 0) ? 'belowBar' : 'belowBar';
mainMarkers.push({ time: ts, position: posCd, color: '#ff9800', shape: 'arrowDown', text: 'CD', size: 0.6 });
}
if (item.near0_return && Number(item.near0_return) > 0) {
mainMarkers.push({ time: ts, position: 'belowBar', color: '#8bc34a', shape: 'circle', text: `${Number(item.near0_return)}`, size: 0.6 });
}
});
}
// 次周期 U 标记(使用不同配色以区分)
if (window.showUOnElement && Array.isArray(elementCm.klu_list)) {
elementCm.klu_list.forEach((item) => {
if (!item || !item.time) return;
const ts = Math.floor(new Date(item.time).getTime() / 1000);
if (isNaN(ts)) return;
if (Number(item.separate_div) > 0) {
const macdVal = elementMacdMap.get(ts);
const posSd = (macdVal > 0) ? 'aboveBar' : (macdVal < 0) ? 'belowBar' : 'aboveBar';
elementMarkers.push({ time: ts, position: posSd, color: '#9c27b0', shape: 'arrowUp', text: `SD${Number(item.separate_div)}`, size: 0.6 });
}
if (item.continue_div === true) {
const macdVal = elementMacdMap.get(ts);
const posCd = (macdVal > 0) ? 'aboveBar' : (macdVal < 0) ? 'belowBar' : 'belowBar';
elementMarkers.push({ time: ts, position: posCd, color: '#4caf50', shape: 'arrowDown', text: 'CD', size: 0.6 });
}
if (item.near0_return && Number(item.near0_return) > 0) {
elementMarkers.push({ time: ts, position: 'belowBar', color: '#009688', shape: 'circle', text: `${Number(item.near0_return)}`, size: 0.6 });
}
});
}
const subSubCm = currentData.sub_sub_chan_macd || {};
const subSubMarkers = [];
const subSubMacdMap = buildMacdTimeMap(currentData.macd, currentData.kline_data);
if (window.showUOnSubSub && Array.isArray(subSubCm.klu_list)) {
subSubCm.klu_list.forEach((item) => {
if (!item || !item.time) return;
const ts = Math.floor(new Date(item.time).getTime() / 1000);
if (isNaN(ts)) return;
if (Number(item.separate_div) > 0) {
const macdVal = subSubMacdMap.get(ts);
const posSd = (macdVal > 0) ? 'aboveBar' : (macdVal < 0) ? 'belowBar' : 'aboveBar';
subSubMarkers.push({ time: ts, position: posSd, color: '#00897b', shape: 'arrowUp', text: `SD${Number(item.separate_div)}`, size: 0.6 });
}
if (item.continue_div === true) {
const macdVal = subSubMacdMap.get(ts);
const posCd = (macdVal > 0) ? 'aboveBar' : (macdVal < 0) ? 'belowBar' : 'belowBar';
subSubMarkers.push({ time: ts, position: posCd, color: '#26a69a', shape: 'arrowDown', text: 'CD', size: 0.6 });
}
if (item.near0_return && Number(item.near0_return) > 0) {
subSubMarkers.push({ time: ts, position: 'belowBar', color: '#00695c', shape: 'circle', text: `${Number(item.near0_return)}`, size: 0.6 });
}
});
}
window.kluDivMarkersSubSub = subSubMarkers;
// 保存到全局,供主图合并标记使用
window.kluDivMarkersMain = mainMarkers;
window.kluDivMarkersElement = elementMarkers;
} catch (e) {
console.warn('处理 KLU 背驰标记出错:', e);
window.kluDivMarkersMain = [];
window.kluDivMarkersElement = [];
window.kluDivMarkersSubSub = [];
}
} else {
console.log('⚠️ 没有ChanMACD分析数据');
// 无数据时清空本次的 KLU 背驰标记
window.kluDivMarkersMain = [];
window.kluDivMarkersElement = [];
window.kluDivMarkersSubSub = [];
}
} else {
console.log('⚠️ ChanMACD图表创建条件不满足');
}
// 独立于当前显示周期:计算主/次周期 SD/CD 标记(用于主图合并显示)
try {
const mainCmAll = currentData.chan_macd || {};
const elementCmAll = currentData.element_chan_macd || {};
const mainMarkersAll = [];
const elementMarkersAll = [];
// 构建 MACD 时间映射,用于依据 MACD 正负决定 SD/CD 的显示上下位置
const buildMacdTimeMapAll = (macdObj, klineArr) => {
const map = new Map();
if (!macdObj || !klineArr || !Array.isArray(klineArr)) return map;
for (let i = 0; i < klineArr.length; i++) {
const k = klineArr[i];
if (!k || !k.date) continue;
const t = Math.floor(new Date(k.date).getTime() / 1000);
const val = (macdObj.macd && macdObj.macd[i] !== undefined && macdObj.macd[i] !== null) ? macdObj.macd[i] : null;
map.set(t, val);
}
return map;
};
const mainMacdMapAll = buildMacdTimeMapAll(currentData.macd, currentData.kline_data);
const elementMacdMapAll = buildMacdTimeMapAll(
(currentData.element_macd || currentData.macd),
(currentData.element_kline_data || currentData.kline_data)
);
if ((typeof window.showUOnMain === 'undefined' ? false : window.showUOnMain) && Array.isArray(mainCmAll.klu_list)) {
mainCmAll.klu_list.forEach((item) => {
if (!item || !item.time) return;
const ts = Math.floor(new Date(item.time).getTime() / 1000);
if (isNaN(ts)) return;
if (Number(item.separate_div) > 0) {
const macdVal = mainMacdMapAll.get(ts);
const posSd = (macdVal > 0) ? 'aboveBar' : (macdVal < 0) ? 'belowBar' : 'aboveBar';
mainMarkersAll.push({ time: ts, position: posSd, color: '#03a9f4', shape: 'arrowUp', text: `SD${Number(item.separate_div)}`, size: 0.6 });
}
if (item.continue_div === true) {
const macdVal = mainMacdMapAll.get(ts);
const posCd = (macdVal > 0) ? 'aboveBar' : (macdVal < 0) ? 'belowBar' : 'belowBar';
mainMarkersAll.push({ time: ts, position: posCd, color: '#ff9800', shape: 'arrowDown', text: 'CD', size: 0.6 });
}
if (item.near0_return && Number(item.near0_return) > 0) {
mainMarkersAll.push({ time: ts, position: 'belowBar', color: '#8bc34a', shape: 'circle', text: `${Number(item.near0_return)}`, size: 0.6 });
}
});
}
if ((typeof window.showUOnElement === 'undefined' ? false : window.showUOnElement) && Array.isArray(elementCmAll.klu_list)) {
elementCmAll.klu_list.forEach((item) => {
if (!item || !item.time) return;
const ts = Math.floor(new Date(item.time).getTime() / 1000);
if (isNaN(ts)) return;
if (Number(item.separate_div) > 0) {
const macdVal = elementMacdMapAll.get(ts);
const posSd = (macdVal > 0) ? 'aboveBar' : (macdVal < 0) ? 'belowBar' : 'aboveBar';
elementMarkersAll.push({ time: ts, position: posSd, color: '#9c27b0', shape: 'arrowUp', text: `SD${Number(item.separate_div)}`, size: 0.6 });
}
if (item.continue_div === true) {
const macdVal = elementMacdMapAll.get(ts);
const posCd = (macdVal > 0) ? 'aboveBar' : (macdVal < 0) ? 'belowBar' : 'belowBar';
elementMarkersAll.push({ time: ts, position: posCd, color: '#4caf50', shape: 'arrowDown', text: 'CD', size: 0.6 });
}
if (item.near0_return && Number(item.near0_return) > 0) {
elementMarkersAll.push({ time: ts, position: 'belowBar', color: '#009688', shape: 'circle', text: `${Number(item.near0_return)}`, size: 0.6 });
}
});
}
window.kluDivMarkersMain = mainMarkersAll;
window.kluDivMarkersElement = elementMarkersAll;
const subSubCmAll = currentData.sub_sub_chan_macd || {};
const subSubMarkersAll = [];
if (window.showUOnSubSub && Array.isArray(subSubCmAll.klu_list)) {
subSubCmAll.klu_list.forEach((item) => {
if (!item || !item.time) return;
const ts = Math.floor(new Date(item.time).getTime() / 1000);
if (isNaN(ts)) return;
if (Number(item.separate_div) > 0) {
subSubMarkersAll.push({ time: ts, position: 'aboveBar', color: '#00897b', shape: 'arrowUp', text: `SD${Number(item.separate_div)}`, size: 0.6 });
}
if (item.continue_div === true) {
subSubMarkersAll.push({ time: ts, position: 'belowBar', color: '#26a69a', shape: 'arrowDown', text: 'CD', size: 0.6 });
}
if (item.near0_return && Number(item.near0_return) > 0) {
subSubMarkersAll.push({ time: ts, position: 'belowBar', color: '#00695c', shape: 'circle', text: `${Number(item.near0_return)}`, size: 0.6 });
}
});
}
window.kluDivMarkersSubSub = subSubMarkersAll;
} catch (e) {
console.warn('独立计算 KLU 背驰标记出错:', e);
window.kluDivMarkersMain = [];
window.kluDivMarkersElement = [];
window.kluDivMarkersSubSub = [];
}
}
+48
View File
@@ -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
+504
View File
@@ -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
View File
@@ -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 全量 livestart 用表单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);
}
});
}
/** 兼容旧调用:手动走 analyzeChartfromAutoRefresh 转 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();
}
}
// 初始化图表
+3 -23
View File
@@ -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
View File
@@ -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