Compare commits

..

No commits in common. "5455f8e4561d67501b2597e62b73679e6dc9028a" and "543572667ba6fb5e7ea0a9a8c6311f8b11e9667f" have entirely different histories.

19 changed files with 2504 additions and 8736 deletions

2
.gitignore vendored
View File

@ -1,2 +0,0 @@
# 收敛三角形图表输出(本地生成,不推送)
outputs/converging_triangles/charts/

View File

@ -1,63 +0,0 @@
# 枢轴点绘图问题记录
> 日期2026-01-22
> 主题:收敛三角形图表中枢轴点位置异常与修复
## 背景现象
`outputs/converging_triangles/charts` 生成的图表中,标注的枢轴点(用于连线的关键点)出现**挤在一起**、位置不正确的问题。
## 根因分析
`fit_pivot_line()` 返回的 `selected_ph / selected_pl` 是**枢轴点数组中的索引**,而非检测窗口的真实索引。
绘图时直接用 `selected_ph` 作为 `high_win`/`low_win` 的索引,导致标注位置偏移到窗口开头。
问题代码路径:
- `scripts/plot_converging_triangles.py`
- 相关逻辑:`selected_ph` / `selected_pl``ph_idx` / `pl_idx` 的索引映射
## 修复方式
将枢轴点索引转换为真实位置:
```python
selected_ph_pos = ph_idx[selected_ph]
selected_pl_pos = pl_idx[selected_pl]
```
绘图时使用真实位置:
```python
ax1.scatter(selected_ph_display, high_win[selected_ph_pos], ...)
ax1.scatter(selected_pl_display, low_win[selected_pl_pos], ...)
```
## 视觉优化
将枢轴点标注样式改为**空心圆圈**,避免遮挡价格曲线:
```python
marker='o', facecolors='none', edgecolors='red/green', linewidths=1.5
```
## 更新文件
- `scripts/plot_converging_triangles.py`
- 修复枢轴点索引映射
- 空心圆圈样式
## 验证步骤
重新生成图表:
```bash
python scripts/plot_converging_triangles.py
```
验证要点:
- 红/绿空心圆应落在真实高点/低点位置
- 不再出现“所有枢轴点挤在一起”的情况
---
如需进一步增强可视化,可新增“显示所有枢轴点”的开关,用于诊断与验证。

View File

