Enhance converging triangle analysis with detailed mode and outlier removal algorithm

- Added `--show-details` parameter to `pipeline_converging_triangle.py` for generating detailed charts that display all pivot points and fitting lines.
- Implemented an iterative outlier removal algorithm in `fit_pivot_line` to improve the accuracy of pivot point fitting by eliminating weak points.
- Updated `USAGE.md` to include new command examples for the detailed mode.
- Revised multiple documentation files to reflect recent changes and improvements in the pivot detection and visualization processes.
This commit is contained in:
褚宏光 2026-01-26 18:43:18 +08:00
parent 6d545eb231
commit 95d13b2cce
17 changed files with 1370 additions and 393 deletions

View File

@ -0,0 +1,132 @@
<!-- a1a89b85-9ffb-45cf-b4ce-7e9456990b83 3ae86d9a-d078-42f4-a070-42c33828036f -->
# 拟合线迭代离群点移除优化
## 问题描述
当前的分段选择算法会选中一些"弱"枢轴点用于拟合:
- 上沿线:某个高点虽然是时间段内最高,但明显低于其他高点(如图中第二个点 5.8 元)
- 这些点会拉低/拉高拟合线,导致与主观判断不符
## 解决方案:迭代离群点移除
### 核心逻辑
```
1. 初始拟合:用所有枢轴点做线性回归
2. 计算残差:每个点到拟合线的偏差
3. 识别离群点:
- 上沿线:价格明显低于拟合线的点 = 弱高点
- 下沿线:价格明显高于拟合线的点 = 弱低点
4. 移除最差的离群点
5. 重新拟合
6. 重复直到收敛
```
### 算法流程图
```mermaid
flowchart TD
A[输入枢轴点] --> B[初始线性回归]
B --> C[计算残差]
C --> D{存在离群点?}
D -->|是| E[移除最大离群点]
E --> F{剩余点 >= 3?}
F -->|是| G{迭代次数 < 3?}
G -->|是| B
G -->|否| H[返回当前拟合]
F -->|否| H
D -->|否| H
```
## 代码修改
### 文件: [src/converging_triangle.py](src/converging_triangle.py)
重写 `fit_pivot_line` 函数(第 230-350 行):
```python
def fit_pivot_line(
pivot_indices: np.ndarray,
pivot_values: np.ndarray,
mode: str = "upper",
min_points: int = 2,
outlier_threshold: float = 1.5, # 新增:离群点阈值(标准差倍数)
max_iterations: int = 3, # 新增:最大迭代次数
) -> Tuple[float, float, np.ndarray]:
"""
迭代离群点移除的枢轴点拟合算法
策略:
1. 先用所有点做初始拟合
2. 识别并移除偏离拟合线的"弱"点
3. 迭代直到收敛
对于上沿线:移除价格明显低于拟合线的点
对于下沿线:移除价格明显高于拟合线的点
"""
```
### 关键参数
| 参数 | 默认值 | 说明 |
|------|--------|------|
| `outlier_threshold` | 1.5 | 残差超过 1.5 倍标准差视为离群点 |
| `max_iterations` | 3 | 最多迭代 3 次,避免过度过滤 |
| `min_points` | 3 | 至少保留 3 个点用于拟合 |
### 离群点判定逻辑
**上沿线upper**:
```python
# 残差 = 拟合值 - 实际值
# 正残差表示点在拟合线下方(弱高点)
residuals = fitted_values - actual_values
outliers = residuals > threshold # 弱高点
```
**下沿线lower**:
```python
# 残差 = 实际值 - 拟合值
# 正残差表示点在拟合线上方(弱低点)
residuals = actual_values - fitted_values
outliers = residuals > threshold # 弱低点
```
### 预期效果
以图中 SZ300278 为例:
- 第二个点5.8元)明显低于拟合线
- 在第一次迭代后会被识别为离群点并移除
- 最终拟合线只使用剩余 3 个更有代表性的高点
## 测试计划
1. 使用 SZ300278 验证修复效果
2. 对比修改前后的图表
3. 确保不会过度过滤正常的枢轴点
## 文档更新
更新 [docs/枢轴点分段选择算法详解.md](docs/枢轴点分段选择算法详解.md),添加迭代离群点移除的说明。
### To-dos
- [x] 在ConvergingTriangleResult中增加detection_mode等3个新字段
- [x] 实现pivots_fractal_hybrid()函数
- [x] 修改detect_converging_triangle()支持real_time_mode参数
- [x] 修改detect_converging_triangle_batch()传递实时模式参数
- [x] 在triangle_config.py中添加REALTIME_MODE和FLEXIBLE_ZONE配置
- [x] 更新run_converging_triangle.py导入和使用实时配置
- [x] 创建test_realtime_mode.py对比测试脚本
- [x] 更新README.md和创建实时模式使用指南
- [ ] 重写 fit_pivot_line 函数,实现迭代离群点移除算法
- [ ] 测试 SZ300278验证修复效果
- [ ] 更新算法文档,添加迭代离群点移除说明

View File

