Enhance converging triangle analysis with boundary utilization scoring and detailed chart mode

- Introduced a new boundary utilization score to measure price proximity to triangle boundaries, improving the accuracy of strength assessments.
- Updated scoring weights to incorporate boundary utilization, adjusting the contributions of convergence, volume, and fitting scores.
- Added detailed chart mode in the stock viewer, allowing users to toggle between standard and detailed views with additional metrics displayed.
- Enhanced documentation to reflect new features, including usage instructions for the boundary utilization score and detailed chart mode.
- Improved error handling in the stock viewer for better user experience.
This commit is contained in:
褚宏光 2026-01-27 18:54:56 +08:00
parent 22582851a1
commit 24652b5790
16 changed files with 1326 additions and 738 deletions

View File

@ -79,3 +79,89 @@ result = detect_converging_triangle(high, low, close, volume, params)
- 分位数回归: `fit_boundary_quantile()`
- 锚点拟合: `fit_boundary_anchor()`
- 分发函数: `fit_pivot_line_dispatch()`
# 拟合度分数低,强度分却整体偏高
![](images/2026-01-27-16-26-02.png)
---
## 已实现边界利用率分数2026-01-27
### 问题分析
观察图中 SZ002748 世龙实业:
- 宽度比0.12(非常收敛)
- 强度分0.177(排名第三)
- 但肉眼观察:价格走势与三角形边界之间有**大量空白**
**原因**
- 原权重:收敛分 20%、拟合贴合度 15%
- 当宽度比 0.12 时,收敛分 = 1 - 0.12 = 0.88
- 收敛分贡献 = 0.20 × 0.88 = 0.176 ≈ 全部强度分
- **收敛分只衡量"形状收窄",不衡量"价格是否贴近边界"**
### 解决方案
新增**边界利用率**分数,衡量价格走势对三角形通道空间的利用程度。
### 新增函数
```python
def calc_boundary_utilization(
high, low,
upper_slope, upper_intercept,
lower_slope, lower_intercept,
start, end,
) -> float:
"""
计算边界利用率 (0~1)
对窗口内每一天:
1. 计算价格到上下边界的距离
2. 空白比例 = (到上沿距离 + 到下沿距离) / 通道宽度
3. 当日利用率 = 1 - 空白比例
返回平均利用率
"""
```
### 新权重配置
| 分量 | 原权重 | 新权重 | 说明 |
|------|--------|--------|------|
| 突破幅度 | 50% | **50%** | 不变 |
| 收敛分 | 20% | **15%** | 降低 |
| 成交量分 | 15% | **10%** | 降低 |
| 拟合贴合度 | 15% | **10%** | 降低 |
| **边界利用率** | - | **15%** | 新增 |
### 空白惩罚(新增)
为避免“通道很宽但价格很空”的误判,加入空白惩罚:
![](images/2026-01-27-18-49-33.png)
![](images/2026-01-27-18-49-17.png)
```
UTILIZATION_FLOOR = 0.20
惩罚系数 = min(1, boundary_utilization / UTILIZATION_FLOOR)
最终强度分 = 原强度分 × 惩罚系数
```
当边界利用率明显偏低时,总分会被进一步压制。
### 结果字段
`ConvergingTriangleResult` 新增字段:
```python
boundary_utilization: float = 0.0 # 边界利用率分数
```
### 效果
- 价格贴近边界(空白少)→ 利用率高 → 强度分高
- 价格远离边界(空白多)→ 利用率低 → 强度分被惩罚
- 当边界利用率 < 0.20 强度分按比例衰减空白惩罚
- 解决"形状收敛但空白多"的误判问题
# 上/下沿线,有些点没有碰到线的边缘
![](images/2026-01-27-17-56-30.png)
![](images/2026-01-27-17-56-41.png)

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 350 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 360 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 287 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 286 KiB

File diff suppressed because it is too large Load Diff

View File