@ -1,347 +0,0 @@
# 收敛三角形检测系统 - 使用指南
> 最后更新2026-01-22
> 版本v1.0
## 目录
1. [系统概述](#系统概述)
2. [快速开始](#快速开始)
3. [参数配置](#参数配置)
4. [脚本说明](#脚本说明)
5. [输出文件](#输出文件)
6. [算法原理](#算法原理)
7. [常见问题](#常见问题)
---
## 系统概述
收敛三角形检测系统用于自动识别股票K线中的收敛三角形形态并计算突破强度分数进行选股。
### 功能特点
- **批量检测**:支持多股票、多日期的滚动窗口检测
- **突破强度评分**0~1 连续分数,量化突破有效性
- **可视化图表**:自动生成个股三角形图表
- **选股报告**:每日 Markdown 格式选股简报
### 系统架构
```
technical-patterns-lab/
├── src/
│ └── converging_triangle.py # 核心算法
├── scripts/
│ ├── triangle_config.py # 参数配置(统一管理)
│ ├── run_converging_triangle.py # 批量检测
│ ├── report_converging_triangles.py # 报告生成
│ ├── plot_converging_triangles.py # 图表绘制
│ └── pipeline_converging_triangle.py # 一键流水线
├── outputs/converging_triangles/
│ ├── all_results.csv # 全部检测结果
│ ├── report.md # 选股报告
│ └── charts/ # 个股图表
└── data/
└── pkl/ # OHLCV 数据文件
```
---
## 快速开始
### 1. 一键运行(推荐)
```bash
cd technical-patterns-lab
python scripts/pipeline_converging_triangle.py
```
这会依次执行:
1. 批量检测 → 生成 `all_results.csv`
2. 报告生成 → 生成 `report.md`
3. 图表绘制 → 生成 `charts/*.png`
### 2. 指定日期运行
```bash
python scripts/pipeline_converging_triangle.py --date 20260120
```
### 3. 跳过部分步骤
```bash
# 跳过检测(使用已有结果),只生成报告和图表
python scripts/pipeline_converging_triangle.py --skip-detection
# 只运行检测
python scripts/pipeline_converging_triangle.py --skip-report --skip-plot
```
### 4. 单独运行各步骤
```bash
# 批量检测
python scripts/run_converging_triangle.py
# 生成报告
python scripts/report_converging_triangles.py
# 绘制图表
python scripts/plot_converging_triangles.py
```
---
## 参数配置
所有参数统一在 `scripts/triangle_config.py` 中管理:
### 检测参数(当前使用严格模式)
| 参数 | 严格模式 | 默认模式 | 宽松模式 | 说明 |
|------|----------|----------|----------|------|
| `window` | 120 | 120 | 120 | 检测窗口(交易日) |
| `pivot_k` | 15 | 15 | 15 | 枢轴点检测周期 |
| `shrink_ratio` | **0.6** | 0.8 | 0.85 | 收敛比例阈值 |
| `break_tol` | **0.005** | 0.001 | 0.001 | 突破判定容差 |
| `vol_k` | **1.5** | 1.3 | 1.2 | 放量确认倍数 |
### 数据范围配置
| 参数 | 值 | 说明 |
|------|-----|------|
| `RECENT_DAYS` | 500 | 计算最近 N 个交易日 |
| `DISPLAY_WINDOW` | 500 | 图表显示范围 |
| `ONLY_VALID` | True | 只输出有效三角形 |
### 切换参数模式
编辑 `scripts/triangle_config.py`,修改 `DETECTION_PARAMS` 的定义:
```python
# 当前使用严格模式(推荐)
DETECTION_PARAMS = ConvergingTriangleParams(
shrink_ratio=0.6, # 更严格的收敛要求
break_tol=0.005, # 更明显的突破
vol_k=1.5, # 更强的放量要求
...
)
```
---
## 脚本说明
### run_converging_triangle.py
**功能**:批量检测收敛三角形
**输出**
- `outputs/converging_triangles/all_results.csv`
- `outputs/converging_triangles/strong_breakout_up.csv`
- `outputs/converging_triangles/strong_breakout_down.csv`
**耗时**:约 15-20 秒108只股票 × 500天
---
### report_converging_triangles.py
**功能**:生成 Markdown 选股报告
**参数**
```bash
# 指定报告日期
python scripts/report_converging_triangles.py --report-date 20260120
# 指定输入输出路径
python scripts/report_converging_triangles.py \
--input outputs/converging_triangles/all_results.csv \
--output outputs/converging_triangles/report.md
```
**输出**`outputs/converging_triangles/report.md`
---
### plot_converging_triangles.py
**功能**:绘制个股收敛三角形图表
**参数**
```bash
# 指定日期
python scripts/plot_converging_triangles.py --date 20260120
# 指定输出目录
python scripts/plot_converging_triangles.py --output-dir outputs/converging_triangles/charts
```
**输出**`outputs/converging_triangles/charts/*.png`
**特点**
- 每次运行自动清空旧图片
- 显示 500 天历史走势
- 检测窗口 120 天高亮显示
- 支持中文字体SimHei/Microsoft YaHei
---
### pipeline_converging_triangle.py
**功能**:一键执行完整流水线
**参数**
| 参数 | 说明 |
|------|------|
| `--date YYYYMMDD` | 指定目标日期 |
| `--skip-detection` | 跳过批量检测 |
| `--skip-report` | 跳过报告生成 |
| `--skip-plot` | 跳过图表绘制 |
**输出**:流水线执行摘要,包含各步骤耗时
---
## 输出文件
### all_results.csv
所有有效收敛三角形检测结果。
| 字段 | 类型 | 说明 |
|------|------|------|
| `stock_idx` | int | 股票索引 |
| `stock_code` | str | 股票代码 |
| `stock_name` | str | 股票名称 |
| `date` | int | 日期YYYYMMDD |
| `is_valid` | bool | 是否有效三角形 |
| `breakout_strength_up` | float | 向上突破强度0~1 |
| `breakout_strength_down` | float | 向下突破强度0~1 |
| `breakout_dir` | str | 突破方向up/down/none |
| `width_ratio` | float | 收敛比例(末端/起始宽度) |
| `volume_confirmed` | bool | 是否放量确认 |
| `touches_upper` | int | 触碰上沿次数 |
| `touches_lower` | int | 触碰下沿次数 |
### report.md
每日选股简报,包含:
- 数据说明(股票池、检测窗口、算法)
- 当日统计(总数、向上/向下/无突破)
- 向上突破排名表
- 向下突破排名表
- 无突破形态列表
### charts/*.png
个股图表,文件名格式:`{date}_{stock_code}_{stock_name}.png`
图表内容:
- 上半部分K线走势 + 趋势线
- 下半部分:成交量柱状图
- 标题:股票信息 + 检测/显示范围
---
## 算法原理
### 收敛三角形定义
收敛三角形是一种技术形态,特征为:
1. **上沿下倾**:高点连线斜率 ≤ 0或轻微上倾
2. **下沿上翘**:低点连线斜率 ≥ 0或轻微下倾
3. **逐渐收敛**:末端宽度 < 起始宽度
4. **多次触碰**:价格至少 2 次触碰上下沿
### 检测流程
```
1. 枢轴点检测
└── 使用分形方法找出局部高点/低点
2. 边界线拟合
└── 分段取极值 + 线性回归
3. 形态验证
├── 斜率约束检查
├── 收敛度检查width_ratio < shrink_ratio
└── 触碰程度检查loss < touch_loss_max
4. 突破判定
├── 向上突破close > upper_line × (1 + break_tol)
└── 向下突破close < lower_line × (1 - break_tol)
5. 强度计算
└── 加权求和:价格分(60%) + 收敛分(25%) + 成交量分(15%)
```
### 突破强度公式
```python
strength = 0.60 × tanh(突破幅度% × 15) + # 价格分
0.25 × (1 - width_ratio) + # 收敛分
0.15 × vol_bonus # 成交量分
```
详见 [突破强度计算方法.md](./突破强度计算方法.md)
---
## 常见问题
### Q1: 图表中文乱码?
确保系统安装了中文字体SimHei 或 Microsoft YaHei。脚本已配置
```python
plt.rcParams['font.sans-serif'] = ['SimHei', 'Microsoft YaHei', 'Arial Unicode MS']
plt.rcParams['axes.unicode_minus'] = False
```
### Q2: 检测结果太少/太多?
调整 `scripts/triangle_config.py` 中的参数:
- 结果太少 → 使用宽松模式(`shrink_ratio=0.85`
- 结果太多 → 使用严格模式(`shrink_ratio=0.6`
### Q3: 突破强度都很高?
旧版本公式有此问题,已在 v2.0 修复。确保使用最新的 `src/converging_triangle.py`
### Q4: 如何添加新股票?
将 OHLCV 数据以 `.pkl` 格式放入 `data/pkl/` 目录,格式要求:
- 二维 numpy 数组shape=(n_stocks, n_days)
- 文件名:`open.pkl`, `high.pkl`, `low.pkl`, `close.pkl`, `volume.pkl`
### Q5: 如何调整检测窗口?
修改 `scripts/triangle_config.py`
```python
DETECTION_PARAMS = ConvergingTriangleParams(
window=120, # 修改此值(如 60, 90, 180
...
)
```
---
## 版本历史
| 版本 | 日期 | 更新内容 |
|------|------|----------|
| v1.0 | 2026-01-22 | 初始版本,加权求和公式,严格模式 |
---
## 相关文档
- [突破强度计算方法.md](./突破强度计算方法.md) - 突破强度公式详解
- [converging_triangles_outputs.md](./converging_triangles_outputs.md) - 输出文件字段说明
- [2026-01-16_converging_triangle_algorithm.md](./2026-01-16_converging_triangle_algorithm.md) - 算法设计文档

View File

@ -1,8 +1,5 @@
# 突破强度计算方法 # 突破强度计算方法
> 最后更新2026-01-22
> 版本v2.0(加权求和 + tanh 非线性归一化)
## 概述 ## 概述
突破强度是一个 **0~1** 的连续分数,用于衡量收敛三角形突破的有效性。 突破强度是一个 **0~1** 的连续分数,用于衡量收敛三角形突破的有效性。
@ -10,225 +7,164 @@
--- ---
## 设计演进 ## 计算公式
### v1.0 乘法组合(已弃用)
```python ```python
# 旧公式
strength = price_score × 5 × (1 + convergence_bonus × 0.5) × (1 + vol_bonus × 0.5) strength = price_score × 5 × (1 + convergence_bonus × 0.5) × (1 + vol_bonus × 0.5)
``` ```
**问题**:乘法组合 + 高乘数×5导致 **73-76% 的突破都是满分 1.0**,无法有效区分突破质量。 最终结果限制在 `[0, 1]` 范围内。
### v2.0 加权求和 + tanh 归一化(当前版本)
```python
# 新公式
strength = 0.60 × tanh(突破幅度% × 15) + # 价格分 (60%)
0.25 × (1 - width_ratio) + # 收敛分 (25%)
0.15 × vol_bonus # 成交量分 (15%)
```
**改进效果**
| 指标 | v1.0 | v2.0 |
|------|------|------|
| 满分比例(>0.9) | **73-76%** | **0.9-6.0%** |
| 平均强度 | ~0.12 | **~0.59** |
| 最大强度 | 1.0000 | **0.9928** |
| 区分度 | 差(大量满分) | **好(均匀分布)** |
--- ---
## 当前计算公式v2.0 ## 三个影响因素
### 公式结构 ### 1. 价格突破幅度 (price_score)
衡量收盘价突破趋势线的程度。
```python ```python
def calc_breakout_strength(close, upper_line, lower_line, volume_ratio, width_ratio): # 向上突破
import math price_up = max(0, (close - upper_line) / upper_line)
# 权重配置 # 向下突破
W_PRICE = 0.60 # 突破幅度权重 price_down = max(0, (lower_line - close) / lower_line)
W_CONVERGENCE = 0.25 # 收敛度权重
W_VOLUME = 0.15 # 成交量权重
TANH_SCALE = 15.0 # tanh 缩放因子
# 1. 价格突破分数tanh 非线性归一化)
pct_up = max(0, (close - upper_line) / upper_line)
price_score_up = math.tanh(pct_up * TANH_SCALE)
# 2. 收敛分数
convergence_score = max(0, min(1, 1 - width_ratio))
# 3. 成交量分数
vol_score = min(1, max(0, volume_ratio - 1))
# 4. 加权求和
strength_up = W_PRICE * price_score_up + W_CONVERGENCE * convergence_score + W_VOLUME * vol_score
return min(1.0, strength_up)
``` ```
| 突破幅度 | price_score |
|----------|-------------|
| 未突破 | 0 |
| 突破 1% | 0.01 |
| 突破 5% | 0.05 |
| 突破 10% | 0.10 |
--- ---
## 三个分量详解 ### 2. 收敛加成 (convergence_bonus)
### 1. 价格突破分数(权重 60%
使用 **tanh 函数** 进行非线性归一化,避免大幅突破导致的满分堆积。
```python
price_score = tanh(突破幅度% × 15)
```
**突破幅度映射表**
| 突破幅度 | price_score | 贡献分数 (×0.6) |
|----------|-------------|-----------------|
| 0.5% | 0.07 | 0.04 |
| 1% | 0.15 | 0.09 |
| 2% | 0.29 | 0.17 |
| 3% | 0.42 | 0.25 |
| 5% | 0.64 | 0.38 |
| 8% | 0.83 | 0.50 |
| 10% | 0.91 | 0.55 |
| 15% | 0.97 | 0.58 |
**设计考量**
- A股涨跌停限制 10%,常见突破在 1-5% 范围
- tanh 函数使小幅突破有区分,大幅突破趋于收敛
- 系数 15 使得 3% 突破 ≈ 0.42 分5% 突破 ≈ 0.64 分
---
### 2. 收敛分数(权重 25%
三角形收敛程度越高,突破越有效。 三角形收敛程度越高,突破越有效。
```python ```python
convergence_score = max(0, 1 - width_ratio) convergence_bonus = max(0, 1 - width_ratio)
``` ```
| width_ratio | 收敛程度 | convergence_score | 贡献分数 (×0.25) | | width_ratio | 收敛程度 | convergence_bonus |
|-------------|----------|-------------------|------------------| |-------------|----------|-------------------|
| 0.8 | 较弱 | 0.20 | 0.05 | | 0.8 | 较弱 | 0.2 |
| 0.6 | 中等 | 0.40 | 0.10 | | 0.5 | 中等 | 0.5 |
| 0.4 | 较强 | 0.60 | 0.15 | | 0.2 | 很强 | 0.8 |
| 0.2 | 很强 | 0.80 | 0.20 | | 0.1 | 极强 | 0.9 |
| 0.1 | 极强 | 0.90 | 0.23 |
| 0.05 | 极度收敛 | 0.95 | 0.24 |
**width_ratio** = 三角形末端宽度 / 起始宽度 **width_ratio** = 三角形末端宽度 / 起始宽度
--- ---
### 3. 成交量分数(权重 15% ### 3. 成交量加成 (vol_bonus)
放量突破更可信。 放量突破更可信。
```python ```python
vol_score = min(1, max(0, volume_ratio - 1)) vol_bonus = min(1, max(0, volume_ratio - 1))
``` ```
| volume_ratio | 成交量状态 | vol_score | 贡献分数 (×0.15) | | volume_ratio | 成交量状态 | vol_bonus |
|--------------|------------|-----------|------------------| |--------------|------------|-----------|
| 0.8 | 缩量 | 0 | 0 | | 0.8 | 缩量 | 0 |
| 1.0 | 平量 | 0 | 0 | | 1.0 | 平量 | 0 |
| 1.5 | 放量 50% | 0.5 | 0.075 | | 1.5 | 放量 50% | 0.5 |
| 2.0 | 放量 100% | 1.0 | 0.15 | | 2.0 | 放量 100% | 1.0 (满分) |
| 3.0 | 放量 200% | 1.0 | 0.15 (上限) | | 3.0 | 放量 200% | 1.0 (上限) |
**volume_ratio** = 当日成交量 / 近 N 日均量 **volume_ratio** = 当日成交量 / 近 N 日均量
--- ---
## 加权系数说明
```python
strength = price_score × 5 × (1 + convergence_bonus × 0.5) × (1 + vol_bonus × 0.5)
```
| 系数 | 作用 |
|------|------|
| `× 5` | 放大价格突破分数,使 2% 突破 = 0.1 基础分 |
| `× (1 + convergence_bonus × 0.5)` | 收敛加成最多增加 50% |
| `× (1 + vol_bonus × 0.5)` | 成交量加成最多增加 50% |
---
## 计算示例 ## 计算示例
### 示例 1强势突破领湃科技 2026-01-20 ### 示例 1强势突破
``` ```
输入: 输入:
突破幅度 ≈ 8% (close 大幅高于 upper_line) close = 10.5, upper_line = 10.0 (突破 5%)
width_ratio = 0.0465 (极度收敛) width_ratio = 0.2 (收敛很强)
volume_ratio > 1.5 (放量确认) volume_ratio = 1.8 (放量 80%)
计算: 计算:
price_score = tanh(0.08 × 15) = tanh(1.2) ≈ 0.83 price_up = (10.5 - 10.0) / 10.0 = 0.05
convergence_score = 1 - 0.0465 = 0.9535 convergence_bonus = 1 - 0.2 = 0.8
vol_score = min(1, 1.5 - 1) = 0.5 vol_bonus = min(1, 1.8 - 1) = 0.8
strength = 0.60 × 0.83 + 0.25 × 0.9535 + 0.15 × 0.5 strength_up = 0.05 × 5 × (1 + 0.8 × 0.5) × (1 + 0.8 × 0.5)
= 0.498 + 0.238 + 0.075 = 0.25 × 1.4 × 1.4
= 0.811 = 0.49
实际结果: 0.9882 (因实际突破幅度更大) 结果: 向上突破强度 = 0.49 (中度突破)
``` ```
### 示例 2中等突破(五芳斋 2026-01-20 ### 示例 2弱势突破
``` ```
输入: 输入:
突破幅度 ≈ 3% close = 10.1, upper_line = 10.0 (突破 1%)
width_ratio = 0.2090 width_ratio = 0.7 (收敛较弱)
volume_ratio ≈ 1.0 (未放量) volume_ratio = 0.9 (缩量)
计算: 计算:
price_score = tanh(0.03 × 15) = tanh(0.45) ≈ 0.42 price_up = 0.01
convergence_score = 1 - 0.2090 = 0.791 convergence_bonus = 0.3
vol_score = 0 vol_bonus = 0
strength = 0.60 × 0.42 + 0.25 × 0.791 + 0.15 × 0 strength_up = 0.01 × 5 × (1 + 0.3 × 0.5) × (1 + 0)
= 0.252 + 0.198 + 0 = 0.05 × 1.15 × 1.0
= 0.450 = 0.0575
实际结果: 0.5816 (因实际突破幅度略大于 3%) 结果: 向上突破强度 = 0.06 (微弱突破)
``` ```
### 示例 3弱势突破(康华生物 2026-01-20 ### 示例 3极强突破
``` ```
输入: 输入:
突破幅度 ≈ 2% close = 11.0, upper_line = 10.0 (突破 10%)
width_ratio = 0.1338 width_ratio = 0.15 (极度收敛)
volume_ratio ≈ 1.0 (未放量) volume_ratio = 2.5 (放量 150%)
计算: 计算:
price_score = tanh(0.02 × 15) = tanh(0.30) ≈ 0.29 price_up = 0.10
convergence_score = 1 - 0.1338 = 0.866 convergence_bonus = 0.85
vol_score = 0 vol_bonus = 1.0
strength = 0.60 × 0.29 + 0.25 × 0.866 + 0.15 × 0 strength_up = 0.10 × 5 × (1 + 0.85 × 0.5) × (1 + 1.0 × 0.5)
= 0.174 + 0.217 + 0 = 0.50 × 1.425 × 1.5
= 0.391 = 1.07 → 截断为 1.0
实际结果: 0.4797 (因实际突破幅度略大于 2%) 结果: 向上突破强度 = 1.0 (满分)
``` ```
--- ---
## 强度等级参考 ## 强度等级参考
| 强度范围 | 等级 | 含义 | 占比参考 | | 强度范围 | 等级 | 含义 |
|----------|------|------|----------| |----------|------|------|
| 0 ~ 0.3 | 微弱 | 假突破风险高,需谨慎 | ~8% | | 0 ~ 0.1 | 无/微弱 | 未突破或假突破风险高 |
| 0.3 ~ 0.5 | 轻度 | 有突破迹象,建议观察 | ~27% | | 0.1 ~ 0.3 | 轻度 | 有突破迹象,需观察确认 |
| 0.5 ~ 0.7 | 中度 | 有效突破,可作为参考 | ~28% | | 0.3 ~ 0.6 | 中度 | 有效突破,可作为参考信号 |
| 0.7 ~ 0.9 | 强势 | 高置信度突破,值得关注 | ~31% | | 0.6 ~ 1.0 | 强势 | 高置信度突破,值得关注 |
| 0.9 ~ 1.0 | 极强 | 顶级突破信号 | ~6% |
---
## 权重选择理由
| 分量 | 权重 | 理由 |
|------|------|------|
| **价格突破** | 60% | 突破幅度是最直接的信号,决定性因素 |
| **收敛程度** | 25% | 收敛越强,蓄势越充分,突破有效性越高 |
| **成交量** | 15% | 放量是确认信号,但非必要条件(有些有效突破不放量) |
**总和 = 100%**,确保最终分数在合理范围内。
--- ---
@ -236,42 +172,16 @@ vol_score = min(1, max(0, volume_ratio - 1))
``` ```
src/converging_triangle.py src/converging_triangle.py
├── calc_breakout_strength() # 突破强度计算函数 (第 180-260 行) ├── calc_breakout_strength() # 突破强度计算函数
└── detect_converging_triangle() # 调用位置 (第 401 行) └── detect_converging_triangle() # 调用位置
scripts/triangle_config.py # 参数配置(严格模式/默认模式/宽松模式)
``` ```
--- ---
## 相关参数配置 ## 相关参数
| 参数 | 严格模式 | 默认模式 | 说明 | | 参数 | 默认值 | 说明 |
|------|----------|----------|------| |------|--------|------|
| `window` | 120 | 120 | 检测窗口大小(交易日) | | `vol_window` | 20 | 计算成交量均值的窗口大小 |
| `shrink_ratio` | 0.6 | 0.8 | 收敛比例阈值(越小越严格) | | `vol_k` | 1.3 | 成交量确认阈值volume_confirmed 判断用) |
| `break_tol` | 0.005 | 0.001 | 突破判定容差0.5% vs 0.1% | | `break_tol` | 0.001 | 突破判定容差 |
| `vol_window` | 20 | 20 | 计算成交量均值的窗口 |
| `vol_k` | 1.5 | 1.3 | 成交量确认阈值 |
当前使用 **严格模式**,详见 `scripts/triangle_config.py`
---
## 附录tanh 函数特性
```
tanh(x) = (e^x - e^-x) / (e^x + e^-x)
```
- 输出范围:(-1, 1)
- 当 x=0 时tanh(0) = 0
- 当 x→∞ 时tanh(x) → 1
- 单调递增,处处可导
- 在 x=0 附近近似线性,大值时趋于饱和
**选择 tanh 的原因**
1. 自然归一化到 (0, 1)
2. 小幅突破有区分度
3. 大幅突破不会无限增长
4. 平滑过渡,无跳变

File diff suppressed because it is too large Load Diff

View File

@ -1,57 +1,71 @@
# 收敛三角形当日选股简报 # 收敛三角形突破强度选股简报
## 数据说明 - 生成时间2026-01-21 17:55
- 数据范围20240909 ~ 20260120
- 记录数1837
- 突破方向统计:上破 258 / 下破 251 / 无突破 1328
- **股票池**108 只个股从万得全A按顺序索引等距50取样 ## 筛选条件
- **检测窗口**120 个交易日 - 强度阈值:> 0.3
- **检测算法**:收敛三角形形态识别(基于枢轴点、趋势线拟合与收敛度判定 - 每方向最多输出20 只(按单只股票的最高强度去重
## 20260120 当日统计 ## 综合突破强度候选
| 排名 | 股票 | 日期 | 方向 | 综合强度 | 宽度比 | 放量确认 |
| --- | --- | --- | --- | --- | --- | --- |
| 1 | SZ300027 华谊兄弟 | 20260120 | down | 1.0000 | 0.1490 | 否 |
| 2 | SZ002802 洪汇新材 | 20251211 | down | 1.0000 | 0.5451 | 否 |
| 3 | SH688550 瑞联新材 | 20251208 | down | 1.0000 | 0.0508 | 否 |
| 4 | SZ002242 九阳股份 | 20251112 | up | 1.0000 | 0.1145 | 是 |
| 5 | SH600395 盘江股份 | 20250916 | up | 1.0000 | 0.0011 | 否 |
| 6 | SZ002493 荣盛石化 | 20250820 | up | 1.0000 | 0.2808 | 是 |
| 7 | SZ002192 融捷股份 | 20250808 | up | 1.0000 | 0.0833 | 否 |
| 8 | SH600846 同济科技 | 20241128 | up | 1.0000 | 0.6111 | 否 |
| 9 | SH600588 用友网络 | 20241104 | up | 1.0000 | 0.1045 | 否 |
| 10 | SH603237 五芳斋 | 20241104 | up | 1.0000 | 0.0482 | 否 |
| 11 | SZ300379 *ST东通 | 20241018 | up | 1.0000 | 0.7060 | 是 |
| 12 | SH603707 健友股份 | 20241008 | up | 1.0000 | 0.3252 | 是 |
| 13 | SZ002092 中泰化学 | 20241008 | up | 1.0000 | 0.1271 | 是 |
| 14 | SH601236 红塔证券 | 20240927 | up | 1.0000 | 0.0672 | 是 |
| 15 | SZ002694 顾地科技 | 20251120 | up | 0.8276 | 0.0439 | 否 |
| 16 | SZ002293 罗莱生活 | 20250925 | down | 0.7794 | 0.1341 | 否 |
| 17 | SH600984 建设机械 | 20260120 | down | 0.7263 | 0.2516 | 否 |
| 18 | SZ002966 苏州银行 | 20260116 | down | 0.6074 | 0.5513 | 是 |
| 19 | SH600475 华光环能 | 20250519 | up | 0.5628 | 0.2505 | 是 |
| 20 | SZ002544 普天科技 | 20250723 | down | 0.5161 | 0.0167 | 否 |
- 生成时间2026-01-22 10:44 ## 向上突破候选
- 当日满足收敛三角形的个股23 只 | 排名 | 股票 | 日期 | 强度 | 宽度比 | 触碰(上/下) | 放量确认 |
- 向上突破18 只 | --- | --- | --- | --- | --- | --- | --- |
- 向下突破3 只 | 1 | SZ002242 九阳股份 | 20251112 | 1.0000 | 0.1145 | 8/5 | 是 |
- 无突破形态成立但未突破2 只 | 2 | SH600395 盘江股份 | 20250916 | 1.0000 | 0.0011 | 6/6 | 否 |
| 3 | SZ002493 荣盛石化 | 20250820 | 1.0000 | 0.2808 | 5/5 | 是 |
| 4 | SZ002192 融捷股份 | 20250808 | 1.0000 | 0.0833 | 4/3 | 否 |
| 5 | SH600846 同济科技 | 20241128 | 1.0000 | 0.6111 | 8/6 | 否 |
| 6 | SH600588 用友网络 | 20241104 | 1.0000 | 0.1045 | 3/5 | 否 |
| 7 | SH603237 五芳斋 | 20241104 | 1.0000 | 0.0482 | 4/5 | 否 |
| 8 | SZ300379 *ST东通 | 20241018 | 1.0000 | 0.7060 | 2/2 | 是 |
| 9 | SH603707 健友股份 | 20241008 | 1.0000 | 0.3252 | 4/8 | 是 |
| 10 | SZ002092 中泰化学 | 20241008 | 1.0000 | 0.1271 | 4/2 | 是 |
| 11 | SH601236 红塔证券 | 20240927 | 1.0000 | 0.0672 | 4/7 | 是 |
| 12 | SZ002694 顾地科技 | 20251120 | 0.8276 | 0.0439 | 3/3 | 否 |
| 13 | SH600475 华光环能 | 20250519 | 0.5628 | 0.2505 | 7/6 | 是 |
| 14 | SZ002544 普天科技 | 20250818 | 0.3096 | 0.0912 | 5/5 | 是 |
## 向上突破 ## 向下突破候选
| 排名 | 股票 | 突破强度 | 宽度比 | 触碰(上/下) | 放量确认 | | 排名 | 股票 | 日期 | 强度 | 宽度比 | 触碰(上/下) | 放量确认 |
| --- | --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- | --- | --- |
| 1 | SH600744 华银电力 | 0.9963 | 0.0125 | 4/2 | 是 | | 1 | SZ300027 华谊兄弟 | 20260120 | 1.0000 | 0.1490 | 4/3 | 否 |
| 2 | SZ300530 领湃科技 | 0.9882 | 0.0465 | 2/3 | 是 | | 2 | SZ002802 洪汇新材 | 20251211 | 1.0000 | 0.5451 | 3/6 | 否 |
| 3 | SZ300479 神思电子 | 0.9360 | 0.1538 | 2/2 | 是 | | 3 | SH688550 瑞联新材 | 20251208 | 1.0000 | 0.0508 | 6/2 | 否 |
| 4 | SH601096 宏盛华源 | 0.9152 | 0.3385 | 4/2 | 是 | | 4 | SZ002293 罗莱生活 | 20250925 | 0.7794 | 0.1341 | 4/3 | 否 |
| 5 | SH688202 美迪西 | 0.8941 | 0.0277 | 2/3 | 否 | | 5 | SH600984 建设机械 | 20260120 | 0.7263 | 0.2516 | 5/3 | 否 |
| 6 | SH688262 国芯科技 | 0.8502 | 0.5413 | 2/3 | 是 | | 6 | SZ002966 苏州银行 | 20260116 | 0.6074 | 0.5513 | 4/5 | 是 |
| 7 | SZ300998 宁波方正 | 0.8380 | 0.0793 | 3/2 | 否 | | 7 | SZ002092 中泰化学 | 20251216 | 0.5789 | 0.2660 | 7/5 | 否 |
| 8 | SZ300278 华昌达 | 0.8311 | 0.0383 | 2/3 | 否 | | 8 | SZ002544 普天科技 | 20250723 | 0.5161 | 0.0167 | 4/4 | 否 |
| 9 | SZ301225 恒勃股份 | 0.8235 | 0.1210 | 2/4 | 否 | | 9 | SZ002694 顾地科技 | 20251212 | 0.5158 | 0.0057 | 3/3 | 是 |
| 10 | SZ301107 瑜欣电子 | 0.7871 | 0.2130 | 2/3 | 否 |
| 11 | SZ300737 科顺股份 | 0.7731 | 0.3105 | 3/4 | 是 |
| 12 | SZ002393 力生制药 | 0.7265 | 0.3651 | 2/2 | 否 |
| 13 | SH688679 通源环境 | 0.7243 | 0.5028 | 2/2 | 否 |
| 14 | SH600588 用友网络 | 0.6931 | 0.4695 | 2/2 | 否 |
| 15 | SH601609 金田股份 | 0.6784 | 0.1401 | 2/2 | 是 |
| 16 | SH603237 五芳斋 | 0.5816 | 0.2090 | 3/2 | 否 |
| 17 | SH603527 众源新材 | 0.5672 | 0.3790 | 2/2 | 否 |
| 18 | SH603118 共进股份 | 0.2500 | 0.4314 | 3/4 | 否 |
## 向下突破
| 排名 | 股票 | 突破强度 | 宽度比 | 触碰(上/下) | 放量确认 |
| --- | --- | --- | --- | --- | --- |
| 1 | SZ000796 凯撒旅业 | 0.7365 | 0.2816 | 2/2 | 否 |
| 2 | SZ300892 品渥食品 | 0.3195 | 0.1505 | 2/2 | 否 |
| 3 | SZ002966 苏州银行 | 0.2008 | 0.5492 | 2/2 | 否 |
## 无突破(形态成立)
| 排名 | 股票 | 宽度比 | 触碰(上/下) |
| --- | --- | --- | --- |
| 1 | SH600281 华阳新材 | 0.5081 | 3/2 |
| 2 | SZ300841 康华生物 | 0.2940 | 2/2 |
## 说明 ## 说明
- 强度由价格突破幅度、收敛程度与成交量放大综合计算。
- **突破强度**价格突破幅度、收敛程度与成交量放大综合计算0~1 - 仅统计 `breakout_dir` 与方向一致的记录。
- **宽度比**:三角形末端宽度 / 起始宽度(越小越收敛) - 每只股票仅保留强度最高的一条记录(同强度取最新日期)。
- **触碰(上/下)**:价格触碰上沿和下沿的次数 - 综合强度 = max(向上强度, 向下强度)。
- **放量确认**:突破时成交量是否显著放大

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,213 +0,0 @@
"""
收敛三角形检测流水线
一键执行完整的检测报告生成和图表绘制流程
1. 运行批量检测 (run_converging_triangle.py)
2. 生成选股报告 (report_converging_triangles.py)
3. 绘制个股图表 (plot_converging_triangles.py)
用法:
python scripts/pipeline_converging_triangle.py
python scripts/pipeline_converging_triangle.py --date 20260120
"""
from __future__ import annotations
import argparse
import os
import sys
import time
from datetime import datetime
# 让脚本能找到 src/ 下的模块
sys.path.append(os.path.join(os.path.dirname(__file__), "..", "src"))
# 导入三个主要模块
from run_converging_triangle import main as run_detection
from report_converging_triangles import main as run_report
from plot_converging_triangles import main as run_plot
def print_section_header(title: str, step: int) -> None:
"""打印流程步骤标题"""
print("\n")
print("=" * 80)
print(f"步骤 {step}: {title}")
print("=" * 80)
print()
def print_step_result(success: bool, duration: float) -> None:
"""打印步骤执行结果"""
status = "[完成]" if success else "[失败]"
print()
print("-" * 80)
print(f"{status} | 耗时: {duration:.2f} 秒 ({duration/60:.2f} 分钟)")
print("-" * 80)
def main() -> None:
parser = argparse.ArgumentParser(description="收敛三角形检测完整流水线")
parser.add_argument(
"--date",
type=int,
default=None,
help="指定日期YYYYMMDD用于报告和图表生成默认为数据最新日",
)
parser.add_argument(
"--skip-detection",
action="store_true",
help="跳过批量检测步骤(如果已有检测结果)",
)
parser.add_argument(
"--skip-report",
action="store_true",
help="跳过报告生成步骤",
)
parser.add_argument(
"--skip-plot",
action="store_true",
help="跳过图表绘制步骤",
)
args = parser.parse_args()
pipeline_start = time.time()
print("=" * 80)
print("收敛三角形检测流水线")
print("=" * 80)
print(f"开始时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
if args.date:
print(f"指定日期: {args.date}")
print("=" * 80)
results = []
# ========================================================================
# 步骤 1: 批量检测
# ========================================================================
if not args.skip_detection:
print_section_header("批量检测 - 识别所有收敛三角形形态", 1)
step_start = time.time()
try:
# 清除命令行参数,避免冲突
sys.argv = [sys.argv[0]]
run_detection()
success = True
except Exception as e:
print(f"\n❌ 检测失败: {e}")
success = False
step_duration = time.time() - step_start
print_step_result(success, step_duration)
results.append(("批量检测", success, step_duration))
if not success:
print("\n[流水线中断:批量检测失败]")
return
else:
print("\n[跳过批量检测步骤(使用已有结果)]")
results.append(("批量检测", None, 0))
# ========================================================================
# 步骤 2: 生成报告
# ========================================================================
if not args.skip_report:
print_section_header("生成报告 - 当日选股简报", 2)
step_start = time.time()
try:
# 设置命令行参数
if args.date:
sys.argv = [sys.argv[0], "--report-date", str(args.date)]
else:
sys.argv = [sys.argv[0]]
run_report()
success = True
except Exception as e:
print(f"\n❌ 报告生成失败: {e}")
success = False
step_duration = time.time() - step_start
print_step_result(success, step_duration)
results.append(("生成报告", success, step_duration))
if not success:
print("\n[报告生成失败,但继续执行图表绘制]")
else:
print("\n[跳过报告生成步骤]")
results.append(("生成报告", None, 0))
# ========================================================================
# 步骤 3: 绘制图表
# ========================================================================
if not args.skip_plot:
print_section_header("绘制图表 - 个股收敛三角形可视化", 3)
step_start = time.time()
try:
# 设置命令行参数
if args.date:
sys.argv = [sys.argv[0], "--date", str(args.date)]
else:
sys.argv = [sys.argv[0]]
run_plot()
success = True
except Exception as e:
print(f"\n❌ 图表绘制失败: {e}")
success = False
step_duration = time.time() - step_start
print_step_result(success, step_duration)
results.append(("绘制图表", success, step_duration))
else:
print("\n[跳过图表绘制步骤]")
results.append(("绘制图表", None, 0))
# ========================================================================
# 流水线总结
# ========================================================================
pipeline_duration = time.time() - pipeline_start
print("\n")
print("=" * 80)
print("流水线执行总结")
print("=" * 80)
for step_name, success, duration in results:
if success is None:
status = "[跳过]"
time_str = "-"
elif success:
status = "[成功]"
time_str = f"{duration:.2f}s"
else:
status = "[失败]"
time_str = f"{duration:.2f}s"
print(f"{step_name:12} | {status:8} | {time_str:>10}")
print("-" * 80)
print(f"总耗时: {pipeline_duration:.2f} 秒 ({pipeline_duration/60:.2f} 分钟)")
print(f"结束时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
# 检查是否所有步骤都成功
all_success = all(s is None or s for _, s, _ in results)
if all_success:
print("\n[流水线执行完成]")
print("\n输出文件:")
print(" - outputs/converging_triangles/all_results.csv")
print(" - outputs/converging_triangles/report.md")
print(" - outputs/converging_triangles/charts/*.png")
else:
print("\n[流水线部分失败,请检查上述错误信息]")
print("=" * 80)
if __name__ == "__main__":
main()

View File

@ -1,387 +0,0 @@
"""
为当日满足收敛三角形的个股生成图表
用法:
python scripts/plot_converging_triangles.py
python scripts/plot_converging_triangles.py --date 20260120
"""
from __future__ import annotations
import argparse
import csv
import os
import pickle
import sys
from typing import Dict, List, Optional
import matplotlib.pyplot as plt
import numpy as np
# 配置中文字体
plt.rcParams['font.sans-serif'] = ['SimHei', 'Microsoft YaHei', 'Arial Unicode MS'] # 支持中文
plt.rcParams['axes.unicode_minus'] = False # 解决负号显示问题
# 让脚本能找到 src/ 下的模块
sys.path.append(os.path.join(os.path.dirname(__file__), "..", "src"))
from converging_triangle import (
ConvergingTriangleParams,
detect_converging_triangle,
fit_pivot_line,
line_y,
pivots_fractal,
)
# 导入统一的参数配置
from triangle_config import DETECTION_PARAMS, DISPLAY_WINDOW
class FakeModule:
"""空壳模块,绕过 model 依赖"""
ndarray = np.ndarray
def load_pkl(pkl_path: str) -> dict:
"""加载 pkl 文件"""
sys.modules['model'] = FakeModule()
sys.modules['model.index_info'] = FakeModule()
with open(pkl_path, 'rb') as f:
data = pickle.load(f)
return data
def load_ohlcv_from_pkl(data_dir: str) -> tuple:
"""从 pkl 文件加载 OHLCV 数据"""
open_data = load_pkl(os.path.join(data_dir, "open.pkl"))
high_data = load_pkl(os.path.join(data_dir, "high.pkl"))
low_data = load_pkl(os.path.join(data_dir, "low.pkl"))
close_data = load_pkl(os.path.join(data_dir, "close.pkl"))
volume_data = load_pkl(os.path.join(data_dir, "volume.pkl"))
dates = close_data["dtes"]
tkrs = close_data["tkrs"]
tkrs_name = close_data["tkrs_name"]
return (
open_data["mtx"],
high_data["mtx"],
low_data["mtx"],
close_data["mtx"],
volume_data["mtx"],
dates,
tkrs,
tkrs_name,
)
def load_daily_stocks(csv_path: str, target_date: int) -> List[Dict]:
"""从CSV读取指定日期的股票列表"""
stocks = []
with open(csv_path, newline="", encoding="utf-8-sig") as f:
reader = csv.DictReader(f)
for row in reader:
try:
date = int(row.get("date", "0"))
if date == target_date:
stocks.append({
"stock_idx": int(row.get("stock_idx", "0")),
"stock_code": row.get("stock_code", ""),
"stock_name": row.get("stock_name", ""),
"breakout_dir": row.get("breakout_dir", "none"),
"breakout_strength_up": float(row.get("breakout_strength_up", "0")),
"breakout_strength_down": float(row.get("breakout_strength_down", "0")),
})
except (ValueError, TypeError):
continue
return stocks
def plot_triangle(
stock_idx: int,
stock_code: str,
stock_name: str,
date_idx: int,
high_mtx: np.ndarray,
low_mtx: np.ndarray,
close_mtx: np.ndarray,
volume_mtx: np.ndarray,
dates: np.ndarray,
params: ConvergingTriangleParams,
output_path: str,
display_window: int = 500, # 新增:显示窗口大小
) -> None:
"""绘制单只股票的收敛三角形图"""
# 提取该股票数据并过滤NaN
high_stock = high_mtx[stock_idx, :]
low_stock = low_mtx[stock_idx, :]
close_stock = close_mtx[stock_idx, :]
volume_stock = volume_mtx[stock_idx, :]
valid_mask = ~np.isnan(close_stock)
valid_indices = np.where(valid_mask)[0]
if len(valid_indices) < params.window:
print(f" [跳过] {stock_code} {stock_name}: 有效数据不足")
return
# 找到date_idx在有效数据中的位置
if date_idx not in valid_indices:
print(f" [跳过] {stock_code} {stock_name}: date_idx不在有效范围")
return
valid_end = np.where(valid_indices == date_idx)[0][0]
if valid_end < params.window - 1:
print(f" [跳过] {stock_code} {stock_name}: 窗口数据不足")
return
# 提取检测窗口数据(用于三角形检测)
detect_start = valid_end - params.window + 1
high_win = high_stock[valid_mask][detect_start:valid_end + 1]
low_win = low_stock[valid_mask][detect_start:valid_end + 1]
close_win = close_stock[valid_mask][detect_start:valid_end + 1]
volume_win = volume_stock[valid_mask][detect_start:valid_end + 1]
# 提取显示窗口数据(用于绘图,更长的历史)
display_start = max(0, valid_end - display_window + 1)
display_high = high_stock[valid_mask][display_start:valid_end + 1]
display_low = low_stock[valid_mask][display_start:valid_end + 1]
display_close = close_stock[valid_mask][display_start:valid_end + 1]
display_volume = volume_stock[valid_mask][display_start:valid_end + 1]
display_dates = dates[valid_indices[display_start:valid_end + 1]]
# 检测三角形(使用检测窗口数据)
result = detect_converging_triangle(
high=high_win,
low=low_win,
close=close_win,
volume=volume_win,
params=params,
stock_idx=stock_idx,
date_idx=date_idx,
)
if not result.is_valid:
print(f" [跳过] {stock_code} {stock_name}: 未识别到有效三角形")
return
# 绘图准备
x_display = np.arange(len(display_close), dtype=float)
# 计算三角形在显示窗口中的位置偏移
triangle_offset = len(display_close) - len(close_win)
# 获取检测窗口的起止索引(相对于检测窗口内部)
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)
# 使用枢轴点连线法拟合边界线(与检测算法一致)
a_u, b_u, selected_ph = fit_pivot_line(
pivot_indices=ph_idx,
pivot_values=high_win[ph_idx],
mode="upper",
)
a_l, b_l, selected_pl = fit_pivot_line(
pivot_indices=pl_idx,
pivot_values=low_win[pl_idx],
mode="lower",
)
# 三角形线段在显示窗口中的X坐标只画检测窗口范围
xw_in_display = np.arange(triangle_offset, triangle_offset + n, dtype=float)
# 计算Y值使用检测窗口内部的X坐标
upper_line = line_y(a_u, b_u, x_win)
lower_line = line_y(a_l, b_l, x_win)
# 获取选中的枢轴点在显示窗口中的位置(用于标注)
ph_display_idx = ph_idx + triangle_offset
pl_display_idx = pl_idx + triangle_offset
selected_ph_pos = ph_idx[selected_ph] if len(selected_ph) > 0 else np.array([], dtype=int)
selected_pl_pos = pl_idx[selected_pl] if len(selected_pl) > 0 else np.array([], dtype=int)
selected_ph_display = selected_ph_pos + triangle_offset
selected_pl_display = selected_pl_pos + triangle_offset
# 三角形检测窗口的日期范围(用于标题)
detect_dates = dates[valid_indices[detect_start:valid_end + 1]]
# 创建图表
fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(14, 8),
gridspec_kw={'height_ratios': [3, 1]})
# 主图:价格和趋势线(使用显示窗口数据)
ax1.plot(x_display, display_close, linewidth=1.5, label='收盘价', color='black', alpha=0.7)
ax1.plot(xw_in_display, upper_line, linewidth=2, label='上沿', color='red', linestyle='--')
ax1.plot(xw_in_display, lower_line, linewidth=2, label='下沿', color='green', linestyle='--')
ax1.axvline(len(display_close) - 1, color='gray', linestyle=':', linewidth=1, alpha=0.5)
# 标注选中的枢轴点(用于连线的关键点)
if len(selected_ph_display) >= 2:
ax1.scatter(
selected_ph_display,
high_win[selected_ph_pos],
marker='o',
s=90,
facecolors='none',
edgecolors='red',
linewidths=1.5,
zorder=5,
label='上沿枢轴点',
)
if len(selected_pl_display) >= 2:
ax1.scatter(
selected_pl_display,
low_win[selected_pl_pos],
marker='o',
s=90,
facecolors='none',
edgecolors='green',
linewidths=1.5,
zorder=5,
label='下沿枢轴点',
)
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)}个交易日) "
f"突破方向: {result.breakout_dir} 宽度比: {result.width_ratio:.2f} "
f"触碰: 上{result.touches_upper}/下{result.touches_lower} "
f"放量确认: {'' if result.volume_confirmed else '' if result.volume_confirmed is False else '-'}",
fontsize=11, pad=10
)
ax1.set_ylabel('价格', fontsize=10)
ax1.legend(loc='best', fontsize=9)
ax1.grid(True, alpha=0.3)
# X轴日期标签稀疏显示基于显示窗口
step = max(1, len(display_dates) // 10)
tick_indices = np.arange(0, len(display_dates), step)
ax1.set_xticks(tick_indices)
ax1.set_xticklabels(display_dates[tick_indices], rotation=45, ha='right', fontsize=8)
# 副图:成交量(使用显示窗口数据)
ax2.bar(x_display, display_volume, width=0.8, color='skyblue', alpha=0.6)
ax2.set_ylabel('成交量', fontsize=10)
ax2.set_xlabel('交易日', fontsize=10)
ax2.grid(True, alpha=0.3, axis='y')
ax2.set_xticks(tick_indices)
ax2.set_xticklabels(display_dates[tick_indices], rotation=45, ha='right', fontsize=8)
plt.tight_layout()
plt.savefig(output_path, dpi=120)
plt.close()
print(f" [完成] {stock_code} {stock_name} -> {output_path}")
def main() -> None:
parser = argparse.ArgumentParser(description="为当日满足收敛三角形的个股生成图表")
parser.add_argument(
"--input",
default=os.path.join("outputs", "converging_triangles", "all_results.csv"),
help="输入 CSV 路径",
)
parser.add_argument(
"--date",
type=int,
default=None,
help="指定日期YYYYMMDD默认为数据最新日",
)
parser.add_argument(
"--output-dir",
default=os.path.join("outputs", "converging_triangles", "charts"),
help="图表输出目录",
)
args = parser.parse_args()
print("=" * 70)
print("收敛三角形图表生成")
print("=" * 70)
# 1. 加载数据
print("\n[1] 加载 OHLCV 数据...")
data_dir = os.path.join(os.path.dirname(__file__), "..", "data")
open_mtx, high_mtx, low_mtx, close_mtx, volume_mtx, dates, tkrs, tkrs_name = load_ohlcv_from_pkl(data_dir)
print(f" 股票数: {len(tkrs)}, 交易日数: {len(dates)}")
# 2. 确定目标日期
if args.date:
target_date = args.date
else:
# 从CSV读取最新日期
with open(args.input, newline="", encoding="utf-8-sig") as f:
reader = csv.DictReader(f)
all_dates = [int(r.get("date", "0")) for r in reader if r.get("date") and r["date"].isdigit()]
target_date = max(all_dates) if all_dates else 0
if target_date == 0:
print("错误: 无法确定目标日期")
return
print(f"\n[2] 目标日期: {target_date}")
# 3. 加载当日股票列表
stocks = load_daily_stocks(args.input, target_date)
print(f" 当日满足三角形的股票数: {len(stocks)}")
if not stocks:
print("当日无满足条件的股票")
return
# 4. 创建输出目录并清空旧图片
os.makedirs(args.output_dir, exist_ok=True)
# 清空目录中的旧图片
print(f"\n[4] 清空输出目录...")
old_files = [f for f in os.listdir(args.output_dir) if f.endswith('.png')]
for f in old_files:
os.remove(os.path.join(args.output_dir, f))
print(f" 已删除 {len(old_files)} 个旧图片")
# 5. 检测参数(从统一配置导入)
params = DETECTION_PARAMS
# 6. 找到target_date在dates中的索引
date_idx = np.where(dates == target_date)[0]
if len(date_idx) == 0:
print(f"错误: 日期 {target_date} 不在数据范围内")
return
date_idx = date_idx[0]
# 7. 生成图表
print(f"\n[3] 生成图表...")
for stock in stocks:
stock_idx = stock["stock_idx"]
stock_code = stock["stock_code"]
stock_name = stock["stock_name"]
output_filename = f"{target_date}_{stock_code}_{stock_name}.png"
output_path = os.path.join(args.output_dir, output_filename)
try:
plot_triangle(
stock_idx=stock_idx,
stock_code=stock_code,
stock_name=stock_name,
date_idx=date_idx,
high_mtx=high_mtx,
low_mtx=low_mtx,
close_mtx=close_mtx,
volume_mtx=volume_mtx,
dates=dates,
params=params,
output_path=output_path,
display_window=DISPLAY_WINDOW, # 从配置文件读取
)
except Exception as e:
print(f" [错误] {stock_code} {stock_name}: {e}")
print(f"\n完成!图表已保存至: {args.output_dir}")
if __name__ == "__main__":
main()

View File

@ -1,21 +1,6 @@
""" """
生成收敛三角形突破强度选股简报Markdown 生成收敛三角形突破强度选股简报Markdown
默认读取 outputs/converging_triangles/all_results.csv 默认读取 outputs/converging_triangles/all_results.csv
用法示例
1) 使用默认参数生成最新交易日排名
python scripts/report_converging_triangles.py
2) 指定强度阈值与输出数量
python scripts/report_converging_triangles.py --threshold 0.4 --top-n 30
3) 指定报告日期YYYYMMDD
python scripts/report_converging_triangles.py --report-date 20260120
4) 指定输入与输出路径
python scripts/report_converging_triangles.py \
--input outputs/converging_triangles/all_results.csv \
--output outputs/converging_triangles/report.md
""" """
from __future__ import annotations from __future__ import annotations
@ -23,16 +8,9 @@ from __future__ import annotations
import argparse import argparse
import csv import csv
import os import os
import sys
from datetime import datetime from datetime import datetime
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
# 让脚本能找到 src/ 下的模块
sys.path.append(os.path.join(os.path.dirname(__file__), "..", "src"))
# 导入统一的参数配置
from triangle_config import DETECTION_PARAMS
def parse_bool(value: str) -> Optional[bool]: def parse_bool(value: str) -> Optional[bool]:
if value is None or value == "": if value is None or value == "":
@ -115,30 +93,41 @@ def best_by_stock(
) )
def daily_rank_by_strength( def best_combined_by_stock(
rows: List[Dict[str, Any]], rows: List[Dict[str, Any]],
target_date: int, threshold: float,
top_n: int,
) -> List[Dict[str, Any]]: ) -> List[Dict[str, Any]]:
candidates: List[Dict[str, Any]] = [] filtered: List[Dict[str, Any]] = []
for r in rows: for r in rows:
if r.get("date") != target_date:
continue
if r.get("breakout_dir") not in ("up", "down"): if r.get("breakout_dir") not in ("up", "down"):
continue continue
strength_up = r.get("breakout_strength_up", 0.0) strength_up = r.get("breakout_strength_up", 0.0)
strength_down = r.get("breakout_strength_down", 0.0) strength_down = r.get("breakout_strength_down", 0.0)
combined = max(strength_up, strength_down) combined = max(strength_up, strength_down)
if combined <= threshold:
continue
r = dict(r) r = dict(r)
r["combined_strength"] = combined r["combined_strength"] = combined
r["combined_dir"] = "up" if strength_up >= strength_down else "down" r["combined_dir"] = "up" if strength_up >= strength_down else "down"
candidates.append(r) filtered.append(r)
best_map: Dict[str, Dict[str, Any]] = {}
for r in filtered:
key = r.get("stock_code") or f"IDX{r.get('stock_idx', '')}"
prev = best_map.get(key)
if prev is None:
best_map[key] = r
continue
if r["combined_strength"] > prev["combined_strength"]:
best_map[key] = r
elif r["combined_strength"] == prev["combined_strength"] and r.get("date", 0) > prev.get("date", 0):
best_map[key] = r
return sorted( return sorted(
candidates, best_map.values(),
key=lambda x: (x.get("combined_strength", 0.0), x.get("date", 0)), key=lambda x: (x.get("combined_strength", 0.0), x.get("date", 0)),
reverse=True, reverse=True,
)[:top_n] )
def count_by_dir(rows: List[Dict[str, Any]]) -> Dict[str, int]: def count_by_dir(rows: List[Dict[str, Any]]) -> Dict[str, int]:
@ -163,118 +152,90 @@ def build_report(
rows: List[Dict[str, Any]], rows: List[Dict[str, Any]],
threshold: float, threshold: float,
top_n: int, top_n: int,
report_date: Optional[int],
output_path: str, output_path: str,
) -> None: ) -> None:
total = len(rows) total = len(rows)
if total == 0: if total == 0:
content = "# 收敛三角形当日选股简报\n\n数据为空。\n" content = "# 收敛三角形突破强度选股简报\n\n数据为空。\n"
with open(output_path, "w", encoding="utf-8") as f: with open(output_path, "w", encoding="utf-8") as f:
f.write(content) f.write(content)
return return
dates = [r.get("date", 0) for r in rows if r.get("date", 0) > 0] dates = [r.get("date", 0) for r in rows if r.get("date", 0) > 0]
date_min = min(dates) if dates else 0
date_max = max(dates) if dates else 0 date_max = max(dates) if dates else 0
counts = count_by_dir(rows)
target_date = report_date or date_max up_picks = best_by_stock(rows, "breakout_strength_up", threshold, "up")[:top_n]
daily_rows = [r for r in rows if r.get("date") == target_date] down_picks = best_by_stock(rows, "breakout_strength_down", threshold, "down")[:top_n]
daily_counts = count_by_dir(daily_rows) combined_picks = best_combined_by_stock(rows, threshold)[:top_n]
# 按方向分类并排序
daily_up = sorted(
[r for r in daily_rows if r.get("breakout_dir") == "up"],
key=lambda x: x.get("breakout_strength_up", 0.0),
reverse=True
)
daily_down = sorted(
[r for r in daily_rows if r.get("breakout_dir") == "down"],
key=lambda x: x.get("breakout_strength_down", 0.0),
reverse=True
)
daily_none = sorted(
[r for r in daily_rows if r.get("breakout_dir") == "none"],
key=lambda x: max(x.get("breakout_strength_up", 0.0), x.get("breakout_strength_down", 0.0)),
reverse=True
)
now_str = datetime.now().strftime("%Y-%m-%d %H:%M") now_str = datetime.now().strftime("%Y-%m-%d %H:%M")
lines: List[str] = [] lines: List[str] = []
lines.append("# 收敛三角形当日选股简报") lines.append("# 收敛三角形突破强度选股简报")
lines.append("")
lines.append("## 数据说明")
lines.append("")
lines.append("- **股票池**108 只个股从万得全A按顺序索引等距50取样")
lines.append(f"- **检测窗口**{DETECTION_PARAMS.window} 个交易日")
lines.append("- **检测算法**:收敛三角形形态识别(基于枢轴点、趋势线拟合与收敛度判定)")
lines.append("")
lines.append(f"## {target_date} 当日统计")
lines.append("") lines.append("")
lines.append(f"- 生成时间:{now_str}") lines.append(f"- 生成时间:{now_str}")
lines.append(f"- 当日满足收敛三角形的个股:{len(daily_rows)}") if date_min and date_max:
lines.append(f" - 向上突破:{daily_counts.get('up', 0)}") lines.append(f"- 数据范围:{date_min} ~ {date_max}")
lines.append(f" - 向下突破:{daily_counts.get('down', 0)}") lines.append(f"- 记录数:{total}")
lines.append(f" - 无突破(形态成立但未突破):{daily_counts.get('none', 0)} ") lines.append(f"- 突破方向统计:上破 {counts.get('up', 0)} / 下破 {counts.get('down', 0)} / 无突破 {counts.get('none', 0)}")
lines.append("") lines.append("")
lines.append("## 筛选条件")
# 向上突破 lines.append(f"- 强度阈值:> {threshold}")
lines.append("## 向上突破") lines.append(f"- 每方向最多输出:{top_n} 只(按单只股票的最高强度去重)")
if daily_up: lines.append("")
lines.append("| 排名 | 股票 | 突破强度 | 宽度比 | 触碰(上/下) | 放量确认 |") lines.append("## 综合突破强度候选")
lines.append("| --- | --- | --- | --- | --- | --- |") if combined_picks:
for i, r in enumerate(daily_up, start=1): lines.append("| 排名 | 股票 | 日期 | 方向 | 综合强度 | 宽度比 | 放量确认 |")
lines.append("| --- | --- | --- | --- | --- | --- | --- |")
for i, r in enumerate(combined_picks, start=1):
stock = f"{r.get('stock_code', '')} {r.get('stock_name', '')}".strip() stock = f"{r.get('stock_code', '')} {r.get('stock_name', '')}".strip()
lines.append( lines.append(
f"| {i} | {stock} | " f"| {i} | {stock} | {r.get('date', '')} | "
f"{r.get('combined_dir', '')} | {r.get('combined_strength', 0.0):.4f} | "
f"{r.get('width_ratio', 0.0):.4f} | {fmt_bool(r.get('volume_confirmed'))} |"
)
else:
lines.append("无满足条件的综合突破记录。")
lines.append("")
lines.append("## 向上突破候选")
if up_picks:
lines.append("| 排名 | 股票 | 日期 | 强度 | 宽度比 | 触碰(上/下) | 放量确认 |")
lines.append("| --- | --- | --- | --- | --- | --- | --- |")
for i, r in enumerate(up_picks, start=1):
stock = f"{r.get('stock_code', '')} {r.get('stock_name', '')}".strip()
lines.append(
f"| {i} | {stock} | {r.get('date', '')} | "
f"{r.get('breakout_strength_up', 0.0):.4f} | " f"{r.get('breakout_strength_up', 0.0):.4f} | "
f"{r.get('width_ratio', 0.0):.4f} | " f"{r.get('width_ratio', 0.0):.4f} | "
f"{r.get('touches_upper', 0)}/{r.get('touches_lower', 0)} | " f"{r.get('touches_upper', 0)}/{r.get('touches_lower', 0)} | "
f"{fmt_bool(r.get('volume_confirmed'))} |" f"{fmt_bool(r.get('volume_confirmed'))} |"
) )
else: else:
lines.append("当日无向上突破记录。") lines.append("满足条件的向上突破记录。")
lines.append("") lines.append("")
lines.append("## 向下突破候选")
# 向下突破 if down_picks:
lines.append("## 向下突破") lines.append("| 排名 | 股票 | 日期 | 强度 | 宽度比 | 触碰(上/下) | 放量确认 |")
if daily_down: lines.append("| --- | --- | --- | --- | --- | --- | --- |")
lines.append("| 排名 | 股票 | 突破强度 | 宽度比 | 触碰(上/下) | 放量确认 |") for i, r in enumerate(down_picks, start=1):
lines.append("| --- | --- | --- | --- | --- | --- |")
for i, r in enumerate(daily_down, start=1):
stock = f"{r.get('stock_code', '')} {r.get('stock_name', '')}".strip() stock = f"{r.get('stock_code', '')} {r.get('stock_name', '')}".strip()
lines.append( lines.append(
f"| {i} | {stock} | " f"| {i} | {stock} | {r.get('date', '')} | "
f"{r.get('breakout_strength_down', 0.0):.4f} | " f"{r.get('breakout_strength_down', 0.0):.4f} | "
f"{r.get('width_ratio', 0.0):.4f} | " f"{r.get('width_ratio', 0.0):.4f} | "
f"{r.get('touches_upper', 0)}/{r.get('touches_lower', 0)} | " f"{r.get('touches_upper', 0)}/{r.get('touches_lower', 0)} | "
f"{fmt_bool(r.get('volume_confirmed'))} |" f"{fmt_bool(r.get('volume_confirmed'))} |"
) )
else: else:
lines.append("当日无向下突破记录。") lines.append("满足条件的向下突破记录。")
lines.append("") lines.append("")
# 无突破(形态成立)
lines.append("## 无突破(形态成立)")
if daily_none:
lines.append("| 排名 | 股票 | 宽度比 | 触碰(上/下) |")
lines.append("| --- | --- | --- | --- |")
for i, r in enumerate(daily_none, start=1):
stock = f"{r.get('stock_code', '')} {r.get('stock_name', '')}".strip()
lines.append(
f"| {i} | {stock} | "
f"{r.get('width_ratio', 0.0):.4f} | "
f"{r.get('touches_upper', 0)}/{r.get('touches_lower', 0)} |"
)
else:
lines.append("当日无未突破记录。")
lines.append("")
lines.append("## 说明") lines.append("## 说明")
lines.append("") lines.append("- 强度由价格突破幅度、收敛程度与成交量放大综合计算。")
lines.append("- **突破强度**价格突破幅度、收敛程度与成交量放大综合计算0~1") lines.append("- 仅统计 `breakout_dir` 与方向一致的记录。")
lines.append("- **宽度比**:三角形末端宽度 / 起始宽度(越小越收敛)") lines.append("- 每只股票仅保留强度最高的一条记录(同强度取最新日期)。")
lines.append("- **触碰(上/下)**:价格触碰上沿和下沿的次数") lines.append("- 综合强度 = max(向上强度, 向下强度)。")
lines.append("- **放量确认**:突破时成交量是否显著放大")
lines.append("") lines.append("")
with open(output_path, "w", encoding="utf-8") as f: with open(output_path, "w", encoding="utf-8") as f:
@ -295,7 +256,6 @@ def main() -> None:
) )
parser.add_argument("--threshold", type=float, default=0.3, help="强度阈值") parser.add_argument("--threshold", type=float, default=0.3, help="强度阈值")
parser.add_argument("--top-n", type=int, default=20, help="每方向输出数量") parser.add_argument("--top-n", type=int, default=20, help="每方向输出数量")
parser.add_argument("--report-date", type=int, default=None, help="指定报告日期YYYYMMDD")
args = parser.parse_args() args = parser.parse_args()
if not os.path.exists(args.input): if not os.path.exists(args.input):
@ -305,7 +265,7 @@ def main() -> None:
output_dir = os.path.dirname(args.output) output_dir = os.path.dirname(args.output)
if output_dir: if output_dir:
os.makedirs(output_dir, exist_ok=True) os.makedirs(output_dir, exist_ok=True)
build_report(rows, args.threshold, args.top_n, args.report_date, args.output) build_report(rows, args.threshold, args.top_n, args.output)
print(f"Report saved to: {args.output}") print(f"Report saved to: {args.output}")