@ -37,6 +37,12 @@ python scripts/pipeline_converging_triangle.py
# 指定日期
python scripts/pipeline_converging_triangle.py --date 20260120
# 生成详情模式图片(显示所有枢轴点和拟合点)
python scripts/pipeline_converging_triangle.py --show-details
# 组合使用
python scripts/pipeline_converging_triangle.py --date 20260120 --show-details
# 跳过检测(仅生成报告与图表)
python scripts/pipeline_converging_triangle.py --skip-detection
```

View File

@ -1,16 +1,61 @@
## 问题1: 末端枢轴点未识别
![](images/2026-01-26-15-44-13.png)
下沿线,明显不对。
**现象**: 图表最右边明显的低点/高点没有被标记为枢轴点
**根因**:
1. 数据中有空值(NaN)导致比较失效
2. 只检测窗口前235天的枢轴点最后5天被忽略
3. 绘图时丢弃了末端的"候选枢轴点"
**解决**:
1. 用 `nanmin/nanmax` 替代 `min/max`,自动跳过空值
2. 把"灵活区域"从5天扩大到15天覆盖更多末端数据
3. 对称短窗口右边只有N天时左边也只看N天而不是固定15天
4. 绘图时把"确认枢轴点"和"候选枢轴点"合并显示
详见 [枢轴点检测与可视化修复](../docs/2026-01-26_枢轴点检测与可视化修复.md)
---
## 问题2: 拟合点选择不合理
![](images/2026-01-26-15-48-56.png)
拟合线的时候,有些点应该去掉,跟主观判断不对齐,比如图中第二个点
**现象**: 某些"弱"枢轴点如5.8元的低高点)被用于拟合,拉偏趋势线
![](images/2026-01-26-15-50-58.png)
**解决**: 迭代离群点移除算法
1. 先用所有点画一条拟合线
2. 找出离拟合线太远的"异常点"
3. 去掉最差的那个点,重新画线
4. 重复2-3次直到没有异常点
### 强度分:是否符合三角形的形态 + 突破强度
详见 [枢轴点拟合算法详解](../docs/枢轴点分段选择算法详解.md)
108个个股按照分数排序
---
可调参数、速度快
后续回测后继续调优
## 问题3: 非收敛形态误判
历史曲线的每个点,都需要计算强度分。
**现象**: "上升三角形"(上沿水平)被误判为"收敛三角形"
![](images/2026-01-26-18-36-50.png)
**解决**: 收紧斜率限制
- 上沿必须向下(或至少水平),不能向上
- 下沿必须向上(或至少水平),不能向下
- 这样就能过滤掉"上升三角形"和"下降通道"等非收敛形态
---
## 待办: 突破强度评分
| 分量 | 权重 | 说明 |
|------|------|------|
| 价格突破 | 60% | 突破幅度 |
| 收敛程度 | 25% | 蓄势充分度 |
| 成交量 | 15% | 放量确认 |
- 枢轴点和拟合线距离。
后续回测调优。

Binary file not shown.

After

Width:  |  Height:  |  Size: 338 KiB

View File

@ -2,15 +2,292 @@
**日期**: 2026-01-26
**功能**: 图表可视化改进 - 简洁模式与详细模式
**版本**: v2.0 (移除分段竖线 + 流水线支持)
---
## 📋 功能概述
为了满足不同使用场景的需求我们为收敛三角形图表增加了**简洁模式**和**详细模式**两种显示方式:
为了满足不同使用场景的需求,我们为收敛三角形图表增加了**简洁模式**和**详细模式**两种显示方式:
- **简洁模式(默认)**: 仅显示收盘价、上沿线、下沿线,图表清爽易读
- **详细模式**: 显示所有枢轴点、拟合点、分段线等调试信息,便于理解算法
- **详细模式**: 显示所有枢轴点、拟合点,便于理解算法
**v2.0 更新**: 移除分段竖线(算法已改为迭代离群点移除)
---
## 🎯 使用场景
### 简洁模式
- ✅ 日常使用和实盘选股
- ✅ 快速查看三角形形态
- ✅ 对外展示和报告
- ✅ 减少视觉干扰
### 详细模式
- ✅ 算法调试和验证
- ✅ 理解枢轴点识别逻辑
- ✅ 验证迭代拟合算法
- ✅ 学习和教学用途
---
## 📊 两种模式对比
| 显示元素 | 简洁模式 | 详细模式 | 说明 |
|---------|---------|---------|------|
| **收盘价线** | ✅ | ✅ | 黑色实线 |
| **上沿线** | ✅ | ✅ | 红色虚线 |
| **下沿线** | ✅ | ✅ | 绿色虚线 |
| **所有高点枢轴点** | ❌ | ✅ | 浅红色小实心圆 |
| **所有低点枢轴点** | ❌ | ✅ | 浅绿色小实心圆 |
| **上沿拟合点** | ❌ | ✅ | 深红色大空心圆(迭代算法选出) |
| **下沿拟合点** | ❌ | ✅ | 深绿色大空心圆(迭代算法选出) |
| **分段竖线** | ❌ | ❌ | v2.0已移除 |
| **输出文件名** | `YYYYMMDD_代码_名称.png` | `YYYYMMDD_代码_名称_detail.png` |
**文件名说明**:
- 简洁模式文件不带后缀
- 详细模式文件带 `_detail` 后缀
- 两种模式可以同时保留,方便对比查看
---
## 🛠️ 如何启用
### 方法1: 流水线脚本(推荐)
```bash
# 简洁模式(默认)
python scripts/pipeline_converging_triangle.py
# 详细模式
python scripts/pipeline_converging_triangle.py --show-details
# 组合使用
python scripts/pipeline_converging_triangle.py --date 20260120 --show-details
```
### 方法2: 单独绘图脚本
```bash
# 简洁模式(默认)
python scripts/plot_converging_triangles.py
# 详细模式
python scripts/plot_converging_triangles.py --show-details
```
### 方法3: 配置文件(默认设置)
编辑 `scripts/triangle_config.py`:
```python
# 图表详细模式(显示枢轴点等调试信息)
SHOW_CHART_DETAILS = False # False=简洁模式默认True=详细模式
```
**优先级**: 命令行参数 > 配置文件
**智能清理**:
- 简洁模式运行时,只清理简洁模式的旧图片
- 详细模式运行时,只清理详细模式的旧图片
- 两种模式互不影响,可以共存
---
## 📈 详细模式显示元素说明
### 1. 所有枢轴点(小实心圆)
**作用**: 显示算法识别的所有局部高点和低点
- **高点枢轴点**: 浅红色size=50alpha=0.4
- **低点枢轴点**: 浅绿色size=50alpha=0.4
**标签**: `所有高点枢轴点(N)` / `所有低点枢轴点(N)`
### 2. 拟合点(大空心圆)
**作用**: 显示迭代离群点移除算法最终选出的关键点
- **上沿拟合点**: 深红色空心圆size=120linewidth=2.5
- **下沿拟合点**: 深绿色空心圆size=120linewidth=2.5
**标签**: `上沿拟合点(N)` / `下沿拟合点(N)`
**说明**:
- 使用迭代离群点移除算法选出代表性强的枢轴点
- 自动过滤"弱"枢轴点(偏离趋势线过大的点)
- 详见 [枢轴点拟合算法详解](./枢轴点分段选择算法详解.md)
### 3. ~~分段竖线~~(已移除)
**v2.0 更新**: 由于算法改为迭代离群点移除,不再使用分段策略,因此移除了分段竖线。
---
## 💡 图表解读示例
### 简洁模式(默认)
```
图表内容:
├─ 黑色实线:收盘价
├─ 红色虚线:上沿线(向下)
└─ 绿色虚线:下沿线(向上)
图例:
- 收盘价
- 上沿
- 下沿
```
**优点**:
- 清晰直观
- 无视觉干扰
- 适合日常使用
---
### 详细模式(`--show-details`
```
图表内容:
├─ 黑色实线:收盘价
├─ 红色虚线:上沿线
├─ 绿色虚线:下沿线
├─ 浅红小圆:所有高点枢轴点
├─ 浅绿小圆:所有低点枢轴点
├─ 深红大圆:上沿拟合点(迭代算法选出)
└─ 深绿大圆:下沿拟合点(迭代算法选出)
图例:
- 收盘价
- 上沿
- 下沿
- 所有高点枢轴点(N)
- 所有低点枢轴点(N)
- 上沿拟合点(N)
- 下沿拟合点(N)
```
**优点**:
- 完整展示算法逻辑
- 便于验证和调试
- 有助于理解原理
---
## 🎨 视觉层次设计
图表元素的 Z-order从后到前
```
1. 网格线alpha=0.3
2. 价格曲线黑色zorder=默认)
3. 所有枢轴点浅色小圆alpha=0.4zorder=4
4. 拟合点深色大圆zorder=5
5. 趋势线(红/绿虚线zorder=默认)
```
**设计原则**:
- 详细信息放在后层(不遮挡主要信息)
- 关键信息拟合点突出显示zorder高
- 使用透明度alpha区分重要性
---
## 📝 代码实现要点
### 1. 流水线参数传递
```python
# pipeline_converging_triangle.py
parser.add_argument("--show-details", action="store_true")
# 传递给绘图脚本
if args.show_details:
cmd_args.append("--show-details")
```
### 2. 绘图脚本参数
```python
# plot_converging_triangles.py
parser.add_argument(
"--show-details",
action="store_true",
help="显示详细调试信息(枢轴点、拟合点等)",
)
```
### 3. 条件渲染
```python
# 详细模式:显示所有枢轴点和拟合点
if show_details:
# 绘制所有枢轴点
ax1.scatter(ph_display_idx, high_win[ph_idx], ...)
# 绘制拟合点
ax1.scatter(selected_ph_display, high_win[selected_ph_pos], ...)
```
---
## ✅ 测试验证
### 测试用例1: 流水线简洁模式(默认)
```bash
python scripts/pipeline_converging_triangle.py
```
**预期结果**:
- ✅ 控制台显示:"图表模式: 简洁模式(仅显示价格和趋势线)"
- ✅ 图表只显示收盘价、上沿、下沿
- ✅ 输出文件: `20260120_SZ300278_华昌达.png`
### 测试用例2: 流水线详细模式
```bash
python scripts/pipeline_converging_triangle.py --show-details
```
**预期结果**:
- ✅ 控制台显示:"图表模式: 详情模式(显示所有枢轴点)"
- ✅ 图表显示所有枢轴点和拟合点
- ✅ 输出文件: `20260120_SZ300278_华昌达_detail.png`
---
## 📚 相关文档
- [README.md](../README.md) - 项目概述
- [USAGE.md](../USAGE.md) - 使用指南
- [枢轴点拟合算法详解.md](./枢轴点分段选择算法详解.md) - 迭代离群点移除算法 ⭐
- [枢轴点检测原理.md](./枢轴点检测原理.md) - 枢轴点算法说明
---
## 🎉 总结
通过添加**简洁模式**和**详细模式**,图表功能更加灵活:
- **对于日常用户**: 简洁清爽的图表,快速查看形态
- **对于研究者**: 完整的算法细节,深入理解逻辑
- **对于开发者**: 便于调试和验证算法正确性
**v2.0 改进**:
- ✅ 移除了不再适用的分段竖线
- ✅ 流水线脚本支持详情模式
- ✅ 更符合新的迭代拟合算法
这个功能提升了项目的易用性和专业性!🎯
---

View File

@ -0,0 +1,157 @@
# 枢轴点检测与可视化修复
**日期**: 2026-01-26
**版本**: v1.0
## 问题概述
收敛三角形检测系统存在多个问题,导致:
1. 非收敛形态(如上升三角形)被误判
2. 绘图与检测结果不一致
3. 窗口末端枢轴点无法识别
---
## 问题1: 斜率约束过于宽松
### 现象
"上升三角形"(上沿水平+下沿向上)被误判为"收敛三角形"。
### 原因
```python
upper_slope_max=0.10, # 允许上沿向上
lower_slope_min=-0.10, # 允许下沿向下
```
### 解决方案
**文件**: `scripts/triangle_config.py`
```python
upper_slope_max=0, # 上沿必须向下或水平≤0
lower_slope_min=0, # 下沿必须向上或水平≥0
```
---
## 问题2: 绘图与检测枢轴点不一致
### 现象
图表显示的枢轴点和趋势线与实际检测结果不同。
### 原因
- 检测代码使用 `pivots_fractal_hybrid`(实时模式)
- 绘图代码使用 `pivots_fractal`(标准模式)
### 解决方案
**文件**: `scripts/plot_converging_triangles.py`
```python
from triangle_config import REALTIME_MODE, FLEXIBLE_ZONE
if REALTIME_MODE:
confirmed_ph, confirmed_pl, candidate_ph, candidate_pl = pivots_fractal_hybrid(
high_win, low_win, k=params.pivot_k, flexible_zone=FLEXIBLE_ZONE
)
# 合并确认枢轴点和候选枢轴点
ph_idx = np.concatenate([confirmed_ph, candidate_ph]) if len(candidate_ph) > 0 else confirmed_ph
pl_idx = np.concatenate([confirmed_pl, candidate_pl]) if len(candidate_pl) > 0 else confirmed_pl
ph_idx = np.sort(ph_idx)
pl_idx = np.sort(pl_idx)
```
---
## 问题3: 窗口末端枢轴点识别失败
### 现象
图表最右边明显的低点/高点没有被识别为枢轴点。
### 原因
`FLEXIBLE_ZONE=5` 太小,末端枢轴点超出灵活区域范围。
### 解决方案
**文件**: `scripts/triangle_config.py`
```python
FLEXIBLE_ZONE = 15 # 与 pivot_k 一致,确保末端枢轴点能被识别
```
---
## 问题4: NaN值导致比较失败
### 现象
包含 NaN 值的数据窗口中,枢轴点检测完全失效。
### 原因
```python
np.min([30.0, np.nan, 29.0]) # 返回 nan
30.0 == nan # 永远为 False
```
### 解决方案
**文件**: `src/converging_triangle.py`
```python
# 使用 nanmin/nanmax 忽略 NaN 值
if not np.isnan(high[i]) and high[i] == np.nanmax(high[i - k : i + k + 1]):
confirmed_ph.append(i)
if not np.isnan(low[i]) and low[i] == np.nanmin(low[i - k : i + k + 1]):
confirmed_pl.append(i)
```
---
## 问题5: 候选枢轴点未合并
### 现象
`pivots_fractal_hybrid` 返回的候选枢轴点在绘图时被丢弃。
### 原因
```python
ph_idx, pl_idx, _, _ = pivots_fractal_hybrid(...) # 候选点被忽略
```
### 解决方案
见问题2的解决方案合并 confirmed 和 candidate。
---
## 问题6: 对称窗口逻辑优化
### 现象
窗口末端的局部低点/高点,因右侧数据不足无法识别。
### 解决方案
**文件**: `src/converging_triangle.py`
```python
# 候选枢轴点使用对称的短窗口
for i in range(max(k, n - flexible_zone), n):
right_avail = n - 1 - i
left_look = min(k, max(right_avail + 1, 3)) # 至少看3天
left_start = max(0, i - left_look)
right_end = min(n, i + right_avail + 1)
if high[i] == np.nanmax(high[left_start : right_end]):
candidate_ph.append(i)
```
---
## 修改文件清单
| 文件 | 修改内容 |
|------|----------|
| `scripts/triangle_config.py` | 斜率约束(`upper_slope_max=0`, `lower_slope_min=0`)、`FLEXIBLE_ZONE=15` |
| `scripts/plot_converging_triangles.py` | 实时模式枢轴点检测、候选点合并、排序 |
| `src/converging_triangle.py` | NaN处理`nanmin/nanmax`)、对称窗口逻辑 |
---
## 效果验证
修复前后对比:
| 指标 | 修复前 | 修复后 |
|------|--------|--------|
| SZ300892 低点枢轴点 | 4个 | 7个 |
| 末端低点识别 | ❌ | ✅ |
| 图表/检测一致性 | ❌ | ✅ |
| 非收敛形态过滤 | ❌ | ✅ |

View File

@ -0,0 +1,214 @@
# 流水线脚本详情模式支持
**日期**: 2026-01-26
**功能**: 为 `pipeline_converging_triangle.py` 添加 `--show-details` 参数
---
## 📋 需求背景
用户希望在使用流水线脚本 `pipeline_converging_triangle.py` 时,能够通过参数控制是否生成详情模式的图片,而不是默认生成。
**之前**:流水线脚本没有详情模式参数,只能生成简洁模式图片。
**现在**:流水线脚本支持 `--show-details` 参数,可以按需生成详情模式图片。
---
## ✅ 实现内容
### 1. 添加命令行参数
`pipeline_converging_triangle.py` 中添加:
```python
parser.add_argument(
"--show-details",
action="store_true",
help="生成详情模式图片(显示所有枢轴点和拟合点)",
)
```
### 2. 参数传递逻辑
将参数传递给绘图脚本:
```python
# 步骤 3: 绘制图表
cmd_args = [sys.argv[0]]
if args.date:
cmd_args.extend(["--date", str(args.date)])
if args.show_details:
cmd_args.append("--show-details")
sys.argv = cmd_args
run_plot()
```
### 3. 控制台提示信息
在流水线开始时显示当前模式:
```python
if args.show_details:
print(f"图表模式: 详情模式(显示所有枢轴点)")
else:
print(f"图表模式: 简洁模式(仅显示价格和趋势线)")
```
---
## 🛠️ 使用方法
### 基本用法
```bash
# 简洁模式(默认)- 不生成详情图片
python scripts/pipeline_converging_triangle.py
# 详情模式 - 生成详情图片
python scripts/pipeline_converging_triangle.py --show-details
```
### 组合参数
```bash
# 指定日期 + 详情模式
python scripts/pipeline_converging_triangle.py --date 20260120 --show-details
# 跳过检测 + 详情模式
python scripts/pipeline_converging_triangle.py --skip-detection --show-details
# 跳过检测和报告,只生成详情图表
python scripts/pipeline_converging_triangle.py --skip-detection --skip-report --show-details
```
---
## 📊 输出对比
### 简洁模式(默认)
```
输出文件: outputs/converging_triangles/charts/20260120_SZ300278_华昌达.png
文件内容: 收盘价 + 上沿线 + 下沿线
```
### 详情模式(--show-details
```
输出文件: outputs/converging_triangles/charts/20260120_SZ300278_华昌达_detail.png
文件内容: 收盘价 + 上沿线 + 下沿线 + 所有枢轴点 + 拟合点
```
**注意**:两种模式的文件可以共存,互不覆盖。
---
## 🎯 参数优先级
流水线脚本的参数传递链:
```
用户命令行
└─ pipeline_converging_triangle.py
├─ --show-details (可选)
└─ 传递给 plot_converging_triangles.py
├─ --show-details (如果用户指定)
└─ 调用 plot_triangle(show_details=True/False)
```
**优先级规则**
1. 流水线脚本的 `--show-details` 参数
2. 传递到绘图脚本
3. 绘图脚本的配置文件 `SHOW_CHART_DETAILS`(命令行参数优先)
---
## 🔍 测试验证
### 测试1帮助信息
```bash
python scripts/pipeline_converging_triangle.py --help
```
**预期**:显示 `--show-details` 参数说明
### 测试2简洁模式默认
```bash
python scripts/pipeline_converging_triangle.py --skip-detection --skip-report
```
**预期**
- ✅ 控制台显示:"图表模式: 简洁模式(仅显示价格和趋势线)"
- ✅ 生成不带 `_detail` 后缀的图片
### 测试3详情模式
```bash
python scripts/pipeline_converging_triangle.py --skip-detection --skip-report --show-details
```
**预期**
- ✅ 控制台显示:"图表模式: 详情模式(显示所有枢轴点)"
- ✅ 控制台显示:"详细模式: 开启 (--show-details)"
- ✅ 生成带 `_detail` 后缀的图片
---
## 📝 修改文件列表
1. **scripts/pipeline_converging_triangle.py**
- 添加 `--show-details` 参数
- 添加模式提示信息
- 参数传递逻辑
2. **USAGE.md**
- 更新流水线脚本的使用说明
- 添加 `--show-details` 参数示例
3. **docs/2026-01-26_图表详细模式功能.md**
- 更新文档,添加流水线使用方法
- 标注为 v2.0 版本
---
## 🎉 效果总结
### 用户体验改进
**之前**
```bash
# 想要详情图片
python scripts/pipeline_converging_triangle.py # ❌ 只能生成简洁模式
# 需要单独运行绘图脚本
python scripts/plot_converging_triangles.py --show-details
```
**现在**
```bash
# 一键生成详情图片
python scripts/pipeline_converging_triangle.py --show-details # ✅ 直接搞定
```
### 优势
1. **一致性**:流水线和绘图脚本参数统一
2. **便捷性**:无需分步操作
3. **灵活性**:默认简洁,按需详细
4. **清晰性**:控制台明确提示当前模式
---
## 📚 相关文档
- [USAGE.md](../USAGE.md) - 使用指南
- [2026-01-26_图表详细模式功能.md](./2026-01-26_图表详细模式功能.md) - 详情模式完整说明
- [枢轴点拟合算法详解.md](./枢轴点分段选择算法详解.md) - 拟合算法原理
---
**总结**:通过添加 `--show-details` 参数,流水线脚本现在完全支持详情模式,用户可以更方便地按需生成详细的调试图表。✅

View File

@ -0,0 +1,117 @@
# 拟合线迭代离群点移除优化
## 问题描述
当前的分段选择算法会选中一些"弱"枢轴点用于拟合:
- 上沿线:某个高点虽然是时间段内最高,但明显低于其他高点(如图中第二个点 5.8 元)
- 这些点会拉低/拉高拟合线,导致与主观判断不符
## 解决方案:迭代离群点移除
### 核心逻辑
```
1. 初始拟合:用所有枢轴点做线性回归
2. 计算残差:每个点到拟合线的偏差
3. 识别离群点:
- 上沿线:价格明显低于拟合线的点 = 弱高点
- 下沿线:价格明显高于拟合线的点 = 弱低点
4. 移除最差的离群点
5. 重新拟合
6. 重复直到收敛
```
### 算法流程图
```mermaid
flowchart TD
A[输入枢轴点] --> B[初始线性回归]
B --> C[计算残差]
C --> D{存在离群点?}
D -->|是| E[移除最大离群点]
E --> F{剩余点 >= 3?}
F -->|是| G{迭代次数 < 3?}
G -->|是| B
G -->|否| H[返回当前拟合]
F -->|否| H
D -->|否| H
```
## 代码修改
### 文件: [src/converging_triangle.py](src/converging_triangle.py)
重写 `fit_pivot_line` 函数(第 230-350 行):
```python
def fit_pivot_line(
pivot_indices: np.ndarray,
pivot_values: np.ndarray,
mode: str = "upper",
min_points: int = 2,
outlier_threshold: float = 1.5, # 新增:离群点阈值(标准差倍数)
max_iterations: int = 3, # 新增:最大迭代次数
) -> Tuple[float, float, np.ndarray]:
"""
迭代离群点移除的枢轴点拟合算法
策略:
1. 先用所有点做初始拟合
2. 识别并移除偏离拟合线的"弱"点
3. 迭代直到收敛
对于上沿线:移除价格明显低于拟合线的点
对于下沿线:移除价格明显高于拟合线的点
"""
```
### 关键参数
| 参数 | 默认值 | 说明 |
|------|--------|------|
| `outlier_threshold` | 1.5 | 残差超过 1.5 倍标准差视为离群点 |
| `max_iterations` | 3 | 最多迭代 3 次,避免过度过滤 |
| `min_points` | 3 | 至少保留 3 个点用于拟合 |
### 离群点判定逻辑
**上沿线upper**:
```python
# 残差 = 拟合值 - 实际值
# 正残差表示点在拟合线下方(弱高点)
residuals = fitted_values - actual_values
outliers = residuals > threshold # 弱高点
```
**下沿线lower**:
```python
# 残差 = 实际值 - 拟合值
# 正残差表示点在拟合线上方(弱低点)
residuals = actual_values - fitted_values
outliers = residuals > threshold # 弱低点
```
### 预期效果
以图中 SZ300278 为例:
- 第二个点5.8元)明显低于拟合线
- 在第一次迭代后会被识别为离群点并移除
- 最终拟合线只使用剩余 3 个更有代表性的高点
## 测试计划
1. 使用 SZ300278 验证修复效果
2. 对比修改前后的图表
3. 确保不会过度过滤正常的枢轴点
## 文档更新
更新 [docs/枢轴点分段选择算法详解.md](docs/枢轴点分段选择算法详解.md),添加迭代离群点移除的说明。

View File

@ -140,6 +140,12 @@
- 文档更新说明
- 项目结构整理
7. **[2026-01-26_枢轴点检测与可视化修复.md](./2026-01-26_枢轴点检测与可视化修复.md)** 🔧
- 斜率约束收紧
- NaN值处理修复
- 候选枢轴点合并
- 对称窗口逻辑优化
### 实时模式实施
7. **[方案4实施完成报告.md](./方案4实施完成报告.md)**

View File

@ -1,6 +1,6 @@
# 枢轴点分段选择算法详解
# 枢轴点拟合算法详解
**日期**: 2026-01-26
**日期**: 2026-01-26 (v2 更新)
**文件**: `src/converging_triangle.py` - `fit_pivot_line()` 函数
---
@ -8,9 +8,9 @@
## 📋 目录
1. [算法概述](#算法概述)
2. [为什么需要分段](#为什么需要分段)
3. [分段算法详解](#分段算法详解)
4. [独立分段机制](#独立分段机制)
2. [迭代离群点移除(当前算法)](#迭代离群点移除当前算法)
3. [历史版本:分段选择算法](#历史版本分段选择算法)
4. [独立处理机制](#独立处理机制)
5. [代码实现](#代码实现)
6. [实际案例分析](#实际案例分析)
7. [边界情况处理](#边界情况处理)
@ -22,13 +22,131 @@
### 核心思想
对于识别出的所有枢轴点,我们不是简单地用所有点或仅用首尾两点来拟合趋势线,而是:
对于识别出的所有枢轴点,我们采用**迭代离群点移除**算法来拟合趋势线:
1. **初始拟合**:先用所有枢轴点做线性回归
2. **识别离群点**:计算残差,找出偏离拟合线的"弱"点
3. **移除离群点**:移除最差的离群点
4. **迭代优化**重复步骤1-3直到收敛
### 设计目标
```python
# 上沿线:移除价格明显低于拟合线的点(弱高点)
# 下沿线:移除价格明显高于拟合线的点(弱低点)
```
---
## 迭代离群点移除(当前算法)
### 为什么需要这个算法?
**问题场景**
```
价格
^
| * ← 强高点 (7.9元)
| \
| * ← 弱高点 (5.8元) ← 问题!
| \
| * ← 强高点 (6.8元)
| \
| * ← 强高点 (6.0元)
└──────────────> 时间
```
如果使用所有4个点拟合5.8元的"弱高点"会把上沿线拉低,导致:
- ❌ 拟合线不是真正的上边界
- ❌ 与主观判断不符
- ❌ 可能错过真实的突破信号
### 解决方案:迭代移除
```
第1次迭代:
拟合线: y = ax + b (用全部4点)
残差: [0.1, 1.8, 0.2, 0.3] ← 5.8元点的残差最大(1.8)
判定: 1.8 > 1.5*std → 离群点
操作: 移除5.8元点
第2次迭代:
拟合线: y = a'x + b' (用剩余3点)
残差: [0.05, 0.08, 0.12]
判定: 无离群点 → 收敛
最终结果: 只用3个强高点拟合
```
### 算法流程图
```mermaid
flowchart TD
A[输入全部枢轴点] --> B[线性回归拟合]
B --> C[计算方向性残差]
C --> D{残差 > 阈值?}
D -->|是| E[移除最大残差点]
E --> F{剩余点 >= 3?}
F -->|是| G{迭代次数 < 3?}
G -->|是| B
G -->|否| H[返回当前拟合]
F -->|否| H
D -->|否| H
```
### 关键参数
| 参数 | 默认值 | 说明 |
|------|--------|------|
| `outlier_threshold` | 1.5 | 残差超过 1.5 倍标准差视为离群点 |
| `max_iterations` | 3 | 最多迭代 3 次,避免过度过滤 |
| `min_keep` | 3 | 至少保留 3 个点用于拟合 |
### 残差计算逻辑
**上沿线upper**
```python
# 残差 = 拟合值 - 实际值
# 正残差 = 点在拟合线下方 = 弱高点(应移除)
residuals = fitted_values - actual_values
outliers = residuals > threshold
```
**下沿线lower**
```python
# 残差 = 实际值 - 拟合值
# 正残差 = 点在拟合线上方 = 弱低点(应移除)
residuals = actual_values - fitted_values
outliers = residuals > threshold
```
### 算法优势
| 优势 | 说明 |
|------|------|
| **自适应** | 自动识别偏离点,无需人工干预 |
| **稳健性** | 不会被单个异常点影响整条线 |
| **可控性** | 通过阈值和迭代次数控制过滤强度 |
| **保守性** | 至少保留3个点避免过度过滤 |
---
## 历史版本:分段选择算法
> **注意**:以下是 v1 版本的分段算法,已被迭代离群点移除算法替代。保留此节供参考。
### 原核心思想
对于识别出的所有枢轴点:
1. **分段策略**:将枢轴点按时间顺序分成 **3 个时间段**
2. **代表点选择**:从每段中选出 **最极端的点**(上沿选最高,下沿选最低)
3. **线性回归**:用这 3 个代表点进行线性回归,拟合趋势线
### 触发条件
### 触发条件
```python
if 枢轴点数量 > 4:
@ -37,6 +155,12 @@ else:
使用全部枢轴点
```
### 原算法的局限性
分段算法会选中一些"弱"枢轴点:
- 某个高点虽然是时间段内最高,但可能明显低于其他时间段的高点
- 这些点会拉低/拉高拟合线,导致与主观判断不符
---
## 为什么需要分段
@ -254,7 +378,7 @@ n = 4 ≤ 4不分段全部使用 ✓
---
## 独立分段机制
## 独立处理机制
### 关键点:高点和低点分别独立处理
@ -323,29 +447,28 @@ else:
## 代码实现
### 完整代码(带详细注释
### 当前算法迭代离群点移除v2
```python
def fit_pivot_line(
pivot_indices: np.ndarray, # 枢轴点的时间索引
pivot_values: np.ndarray, # 枢轴点的价格
mode: str = "upper", # "upper" 或 "lower"
pivot_indices: np.ndarray,
pivot_values: np.ndarray,
mode: str = "upper",
min_points: int = 2,
outlier_threshold: float = 1.5, # 离群点阈值(标准差倍数)
max_iterations: int = 3, # 最大迭代次数
) -> Tuple[float, float, np.ndarray]:
"""
拟合枢轴点趋势线(使用分段选择策略)
迭代离群点移除的枢轴点拟合算法
策略:
- 如果枢轴点 > 4分3段每段选1个极值点共3个拟合点
- 如果枢轴点 ≤ 4全部使用
1. 先用所有点做初始拟合
2. 识别并移除偏离拟合线的"弱"点
3. 迭代直到收敛
Returns:
(斜率a, 截距b, 选中的枢轴点索引)
对于上沿线:移除价格明显低于拟合线的点(弱高点)
对于下沿线:移除价格明显高于拟合线的点(弱低点)
"""
# ─────────────────────────────────────────────────────────
# 第1步基本检查和排序
# ─────────────────────────────────────────────────────────
if len(pivot_indices) < min_points:
return 0.0, 0.0, np.array([])
@ -353,115 +476,113 @@ def fit_pivot_line(
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)
# 初始化:所有点都参与
active_mask = np.ones(n, dtype=bool)
min_keep = max(3, min_points) # 至少保留3个点
# 计算每段大小
segment_size = n // 3
if segment_size < 1:
segment_size = 1
# ─────────────────────────────────────────────────────────
# 迭代离群点移除
# ─────────────────────────────────────────────────────────
for iteration in range(max_iterations):
active_indices = np.where(active_mask)[0]
if len(active_indices) <= min_keep:
break
# 定义三个时间段
segments = [
range(0, min(segment_size, n)), # 第1段
range(segment_size, min(2 * segment_size, n)), # 第2段
range(2 * segment_size, n), # 第3段
]
# 当前活跃点
x_active = x_sorted[active_mask]
y_active = y_sorted[active_mask]
# 线性回归
a, b = fit_line(x_active, y_active)
# 计算拟合值
fitted_values = a * x_active + b
# ─────────────────────────────────────────────────────
# 第3步从每段选择极值点
# 计算方向性残差
# ─────────────────────────────────────────────────────
for seg in segments:
if len(seg) == 0:
continue
if mode == "upper":
# 上沿:残差 = 拟合值 - 实际值
# 正残差表示点在拟合线下方(弱高点)
residuals = fitted_values - y_active
else:
# 下沿:残差 = 实际值 - 拟合值
# 正残差表示点在拟合线上方(弱低点)
residuals = y_active - fitted_values
seg_list = list(seg)
seg_y = y_sorted[seg_list]
# 计算标准差
std_residual = np.std(residuals)
if std_residual < 1e-10:
# 所有点几乎在一条线上,无需移除
break
if mode == "upper":
# 上沿:选该段最高点
best_idx_in_seg = np.argmax(seg_y)
else: # mode == "lower"
# 下沿:选该段最低点
best_idx_in_seg = np.argmin(seg_y)
# ─────────────────────────────────────────────────────
# 识别并移除最大离群点
# ─────────────────────────────────────────────────────
threshold = outlier_threshold * std_residual
max_outlier_idx = np.argmax(residuals)
# 标记选中
selected_mask[seg_list[best_idx_in_seg]] = True
if residuals[max_outlier_idx] <= threshold:
# 最大残差不超过阈值,收敛
break
if np.sum(active_mask) <= min_keep:
break
# 移除离群点
original_idx = active_indices[max_outlier_idx]
active_mask[original_idx] = False
# ─────────────────────────────────────────────────────────
# 第4步提取选中的点
# 最终拟合
# ─────────────────────────────────────────────────────────
selected_x = x_sorted[selected_mask]
selected_y = y_sorted[selected_mask]
selected_indices_sorted = np.where(selected_mask)[0]
selected_indices_sorted = np.where(active_mask)[0]
selected_x = x_sorted[active_mask]
selected_y = y_sorted[active_mask]
# 保底:至少选首尾两点
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]
# 兜底:使用首尾两点
active_mask = np.zeros(n, dtype=bool)
active_mask[0] = True
active_mask[-1] = True
selected_x = x_sorted[active_mask]
selected_y = y_sorted[active_mask]
selected_indices_sorted = np.where(active_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
```
### 历史算法分段选择v1
> 以下代码已被替代,保留供参考。
```python
def fit_pivot_line_v1(
pivot_indices: np.ndarray,
pivot_values: np.ndarray,
mode: str = "upper",
min_points: int = 2,
) -> Tuple[float, float, np.ndarray]:
"""
分段选择策略(已废弃)
策略:
- 如果枢轴点 > 4分3段每段选1个极值点共3个拟合点
- 如果枢轴点 ≤ 4全部使用
"""
# ... 详见历史版本
```
---
## 实际案例分析
@ -616,90 +737,45 @@ if len(selected_x) < 2:
```
图表元素 对应内容
─────────────────────────────────────────────
浅红色小实心圆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', ...)
```
**注意**:自 v2.0 起,分段竖线已被移除(因算法不再使用分段策略)。
---
## 总结
### 分段策略的核心要点
### 迭代离群点移除算法的核心要点
1. **触发条件**: 枢轴点 > 4
2. **分段数量**: 固定3段
3. **分段方式**: 时间均分(每段 `n//3` 个点)
4. **选择策略**: 每段选1个极值点
5. **独立性**: 高点和低点各自独立分段
6. **保底机制**: 覆盖性验证 + 全局极值保护
1. **初始状态**: 所有枢轴点参与拟合
2. **迭代移除**: 每次移除最大残差的离群点
3. **方向性残差**: 上沿移除低于线的点,下沿移除高于线的点
4. **收敛条件**: 无离群点 / 达到最大迭代次数 / 点数达到下限
5. **独立性**: 高点和低点各自独立处理
6. **保底机制**: 至少保留3个点用于拟合
### 算法优势
| 优势 | 说明 |
|------|------|
| **时间均衡** | 前、中、后三个时期都有代表点 |
| **代表性强** | 每段选最极端的点,确保边界性 |
| **抗噪性好** | 不易被局部密集点影响 |
| **稳健性高** | 多点回归比两点连线更稳定 |
| **可扩展性** | 点数增加时仍保持3个拟合点 |
| **自适应** | 自动识别并移除偏离点 |
| **稳健性** | 不受单个异常点影响 |
| **可控性** | 通过阈值和迭代次数控制 |
| **保守性** | 至少保留3个点避免过度过滤 |
| **符合直觉** | 拟合结果与主观判断一致 |
### 与其他方法的对比
| 方法 | 优点 | 缺点 |
|------|------|------|
| **两点连线** | 简单快速 | 忽略中间点,易受噪声影响 |
| **全部点回归** | 利用所有信息 | 权重不均,点密集区域主导 |
| **分段选择(当前)** | 时间均衡,代表性强 | 略复杂,需要分段逻辑 |
| **滑动窗口** | 平滑效果好 | 计算复杂,参数敏感 |
| **全部点回归** | 利用所有信息 | 权重不均,异常点影响大 |
| **分段选择v1** | 时间均衡 | 可能选中弱枢轴点 |
| **迭代离群点移除(当前)** | 自适应,稳健 | 需要迭代计算 |
---
@ -711,6 +787,9 @@ if len(ph_idx) > 4:
---
**文档版本**: v1.0
**文档版本**: v2.0
**最后更新**: 2026-01-26
**变更记录**:
- v2.0: 采用迭代离群点移除算法,替代原分段选择策略
- v1.0: 初始版本,分段选择算法

View File

@ -9,6 +9,7 @@
用法:
python scripts/pipeline_converging_triangle.py
python scripts/pipeline_converging_triangle.py --date 20260120
python scripts/pipeline_converging_triangle.py --show-details # 生成详情模式图片
"""
from __future__ import annotations
@ -54,6 +55,11 @@ def main() -> None:
default=None,
help="指定日期YYYYMMDD用于报告和图表生成默认为数据最新日",
)
parser.add_argument(
"--show-details",
action="store_true",
help="生成详情模式图片(显示所有枢轴点和拟合点)",
)
parser.add_argument(
"--skip-detection",
action="store_true",
@ -79,6 +85,10 @@ def main() -> None:
print(f"开始时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
if args.date:
print(f"指定日期: {args.date}")
if args.show_details:
print(f"图表模式: 详情模式(显示所有枢轴点)")
else:
print(f"图表模式: 简洁模式(仅显示价格和趋势线)")
print("=" * 80)
results = []
@ -149,10 +159,13 @@ def main() -> None:
try:
# 设置命令行参数
cmd_args = [sys.argv[0]]
if args.date:
sys.argv = [sys.argv[0], "--date", str(args.date)]
else:
sys.argv = [sys.argv[0]]
cmd_args.extend(["--date", str(args.date)])
if args.show_details:
cmd_args.append("--show-details")
sys.argv = cmd_args
run_plot()
success = True

View File

@ -37,10 +37,11 @@ from converging_triangle import (
fit_pivot_line,
line_y,
pivots_fractal,
pivots_fractal_hybrid,
)
# 导入统一的参数配置
from triangle_config import DETECTION_PARAMS, DISPLAY_WINDOW, SHOW_CHART_DETAILS
from triangle_config import DETECTION_PARAMS, DISPLAY_WINDOW, SHOW_CHART_DETAILS, REALTIME_MODE, FLEXIBLE_ZONE
class FakeModule:
@ -159,7 +160,11 @@ def plot_triangle(
display_volume = volume_stock[valid_mask][display_start:valid_end + 1]
display_dates = dates[valid_indices[display_start:valid_end + 1]]
# 检测三角形(使用检测窗口数据)
# ========================================================================
# 计算三角形参数(用于绘图)
# 注意:不验证 is_valid因为CSV中已经验证通过了
# 这里只是重新计算参数用于可视化
# ========================================================================
result = detect_converging_triangle(
high=high_win,
low=low_win,
@ -170,9 +175,8 @@ def plot_triangle(
date_idx=date_idx,
)
if not result.is_valid:
print(f" [跳过] {stock_code} {stock_name}: 未识别到有效三角形")
return
# 不再检查 is_valid直接绘图
# 原因CSV中已经包含了通过验证的股票这里只需要可视化
# 绘图准备
x_display = np.arange(len(display_close), dtype=float)
@ -184,8 +188,19 @@ def plot_triangle(
n = len(close_win)
x_win = np.arange(n, dtype=float)
# 计算枢轴点(与检测算法一致)
ph_idx, pl_idx = pivots_fractal(high_win, low_win, k=params.pivot_k)
# 计算枢轴点(与检测算法一致,考虑实时模式)
if REALTIME_MODE:
confirmed_ph, confirmed_pl, candidate_ph, candidate_pl = pivots_fractal_hybrid(
high_win, low_win, k=params.pivot_k, flexible_zone=FLEXIBLE_ZONE
)
# 合并确认枢轴点和候选枢轴点
ph_idx = np.concatenate([confirmed_ph, candidate_ph]) if len(candidate_ph) > 0 else confirmed_ph
pl_idx = np.concatenate([confirmed_pl, candidate_pl]) if len(candidate_pl) > 0 else confirmed_pl
# 排序以保证顺序
ph_idx = np.sort(ph_idx)
pl_idx = np.sort(pl_idx)
else:
ph_idx, pl_idx = pivots_fractal(high_win, low_win, k=params.pivot_k)
# 使用枢轴点连线法拟合边界线(与检测算法一致)
a_u, b_u, selected_ph = fit_pivot_line(
@ -282,108 +297,6 @@ def plot_triangle(
label=f'下沿拟合点({len(selected_pl_pos)})',
)
# 绘制分段竖线(显示算法如何分段选择枢轴点)
# 高点和低点分别独立分段,用不同颜色显示
y_min, y_max = ax1.get_ylim()
# 绘制高点枢轴点的分段线(红色)
if len(ph_idx) > 4:
n_high = len(ph_idx)
segment_size_high = n_high // 3
# 第1段结束 = 第2段开始
if segment_size_high < n_high:
boundary_1 = ph_idx[segment_size_high] + triangle_offset
ax1.axvline(
boundary_1,
color='red',
linestyle='-.',
linewidth=1.2,
alpha=0.4,
zorder=3,
)
ax1.text(
boundary_1,
y_max * 0.96,
'高1|2',
ha='center',
va='top',
fontsize=7,
color='red',
bbox=dict(boxstyle='round,pad=0.2', facecolor='white', edgecolor='red', alpha=0.7),
)
# 第2段结束 = 第3段开始
if 2 * segment_size_high < n_high:
boundary_2 = ph_idx[2 * segment_size_high] + triangle_offset
ax1.axvline(
boundary_2,
color='red',
linestyle='-.',
linewidth=1.2,
alpha=0.4,
zorder=3,
)
ax1.text(
boundary_2,
y_max * 0.96,
'高2|3',
ha='center',
va='top',
fontsize=7,
color='red',
bbox=dict(boxstyle='round,pad=0.2', facecolor='white', edgecolor='red', alpha=0.7),
)
# 绘制低点枢轴点的分段线(绿色)
if len(pl_idx) > 4:
n_low = len(pl_idx)
segment_size_low = n_low // 3
# 第1段结束 = 第2段开始
if segment_size_low < n_low:
boundary_1 = pl_idx[segment_size_low] + triangle_offset
ax1.axvline(
boundary_1,
color='green',
linestyle='-.',
linewidth=1.2,
alpha=0.4,
zorder=3,
)
ax1.text(
boundary_1,
y_min + (y_max - y_min) * 0.04,
'低1|2',
ha='center',
va='bottom',
fontsize=7,
color='green',
bbox=dict(boxstyle='round,pad=0.2', facecolor='white', edgecolor='green', alpha=0.7),
)
# 第2段结束 = 第3段开始
if 2 * segment_size_low < n_low:
boundary_2 = pl_idx[2 * segment_size_low] + triangle_offset
ax1.axvline(
boundary_2,
color='green',
linestyle='-.',
linewidth=1.2,
alpha=0.4,
zorder=3,
)
ax1.text(
boundary_2,
y_min + (y_max - y_min) * 0.04,
'低2|3',
ha='center',
va='bottom',
fontsize=7,
color='green',
bbox=dict(boxstyle='round,pad=0.2', facecolor='white', edgecolor='green', alpha=0.7),
)
ax1.set_title(
f"{stock_code} {stock_name} - 收敛三角形 (检测窗口: {detect_dates[0]} ~ {detect_dates[-1]})\n"
f"显示范围: {display_dates[0]} ~ {display_dates[-1]} ({len(display_dates)}个交易日) "

View File

@ -31,9 +31,9 @@ DETECTION_PARAMS = ConvergingTriangleParams(
boundary_n_segments=2, # 边界线分段数
boundary_source="full", # 边界拟合数据源: "full"(全部) 或 "pivot"(仅枢轴点)
# 斜率约束
upper_slope_max=0.10, # 上沿最大斜率(正值,向上倾斜
lower_slope_min=-0.10, # 下沿最小斜率(负值,向下倾斜
# 斜率约束(严格收敛三角形)
upper_slope_max=0, # 上沿必须向下或水平≤0
lower_slope_min=0, # 下沿必须向上或水平≥0
# 注意:算法会自动过滤"同向通道"(上下沿都向上或都向下)
# 只保留真正的收敛形态(上下沿相向运动)
@ -92,8 +92,8 @@ DISPLAY_WINDOW = 500
REALTIME_MODE = True # True=实时模式(默认), False=标准模式
# 灵活区域大小(仅在实时模式下生效)
FLEXIBLE_ZONE = 5 # 最近5天使用降低标准
# 建议: 3-7天太大会引入噪音
FLEXIBLE_ZONE = 15 # 最近15天使用降低标准与pivot_k一致
# 说明: 设为与pivot_k相同确保窗口末端的所有潜在枢轴点都能被识别
# 实时模式说明:
# - 实时模式(推荐):使用确认+候选枢轴点(允许右边数据不完整),适合实时选股

View File

@ -103,14 +103,17 @@ class ConvergingTriangleResult:
def pivots_fractal(
high: np.ndarray, low: np.ndarray, k: int = 3
) -> Tuple[np.ndarray, np.ndarray]:
"""左右窗口分形:返回 pivot_high_idx, pivot_low_idx"""
"""左右窗口分形:返回 pivot_high_idx, pivot_low_idx
使用 nanmax/nanmin 来忽略 NaN
"""
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]):
if not np.isnan(high[i]) and high[i] == np.nanmax(high[i - k : i + k + 1]):
ph.append(i)
if low[i] == np.min(low[i - k : i + k + 1]):
if not np.isnan(low[i]) and low[i] == np.nanmin(low[i - k : i + k + 1]):
pl.append(i)
return np.array(ph, dtype=int), np.array(pl, dtype=int)
@ -138,22 +141,36 @@ def pivots_fractal_hybrid(
n = len(high)
# 确认枢轴点(完整窗口)
# 使用 nanmax/nanmin 来忽略 NaN 值
confirmed_ph: List[int] = []
confirmed_pl: List[int] = []
for i in range(k, n - k):
if high[i] == np.max(high[i - k : i + k + 1]):
if not np.isnan(high[i]) and high[i] == np.nanmax(high[i - k : i + k + 1]):
confirmed_ph.append(i)
if low[i] == np.min(low[i - k : i + k + 1]):
if not np.isnan(low[i]) and low[i] == np.nanmin(low[i - k : i + k + 1]):
confirmed_pl.append(i)
# 候选枢轴点灵活窗口最近flexible_zone天
# 优化逻辑:使用对称窗口,左右各看 min(k, right_avail+1) 天
# 这样可以识别出窗口末端的局部枢轴点
# 使用 nanmax/nanmin 来忽略 NaN 值
candidate_ph: List[int] = []
candidate_pl: List[int] = []
for i in range(max(k, n - flexible_zone), n):
if np.isnan(high[i]) or np.isnan(low[i]):
continue # 跳过 NaN 点
right_avail = n - 1 - i
if high[i] == np.max(high[i - k : i + right_avail + 1]):
# 使用对称的短窗口:左右各看 right_avail+1 天至少1天
# 但左边最多看 k 天(保证有足够数据)
left_look = min(k, max(right_avail + 1, 3)) # 至少看3天
left_start = max(0, i - left_look)
right_end = min(n, i + right_avail + 1)
# 在对称窗口内是最大/最小即为候选枢轴点
if high[i] == np.nanmax(high[left_start : right_end]):
candidate_ph.append(i)
if low[i] == np.min(low[i - k : i + right_avail + 1]):
if low[i] == np.nanmin(low[left_start : right_end]):
candidate_pl.append(i)
return (
@ -232,21 +249,27 @@ def fit_pivot_line(
pivot_values: np.ndarray,
mode: str = "upper",
min_points: int = 2,
outlier_threshold: float = 2.5,
max_iterations: int = 2,
) -> Tuple[float, float, np.ndarray]:
"""
枢轴点连线法选择合适的枢轴点连成边界线
迭代离群点移除的枢轴点拟合算法
改进策略2026-01-26
- 使用多个枢轴点进行线性回归而不是只选2个点
- 将时间轴分为3段每段选择最具代表性的点
- 上沿选最高点下沿选最低点
- 使用选中的点进行线性回归充分利用所有信息
改进策略2026-01-26 v2
1. 先用所有点做初始拟合
2. 识别并移除偏离拟合线的""
3. 迭代直到收敛
对于上沿线移除价格明显低于拟合线的点弱高点
对于下沿线移除价格明显高于拟合线的点弱低点
Args:
pivot_indices: 枢轴点的X坐标索引
pivot_values: 枢轴点的Y值价格
mode: "upper"(上沿) "lower"(下沿)
min_points: 最少需要的枢轴点数
min_points: 最少需要的枢轴点数默认2实际保留至少3个
outlier_threshold: 离群点阈值标准差倍数默认2.5更宽松
max_iterations: 最大迭代次数默认2避免过度过滤
Returns:
(slope, intercept, selected_indices): 斜率截距选中的枢轴点索引
@ -263,87 +286,82 @@ def fit_pivot_line(
if n < 2:
return 0.0, 0.0, np.array([])
# 新策略:分段选择代表性点进行回归
if n <= 4:
# 点数少,直接用所有点
selected_mask = np.ones(n, dtype=bool)
else:
# 点数多分3段选择
selected_mask = np.zeros(n, dtype=bool)
# 初始化:所有点都参与
active_mask = np.ones(n, dtype=bool)
min_keep = max(3, min_points) # 至少保留3个点
# 分3段
segment_size = n // 3
if segment_size < 1:
segment_size = 1
# 迭代离群点移除
for iteration in range(max_iterations):
active_indices = np.where(active_mask)[0]
if len(active_indices) <= min_keep:
break
segments = [
range(0, min(segment_size, n)),
range(segment_size, min(2 * segment_size, n)),
range(2 * segment_size, n),
]
# 当前活跃点
x_active = x_sorted[active_mask]
y_active = y_sorted[active_mask]
for seg in segments:
if len(seg) == 0:
continue
# 线性回归
a, b = fit_line(x_active, y_active)
seg_list = list(seg)
seg_y = y_sorted[seg_list]
# 计算拟合值
fitted_values = a * x_active + b
if mode == "upper":
# 上沿:选该段最高点
best_idx_in_seg = np.argmax(seg_y)
else:
# 下沿:选该段最低点
best_idx_in_seg = np.argmin(seg_y)
# 计算残差(方向性残差)
if mode == "upper":
# 上沿:残差 = 拟合值 - 实际值
# 正残差表示点在拟合线下方(弱高点)
residuals = fitted_values - y_active
else:
# 下沿:残差 = 实际值 - 拟合值
# 正残差表示点在拟合线上方(弱低点)
residuals = y_active - fitted_values
selected_mask[seg_list[best_idx_in_seg]] = True
# 计算标准差
std_residual = np.std(residuals)
if std_residual < 1e-10:
# 所有点几乎在一条线上,无需移除
break
# 获取选中的点
selected_x = x_sorted[selected_mask]
selected_y = y_sorted[selected_mask]
selected_indices_sorted = np.where(selected_mask)[0]
# 识别离群点(残差 > threshold * std
threshold = outlier_threshold * std_residual
outlier_mask_active = residuals > threshold
if not np.any(outlier_mask_active):
# 没有离群点,收敛
break
# 找到最大离群点
max_outlier_idx_in_active = np.argmax(residuals)
# 检查是否确实是离群点
if residuals[max_outlier_idx_in_active] <= threshold:
break
# 检查移除后是否仍有足够的点
if np.sum(active_mask) <= min_keep:
break
# 在原始索引中标记移除
original_idx = active_indices[max_outlier_idx_in_active]
active_mask[original_idx] = False
# 最终拟合
selected_indices_sorted = np.where(active_mask)[0]
selected_x = x_sorted[active_mask]
selected_y = y_sorted[active_mask]
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]
active_mask = np.zeros(n, dtype=bool)
active_mask[0] = True
active_mask[-1] = True
selected_x = x_sorted[active_mask]
selected_y = y_sorted[active_mask]
selected_indices_sorted = np.where(active_mask)[0]
# 线性回归
a, b = fit_line(selected_x, selected_y)
# 验证覆盖情况
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:
# 下沿:确保所有点在线上方或附近
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)
# 返回原始索引
selected_original = sort_idx[selected_indices_sorted]