@ -68,6 +68,7 @@ def load_stock_data(csv_path: str, target_date: int = None, all_stocks_mode: boo
'touchesUpper': int(row.get('touches_upper', '0')),
'touchesLower': int(row.get('touches_lower', '0')),
'volumeConfirmed': row.get('volume_confirmed', ''),
'boundaryUtilization': float(row.get('boundary_utilization', '0')),
'date': date,
'hasTriangle': True # 标记为有三角形形态
}
@ -77,6 +78,7 @@ def load_stock_data(csv_path: str, target_date: int = None, all_stocks_mode: boo
# 清理文件名中的非法字符
clean_name = stock['name'].replace('*', '').replace('?', '').replace('"', '').replace('<', '').replace('>', '').replace('|', '').replace(':', '').replace('/', '').replace('\\', '')
stock['chartPath'] = f"charts/{date}_{stock_code}_{clean_name}.png"
stock['chartPathDetail'] = f"charts/{date}_{stock_code}_{clean_name}_detail.png"
if stock_code not in stocks_map or stocks_map[stock_code]['strength'] < stock['strength']:
stocks_map[stock_code] = stock
@ -103,8 +105,10 @@ def load_stock_data(csv_path: str, target_date: int = None, all_stocks_mode: boo
'touchesUpper': 0,
'touchesLower': 0,
'volumeConfirmed': '',
'boundaryUtilization': 0.0,
'date': use_date,
'chartPath': f"charts/{use_date}_{code}_{clean_name}.png",
'chartPathDetail': f"charts/{use_date}_{code}_{clean_name}_detail.png",
'hasTriangle': False # 标记为无三角形形态
}
@ -873,6 +877,33 @@ def generate_html(stocks: list, date: int, output_path: str):
color: var(--accent-warm);
}
/* Detail Toggle */
.detail-toggle {
padding: 10px 18px;
background: var(--bg-card);
border: 1px solid var(--border-subtle);
border-radius: 10px;
color: var(--text-secondary);
font-size: 14px;
font-family: 'Noto Sans SC', sans-serif;
cursor: pointer;
transition: all var(--transition-fast);
display: flex;
align-items: center;
gap: 6px;
}
.detail-toggle:hover {
border-color: var(--accent-primary);
color: var(--accent-primary);
}
.detail-toggle.active {
background: rgba(0, 212, 170, 0.1);
border-color: var(--accent-primary);
color: var(--accent-primary);
}
/* Filter Chips */
.filter-chips {
display: flex;
@ -1033,6 +1064,16 @@ def generate_html(stocks: list, date: int, output_path: str):
background: var(--accent-cool);
}
</style>
<script>
// 工具函数需要在页面加载前定义因为HTML元素的onerror会直接调用
function handleModalImageError(img) {
const fallbackSrc = img.dataset.fallbackSrc;
if (fallbackSrc && img.src !== fallbackSrc && img.dataset.fallbackTried !== 'true') {
img.dataset.fallbackTried = 'true';
img.src = fallbackSrc;
}
}
</script>
</head>
<body>
<div class="app-container">
@ -1062,6 +1103,9 @@ def generate_html(stocks: list, date: int, output_path: str):
</select>
<button class="sort-toggle active" id="sortOrder" title="切换排序顺序"></button>
</div>
<button class="detail-toggle" id="detailToggle" title="切换详细图">
详细图:
</button>
<button class="reset-btn" id="resetBtn">
<span></span> 重置筛选
</button>
@ -1136,13 +1180,14 @@ def generate_html(stocks: list, date: int, output_path: str):
<div id="imageModal" class="modal">
<div class="modal-content">
<button class="close-modal" onclick="closeModal()">&times;</button>
<img id="modalImage" src="" alt="股票图表">
<img id="modalImage" src="" alt="股票图表" onerror="handleModalImageError(this)">
</div>
</div>
<script>
// 内嵌数据
const allStocks = STOCK_DATA;
let currentThreshold = 0;
let showDetailCharts = false;
// 筛选和排序状态
let filters = {
@ -1209,6 +1254,15 @@ def generate_html(stocks: list, date: int, output_path: str):
filterAndDisplayStocks();
});
// 详细图切换
const detailToggle = document.getElementById('detailToggle');
detailToggle.addEventListener('click', function() {
showDetailCharts = !showDetailCharts;
this.textContent = showDetailCharts ? '详细图: 开' : '详细图: 关';
this.classList.toggle('active', showDetailCharts);
filterAndDisplayStocks();
});
// 方向筛选芯片
const directionFilter = document.getElementById('directionFilter');
directionFilter.addEventListener('click', function(e) {
@ -1350,9 +1404,11 @@ def generate_html(stocks: list, date: int, output_path: str):
stock.direction === 'down' ? 'direction-down' : '';
const volumeText = stock.volumeConfirmed === 'True' ? '' :
stock.volumeConfirmed === 'False' ? '' : '';
const chartPath = showDetailCharts ? stock.chartPathDetail : stock.chartPath;
const fallbackPath = showDetailCharts ? stock.chartPath : stock.chartPathDetail;
return `
<div class="stock-card" onclick="openModal('${stock.chartPath}')">
<div class="stock-card" onclick="openModal('${stock.chartPath}', '${stock.chartPathDetail}')">
<div class="card-header">
<div class="stock-identity">
<div class="stock-name">${stock.name}</div>
@ -1381,11 +1437,16 @@ def generate_html(stocks: list, date: int, output_path: str):
<span class="metric-label">放量确认</span>
<span class="metric-value">${volumeText}</span>
</div>
<div class="metric-item">
<span class="metric-label">边界利用率</span>
<span class="metric-value">${(stock.boundaryUtilization || 0).toFixed(2)}</span>
</div>
</div>
<div class="chart-container">
<img src="${stock.chartPath}"
<img src="${chartPath}"
alt="${stock.name}"
class="stock-chart"
data-fallback-src="${fallbackPath}"
onerror="handleImageError(this)">
</div>
</div>
@ -1394,6 +1455,12 @@ def generate_html(stocks: list, date: int, output_path: str):
}
function handleImageError(img) {
const fallbackSrc = img.dataset.fallbackSrc;
if (fallbackSrc && img.src !== fallbackSrc && img.dataset.fallbackTried !== 'true') {
img.dataset.fallbackTried = 'true';
img.src = fallbackSrc;
return;
}
if (img.dataset.errorHandled) return;
img.dataset.errorHandled = 'true';
img.style.display = 'none';
@ -1403,9 +1470,14 @@ def generate_html(stocks: list, date: int, output_path: str):
img.parentElement.appendChild(noChart);
}
function openModal(imagePath) {
function openModal(defaultPath, detailPath) {
event.stopPropagation();
document.getElementById('modalImage').src = imagePath;
const modalImage = document.getElementById('modalImage');
const targetPath = showDetailCharts ? detailPath : defaultPath;
const fallbackPath = showDetailCharts ? defaultPath : detailPath;
modalImage.dataset.fallbackTried = 'false';
modalImage.dataset.fallbackSrc = fallbackPath;
modalImage.src = targetPath;
document.getElementById('imageModal').classList.add('show');
}

View File

@ -285,6 +285,30 @@ def plot_triangle(
# 详细模式:显示拟合点(仅在 show_details=True 且有三角形时)
# ========================================================================
if show_details and has_triangle and has_enough_data:
# 标注所有枢轴点(用于查看拐点分布)
if len(ph_display_idx) > 0:
ax1.scatter(
ph_display_idx,
high_win[ph_idx],
marker='x',
s=60,
color='red',
alpha=0.6,
zorder=4,
label=f'上沿枢轴点({len(ph_idx)})',
)
if len(pl_display_idx) > 0:
ax1.scatter(
pl_display_idx,
low_win[pl_idx],
marker='x',
s=60,
color='green',
alpha=0.6,
zorder=4,
label=f'下沿枢轴点({len(pl_idx)})',
)
# 标注选中的枢轴点(用于拟合线的关键点)
if len(selected_ph_display) >= 2:
ax1.scatter(
@ -324,6 +348,14 @@ def plot_triangle(
strength = max(result.breakout_strength_up, result.breakout_strength_down)
price_score = max(result.price_score_up, result.price_score_down)
# 获取边界利用率与惩罚系数(兼容旧数据)
boundary_util = getattr(result, 'boundary_utilization', 0.0)
utilization_floor = 0.20
if utilization_floor > 0:
utilization_penalty = min(1.0, boundary_util / utilization_floor)
else:
utilization_penalty = 1.0
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)}个交易日) "
@ -331,8 +363,9 @@ def plot_triangle(
f"枢轴点: 高{len(ph_idx)}/低{len(pl_idx)} 触碰: 上{result.touches_upper}/下{result.touches_lower} "
f"放量确认: {'' if result.volume_confirmed else '' if result.volume_confirmed is False else '-'}\n"
f"强度分: {strength:.3f} "
f"(价格: {price_score:.3f}×50% + 收敛: {result.convergence_score:.3f}×20% + "
f"成交量: {result.volume_score:.3f}×15% + 拟合贴合度: {result.fitting_score:.3f}×15%)",
f"(价格: {price_score:.3f}×50% + 收敛: {result.convergence_score:.3f}×15% + "
f"成交量: {result.volume_score:.3f}×10% + 拟合贴合度: {result.fitting_score:.3f}×10% + "
f"边界利用率: {boundary_util:.3f}×15%) × 利用率惩罚: {utilization_penalty:.2f}",
fontsize=11, pad=10
)
else:
@ -396,6 +429,11 @@ def main() -> None:
action="store_true",
help="为所有108只股票生成图表包括不满足收敛三角形条件的",
)
parser.add_argument(
"--clear",
action="store_true",
help="清空当前模式的旧图片(默认不清空)",
)
args = parser.parse_args()
# 确定是否显示详细信息(命令行参数优先)
@ -467,22 +505,24 @@ def main() -> None:
print("当日无满足条件的股票")
return
# 4. 创建输出目录并清空对应模式的旧图片
# 4. 创建输出目录按需清空对应模式的旧图片
os.makedirs(args.output_dir, exist_ok=True)
# 只清空当前模式的图片(简洁模式或详细模式)
print(f"\n[4] 清空当前模式的旧图片...")
suffix = "_detail.png" if show_details else ".png"
# 找出当前模式的文件简洁模式是不含_detail的.png详细模式是_detail.png
if show_details:
old_files = [f for f in os.listdir(args.output_dir) if f.endswith('_detail.png')]
if args.clear:
# 只清空当前模式的图片(简洁模式或详细模式)
print(f"\n[4] 清空当前模式的旧图片...")
# 找出当前模式的文件简洁模式是不含_detail的.png详细模式是_detail.png
if show_details:
old_files = [f for f in os.listdir(args.output_dir) if f.endswith('_detail.png')]
else:
old_files = [f for f in os.listdir(args.output_dir)
if f.endswith('.png') and not f.endswith('_detail.png')]
for f in old_files:
os.remove(os.path.join(args.output_dir, f))
print(f" 已删除 {len(old_files)} 个旧图片 ({'详细模式' if show_details else '简洁模式'})")
else:
old_files = [f for f in os.listdir(args.output_dir)
if f.endswith('.png') and not f.endswith('_detail.png')]
for f in old_files:
os.remove(os.path.join(args.output_dir, f))
print(f" 已删除 {len(old_files)} 个旧图片 ({'详细模式' if show_details else '简洁模式'})")
print(f"\n[4] 跳过清空旧图片(使用 --clear 可手动清空)")
# 5. 检测参数(从统一配置导入)
params = DETECTION_PARAMS

View File

@ -80,6 +80,7 @@ class ConvergingTriangleResult:
convergence_score: float = 0.0 # 收敛分数
volume_score: float = 0.0 # 成交量分数
fitting_score: float = 0.0 # 拟合贴合度分数
boundary_utilization: float = 0.0 # 边界利用率分数
# 几何属性
upper_slope: float = 0.0
@ -811,6 +812,72 @@ def calc_fitting_adherence(
return min(1.0, max(0.0, adherence_score))
def calc_boundary_utilization(
high: np.ndarray,
low: np.ndarray,
upper_slope: float,
upper_intercept: float,
lower_slope: float,
lower_intercept: float,
start: int,
end: int,
) -> float:
"""
计算边界利用率 (0~1)
衡量价格走势对三角形通道空间的利用程度
如果价格总是远离边界线大量空白利用率低
如果价格频繁接近或触碰边界线利用率高
计算方法
1. 对窗口内每一天计算价格到上下边界的相对距离
2. 利用率 = 1 - 平均相对空白比例
归一化映射
- 空白 0% 利用率 1.00 (价格完全贴合边界)
- 空白 25% 利用率 0.75 (良好)
- 空白 50% 利用率 0.50 (一般)
- 空白 75% 利用率 0.25 (较差大量空白)
Args:
high, low: 价格数据
upper_slope, upper_intercept: 上沿线参数
lower_slope, lower_intercept: 下沿线参数
start, end: 窗口范围
Returns:
utilization: 0~1越大表示利用率越高
"""
total_utilization = 0.0
valid_days = 0
for i in range(start, end + 1):
upper_line = upper_slope * i + upper_intercept
lower_line = lower_slope * i + lower_intercept
channel_width = upper_line - lower_line
if channel_width <= 0:
continue
# 计算价格到边界的距离(价格应在通道内,所以距离 >= 0
dist_to_upper = max(0.0, upper_line - high[i])
dist_to_lower = max(0.0, low[i] - lower_line)
# 当日空白比例 = (到上沿距离 + 到下沿距离) / 通道宽度
blank_ratio = (dist_to_upper + dist_to_lower) / channel_width
# 当日利用率 = 1 - 空白比例,限制在 [0, 1]
day_utilization = max(0.0, min(1.0, 1.0 - blank_ratio))
total_utilization += day_utilization
valid_days += 1
if valid_days == 0:
return 0.0
return total_utilization / valid_days
def calc_breakout_strength(
close: float,
upper_line: float,
@ -818,7 +885,8 @@ def calc_breakout_strength(
volume_ratio: float,
width_ratio: float,
fitting_adherence: float,
) -> Tuple[float, float, float, float, float, float]:
boundary_utilization: float,
) -> Tuple[float, float, float, float, float, float, float, float]:
"""
计算形态强度分 (0~1)
@ -827,9 +895,13 @@ def calc_breakout_strength(
使用加权求和各分量权重
- 突破幅度分 (50%): tanh 非线性归一化3%突破0.425%突破0.6410%突破0.91
- 收敛分 (20%): 1 - width_ratio收敛越强分数越高
- 成交量分 (15%): 放量程度2倍放量=满分
- 拟合贴合度 (15%): 枢轴点到拟合线的贴合程度形态纯度
- 收敛分 (15%): 1 - width_ratio收敛越强分数越高
- 成交量分 (10%): 放量程度2倍放量=满分
- 拟合贴合度 (10%): 枢轴点到拟合线的贴合程度形态纯度
- 边界利用率 (15%): 价格走势对通道空间的利用程度
额外惩罚项
- 当边界利用率过低时对总强度进行空白惩罚避免通道很宽但价格很空的误判
突破幅度分布参考使用 tanh(pct * 15)
- 1% 突破 0.15
@ -846,19 +918,23 @@ def calc_breakout_strength(
volume_ratio: 成交量相对均值的倍数
width_ratio: 末端宽度/起始宽度
fitting_adherence: 拟合贴合度分数 (0~1)
boundary_utilization: 边界利用率分数 (0~1)
Returns:
(strength_up, strength_down, price_score_up, price_score_down, convergence_score, vol_score, fitting_score)
(strength_up, strength_down, price_score_up, price_score_down,
convergence_score, vol_score, fitting_score, boundary_util_score)
返回总强度和各分量分数用于可视化和分析
"""
import math
# 权重配置
W_PRICE = 0.50 # 突破幅度权重
W_CONVERGENCE = 0.20 # 收敛度权重
W_VOLUME = 0.15 # 成交量权重
W_FITTING = 0.15 # 拟合贴合度权重
TANH_SCALE = 15.0 # tanh 缩放因子
# 权重配置(调整后,总和 = 100%
W_PRICE = 0.50 # 突破幅度权重
W_CONVERGENCE = 0.15 # 收敛度权重(原 20%,降低)
W_VOLUME = 0.10 # 成交量权重(原 15%,降低)
W_FITTING = 0.10 # 拟合贴合度权重(原 15%,降低)
W_UTILIZATION = 0.15 # 边界利用率权重(新增)
TANH_SCALE = 15.0 # tanh 缩放因子
UTILIZATION_FLOOR = 0.20 # 边界利用率下限(用于空白惩罚)
# 1. 价格突破分数tanh 非线性归一化)
if upper_line > 0:
@ -882,22 +958,36 @@ def calc_breakout_strength(
# 4. 拟合贴合度分数(直接使用传入的分数)
fitting_score = max(0.0, min(1.0, fitting_adherence))
# 5. 加权求和(计算综合强度分)
# 5. 边界利用率分数(直接使用传入的分数)
boundary_util_score = max(0.0, min(1.0, boundary_utilization))
# 6. 加权求和(计算综合强度分)
# 不再要求必须突破,而是计算形态的综合质量分数
strength_up = (
W_PRICE * price_score_up +
W_CONVERGENCE * convergence_score +
W_VOLUME * vol_score +
W_FITTING * fitting_score
W_FITTING * fitting_score +
W_UTILIZATION * boundary_util_score
)
strength_down = (
W_PRICE * price_score_down +
W_CONVERGENCE * convergence_score +
W_VOLUME * vol_score +
W_FITTING * fitting_score
W_FITTING * fitting_score +
W_UTILIZATION * boundary_util_score
)
# 7. 空白惩罚(边界利用率过低时降低总分)
if UTILIZATION_FLOOR > 0:
utilization_penalty = min(1.0, boundary_util_score / UTILIZATION_FLOOR)
else:
utilization_penalty = 1.0
strength_up *= utilization_penalty
strength_down *= utilization_penalty
return (
min(1.0, strength_up),
min(1.0, strength_down),
@ -905,7 +995,8 @@ def calc_breakout_strength(
price_score_down,
convergence_score,
vol_score,
fitting_score
fitting_score,
boundary_util_score
)
@ -1123,16 +1214,30 @@ def detect_converging_triangle(
# 综合上下沿贴合度(取平均)
fitting_adherence = (adherence_upper + adherence_lower) / 2.0
# 计算边界利用率(价格走势对三角形通道的利用程度)
boundary_utilization = calc_boundary_utilization(
high=high,
low=low,
upper_slope=a_u,
upper_intercept=b_u,
lower_slope=a_l,
lower_intercept=b_l,
start=start,
end=end,
)
# 计算突破强度(返回总强度和各分量分数)
(strength_up, strength_down,
price_score_up, price_score_down,
convergence_score, vol_score, fitting_score) = calc_breakout_strength(
convergence_score, vol_score, fitting_score,
boundary_util_score) = calc_breakout_strength(
close=close[end],
upper_line=upper_end,
lower_line=lower_end,
volume_ratio=volume_ratio,
width_ratio=width_ratio,
fitting_adherence=fitting_adherence,
boundary_utilization=boundary_utilization,
)
return ConvergingTriangleResult(
@ -1146,6 +1251,7 @@ def detect_converging_triangle(
convergence_score=convergence_score,
volume_score=vol_score,
fitting_score=fitting_score,
boundary_utilization=boundary_util_score,
upper_slope=a_u,
lower_slope=a_l,
width_ratio=width_ratio,