View File

@ -6,7 +6,6 @@
import os import os
import sys import sys
import pickle import pickle
import time
import numpy as np import numpy as np
import pandas as pd import pandas as pd
@ -26,14 +25,31 @@ from converging_triangle import (
# --- 数据源 --- # --- 数据源 ---
DATA_DIR = os.path.join(os.path.dirname(__file__), "..", "data") DATA_DIR = os.path.join(os.path.dirname(__file__), "..", "data")
# --- 导入统一的参数配置 --- # --- 检测参数 ---
from triangle_config import ( PARAMS = ConvergingTriangleParams(
DETECTION_PARAMS as PARAMS, window=400,
RECENT_DAYS, pivot_k=20,
ONLY_VALID, boundary_n_segments=2,
VERBOSE, boundary_source="full",
upper_slope_max=0.10,
lower_slope_min=-0.10,
touch_tol=0.10,
touch_loss_max=0.10,
shrink_ratio=0.8,
break_tol=0.001,
vol_window=20,
vol_k=1.3,
false_break_m=5,
) )
# --- 计算范围 ---
# 设为 None 表示计算全部历史;设为具体数字可以只算最近 N 天
RECENT_DAYS = 500 # 默认只算最近 500 天,避免计算时间过长
# --- 输出控制 ---
ONLY_VALID = True # True: 只输出识别到三角形的记录; False: 输出所有记录
VERBOSE = True
# ============================================================================ # ============================================================================
# pkl 数据加载 # pkl 数据加载
@ -92,22 +108,17 @@ def load_ohlcv_from_pkl(data_dir: str) -> tuple:
# ============================================================================ # ============================================================================
def main() -> None: def main() -> None:
start_time = time.time()
print("=" * 70) print("=" * 70)
print("Converging Triangle Batch Detection") print("Converging Triangle Batch Detection")
print("=" * 70) print("=" * 70)
# 1. 加载数据 # 1. 加载数据
print("\n[1] Loading OHLCV pkl files...") print("\n[1] Loading OHLCV pkl files...")
load_start = time.time()
open_mtx, high_mtx, low_mtx, close_mtx, volume_mtx, dates, tkrs, tkrs_name = load_ohlcv_from_pkl(DATA_DIR) open_mtx, high_mtx, low_mtx, close_mtx, volume_mtx, dates, tkrs, tkrs_name = load_ohlcv_from_pkl(DATA_DIR)
load_time = time.time() - load_start
n_stocks, n_days = close_mtx.shape n_stocks, n_days = close_mtx.shape
print(f" Stocks: {n_stocks}") print(f" Stocks: {n_stocks}")
print(f" Days: {n_days}") print(f" Days: {n_days}")
print(f" Date range: {dates[0]} ~ {dates[-1]}") print(f" Date range: {dates[0]} ~ {dates[-1]}")
print(f" 加载耗时: {load_time:.2f}")
# 2. 找有效数据范围(排除全 NaN 的列) # 2. 找有效数据范围(排除全 NaN 的列)
any_valid = np.any(~np.isnan(close_mtx), axis=0) any_valid = np.any(~np.isnan(close_mtx), axis=0)
@ -131,7 +142,6 @@ def main() -> None:
# 3. 批量检测 # 3. 批量检测
print("\n[3] Running batch detection...") print("\n[3] Running batch detection...")
detect_start = time.time()
df = detect_converging_triangle_batch( df = detect_converging_triangle_batch(
open_mtx=open_mtx, open_mtx=open_mtx,
high_mtx=high_mtx, high_mtx=high_mtx,
@ -144,8 +154,6 @@ def main() -> None:
only_valid=ONLY_VALID, only_valid=ONLY_VALID,
verbose=VERBOSE, verbose=VERBOSE,
) )
detect_time = time.time() - detect_start
print(f" 检测耗时: {detect_time:.2f}")
# 4. 添加股票代码、名称和真实日期 # 4. 添加股票代码、名称和真实日期
if len(df) > 0: if len(df) > 0:
@ -217,11 +225,6 @@ def main() -> None:
display_cols = [c for c in display_cols if c in df.columns] display_cols = [c for c in display_cols if c in df.columns]
print(df[display_cols].head(10).to_string(index=False)) print(df[display_cols].head(10).to_string(index=False))
total_time = time.time() - start_time
print("\n" + "=" * 70)
print(f"总耗时: {total_time:.2f} 秒 ({total_time/60:.2f} 分钟)")
print("=" * 70)
if __name__ == "__main__": if __name__ == "__main__":
main() main()

View File

@ -1,166 +0,0 @@
"""
收敛三角形检测参数配置
所有相关脚本共享此配置确保参数一致性
- run_converging_triangle.py (批量检测)
- report_converging_triangles.py (报告生成)
- plot_converging_triangles.py (图表绘制)
"""
import os
import sys
# 让脚本能找到 src/ 下的模块
sys.path.append(os.path.join(os.path.dirname(__file__), "..", "src"))
from converging_triangle import ConvergingTriangleParams
# ============================================================================
# 核心检测参数
# ============================================================================
# 严格模式:更严格的收敛和突破要求(当前激活)
DETECTION_PARAMS = ConvergingTriangleParams(
# 基础窗口
window=120, # 检测窗口大小(交易日)
pivot_k=15, # 枢轴点检测周期
# 边界拟合
boundary_n_segments=2, # 边界线分段数
boundary_source="full", # 边界拟合数据源: "full"(全部) 或 "pivot"(仅枢轴点)
# 斜率约束
upper_slope_max=0.10, # 上沿最大斜率(正值,向上倾斜)
lower_slope_min=-0.10, # 下沿最小斜率(负值,向下倾斜)
# 触碰检测
touch_tol=0.10, # 触碰容差10%以内算触碰)
touch_loss_max=0.10, # 平均触碰误差上限
# 收敛度要求
shrink_ratio=0.6, # 🔧 更严格末端≤60%起始宽度
# 突破检测
break_tol=0.005, # 🔧 更明显的突破0.5%
# 成交量确认
vol_window=20, # 成交量移动窗口
vol_k=1.5, # 🔧 更强的放量要求
# 假突破过滤
false_break_m=5, # 假突破回看天数
)
# 默认模式(备用)
DEFAULT_PARAMS = ConvergingTriangleParams(
window=120,
pivot_k=15,
boundary_n_segments=2,
boundary_source="full",
upper_slope_max=0.10,
lower_slope_min=-0.10,
touch_tol=0.10,
touch_loss_max=0.10,
shrink_ratio=0.8,
break_tol=0.001,
vol_window=20,
vol_k=1.3,
false_break_m=5,
)
# ============================================================================
# 数据范围配置
# ============================================================================
# 计算范围None = 全部历史,具体数字 = 最近N天
RECENT_DAYS = 500
# 显示范围:图表中显示的交易日数
DISPLAY_WINDOW = 500
# ============================================================================
# 输出控制
# ============================================================================
# 是否只输出有效三角形
ONLY_VALID = True
# 是否显示详细日志
VERBOSE = True
# ============================================================================
# 推荐参数预设(备选方案)
# ============================================================================
# 宽松模式:捕获更多潜在形态
LOOSE_PARAMS = ConvergingTriangleParams(
window=120,
pivot_k=15,
boundary_n_segments=2,
boundary_source="full",
upper_slope_max=0.15, # 允许更陡峭的斜率
lower_slope_min=-0.15,
touch_tol=0.12,
touch_loss_max=0.12,
shrink_ratio=0.85, # 更宽松的收敛要求
break_tol=0.001,
vol_window=20,
vol_k=1.2,
false_break_m=5,
)
# ============================================================================
# 使用说明
# ============================================================================
def get_params(mode: str = "strict") -> ConvergingTriangleParams:
"""
获取检测参数
Args:
mode: 参数模式可选值
- "strict": 严格模式当前默认推荐
- "default": 默认参数
- "loose": 宽松模式
Returns:
ConvergingTriangleParams 实例
"""
if mode == "default":
return DEFAULT_PARAMS
elif mode == "loose":
return LOOSE_PARAMS
else:
return DETECTION_PARAMS # strict
if __name__ == "__main__":
print("=" * 70)
print("收敛三角形检测参数配置")
print("=" * 70)
print("\n[当前激活: 严格模式]")
print(f" 检测窗口: {DETECTION_PARAMS.window} 个交易日")
print(f" 收敛比例: ≤ {DETECTION_PARAMS.shrink_ratio} (末端宽度/起始宽度)")
print(f" 突破阈值: {DETECTION_PARAMS.break_tol * 100:.2f}%")
print(f" 放量倍数: ≥ {DETECTION_PARAMS.vol_k}x")
print("\n[数据范围]")
print(f" 计算范围: 最近 {RECENT_DAYS} 个交易日")
print(f" 图表显示: {DISPLAY_WINDOW} 个交易日")
print("\n[可选模式]")
print(" - strict: 严格模式(当前使用,高质量)")
print(" - default: 默认参数(较宽松)")
print(" - loose: 宽松模式(更多候选)")
print("\n[关键差异]")
print(" 参数 | 严格模式 | 默认模式 | 宽松模式")
print(" ------------|---------|---------|----------")
print(" 收敛比例 | ≤0.6 | ≤0.8 | ≤0.85")
print(" 突破阈值 | 0.5% | 0.1% | 0.1%")
print(" 放量倍数 | ≥1.5x | ≥1.3x | ≥1.2x")
print("=" * 70)

View File

@ -173,118 +173,6 @@ def line_y(a: float, b: float, x: np.ndarray) -> np.ndarray:
return a * x + b return a * x + b
def fit_pivot_line(
pivot_indices: np.ndarray,
pivot_values: np.ndarray,
mode: str = "upper",
min_points: int = 2,
) -> Tuple[float, float, np.ndarray]:
"""
枢轴点连线法选择合适的枢轴点连成边界线
上沿(upper)选择形成下降趋势的高点对
下沿(lower)选择形成上升趋势的低点对
Args:
pivot_indices: 枢轴点的X坐标索引
pivot_values: 枢轴点的Y值价格
mode: "upper"(上沿) "lower"(下沿)
min_points: 最少需要的枢轴点数
Returns:
(slope, intercept, selected_indices): 斜率截距选中的枢轴点索引
"""
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 mode == "upper":
# 上沿:寻找形成下降趋势的高点对
# 策略:选择前半部分最高点和后半部分最高点
mid = n // 2
if mid < 1:
mid = 1
# 前半部分(包括中点)的最高点
front_idx = np.argmax(y_sorted[:mid + 1])
# 后半部分的最高点
back_idx = mid + np.argmax(y_sorted[mid:])
# 如果后点比前点高,尝试找其他组合
if y_sorted[back_idx] > y_sorted[front_idx]:
# 尝试用全局最高点作为前点
global_max_idx = np.argmax(y_sorted)
if global_max_idx < n - 1:
# 在最高点之后找第二高的点
remaining_idx = np.argmax(y_sorted[global_max_idx + 1:]) + global_max_idx + 1
front_idx = global_max_idx
back_idx = remaining_idx
else:
# 最高点在最后,找前面第二高的点
front_idx = np.argmax(y_sorted[:-1])
back_idx = global_max_idx
selected = np.array([front_idx, back_idx])
else: # mode == "lower"
# 下沿:寻找形成上升趋势的低点对
# 策略:选择前半部分最低点和后半部分最低点
mid = n // 2
if mid < 1:
mid = 1
# 前半部分(包括中点)的最低点
front_idx = np.argmin(y_sorted[:mid + 1])
# 后半部分的最低点
back_idx = mid + np.argmin(y_sorted[mid:])
# 如果后点比前点低,尝试找其他组合
if y_sorted[back_idx] < y_sorted[front_idx]:
# 尝试用全局最低点作为前点
global_min_idx = np.argmin(y_sorted)
if global_min_idx < n - 1:
# 在最低点之后找第二低的点
remaining_idx = np.argmin(y_sorted[global_min_idx + 1:]) + global_min_idx + 1
front_idx = global_min_idx
back_idx = remaining_idx
else:
# 最低点在最后,找前面第二低的点
front_idx = np.argmin(y_sorted[:-1])
back_idx = global_min_idx
selected = np.array([front_idx, back_idx])
# 确保选择的两个点不同
if front_idx == back_idx:
# 如果只有一个点,尝试用第一个和最后一个
if n >= 2:
selected = np.array([0, n - 1])
else:
return 0.0, float(y_sorted[0]), np.array([sort_idx[0]])
# 计算斜率和截距
x1, x2 = x_sorted[selected[0]], x_sorted[selected[1]]
y1, y2 = y_sorted[selected[0]], y_sorted[selected[1]]
if abs(x2 - x1) < 1e-9:
slope = 0.0
intercept = (y1 + y2) / 2
else:
slope = (y2 - y1) / (x2 - x1)
intercept = y1 - slope * x1
# 返回原始索引顺序中的选中点
selected_original = sort_idx[selected]
return float(slope), float(intercept), selected_original
# ============================================================================ # ============================================================================
# 突破强度计算 # 突破强度计算
# ============================================================================ # ============================================================================
@ -299,77 +187,30 @@ def calc_breakout_strength(
""" """
计算向上/向下突破强度 (0~1) 计算向上/向下突破强度 (0~1)
使用加权求和各分量权重 综合考虑:
- 突破幅度分 (60%): tanh 非线性归一化3%突破0.425%突破0.6410%突破0.91 - 价格突破幅度 (close 相对于上/下沿的距离)
- 收敛分 (25%): 1 - width_ratio收敛越强分数越高 - 成交量放大倍数
- 成交量分 (15%): 放量程度2倍放量=满分 - 收敛程度 (width_ratio 越小越强)
突破幅度分布参考使用 tanh(pct * 15)
- 1% 突破 0.15
- 2% 突破 0.29
- 3% 突破 0.42
- 5% 突破 0.64
- 8% 突破 0.83
- 10% 突破 0.91
Args:
close: 收盘价
upper_line: 上沿价格
lower_line: 下沿价格
volume_ratio: 成交量相对均值的倍数
width_ratio: 末端宽度/起始宽度
Returns: Returns:
(strength_up, strength_down) (strength_up, strength_down)
""" """
import math # 价格突破分数
price_up = max(0, (close - upper_line) / upper_line) if upper_line > 0 else 0
price_down = max(0, (lower_line - close) / lower_line) if lower_line > 0 else 0
# 权重配置 # 收敛加成 (越收敛, 突破越有效)
W_PRICE = 0.60 # 突破幅度权重 convergence_bonus = max(0, 1 - width_ratio)
W_CONVERGENCE = 0.25 # 收敛度权重
W_VOLUME = 0.15 # 成交量权重
TANH_SCALE = 15.0 # tanh 缩放因子
# 1. 价格突破分数tanh 非线性归一化) # 成交量加成 (放量2倍=满分)
if upper_line > 0: vol_bonus = min(1, max(0, volume_ratio - 1)) if volume_ratio > 0 else 0
pct_up = max(0.0, (close - upper_line) / upper_line)
price_score_up = math.tanh(pct_up * TANH_SCALE)
else:
price_score_up = 0.0
if lower_line > 0: # 加权合成
pct_down = max(0.0, (lower_line - close) / lower_line) # 基础分数 * 收敛加成 * 成交量加成
price_score_down = math.tanh(pct_down * TANH_SCALE) strength_up = min(1.0, price_up * 5 * (1 + convergence_bonus * 0.5) * (1 + vol_bonus * 0.5))
else: strength_down = min(1.0, price_down * 5 * (1 + convergence_bonus * 0.5) * (1 + vol_bonus * 0.5))
price_score_down = 0.0
# 2. 收敛分数width_ratio 越小越好) return strength_up, strength_down
convergence_score = max(0.0, min(1.0, 1.0 - width_ratio))
# 3. 成交量分数vol_ratio > 1 时才有分)
vol_score = min(1.0, max(0.0, volume_ratio - 1.0)) if volume_ratio > 0 else 0.0
# 4. 加权求和
# 只有发生突破price_score > 0时才计算完整强度
if price_score_up > 0:
strength_up = (
W_PRICE * price_score_up +
W_CONVERGENCE * convergence_score +
W_VOLUME * vol_score
)
else:
strength_up = 0.0
if price_score_down > 0:
strength_down = (
W_PRICE * price_score_down +
W_CONVERGENCE * convergence_score +
W_VOLUME * vol_score
)
else:
strength_down = 0.0
return min(1.0, strength_up), min(1.0, strength_down)
# ============================================================================ # ============================================================================
@ -428,23 +269,20 @@ def detect_converging_triangle(
if len(ph_in) < 2 or len(pl_in) < 2: if len(ph_in) < 2 or len(pl_in) < 2:
return invalid_result return invalid_result
# 使用枢轴点连线法拟合边界线 # 拟合边界线
# 上沿:连接高点枢轴点,形成下降趋势 if params.boundary_source == "full":
a_u, b_u, selected_ph = fit_pivot_line( x_upper = x_all[start : end + 1]
pivot_indices=ph_in, y_upper = high[start : end + 1]
pivot_values=high[ph_in], x_lower = x_all[start : end + 1]
mode="upper", y_lower = low[start : end + 1]
) else:
x_upper = x_all[ph_in]
y_upper = high[ph_in]
x_lower = x_all[pl_in]
y_lower = low[pl_in]
# 下沿:连接低点枢轴点,形成上升趋势 a_u, b_u = fit_boundary_line(x_upper, y_upper, mode="upper", n_segments=params.boundary_n_segments)
a_l, b_l, selected_pl = fit_pivot_line( a_l, b_l = fit_boundary_line(x_lower, y_lower, mode="lower", n_segments=params.boundary_n_segments)
pivot_indices=pl_in,
pivot_values=low[pl_in],
mode="lower",
)
if len(selected_ph) < 2 or len(selected_pl) < 2:
return invalid_result
# 斜率检查 # 斜率检查
if not (a_u <= params.upper_slope_max and a_l >= params.lower_slope_min): if not (a_u <= params.upper_slope_max and a_l >= params.lower_slope_min):
@ -466,30 +304,21 @@ def detect_converging_triangle(
if width_ratio > params.shrink_ratio: if width_ratio > params.shrink_ratio:
return invalid_result return invalid_result
# 触碰检测(枢轴点连线法) # 触碰程度检查
# 上沿:检查高点是否在上沿线下方或接近(不能大幅超过) ph_dist = np.abs(high[ph_in] - line_y(a_u, b_u, x_all[ph_in])) / np.maximum(
upper_line_at_ph = line_y(a_u, b_u, x_all[ph_in].astype(float)) line_y(a_u, b_u, x_all[ph_in]), 1e-9
ph_deviation = (high[ph_in] - upper_line_at_ph) / np.maximum(upper_line_at_ph, 1e-9) )
# 触碰 = 高点接近上沿线(在容差范围内) pl_dist = np.abs(low[pl_in] - line_y(a_l, b_l, x_all[pl_in])) / np.maximum(
touches_upper = int((np.abs(ph_deviation) <= params.touch_tol).sum()) line_y(a_l, b_l, x_all[pl_in]), 1e-9
# 违规 = 高点大幅超过上沿线 )
violations_upper = int((ph_deviation > params.touch_tol).sum())
# 下沿:检查低点是否在下沿线上方或接近(不能大幅低于) touches_upper = int((ph_dist <= params.touch_tol).sum())
lower_line_at_pl = line_y(a_l, b_l, x_all[pl_in].astype(float)) touches_lower = int((pl_dist <= params.touch_tol).sum())
pl_deviation = (lower_line_at_pl - low[pl_in]) / np.maximum(lower_line_at_pl, 1e-9)
# 触碰 = 低点接近下沿线(在容差范围内)
touches_lower = int((np.abs(pl_deviation) <= params.touch_tol).sum())
# 违规 = 低点大幅低于下沿线
violations_lower = int((pl_deviation > params.touch_tol).sum())
# 验证:违规点不能太多(允许少量异常) loss_upper = float(np.mean(ph_dist)) if len(ph_dist) else float("inf")
max_violations = max(1, len(ph_in) // 3) # 最多1/3的点可以违规 loss_lower = float(np.mean(pl_dist)) if len(pl_dist) else float("inf")
if violations_upper > max_violations or violations_lower > max_violations:
return invalid_result
# 确保至少有2个触碰点包括选中的枢轴点 if loss_upper > params.touch_loss_max or loss_lower > params.touch_loss_max:
if touches_upper < 2 or touches_lower < 2:
return invalid_result return invalid_result
# Apex 计算 # Apex 计算