- Created README.md and USAGE.md for project overview and usage instructions. - Added core algorithm in src/converging_triangle.py for batch processing of stock data. - Introduced data files (open.pkl, high.pkl, low.pkl, close.pkl, volume.pkl) for OHLCV data. - Developed output documentation for results and breakout strength calculations. - Implemented scripts for running the detection and generating reports. - Added SVG visualizations and markdown documentation for algorithm details and usage examples.
321 lines
11 KiB
Markdown
321 lines
11 KiB
Markdown
# 对称三角形识别(Symmetrical Triangle)——规则口径 + Python 实现
|
||
|
||
> 目标:只实现**对称三角形**的自动识别,并输出可解释结果:上沿/下沿、收敛度、触碰次数、是否突破、是否假突破(失败回撤)。
|
||
|
||
---
|
||
|
||
## 1. 概念对齐:上沿、下沿、突破、失败回撤
|
||
|
||
- **上沿(Upper line / Resistance)**
|
||
用“枢轴高点”(一串冲高回落的峰)拟合出的压力线;对称三角形要求它**向右下倾**。
|
||
|
||
- **下沿(Lower line / Support)**
|
||
用“枢轴低点”(一串探底回升的谷)拟合出的支撑线;对称三角形要求它**向右上升**。
|
||
|
||
- **向上突破(Upward breakout)**
|
||
价格从两线之间运行,某一根开始**收盘价明显站上上沿**(不是影线擦边)。
|
||
|
||
- **向下跌破(Downward breakdown)**
|
||
收盘价明显跌到下沿之下。
|
||
|
||
- **失败回撤 / 假突破(False breakout / Failed retest)**
|
||
“看起来突破了”,但在接下来 \(m\) 根内又回到三角形内部:
|
||
- 向上突破后:`close` 又回到 `upper_line` 下方
|
||
- 向下跌破后:`close` 又回到 `lower_line` 上方
|
||
含义:突破没站稳,信号可靠性下降(回测/复盘里非常重要)。
|
||
|
||
---
|
||
|
||
## 2. 输入数据要求
|
||
|
||
`df: pandas.DataFrame` 至少包含:
|
||
- `open`, `high`, `low`, `close`
|
||
- `volume`(可选;用于“放量确认突破”,没有则跳过确认)
|
||
|
||
实现里用 `bar_index = 0..n-1` 作为横轴。
|
||
|
||
---
|
||
|
||
## 3. 可编码的识别口径(只针对对称三角形)
|
||
|
||
### 3.1 枢轴点(Pivot / Fractal)
|
||
用左右窗口 `k` 识别局部极值:
|
||
- 枢轴高点:`high[i]` 是 `[i-k, i+k]` 最大值
|
||
- 枢轴低点:`low[i]` 是 `[i-k, i+k]` 最小值
|
||
|
||
> 直觉:枢轴点就是你手工画线时会“圈出来”的那些关键峰/谷。
|
||
|
||
### 3.2 趋势线拟合
|
||
在一个检测窗口 `window` 内:
|
||
- 取该窗口里的枢轴高点集合拟合上沿:\(y = a_u x + b_u\)
|
||
- 取该窗口里的枢轴低点集合拟合下沿:\(y = a_l x + b_l\)
|
||
|
||
### 3.3 对称三角形硬条件(建议)
|
||
在窗口内必须满足:
|
||
- **斜率方向**:`a_u < 0` 且 `a_l > 0`
|
||
- **收敛**:末端宽度显著小于起始宽度
|
||
`width_end / width_start <= shrink_ratio`
|
||
- **触碰次数**:上沿触碰 ≥ 2 且下沿触碰 ≥ 2
|
||
触碰判定:`abs(pivot_y - line_y) / line_y <= touch_tol`
|
||
- **Apex 合理性(可选)**:两线交点在窗口右侧不远处,避免两线近似平行导致的伪形态
|
||
|
||
### 3.4 突破/确认/假突破(信号层)
|
||
突破(价格):
|
||
- 向上:`close[t] > upper[t] * (1 + break_tol)`
|
||
- 向下:`close[t] < lower[t] * (1 - break_tol)`
|
||
|
||
成交量确认(可选):
|
||
- `volume[t] > MA(volume, vol_window)[t] * vol_k`
|
||
|
||
假突破(回测/复盘可用):
|
||
- 若在 `m` 根内再次回到两线之间,则标记为 `false_breakout=True`
|
||
|
||
---
|
||
|
||
## 4. 参数建议(先跑通,再调参)
|
||
|
||
日线常用默认值(你可以直接用):
|
||
- `window=120`(约 6 个月)
|
||
- `pivot_k=3~5`
|
||
- `touch_tol=0.006`(0.6% 贴线容差)
|
||
- `shrink_ratio=0.6`(末端宽度 ≤ 起始宽度的 60%)
|
||
- `break_tol=0.003`(突破需离线 0.3%)
|
||
- `vol_window=20`, `vol_k=1.5`
|
||
- `false_break_m=5`
|
||
|
||
---
|
||
|
||
## 5. Python 实现(可直接复制使用)
|
||
|
||
```python
|
||
from __future__ import annotations
|
||
|
||
from dataclasses import dataclass
|
||
from typing import Dict, List, Literal, Optional, Tuple
|
||
|
||
import numpy as np
|
||
import pandas as pd
|
||
|
||
|
||
@dataclass
|
||
class SymTriangleResult:
|
||
start: int
|
||
end: int
|
||
upper_coef: Tuple[float, float] # (a_u, b_u)
|
||
lower_coef: Tuple[float, float] # (a_l, b_l)
|
||
width_start: float
|
||
width_end: float
|
||
width_ratio: float
|
||
touches_upper: int
|
||
touches_lower: int
|
||
apex_x: float
|
||
breakout: Literal["up", "down", "none"]
|
||
breakout_idx: Optional[int]
|
||
volume_confirmed: Optional[bool]
|
||
false_breakout: Optional[bool]
|
||
|
||
|
||
def pivots_fractal(high: np.ndarray, low: np.ndarray, k: int = 3) -> Tuple[np.ndarray, np.ndarray]:
|
||
"""左右窗口分形:返回 pivot_high_idx, pivot_low_idx。"""
|
||
n = len(high)
|
||
ph: List[int] = []
|
||
pl: List[int] = []
|
||
for i in range(k, n - k):
|
||
if high[i] == np.max(high[i - k : i + k + 1]):
|
||
ph.append(i)
|
||
if low[i] == np.min(low[i - k : i + k + 1]):
|
||
pl.append(i)
|
||
return np.array(ph, dtype=int), np.array(pl, dtype=int)
|
||
|
||
|
||
def fit_line(x: np.ndarray, y: np.ndarray) -> Tuple[float, float]:
|
||
"""拟合 y = a*x + b"""
|
||
a, b = np.polyfit(x, y, deg=1)
|
||
return float(a), float(b)
|
||
|
||
|
||
def line_y(a: float, b: float, x: np.ndarray) -> np.ndarray:
|
||
return a * x + b
|
||
|
||
|
||
def detect_sym_triangle(
|
||
df: pd.DataFrame,
|
||
window: int = 120,
|
||
pivot_k: int = 3,
|
||
touch_tol: float = 0.006,
|
||
shrink_ratio: float = 0.6,
|
||
break_tol: float = 0.003,
|
||
vol_window: int = 20,
|
||
vol_k: float = 1.5,
|
||
false_break_m: int = 5,
|
||
) -> Optional[SymTriangleResult]:
|
||
"""
|
||
只检测“最近一个窗口”是否存在对称三角形,并给出突破/确认/假突破信息。
|
||
- 实时:false_breakout 只能返回 None(因为需要未来 m 根确认)
|
||
- 回测:如果 df 包含突破后的 m 根数据,才会输出 false_breakout True/False
|
||
"""
|
||
required = {"open", "high", "low", "close"}
|
||
if not required.issubset(df.columns):
|
||
raise ValueError(f"df must contain columns: {sorted(required)}")
|
||
|
||
n = len(df)
|
||
if n < max(window, 2 * pivot_k + 5):
|
||
return None
|
||
|
||
high = df["high"].to_numpy(dtype=float)
|
||
low = df["low"].to_numpy(dtype=float)
|
||
close = df["close"].to_numpy(dtype=float)
|
||
has_volume = "volume" in df.columns and df["volume"].notna().any()
|
||
volume = df["volume"].to_numpy(dtype=float) if has_volume else None
|
||
|
||
ph_idx, pl_idx = pivots_fractal(high, low, k=pivot_k)
|
||
|
||
end = n - 1
|
||
start = max(0, end - window + 1)
|
||
x_all = np.arange(n, dtype=float)
|
||
|
||
ph_in = ph_idx[(ph_idx >= start) & (ph_idx <= end)]
|
||
pl_in = pl_idx[(pl_idx >= start) & (pl_idx <= end)]
|
||
if len(ph_in) < 2 or len(pl_in) < 2:
|
||
return None
|
||
|
||
# 拟合两条线
|
||
a_u, b_u = fit_line(x_all[ph_in], high[ph_in])
|
||
a_l, b_l = fit_line(x_all[pl_in], low[pl_in])
|
||
|
||
# 斜率:对称三角形硬条件
|
||
if not (a_u < 0 and a_l > 0):
|
||
return None
|
||
|
||
# 宽度收敛
|
||
upper_start = float(line_y(a_u, b_u, np.array([start]))[0])
|
||
lower_start = float(line_y(a_l, b_l, np.array([start]))[0])
|
||
upper_end = float(line_y(a_u, b_u, np.array([end]))[0])
|
||
lower_end = float(line_y(a_l, b_l, np.array([end]))[0])
|
||
width_start = upper_start - lower_start
|
||
width_end = upper_end - lower_end
|
||
if width_start <= 0 or width_end <= 0:
|
||
return None
|
||
width_ratio = width_end / width_start
|
||
if width_ratio > shrink_ratio:
|
||
return None
|
||
|
||
# 触碰次数(用 pivot 点贴线程度判定)
|
||
ph_dist = np.abs(high[ph_in] - line_y(a_u, b_u, x_all[ph_in])) / np.maximum(
|
||
line_y(a_u, b_u, x_all[ph_in]), 1e-9
|
||
)
|
||
pl_dist = np.abs(low[pl_in] - line_y(a_l, b_l, x_all[pl_in])) / np.maximum(
|
||
line_y(a_l, b_l, x_all[pl_in]), 1e-9
|
||
)
|
||
touches_upper = int((ph_dist <= touch_tol).sum())
|
||
touches_lower = int((pl_dist <= touch_tol).sum())
|
||
if touches_upper < 2 or touches_lower < 2:
|
||
return None
|
||
|
||
# apex(两线交点)仅做合理性输出(可扩展为硬条件)
|
||
denom = (a_u - a_l)
|
||
apex_x = float((b_l - b_u) / denom) if abs(denom) > 1e-12 else float("inf")
|
||
|
||
# === 突破判定(用最后一根 close 做实时判断)===
|
||
upper_last = upper_end
|
||
lower_last = lower_end
|
||
breakout: Literal["up", "down", "none"] = "none"
|
||
breakout_idx: Optional[int] = None
|
||
if close[end] > upper_last * (1 + break_tol):
|
||
breakout = "up"
|
||
breakout_idx = end
|
||
elif close[end] < lower_last * (1 - break_tol):
|
||
breakout = "down"
|
||
breakout_idx = end
|
||
|
||
# 成交量确认(可选)
|
||
volume_confirmed: Optional[bool] = None
|
||
if breakout != "none" and has_volume and volume is not None:
|
||
vol_ma = pd.Series(volume).rolling(vol_window).mean().to_numpy()
|
||
if np.isfinite(vol_ma[end]) and vol_ma[end] > 0:
|
||
volume_confirmed = bool(volume[end] > vol_ma[end] * vol_k)
|
||
else:
|
||
volume_confirmed = None
|
||
|
||
# 假突破(需要未来 m 根数据,实时无法得知)
|
||
false_breakout: Optional[bool] = None
|
||
if breakout != "none" and breakout_idx is not None:
|
||
# 如果数据不足以观察未来 m 根,则返回 None
|
||
if breakout_idx + false_break_m < n:
|
||
false_breakout = False
|
||
for t in range(breakout_idx + 1, breakout_idx + false_break_m + 1):
|
||
upper_t = float(line_y(a_u, b_u, np.array([t]))[0])
|
||
lower_t = float(line_y(a_l, b_l, np.array([t]))[0])
|
||
if breakout == "up" and close[t] < upper_t:
|
||
false_breakout = True
|
||
break
|
||
if breakout == "down" and close[t] > lower_t:
|
||
false_breakout = True
|
||
break
|
||
else:
|
||
false_breakout = None
|
||
|
||
return SymTriangleResult(
|
||
start=start,
|
||
end=end,
|
||
upper_coef=(a_u, b_u),
|
||
lower_coef=(a_l, b_l),
|
||
width_start=float(width_start),
|
||
width_end=float(width_end),
|
||
width_ratio=float(width_ratio),
|
||
touches_upper=touches_upper,
|
||
touches_lower=touches_lower,
|
||
apex_x=apex_x,
|
||
breakout=breakout,
|
||
breakout_idx=breakout_idx,
|
||
volume_confirmed=volume_confirmed,
|
||
false_breakout=false_breakout,
|
||
)
|
||
|
||
|
||
def plot_sym_triangle(df: pd.DataFrame, res: SymTriangleResult) -> None:
|
||
"""简单可视化验算(需要 matplotlib)。"""
|
||
import matplotlib.pyplot as plt
|
||
|
||
close = df["close"].to_numpy(dtype=float)
|
||
x = np.arange(len(df), dtype=float)
|
||
a_u, b_u = res.upper_coef
|
||
a_l, b_l = res.lower_coef
|
||
|
||
start, end = res.start, res.end
|
||
xw = np.arange(start, end + 1, dtype=float)
|
||
upper = line_y(a_u, b_u, xw)
|
||
lower = line_y(a_l, b_l, xw)
|
||
|
||
plt.figure(figsize=(12, 5))
|
||
plt.plot(x, close, linewidth=1.2, label="close")
|
||
plt.plot(xw, upper, linewidth=2, label="upper")
|
||
plt.plot(xw, lower, linewidth=2, label="lower")
|
||
plt.axvline(end, color="gray", linestyle="--", linewidth=1)
|
||
plt.title(
|
||
f"sym_triangle: width_ratio={res.width_ratio:.2f}, touches=({res.touches_upper},{res.touches_lower}), "
|
||
f"breakout={res.breakout}, vol_ok={res.volume_confirmed}, false={res.false_breakout}"
|
||
)
|
||
plt.legend()
|
||
plt.show()
|
||
```
|
||
|
||
---
|
||
|
||
## 6. 输出如何用于“评级/打分”(建议字段)
|
||
|
||
你可以直接把 `SymTriangleResult` 的字段映射为评分输入:
|
||
- **形态质量**:`width_ratio`(越小越收敛)、`touches_upper/lower`(越多越可信)
|
||
- **突破质量**:`breakout` + `volume_confirmed`(放量加分)
|
||
- **风险提示**:`false_breakout`(为 True 则大幅降分;为 None 表示实时待确认)
|
||
|
||
---
|
||
|
||
## 7. 你该如何验证“程序画的线”是否接近你手工画线
|
||
|
||
1) 先挑一个你手工画过的例子(如你图中的那段)
|
||
2) 跑 `detect_sym_triangle` 得到 `res`
|
||
3) 用 `plot_sym_triangle(df, res)` 画出 close + upper/lower
|
||
4) 调整 `pivot_k / touch_tol / window / shrink_ratio`,直到“触点选择”和你肉眼一致
|
||
|