technical-patterns-lab/docs/枢轴点分段选择算法详解.md
褚宏光 6d545eb231 Enhance converging triangle detection with new features and documentation updates
- Added support for a detailed chart mode in plot_converging_triangles.py, allowing users to visualize all pivot points and fitting lines.
- Improved pivot fitting logic to utilize multiple representative points, enhancing detection accuracy and reducing false positives.
- Introduced a new real-time detection mode with flexible zone parameters for better responsiveness in stock analysis.
- Updated README.md and USAGE.md to reflect new features and usage instructions.
- Added multiple documentation files detailing recent improvements, including pivot point fitting and visualization enhancements.
- Cleaned up and archived outdated scripts to streamline the project structure.
2026-01-26 16:21:36 +08:00

717 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 枢轴点分段选择算法详解
**日期**: 2026-01-26
**文件**: `src/converging_triangle.py` - `fit_pivot_line()` 函数
---
## 📋 目录
1. [算法概述](#算法概述)
2. [为什么需要分段](#为什么需要分段)
3. [分段算法详解](#分段算法详解)
4. [独立分段机制](#独立分段机制)
5. [代码实现](#代码实现)
6. [实际案例分析](#实际案例分析)
7. [边界情况处理](#边界情况处理)
8. [可视化说明](#可视化说明)
---
## 算法概述
### 核心思想
对于识别出的所有枢轴点,我们不是简单地用所有点或仅用首尾两点来拟合趋势线,而是:
1. **分段策略**:将枢轴点按时间顺序分成 **3 个时间段**
2. **代表点选择**:从每段中选出 **最极端的点**(上沿选最高,下沿选最低)
3. **线性回归**:用这 3 个代表点进行线性回归,拟合趋势线
### 触发条件
```python
if 枢轴点数量 > 4:
使用分段策略3每段1个代表点
else:
使用全部枢轴点
```
---
## 为什么需要分段
### 问题1: 仅用两点的缺陷
如果只用首尾两点画线:
```
价格
^
| * * ← 两个高点
| \ /
| \ /
| X ← 两点连线
| / \
| / \
| * * ← 被忽略的中间高点
└──────────────> 时间
```
**问题**
- ❌ 中间的极值点被忽略
- ❌ 线可能不是真正的边界
- ❌ 容易被噪声影响(首尾点恰好是噪声)
### 问题2: 使用全部点的问题
如果用所有枢轴点进行回归:
```
价格
^
| * * * * * * ← 6个高点但分布不均
| └─┬─┘ └──┬──┘
| 前期 后期
| 密集 稀疏
└──────────────> 时间
```
**问题**
- ❌ 某些时间段的点过多,权重过大
- ❌ 回归结果偏向点密集的区域
- ❌ 不能均衡反映整个周期的趋势
### 解决方案: 时间均衡分段
```
价格
^
| * * * * * * ← 6个高点
| └─┬─┘ └┬┘ └┬┘
| 第1段 第2段 第3段
| ↓ ↓ ↓
| * * * ← 每段选1个最高点3个拟合点
└──────────────> 时间
```
**优点**
- ✅ 时间均衡(前、中、后都有代表点)
- ✅ 代表性强(每段选最极端的点)
- ✅ 稳健性好(不易被局部噪声影响)
- ✅ 覆盖性好(确保边界线包络所有点)
---
## 分段算法详解
### 第1步: 排序枢轴点
```python
# 按时间顺序排序(从早到晚)
sort_idx = np.argsort(pivot_indices)
x_sorted = pivot_indices[sort_idx] # 时间索引
y_sorted = pivot_values[sort_idx] # 价格值
```
**示例**
```
原始枢轴点(未排序):
索引: [50, 10, 80, 30, 90, 60]
价格: [95, 100, 98, 96, 92, 94]
排序后:
索引: [10, 30, 50, 60, 80, 90] ← 时间从早到晚
价格: [100, 96, 95, 94, 98, 92]
```
### 第2步: 计算分段大小
```python
n = len(x_sorted) # 总点数,例如 n = 6
segment_size = n // 3 # 整除segment_size = 6 // 3 = 2
```
**分段规则**
```
总数 n → segment_size → 分段方式
─────────────────────────────
n = 5 → 1 → [0:1], [1:2], [2:5] (1,1,3个点)
n = 6 → 2 → [0:2], [2:4], [4:6] (2,2,2个点)
n = 7 → 2 → [0:2], [2:4], [4:7] (2,2,3个点)
n = 8 → 2 → [0:2], [2:4], [4:8] (2,2,4个点)
n = 9 → 3 → [0:3], [3:6], [6:9] (3,3,3个点)
n = 10 → 3 → [0:3], [3:6], [6:10] (3,3,4个点)
```
**重要特性**
- 第3段可能比前两段多点余数分配给第3段
- 确保每段至少有1个点
- 第3段包含最新的数据点
### 第3步: 定义三个时间段
```python
segments = [
range(0, segment_size), # 第1段: [0, segment_size)
range(segment_size, 2 * segment_size), # 第2段: [segment_size, 2*segment_size)
range(2 * segment_size, n), # 第3段: [2*segment_size, n)
]
```
**以 n=6, segment_size=2 为例**
```
索引位置: 0 1 | 2 3 | 4 5
[ 第1段 | 第2段 | 第3段 ]
时间: 早期 中期 晚期
范围: [0:2) [2:4) [4:6)
点数: 2个 2个 2个
```
**以 n=7, segment_size=2 为例**
```
索引位置: 0 1 | 2 3 | 4 5 6
[ 第1段 | 第2段 | 第3段 ]
时间: 早期 中期 晚期
范围: [0:2) [2:4) [4:7)
点数: 2个 2个 3个 ← 第3段多1个
```
### 第4步: 从每段选择极值点
```python
for seg in segments:
seg_list = list(seg)
seg_y = y_sorted[seg_list] # 该段的所有价格
if mode == "upper":
# 上沿:选该段最高点
best_idx_in_seg = np.argmax(seg_y)
else: # mode == "lower"
# 下沿:选该段最低点
best_idx_in_seg = np.argmin(seg_y)
# 标记为选中
selected_mask[seg_list[best_idx_in_seg]] = True
```
**上沿示例(选最高点)**
```
第1段 [0:2):
索引0: 价格100 ← 最高 ✓
索引1: 价格96
第2段 [2:4):
索引2: 价格95
索引3: 价格94
第3段 [4:6):
索引4: 价格98 ← 最高 ✓
索引5: 价格92
结果: 选中索引 0, 3, 4 (没选错第2段最高是索引2的95)
```
等等,让我重新计算:
```
排序后的数据:
索引: [10, 30, 50, 60, 80, 90]
价格: [100, 96, 95, 94, 98, 92]
位置: 0 1 2 3 4 5
第1段 [0:2) - 位置0,1:
位置0: 价格100 ← 最高 ✓
位置1: 价格96
第2段 [2:4) - 位置2,3:
位置2: 价格95 ← 最高 ✓
位置3: 价格94
第3段 [4:6) - 位置4,5:
位置4: 价格98 ← 最高 ✓
位置5: 价格92
选中的拟合点:
位置0 (时间10, 价格100)
位置2 (时间50, 价格95)
位置4 (时间80, 价格98)
```
**下沿示例(选最低点)**
```
排序后的数据:
索引: [15, 35, 55, 75]
价格: [92, 90, 88, 86]
位置: 0 1 2 3
n = 4 ≤ 4不分段全部使用 ✓
```
---
## 独立分段机制
### 关键点:高点和低点分别独立处理
```python
# 伪代码展示独立性
高点枢轴点 = [6] 分3段 选3个拟合点
低点枢轴点 = [4] 不分段 全部4个拟合点
# 两者完全独立,互不影响
```
### 为什么要独立分段?
**原因1: 时间分布不同**
```
价格
^
| H H H H H H ← 6个高点分布较均匀
| \ / | \ /
| \ / | \ /
| \ / | \ /
| X | X
| / \ | / \
| / \ | / \
| L L L L ← 4个低点集中在两侧
└───────────────────────> 时间
```
- 高点和低点出现的时间点不同
- 如果混合分段,会破坏各自的时间均衡性
**原因2: 数量可能不同**
```
高点: 6个 > 4 → 需要分段
低点: 4个 ≤ 4 → 不需要分段
```
- 如果混合判断,要么都分段,要么都不分段
- 独立判断更灵活,更合理
**原因3: 含义不同**
- 高点定义上沿(压力线)
- 低点定义下沿(支撑线)
- 两条线的拟合完全独立
### 独立分段的实现
```python
# 1. 高点独立处理
if len(高点枢轴) > 4:
高点分3段 选3个高点拟合上沿线
else:
全部高点拟合上沿线
# 2. 低点独立处理(完全独立的逻辑)
if len(低点枢轴) > 4:
低点分3段 选3个低点拟合下沿线
else:
全部低点拟合下沿线
```
---
## 代码实现
### 完整代码(带详细注释)
```python
def fit_pivot_line(
pivot_indices: np.ndarray, # 枢轴点的时间索引
pivot_values: np.ndarray, # 枢轴点的价格
mode: str = "upper", # "upper" 或 "lower"
min_points: int = 2,
) -> Tuple[float, float, np.ndarray]:
"""
拟合枢轴点趋势线(使用分段选择策略)
策略:
- 如果枢轴点 > 4分3段每段选1个极值点共3个拟合点
- 如果枢轴点 ≤ 4全部使用
Returns:
(斜率a, 截距b, 选中的枢轴点索引)
"""
# ─────────────────────────────────────────────────────────
# 第1步基本检查和排序
# ─────────────────────────────────────────────────────────
if len(pivot_indices) < min_points:
return 0.0, 0.0, np.array([])
# 按时间排序
sort_idx = np.argsort(pivot_indices)
x_sorted = pivot_indices[sort_idx].astype(float)
y_sorted = pivot_values[sort_idx]
n = len(x_sorted)
if n < 2:
return 0.0, 0.0, np.array([])
# ─────────────────────────────────────────────────────────
# 第2步决定是否分段
# ─────────────────────────────────────────────────────────
if n <= 4:
# 点数少,全部使用
selected_mask = np.ones(n, dtype=bool)
else:
# 点数多,使用分段策略
selected_mask = np.zeros(n, dtype=bool)
# 计算每段大小
segment_size = n // 3
if segment_size < 1:
segment_size = 1
# 定义三个时间段
segments = [
range(0, min(segment_size, n)), # 第1段
range(segment_size, min(2 * segment_size, n)), # 第2段
range(2 * segment_size, n), # 第3段
]
# ─────────────────────────────────────────────────────
# 第3步从每段选择极值点
# ─────────────────────────────────────────────────────
for seg in segments:
if len(seg) == 0:
continue
seg_list = list(seg)
seg_y = y_sorted[seg_list]
if mode == "upper":
# 上沿:选该段最高点
best_idx_in_seg = np.argmax(seg_y)
else: # mode == "lower"
# 下沿:选该段最低点
best_idx_in_seg = np.argmin(seg_y)
# 标记选中
selected_mask[seg_list[best_idx_in_seg]] = True
# ─────────────────────────────────────────────────────────
# 第4步提取选中的点
# ─────────────────────────────────────────────────────────
selected_x = x_sorted[selected_mask]
selected_y = y_sorted[selected_mask]
selected_indices_sorted = np.where(selected_mask)[0]
# 保底:至少选首尾两点
if len(selected_x) < 2:
selected_mask = np.zeros(n, dtype=bool)
selected_mask[0] = True
selected_mask[-1] = True
selected_x = x_sorted[selected_mask]
selected_y = y_sorted[selected_mask]
selected_indices_sorted = np.where(selected_mask)[0]
# ─────────────────────────────────────────────────────────
# 第5步线性回归
# ─────────────────────────────────────────────────────────
a, b = fit_line(selected_x, selected_y)
# ─────────────────────────────────────────────────────────
# 第6步覆盖性验证确保线不穿透任何枢轴点
# ─────────────────────────────────────────────────────────
fitted_all = a * x_sorted + b
tolerance = 0.03 # 3%容差
if mode == "upper":
# 上沿线不应低于任何高点
violations = y_sorted > fitted_all + tolerance * np.mean(y_sorted)
if np.any(violations):
# 强制包含全局最高点
global_max_idx = np.argmax(y_sorted)
if not selected_mask[global_max_idx]:
selected_mask[global_max_idx] = True
selected_x = x_sorted[selected_mask]
selected_y = y_sorted[selected_mask]
selected_indices_sorted = np.where(selected_mask)[0]
a, b = fit_line(selected_x, selected_y)
else: # mode == "lower"
# 下沿线不应高于任何低点
violations = y_sorted < fitted_all - tolerance * np.mean(y_sorted)
if np.any(violations):
# 强制包含全局最低点
global_min_idx = np.argmin(y_sorted)
if not selected_mask[global_min_idx]:
selected_mask[global_min_idx] = True
selected_x = x_sorted[selected_mask]
selected_y = y_sorted[selected_mask]
selected_indices_sorted = np.where(selected_mask)[0]
a, b = fit_line(selected_x, selected_y)
# ─────────────────────────────────────────────────────────
# 第7步返回结果
# ─────────────────────────────────────────────────────────
# 将排序后的索引映射回原始索引
selected_original = sort_idx[selected_indices_sorted]
return float(a), float(b), selected_original
```
---
## 实际案例分析
### 案例1: SZ002343 慈文传媒
**高点枢轴点6个**
```
时间索引: [2025-05-12, 2025-09-23, 2025-11-04, 2025-11-26, 2025-12-09, 2026-01-13]
价格: [9.50, 9.36, 10.07, 9.63, 9.21, 8.92]
位置: 0 1 2 3 4 5
```
**分段处理**
```
n = 6 > 4需要分段
segment_size = 6 // 3 = 2
第1段 [0:2) - 位置0,12025-05到2025-09:
位置0: 9.50
位置1: 9.36
→ 选最高: 位置0, 价格9.50 ✓
第2段 [2:4) - 位置2,32025-11:
位置2: 10.07 ← 最高 ✓
位置3: 9.63
→ 选最高: 位置2, 价格10.07 ✓
第3段 [4:6) - 位置4,52025-12到2026-01:
位置4: 9.21
位置5: 8.92
→ 选最高: 位置4, 价格9.21 ✓
拟合点3个:
2025-05-12: 9.50 (早期高点)
2025-11-04: 10.07 (中期高点) ← 最高点
2025-12-09: 9.21 (晚期高点)
```
**低点枢轴点4个**
```
时间索引: [2025-08-08, 2025-12-11, 2025-12-30, 2026-01-20]
价格: [5.17, 7.37, 7.42, 6.87]
位置: 0 1 2 3
```
**不分段处理**
```
n = 4 ≤ 4全部使用
拟合点4个:
2025-08-08: 5.17 ✓
2025-12-11: 7.37 ✓
2025-12-30: 7.42 ✓
2026-01-20: 6.87 ✓
```
**结果对比**
| 项目 | 高点枢轴 | 低点枢轴 |
|------|---------|---------|
| 总数 | 6个 | 4个 |
| 是否分段 | 是(>4 | 否≤4 |
| 拟合点数 | 3个 | 4个 |
| 时间跨度 | 2025-05 → 2026-01 | 2025-08 → 2026-01 |
---
## 边界情况处理
### 情况1: 点数恰好等于4
```python
n = 4 # 不分段
selected_mask = np.ones(4, dtype=bool) # 全部选中
```
**理由**
- 4个点已经足够稳定
- 分3段会导致某些段只有1个点
- 全部使用能获得更好的拟合效果
### 情况2: 点数为5
```python
n = 5 > 4 # 需要分段
segment_size = 5 // 3 = 1
第1段: [0:1) 1个点 选1个
第2段: [1:2) 1个点 选1个
第3段: [2:5) 3个点 选最极值的1个
拟合点: 3
```
### 情况3: 点数很多如10个
```python
n = 10 > 4 # 需要分段
segment_size = 10 // 3 = 3
第1段: [0:3) 3个点 选最极值的1个
第2段: [3:6) 3个点 选最极值的1个
第3段: [6:10) 4个点 选最极值的1个
拟合点: 3
```
**效果**
- ✅ 无论点数多少最终都是3个代表点
- ✅ 时间均衡前1/3、中1/3、后1/3
- ✅ 代表性强(每段最极端)
### 情况4: 分段后某段为空
```python
for seg in segments:
if len(seg) == 0: # 跳过空段
continue
# 处理非空段...
```
**何时发生**
- 理论上不会发生(`segment_size ≥ 1`
- 但代码仍然防御性地检查
### 情况5: 选中点少于2个
```python
if len(selected_x) < 2:
# 保底方案:强制选首尾两点
selected_mask[0] = True
selected_mask[-1] = True
```
**何时触发**
- 极端异常情况(理论上不应发生)
- 确保至少有两点可以画线
---
## 可视化说明
### 图表元素对应关系
在详细模式(`--show-details`)下,图表显示:
```
图表元素 对应内容
─────────────────────────────────────────────
浅红色小实心圆6个 所有高点枢轴点
深红色大空心圆3个 上沿拟合点从6个中选出
红色点划竖线 高点分段边界
- "高1|2" 标签 第1段和第2段分界
- "高2|3" 标签 第2段和第3段分界
浅绿色小实心圆4个 所有低点枢轴点
深绿色大空心圆4个 下沿拟合点全部使用因≤4
(无绿色竖线) 低点不分段≤4
```
### 分段线的位置
**分段边界的时间索引**
```python
# 以高点为例n=6, segment_size=2
排序后的高点索引: [10, 30, 50, 60, 80, 90]
位置0 1 2 3 4 5
第1段结束 = 第2段开始:
boundary_1 = 索引[segment_size] = 索引[2] = 50
在时间50处画竖线
第2段结束 = 第3段开始:
boundary_2 = 索引[2*segment_size] = 索引[4] = 80
在时间80处画竖线
分段结果:
第1段: [10, 30] | 第2段: [50, 60] | 第3段: [80, 90]
boundary_1 boundary_2
```
**代码实现**
```python
if len(ph_idx) > 4:
n_high = len(ph_idx)
segment_size_high = n_high // 3
# 第1条竖线
if segment_size_high < n_high:
boundary_1 = ph_idx[segment_size_high]
ax.axvline(boundary_1, color='red', linestyle='-.', ...)
ax.text(boundary_1, y_max*0.96, '高1|2', ...)
# 第2条竖线
if 2 * segment_size_high < n_high:
boundary_2 = ph_idx[2 * segment_size_high]
ax.axvline(boundary_2, color='red', linestyle='-.', ...)
ax.text(boundary_2, y_max*0.96, '高2|3', ...)
```
---
## 总结
### 分段策略的核心要点
1. **触发条件**: 枢轴点 > 4
2. **分段数量**: 固定3段
3. **分段方式**: 时间均分(每段 `n//3` 个点)
4. **选择策略**: 每段选1个极值点
5. **独立性**: 高点和低点各自独立分段
6. **保底机制**: 覆盖性验证 + 全局极值保护
### 算法优势
| 优势 | 说明 |
|------|------|
| **时间均衡** | 前、中、后三个时期都有代表点 |
| **代表性强** | 每段选最极端的点,确保边界性 |
| **抗噪性好** | 不易被局部密集点影响 |
| **稳健性高** | 多点回归比两点连线更稳定 |
| **可扩展性** | 点数增加时仍保持3个拟合点 |
### 与其他方法的对比
| 方法 | 优点 | 缺点 |
|------|------|------|
| **两点连线** | 简单快速 | 忽略中间点,易受噪声影响 |
| **全部点回归** | 利用所有信息 | 权重不均,点密集区域主导 |
| **分段选择(当前)** | 时间均衡,代表性强 | 略复杂,需要分段逻辑 |
| **滑动窗口** | 平滑效果好 | 计算复杂,参数敏感 |
---
## 参考资料
- [枢轴点拟合改进.md](./2026-01-26_枢轴点拟合改进.md) - 改进历程
- [图表详细模式功能.md](./2026-01-26_图表详细模式功能.md) - 可视化说明
- [converging_triangle.py](../src/converging_triangle.py) - 源代码实现
---
**文档版本**: v1.0
**最后更新**: 2026-01-26