Merge PR #1873: emit swing signals only after confirmation
This commit is contained in:
commit
fed85e30f2
5 files changed
+138
-40
No files matched your search
@@ -54,3 +54,4 @@ pip install pandas numpy requests
|
||||
## Signal Convention
|
||||
|
||||
- `1` = long, `-1` = short, `0` = stand aside
|
||||
- A swing point is only known `swing_window` bars after it forms, so the signal is stamped on that confirmation bar, not on the swing bar itself
|
||||
@@ -53,15 +53,19 @@ class SignalEngine:
|
||||
def _find_swings(
|
||||
self, high: pd.Series, low: pd.Series
|
||||
) -> List[Dict]:
|
||||
"""用滚动窗口找局部高低点,生成交替的 Zigzag 序列。
|
||||
"""用滚动窗口找局部高低点(原始 Swing 点,未做 Zigzag 过滤)。
|
||||
|
||||
中心窗口的极值要等右侧 ``swing_window`` 根 K 线收盘后才能确认,
|
||||
因此每个点带 ``confirmed``:该点实际可被观察到的那根 K 线。
|
||||
|
||||
Args:
|
||||
high: 最高价序列。
|
||||
low: 最低价序列。
|
||||
|
||||
Returns:
|
||||
交替排列的 Swing 点列表,每个元素为
|
||||
{"index": datetime, "price": float, "type": "H"|"L"}。
|
||||
按时间排序的 Swing 点列表,每个元素为
|
||||
{"index": datetime, "price": float, "type": "H"|"L",
|
||||
"confirmed": datetime}。
|
||||
"""
|
||||
w = self.swing_window
|
||||
full_w = w * 2 + 1
|
||||
@@ -69,33 +73,44 @@ class SignalEngine:
|
||||
if len(high) < full_w:
|
||||
return []
|
||||
|
||||
# 检测局部极值
|
||||
roll_max = high.rolling(full_w, center=True).max()
|
||||
roll_min = low.rolling(full_w, center=True).min()
|
||||
swing_high_mask = high == roll_max
|
||||
swing_low_mask = low == roll_min
|
||||
swing_high_mask = (high == roll_max).to_numpy()
|
||||
swing_low_mask = (low == roll_min).to_numpy()
|
||||
|
||||
# 收集所有候选 Swing 点
|
||||
raw_points = []
|
||||
for idx in high.index:
|
||||
is_h = bool(swing_high_mask.get(idx, False))
|
||||
is_l = bool(swing_low_mask.get(idx, False))
|
||||
for position, idx in enumerate(high.index):
|
||||
is_h = bool(swing_high_mask[position])
|
||||
is_l = bool(swing_low_mask[position])
|
||||
if is_h and is_l:
|
||||
# 同一根K线同时是高低点,取幅度更大的
|
||||
pass
|
||||
elif is_h:
|
||||
raw_points.append({"index": idx, "price": float(high[idx]), "type": "H"})
|
||||
elif is_l:
|
||||
raw_points.append({"index": idx, "price": float(low[idx]), "type": "L"})
|
||||
continue
|
||||
if is_h or is_l:
|
||||
raw_points.append(
|
||||
{
|
||||
"index": idx,
|
||||
"price": float(high.iloc[position] if is_h else low.iloc[position]),
|
||||
"type": "H" if is_h else "L",
|
||||
"confirmed": high.index[position + w],
|
||||
}
|
||||
)
|
||||
return raw_points
|
||||
|
||||
@staticmethod
|
||||
def _zigzag(raw_points: List[Dict]) -> List[Dict]:
|
||||
"""把原始 Swing 点过滤为严格交替的 H/L 序列。
|
||||
|
||||
Args:
|
||||
raw_points: ``_find_swings`` 返回的 Swing 点(可以是其前缀)。
|
||||
|
||||
Returns:
|
||||
交替排列的 Swing 点列表;连续同类型点保留更极端的一个。
|
||||
"""
|
||||
if len(raw_points) < 2:
|
||||
return raw_points
|
||||
return list(raw_points)
|
||||
|
||||
# 过滤为严格交替的 H/L 序列
|
||||
zigzag = [raw_points[0]]
|
||||
for pt in raw_points[1:]:
|
||||
if pt["type"] == zigzag[-1]["type"]:
|
||||
# 连续同类型:保留更极端的
|
||||
if pt["type"] == "H" and pt["price"] > zigzag[-1]["price"]:
|
||||
zigzag[-1] = pt
|
||||
elif pt["type"] == "L" and pt["price"] < zigzag[-1]["price"]:
|
||||
@@ -369,21 +384,24 @@ class SignalEngine:
|
||||
for code, df in data_map.items():
|
||||
signal = pd.Series(0, index=df.index, dtype=int)
|
||||
|
||||
swings = self._find_swings(df["high"], df["low"])
|
||||
if len(swings) < 4:
|
||||
result[code] = signal
|
||||
continue
|
||||
raw_points = self._find_swings(df["high"], df["low"])
|
||||
for count in range(4, len(raw_points) + 1):
|
||||
swings = self._zigzag(raw_points[:count])
|
||||
newest = raw_points[count - 1]
|
||||
if len(swings) < 4 or swings[-1]["index"] != newest["index"]:
|
||||
continue
|
||||
confirmed = newest["confirmed"]
|
||||
if confirmed not in signal.index:
|
||||
continue
|
||||
|
||||
# 5 浪推动结构
|
||||
if len(swings) >= 6:
|
||||
for idx, direction in self._find_impulse(swings):
|
||||
if idx in signal.index:
|
||||
signal[idx] = direction
|
||||
if len(swings) >= 6:
|
||||
for idx, direction in self._find_impulse(swings):
|
||||
if idx == newest["index"]:
|
||||
signal[confirmed] = direction
|
||||
|
||||
# ABC 调整结构
|
||||
for idx, direction in self._find_abc(swings):
|
||||
if idx in signal.index:
|
||||
signal[idx] = direction
|
||||
for idx, direction in self._find_abc(swings):
|
||||
if idx == newest["index"]:
|
||||
signal[confirmed] = direction
|
||||
|
||||
result[code] = signal
|
||||
|
||||
|
||||
@@ -41,3 +41,4 @@ engine falls back to the bundled detector.
|
||||
## Signal Convention
|
||||
|
||||
- `1` = long, `-1` = short, `0` = stand aside
|
||||
- A swing point is only known `swing_window` bars after it forms, so the signal is stamped on that confirmation bar, not on the swing bar itself (bundled detector only; the optional `pyharmonics` path is unchanged)
|
||||
@@ -192,15 +192,16 @@ def _detect_patterns_fallback(
|
||||
检测到的形态列表,每项含 pattern/direction/d_index/d_price 等字段。
|
||||
"""
|
||||
swing_highs, swing_lows = _find_swings(df["high"], df["low"], swing_window)
|
||||
merged = _merge_swings(swing_highs, swing_lows)
|
||||
|
||||
if len(merged) < 5:
|
||||
return []
|
||||
|
||||
found = []
|
||||
# 滑动窗口,每次取5个连续摆动点作为 X-A-B-C-D 候选
|
||||
for i in range(len(merged) - 4):
|
||||
pts = merged[i : i + 5]
|
||||
# 摆动点要等右侧 swing_window 根K线收盘才能确认,因此按时间逐点推进,
|
||||
# 只使用截至该点为止已出现的摆动点,形态在确认那根K线上才算出现
|
||||
for d_ts in sorted(set(swing_highs.index) | set(swing_lows.index)):
|
||||
merged = _merge_swings(swing_highs.loc[:d_ts], swing_lows.loc[:d_ts])
|
||||
if len(merged) < 5 or merged[-1][0] != d_ts:
|
||||
continue
|
||||
|
||||
pts = merged[-5:]
|
||||
x_ts, x_price, x_type = pts[0]
|
||||
a_ts, a_price, a_type = pts[1]
|
||||
b_ts, b_price, b_type = pts[2]
|
||||
@@ -225,6 +226,7 @@ def _detect_patterns_fallback(
|
||||
else:
|
||||
direction = "bearish"
|
||||
|
||||
confirmed = df.index[df.index.get_loc(d_ts) + swing_window]
|
||||
found.append(
|
||||
{
|
||||
"pattern": pattern_name,
|
||||
@@ -234,7 +236,7 @@ def _detect_patterns_fallback(
|
||||
"b": (b_ts, b_price),
|
||||
"c": (c_ts, c_price),
|
||||
"d": (d_ts, d_price),
|
||||
"d_index": d_ts,
|
||||
"d_index": confirmed,
|
||||
"d_price": d_price,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
"""Swing-based skill engines must not trade on pivots before they are confirmed.
|
||||
|
||||
A centred rolling extreme at bar ``t`` needs the next ``swing_window`` bars to
|
||||
exist, so a signal stamped on the pivot bar itself reads the future. Signals
|
||||
must land on the confirmation bar, and truncating the history after bar ``t``
|
||||
must not change any signal on or before ``t``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
SKILLS_ROOT = Path(__file__).resolve().parents[1] / "src" / "skills"
|
||||
|
||||
|
||||
def _load(skill: str):
|
||||
path = SKILLS_ROOT / skill / "example_signal_engine.py"
|
||||
spec = importlib.util.spec_from_file_location(f"_causality_{skill.replace('-', '_')}", path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def _frame(seed: int, bars: int = 320) -> pd.DataFrame:
|
||||
rng = np.random.default_rng(seed)
|
||||
close = 100 + np.cumsum(rng.normal(0, 1.5, bars))
|
||||
index = pd.date_range("2024-01-01", periods=bars, freq="D")
|
||||
return pd.DataFrame(
|
||||
{
|
||||
"open": close,
|
||||
"high": close + rng.uniform(0.2, 1.0, bars),
|
||||
"low": close - rng.uniform(0.2, 1.0, bars),
|
||||
"close": close,
|
||||
"volume": 1.0,
|
||||
},
|
||||
index=index,
|
||||
)
|
||||
|
||||
|
||||
def _engines():
|
||||
elliott = _load("elliott-wave").SignalEngine(swing_window=3, fib_tolerance=0.6, min_wave_bars=1)
|
||||
harmonic = _load("harmonic").SignalEngine(swing_window=3, tol=0.3)
|
||||
harmonic.use_pyharmonics = False
|
||||
return {"elliott-wave": elliott, "harmonic": harmonic}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("skill", ["elliott-wave", "harmonic"])
|
||||
def test_truncating_history_never_changes_earlier_signals(skill):
|
||||
engine = _engines()[skill]
|
||||
emitted = 0
|
||||
for seed in range(6):
|
||||
frame = _frame(seed)
|
||||
full = engine.generate({"X": frame})["X"]
|
||||
emitted += int((full != 0).sum())
|
||||
for cut in range(40, len(frame), 17):
|
||||
head = engine.generate({"X": frame.iloc[: cut + 1]})["X"]
|
||||
pd.testing.assert_series_equal(head, full.iloc[: cut + 1])
|
||||
assert emitted > 0
|
||||
|
||||
|
||||
def test_harmonic_signal_bar_is_the_pivot_confirmation_bar():
|
||||
module = _load("harmonic")
|
||||
found = 0
|
||||
for seed in range(6):
|
||||
frame = _frame(seed)
|
||||
for pattern in module._detect_patterns_fallback(frame, swing_window=3, tol=0.3):
|
||||
pivot = frame.index.get_loc(pattern["d"][0])
|
||||
assert frame.index.get_loc(pattern["d_index"]) == pivot + 3
|
||||
found += 1
|
||||
assert found > 0
|
||||
|
||||
Reference in new issue
Block a user