diff --git a/discuss/20260129-讨论.md b/discuss/20260129-讨论.md
new file mode 100644
index 0000000..e69de29
diff --git a/docs/2026-01-29_迁移到data_server.md b/docs/2026-01-29_迁移到data_server.md
new file mode 100644
index 0000000..4e04440
--- /dev/null
+++ b/docs/2026-01-29_迁移到data_server.md
@@ -0,0 +1,111 @@
+## 1. `三角形强度()` - 全市场批量筛选
+
+**作用**:对全市场所有股票、所有日期进行批量检测,返回强度矩阵,用于筛选和排序。
+
+**入参**:
+| 参数 | 类型 | 默认值 | 说明 |
+|-----|------|--------|------|
+| `window` | int | 240 | 检测窗口(交易日) |
+| `min_convergence` | float | 0.45 | 最小收敛比例(末端/起始宽度) |
+| `breakout_threshold` | float | 0.005 | 突破阈值(0.5%) |
+| `volume_multiplier` | float | 1.5 | 放量确认倍数 |
+| `start_day` | int | -1 | 起始日索引(-1=自动) |
+| `end_day` | int | -1 | 结束日索引(-1=自动) |
+
+**返回值**:
+```python
+np.ndarray # shape: (n_stocks, n_days)
+```
+- 正值 `+0 ~ +1`:向上突破/潜力,值越大越强
+- 负值 `-1 ~ -0`:向下突破/潜力,绝对值越大越强
+- `NaN`:无有效形态
+
+**使用场景**:
+```python
+强度 = 三角形强度()
+
+# 筛选最新一天强向上的股票
+强向上 = 强度[:, -1] > 0.5
+
+# 按强度排序
+排名 = np.argsort(强度[:, -1])[::-1]
+```
+
+---
+
+## 2. `三角形详情(ticker)` - 单股票详情
+
+**作用**:对单只股票进行检测,返回完整详情(强度分量、几何属性、图表数据),用于展示和分析。
+
+**入参**:
+| 参数 | 类型 | 默认值 | 说明 |
+|-----|------|--------|------|
+| `ticker` | str | 必填 | 股票代码,如 `"SH600519"` |
+| `window` | int | 240 | 检测窗口 |
+| `min_convergence` | float | 0.45 | 最小收敛比例 |
+| `breakout_threshold` | float | 0.005 | 突破阈值 |
+| `volume_multiplier` | float | 1.5 | 放量倍数 |
+| `display_days` | int | 300 | 图表显示天数 |
+
+**返回值**:`TriangleDetail` 对象
+
+| 字段 | 类型 | 说明 |
+|-----|------|------|
+| `strength` | float | 综合强度 (-1 ~ +1) |
+| `is_valid` | bool | 是否有效形态 |
+| `direction` | str | `"up"` / `"down"` / `"none"` |
+| `strength_up` | float | 向上强度原始值 (0~1) |
+| `strength_down` | float | 向下强度原始值 (0~1) |
+| `convergence_score` | float | 收敛分 (0~1) |
+| `volume_score` | float | 成交量分 (0~1) |
+| `fitting_score` | float | 拟合分 (0~1) |
+| `width_ratio` | float | 收敛比例 |
+| `upper_slope` | float | 上沿斜率 |
+| `lower_slope` | float | 下沿斜率 |
+| `touches_upper` | int | 触碰上沿次数 |
+| `touches_lower` | int | 触碰下沿次数 |
+| `volume_confirmed` | bool | 成交量是否确认 |
+| `chart_data` | dict | 图表数据(ECharts 格式) |
+
+**`chart_data` 结构**:
+```python
+{
+ 'dates': [20260101, 20260102, ...], # 日期
+ 'candlestick': [[o,c,l,h], ...], # K线数据
+ 'upper_line': [[x1,y1], [x2,y2]], # 上沿线
+ 'lower_line': [[x1,y1], [x2,y2]], # 下沿线
+ 'detection_window': [start, end], # 检测窗口
+ 'ticker': "SH600519",
+ 'strength': 0.65,
+ 'direction': "up",
+}
+```
+
+**使用场景**:
+```python
+详情 = 三角形详情("SH600519")
+
+# 查看结果
+print(f"强度: {详情.strength}")
+print(f"方向: {详情.direction}")
+print(f"收敛度: {详情.width_ratio}")
+
+# 前端绑定
+图表配置 = 详情.chart_data
+```
+
+---
+
+## 典型工作流
+
+```python
+# 1. 批量筛选
+强度 = 三角形强度()
+目标股票索引 = np.where(强度[:, -1] > 0.5)[0]
+
+# 2. 获取详情
+tkrs = g.load_pkl()['tkrs']
+for idx in 目标股票索引[:10]:
+ 详情 = 三角形详情(tkrs[idx])
+ print(f"{tkrs[idx]}: {详情.strength:.2f}, {详情.direction}")
+```
\ No newline at end of file
diff --git a/docs/triangle_api_reference.md b/docs/triangle_api_reference.md
new file mode 100644
index 0000000..6e438aa
--- /dev/null
+++ b/docs/triangle_api_reference.md
@@ -0,0 +1,475 @@
+# 收敛三角形检测 API 参考文档
+
+## 概览
+
+`triangle_detector_api.py` 提供收敛三角形形态检测的完整封装,核心设计:
+
+- **主函数 `detect_matrix()`**:全市场矩阵批量检测(如万得全A所有A股)
+- **一个 `strength` 搞定筛选**:正值=向上,负值=向下,绝对值=强度
+- **4 个核心参数**:都有合理默认值,大多数场景无需调整
+
+---
+
+## 快速开始
+
+```python
+from triangle_detector_api import detect_matrix
+
+# 输入:OHLCV 矩阵 (n_stocks, n_days)
+df = detect_matrix(
+ high_mtx=high_mtx, # shape: (n_stocks, n_days)
+ low_mtx=low_mtx,
+ close_mtx=close_mtx,
+ volume_mtx=volume_mtx,
+ dates=dates, # shape: (n_days,)
+ tkrs=tkrs, # shape: (n_stocks,) 股票代码
+ tkrs_name=tkrs_name, # shape: (n_stocks,) 股票名称
+)
+
+# 筛选
+df[df['strength'] > 0.5] # 强向上突破
+df[df['strength'] < -0.5] # 强向下突破
+df.sort_values('strength', ascending=False) # 排序
+```
+
+---
+
+## 核心函数
+
+### `detect_matrix()` ⭐ 主函数
+
+全市场矩阵批量检测:每只股票每天都进行检测。适用于万得全A等全市场扫描场景。
+
+```python
+def detect_matrix(
+ high_mtx: np.ndarray, # (n_stocks, n_days)
+ low_mtx: np.ndarray,
+ close_mtx: np.ndarray,
+ volume_mtx: np.ndarray,
+ dates: np.ndarray, # (n_days,)
+ tkrs: np.ndarray = None, # (n_stocks,) 股票代码
+ tkrs_name: np.ndarray = None, # (n_stocks,) 股票名称
+ params: DetectionParams = None,
+ start_day: int = None,
+ end_day: int = None,
+ only_valid: bool = True,
+ verbose: bool = False,
+) -> pd.DataFrame
+```
+
+**参数说明:**
+
+| 参数 | 类型 | 必填 | 说明 |
+|------|------|------|------|
+| `high_mtx` | ndarray | ✅ | 最高价矩阵 (n_stocks, n_days) |
+| `low_mtx` | ndarray | ✅ | 最低价矩阵 |
+| `close_mtx` | ndarray | ✅ | 收盘价矩阵 |
+| `volume_mtx` | ndarray | ✅ | 成交量矩阵 |
+| `dates` | ndarray | ✅ | 日期数组 (n_days,) |
+| `tkrs` | ndarray | ❌ | 股票代码数组 |
+| `tkrs_name` | ndarray | ❌ | 股票名称数组 |
+| `params` | DetectionParams | ❌ | 检测参数 |
+| `start_day` | int | ❌ | 起始日索引 |
+| `end_day` | int | ❌ | 结束日索引 |
+| `only_valid` | bool | ❌ | 只返回有效形态,默认 True |
+| `verbose` | bool | ❌ | 打印进度 |
+
+**返回值:** DataFrame
+
+| 列名 | 说明 |
+|------|------|
+| `stock_idx` | 股票索引 |
+| `stock_code` | 股票代码 |
+| `stock_name` | 股票名称 |
+| `date_idx` | 日期索引 |
+| `date` | 日期 |
+| `strength` | **强度分(核心输出)** |
+| `is_valid` | 是否有效形态 |
+| `direction` | 突破方向 |
+| `width_ratio` | 收敛比例 |
+
+**示例:**
+
+```python
+from triangle_detector_api import detect_matrix
+
+# 批量检测
+df = detect_matrix(high, low, close, volume, dates, tkrs, tkrs_name, verbose=True)
+
+# 筛选
+strong_up = df[df['strength'] > 0.5]
+strong_down = df[df['strength'] < -0.5]
+
+# 某日最强股票
+latest_date = df['date'].max()
+df[df['date'] == latest_date].sort_values('strength', ascending=False).head(10)
+```
+
+---
+
+### `detect_triangle()`
+
+单股票检测(用于生成图表数据)。
+
+```python
+def detect_triangle(
+ ohlc_data: Dict,
+ params: DetectionParams = None,
+ include_pivots: bool = True,
+ display_window: int = None,
+) -> DetectionResult
+```
+
+**ohlc_data 格式:**
+
+```python
+{'dates': [...], 'open': [...], 'high': [...], 'low': [...], 'close': [...], 'volume': [...]}
+```
+
+**返回值:** `DetectionResult` 对象,包含 `chart_data` 可生成 ECharts 配置
+
+---
+
+## 入参配置
+
+### `DetectionParams`
+
+检测参数类,所有参数都有合理默认值。
+
+```python
+from triangle_detector_api import DetectionParams
+
+# 使用默认参数(推荐)
+params = DetectionParams()
+
+# 自定义参数
+params = DetectionParams(
+ window=120,
+ min_convergence=0.6,
+)
+```
+
+**可调参数:**
+
+| 参数 | 类型 | 默认值 | 说明 |
+|------|------|--------|------|
+| `window` | int | 240 | 检测窗口大小(交易日) |
+| `min_convergence` | float | 0.45 | 收敛比例阈值(末端宽度/起始宽度) |
+| `breakout_threshold` | float | 0.005 | 突破阈值(0.5%) |
+| `volume_multiplier` | float | 1.5 | 放量确认倍数 |
+
+**参数调整建议:**
+
+| 参数 | 较小值效果 | 较大值效果 |
+|------|-----------|-----------|
+| `window` | 检测短期形态,信号及时 | 检测中长期形态,信号可靠 |
+| `min_convergence` | 要求更强收敛,形态标准 | 放宽收敛,更多候选 |
+| `breakout_threshold` | 更敏感,可能假突破 | 减少假突破,可能漏信号 |
+| `volume_multiplier` | 允许轻度放量 | 要求更强放量确认 |
+
+---
+
+## 输出结构
+
+### `DetectionResult`
+
+检测结果类。
+
+```python
+@dataclass
+class DetectionResult:
+ # 核心输出
+ strength: float # 综合强度分 (-1 ~ +1)
+ is_valid: bool # 是否检测到有效形态
+ direction: BreakoutDirection # 突破方向
+
+ # 辅助输出
+ strength_up: float # 向上强度原始值 (0~1)
+ strength_down: float # 向下强度原始值 (0~1)
+ strength_components: StrengthComponents # 强度分分量
+
+ # 形态几何属性
+ width_ratio: float # 收敛比例
+ touches_upper: int # 触碰上沿次数
+ touches_lower: int # 触碰下沿次数
+ volume_confirmed: bool # 成交量确认
+
+ # 前端数据
+ chart_data: ChartData # 图表数据
+```
+
+### 核心字段:`strength`
+
+**范围:-1 ~ +1**
+
+| 范围 | 含义 | 建议操作 |
+|------|------|----------|
+| +0.7 ~ +1.0 | 强向上突破 ⬆️⬆️⬆️ | 重点关注 |
+| +0.4 ~ +0.7 | 中等向上 ⬆️⬆️ | 关注 |
+| +0.2 ~ +0.4 | 向上潜力 ⬆️ | 观察 |
+| 0 | 无效形态 | 忽略 |
+| -0.2 ~ -0.4 | 向下潜力 ⬇️ | 观察 |
+| -0.4 ~ -0.7 | 中等向下 ⬇️⬇️ | 关注 |
+| -0.7 ~ -1.0 | 强向下突破 ⬇️⬇️⬇️ | 重点关注 |
+
+### `StrengthComponents`
+
+强度分各分量,便于理解分数构成。
+
+| 分量 | 权重 | 说明 |
+|------|------|------|
+| `price_score` | 50% | 价格突破幅度 |
+| `convergence_score` | 15% | 收敛程度 |
+| `volume_score` | 10% | 放量程度 |
+| `fitting_score` | 10% | 拟合贴合度 |
+| `utilization_score` | 15% | 边界利用率 |
+
+---
+
+## 筛选示例
+
+### 基础筛选
+
+```python
+from triangle_detector_api import detect_matrix
+
+# 批量检测
+df = detect_matrix(high, low, close, volume, dates, tkrs, tkrs_name)
+
+# 强向上突破
+df[df['strength'] > 0.5]
+
+# 强向下突破
+df[df['strength'] < -0.5]
+
+# 任意强信号
+df[abs(df['strength']) > 0.3]
+
+# 按强度排序
+df.sort_values('strength', ascending=False)
+```
+
+### 按日期筛选
+
+```python
+# 某日的检测结果
+target_date = 20260120
+df_day = df[df['date'] == target_date]
+
+# 某日最强的10只股票
+df_day.sort_values('strength', ascending=False).head(10)
+```
+
+### 按股票筛选
+
+```python
+# 某只股票的历史检测结果
+df_stock = df[df['stock_code'] == '000001.SZ']
+
+# 查看该股票的强度分变化
+df_stock[['date', 'strength', 'direction']].tail(20)
+```
+
+### 统计分析
+
+```python
+# 每日有效形态数量
+df.groupby('date')['is_valid'].sum()
+
+# 每日强向上突破数量
+df[df['strength'] > 0.5].groupby('date').size()
+
+# 强度分分布
+df['strength'].describe()
+```
+
+---
+
+## 前端集成
+
+### Vue3 + ECharts
+
+**1. 安装依赖**
+
+```bash
+npm install echarts vue-echarts
+```
+
+**2. 获取 ECharts 配置**
+
+```python
+result = detect_triangle(ohlc_data)
+
+if result.chart_data:
+ echarts_option = result.chart_data.to_echarts_option("股票名称")
+ # echarts_option 可直接用于前端
+```
+
+**3. Vue3 组件使用**
+
+```vue
+
+
+
+
+
+```
+
+### ChartData 结构
+
+```python
+@dataclass
+class ChartData:
+ candlestick: List[List[float]] # K线数据 [[open, close, low, high], ...]
+ dates: List[str] # 日期数组
+ volumes: List[float] # 成交量
+ upper_line: Optional[TrendLine] # 上沿趋势线
+ lower_line: Optional[TrendLine] # 下沿趋势线
+ pivots: List[PivotPoint] # 枢轴点列表
+ detection_window: Tuple[int, int] # 检测窗口范围
+```
+
+**注意:** ECharts K线格式为 `[open, close, low, high]`,与标准 OHLC 顺序不同。
+
+---
+
+## 完整示例
+
+```python
+from triangle_detector_api import detect_triangle, DetectionParams, quick_detect
+
+# ============ 示例1:基础使用 ============
+ohlc_data = {
+ 'dates': dates_list,
+ 'open': open_list,
+ 'high': high_list,
+ 'low': low_list,
+ 'close': close_list,
+ 'volume': volume_list,
+}
+
+result = detect_triangle(ohlc_data)
+
+print(f"是否有效形态: {result.is_valid}")
+print(f"强度分: {result.strength}")
+print(f"突破方向: {result.direction.value}")
+
+if result.is_valid:
+ print(f"收敛比例: {result.width_ratio:.2f}")
+ print(f"触碰次数: 上沿 {result.touches_upper}, 下沿 {result.touches_lower}")
+
+# ============ 示例2:自定义参数 ============
+params = DetectionParams(
+ window=120, # 短窗口,检测短期形态
+ min_convergence=0.6, # 放宽收敛要求
+ breakout_threshold=0.01, # 更严格的突破阈值
+)
+result = detect_triangle(ohlc_data, params)
+
+# ============ 示例3:快速筛选 ============
+strength = quick_detect(
+ dates=dates_list,
+ open_=open_list,
+ high=high_list,
+ low=low_list,
+ close=close_list,
+ volume=volume_list,
+)
+
+if strength > 0.5:
+ print("发现强向上突破信号!")
+elif strength < -0.5:
+ print("发现强向下突破信号!")
+
+# ============ 示例4:获取前端数据 ============
+if result.chart_data:
+ # 获取 ECharts 配置
+ echarts_option = result.chart_data.to_echarts_option("000001.SZ 平安银行")
+
+ # 转为 JSON(用于 API 响应)
+ import json
+ json_data = json.dumps(echarts_option, ensure_ascii=False)
+```
+
+---
+
+## 常见问题
+
+### Q: 为什么强度分为 0?
+
+**可能原因:**
+1. 数据长度不足(需要至少 `window` 天的数据)
+2. 未形成有效的收敛三角形形态
+3. 斜率不满足收敛要求(上沿应向下,下沿应向上)
+
+### Q: 如何调整检测敏感度?
+
+```python
+# 更严格(高质量形态)
+params = DetectionParams(
+ min_convergence=0.4, # 要求更强收敛
+ breakout_threshold=0.01, # 要求更明显突破
+ volume_multiplier=1.8, # 要求更强放量
+)
+
+# 更宽松(更多候选)
+params = DetectionParams(
+ min_convergence=0.7, # 放宽收敛要求
+ breakout_threshold=0.002, # 允许轻微突破
+ volume_multiplier=1.2, # 允许轻度放量
+)
+```
+
+### Q: 图表数据格式与其他库不兼容?
+
+ECharts K线格式为 `[open, close, low, high]`。
+
+如需标准 OHLC 格式 `[open, high, low, close]`,可自行转换:
+
+```python
+# ECharts -> 标准 OHLC
+def to_standard_ohlc(echarts_data):
+ return [[row[0], row[3], row[2], row[1]] for row in echarts_data]
+```
+
+---
+
+## 版本信息
+
+- **模块**: `triangle_detector_api.py`
+- **核心依赖**: `converging_triangle.py`(自动使用 Numba 优化版本)
+- **Python**: 3.8+
+- **NumPy**: 1.20+
+- **Numba**: 0.55+(可选,安装后自动启用加速)
+
+> **性能优化说明**:`converging_triangle.py` 会自动检测并加载 `converging_triangle_optimized.py`(Numba 加速版)。启动时会打印:
+> - `[性能优化] 已启用Numba加速` - 优化版本生效
+> - `[性能优化] 未启用Numba加速` - 回退到原版(numba 未安装)
diff --git a/src/triangle_detector_api.py b/src/triangle_detector_api.py
new file mode 100644
index 0000000..edd3dc0
--- /dev/null
+++ b/src/triangle_detector_api.py
@@ -0,0 +1,1001 @@
+"""
+收敛三角形检测 API
+
+主函数: detect_matrix() - 全市场矩阵批量检测
+核心输出: strength (-1 ~ +1),正=向上,负=向下,绝对值=强度
+
+Example:
+ df = detect_matrix(high, low, close, volume, dates, tkrs, tkrs_name)
+ df[df['strength'] > 0.5] # 强向上突破
+
+详细文档: docs/triangle_api_reference.md
+"""
+
+from __future__ import annotations
+
+import sys
+import os
+from dataclasses import dataclass, field, asdict
+from typing import Dict, List, Literal, Optional, Tuple, Any, Union
+from enum import Enum
+
+import numpy as np
+
+# 添加路径以导入核心模块
+sys.path.append(os.path.dirname(__file__))
+
+from converging_triangle import (
+ ConvergingTriangleParams,
+ detect_converging_triangle,
+ pivots_fractal,
+ pivots_fractal_hybrid,
+ fit_pivot_line_dispatch,
+ line_y,
+)
+
+
+# ============================================================================
+# 枚举类型定义
+# ============================================================================
+
+class BreakoutDirection(str, Enum):
+ """突破方向"""
+ UP = "up" # 向上突破
+ DOWN = "down" # 向下突破
+ NONE = "none" # 未突破
+
+
+# ============================================================================
+# 参数对象(用户可配置)
+# ============================================================================
+
+@dataclass
+class DetectionParams:
+ """检测参数,都有合理默认值,大多数场景无需调整"""
+ # ===== 用户可调参数 =====
+ window: int = 240
+ min_convergence: float = 0.45
+ breakout_threshold: float = 0.005
+ volume_multiplier: float = 1.5
+
+ # ===== 内部参数(已优化,无需调整) =====
+ _realtime_mode: bool = field(default=True, repr=False)
+ _pivot_lookback: int = field(default=15, repr=False)
+
+ def to_internal_params(self) -> ConvergingTriangleParams:
+ """转换为内部检测参数"""
+ return ConvergingTriangleParams(
+ window=self.window,
+ pivot_k=self._pivot_lookback,
+ boundary_n_segments=2,
+ boundary_source="full",
+ fitting_method="anchor",
+ upper_slope_max=0, # 上沿必须向下或水平
+ lower_slope_min=0, # 下沿必须向上或水平
+ touch_tol=0.10,
+ touch_loss_max=0.10,
+ shrink_ratio=self.min_convergence,
+ break_tol=self.breakout_threshold,
+ vol_window=20,
+ vol_k=self.volume_multiplier,
+ false_break_m=5,
+ )
+
+
+# ============================================================================
+# 图表数据结构(前端绑定用)
+# ============================================================================
+
+@dataclass
+class TrendLine:
+ """
+ 趋势线数据(用于 ECharts markLine)
+
+ 前端使用示例(ECharts):
+ markLine: {
+ data: [
+ [{coord: [startIndex, startPrice]}, {coord: [endIndex, endPrice]}]
+ ]
+ }
+ """
+ start_index: int # 起点X坐标(K线索引)
+ start_price: float # 起点Y坐标(价格)
+ end_index: int # 终点X坐标
+ end_price: float # 终点Y坐标
+ slope: float # 斜率
+ intercept: float # 截距
+
+ def to_echarts_markline(self) -> List[Dict]:
+ """转换为 ECharts markLine 格式"""
+ return [
+ {"coord": [self.start_index, self.start_price]},
+ {"coord": [self.end_index, self.end_price]}
+ ]
+
+
+@dataclass
+class PivotPoint:
+ """枢轴点数据"""
+ index: int
+ price: float
+ is_high: bool
+ is_confirmed: bool = True
+
+
+@dataclass
+class ChartData:
+ """前端图表数据,to_echarts_option() 直接生成 ECharts 配置"""
+ # K线数据(ECharts candlestick 格式)
+ candlestick: List[List[float]] # [[open, close, low, high], ...]
+ dates: List[str] # 日期数组
+ volumes: List[float] # 成交量数组
+
+ # 趋势线
+ upper_line: Optional[TrendLine] = None
+ lower_line: Optional[TrendLine] = None
+
+ # 枢轴点
+ pivots: List[PivotPoint] = field(default_factory=list)
+
+ # 检测窗口范围
+ detection_window: Optional[Tuple[int, int]] = None
+
+ # 交汇点(三角形顶点)
+ apex_index: Optional[float] = None # 可能在未来
+
+ def to_echarts_option(self, title: str = "") -> Dict[str, Any]:
+ """
+ 生成完整的 ECharts 配置项
+
+ 可直接用于 Vue3 + ECharts:
+
+
+ Returns:
+ ECharts option 配置对象
+ """
+ option = {
+ "title": {"text": title, "left": "center"},
+ "tooltip": {
+ "trigger": "axis",
+ "axisPointer": {"type": "cross"}
+ },
+ "legend": {
+ "data": ["K线", "成交量"],
+ "top": "30"
+ },
+ "grid": [
+ {"left": "10%", "right": "10%", "top": "15%", "height": "55%"},
+ {"left": "10%", "right": "10%", "top": "75%", "height": "15%"}
+ ],
+ "xAxis": [
+ {
+ "type": "category",
+ "data": self.dates,
+ "gridIndex": 0,
+ "axisLabel": {"rotate": 45}
+ },
+ {
+ "type": "category",
+ "data": self.dates,
+ "gridIndex": 1,
+ "axisLabel": {"show": False}
+ }
+ ],
+ "yAxis": [
+ {"type": "value", "gridIndex": 0, "scale": True},
+ {"type": "value", "gridIndex": 1, "scale": True}
+ ],
+ "dataZoom": [
+ {"type": "inside", "xAxisIndex": [0, 1], "start": 50, "end": 100},
+ {"type": "slider", "xAxisIndex": [0, 1], "start": 50, "end": 100}
+ ],
+ "series": []
+ }
+
+ # K线系列
+ candlestick_series = {
+ "name": "K线",
+ "type": "candlestick",
+ "data": self.candlestick,
+ "xAxisIndex": 0,
+ "yAxisIndex": 0,
+ "itemStyle": {
+ "color": "#ef232a", # 涨(阳线)
+ "color0": "#14b143", # 跌(阴线)
+ "borderColor": "#ef232a",
+ "borderColor0": "#14b143"
+ }
+ }
+
+ # 添加趋势线(markLine)
+ if self.upper_line or self.lower_line:
+ mark_line_data = []
+
+ if self.upper_line:
+ mark_line_data.append(self.upper_line.to_echarts_markline() + [
+ {"lineStyle": {"color": "#ef232a", "type": "dashed", "width": 2}}
+ ])
+
+ if self.lower_line:
+ mark_line_data.append(self.lower_line.to_echarts_markline() + [
+ {"lineStyle": {"color": "#14b143", "type": "dashed", "width": 2}}
+ ])
+
+ candlestick_series["markLine"] = {
+ "symbol": "none",
+ "data": mark_line_data
+ }
+
+ option["series"].append(candlestick_series)
+
+ # 成交量系列
+ option["series"].append({
+ "name": "成交量",
+ "type": "bar",
+ "data": self.volumes,
+ "xAxisIndex": 1,
+ "yAxisIndex": 1,
+ "itemStyle": {"color": "#7fbbe9"}
+ })
+
+ # 枢轴点系列(如果有)
+ if self.pivots:
+ high_pivots = [[p.index, p.price] for p in self.pivots if p.is_high]
+ low_pivots = [[p.index, p.price] for p in self.pivots if not p.is_high]
+
+ if high_pivots:
+ option["series"].append({
+ "name": "高点",
+ "type": "scatter",
+ "data": high_pivots,
+ "symbol": "triangle",
+ "symbolSize": 10,
+ "itemStyle": {"color": "#ef232a"}
+ })
+
+ if low_pivots:
+ option["series"].append({
+ "name": "低点",
+ "type": "scatter",
+ "data": low_pivots,
+ "symbol": "triangle",
+ "symbolRotate": 180,
+ "symbolSize": 10,
+ "itemStyle": {"color": "#14b143"}
+ })
+
+ return option
+
+ def to_dict(self) -> Dict[str, Any]:
+ """转换为字典(JSON序列化友好)"""
+ return {
+ "candlestick": self.candlestick,
+ "dates": self.dates,
+ "volumes": self.volumes,
+ "upper_line": asdict(self.upper_line) if self.upper_line else None,
+ "lower_line": asdict(self.lower_line) if self.lower_line else None,
+ "pivots": [asdict(p) for p in self.pivots],
+ "detection_window": self.detection_window,
+ "apex_index": self.apex_index,
+ }
+
+
+# ============================================================================
+# 检测结果
+# ============================================================================
+
+@dataclass
+class StrengthComponents:
+ """
+ 强度分分量(用于分析和可视化)
+
+ 总强度 = 价格分×50% + 收敛分×15% + 成交量分×10% + 拟合分×10% + 边界利用率×15%
+ """
+ price_score: float # 价格突破分数 (0~1)
+ convergence_score: float # 收敛分数 (0~1)
+ volume_score: float # 成交量分数 (0~1)
+ fitting_score: float # 拟合贴合度分数 (0~1)
+ utilization_score: float # 边界利用率分数 (0~1)
+
+ def to_dict(self) -> Dict[str, float]:
+ return asdict(self)
+
+
+@dataclass
+class DetectionResult:
+ """
+检测结果,strength 为核心输出(正=向上,负=向下)"""
+ # 核心输出
+ strength: float # 正=向上突破, 负=向下突破, 0=未突破/无效
+ is_valid: bool
+ direction: BreakoutDirection
+
+ # 辅助输出
+ strength_up: float = 0.0 # 向上强度原始值 (0~1)
+ strength_down: float = 0.0 # 向下强度原始值 (0~1)
+ strength_components: Optional[StrengthComponents] = None
+
+ # 形态几何属性
+ width_ratio: float = 0.0 # 收敛比例(末端/起始宽度)
+ touches_upper: int = 0 # 触碰上沿次数
+ touches_lower: int = 0 # 触碰下沿次数
+ volume_confirmed: Optional[bool] = None # 成交量确认
+
+ # 前端数据
+ chart_data: Optional[ChartData] = None
+
+ # 检测模式信息
+ detection_mode: str = "standard"
+
+ def to_dict(self) -> Dict[str, Any]:
+ """转换为字典(JSON序列化友好)"""
+ return {
+ "strength": self.strength,
+ "is_valid": self.is_valid,
+ "direction": self.direction.value,
+ "strength_up": self.strength_up,
+ "strength_down": self.strength_down,
+ "strength_components": self.strength_components.to_dict() if self.strength_components else None,
+ "width_ratio": self.width_ratio,
+ "touches_upper": self.touches_upper,
+ "touches_lower": self.touches_lower,
+ "volume_confirmed": self.volume_confirmed,
+ "chart_data": self.chart_data.to_dict() if self.chart_data else None,
+ "detection_mode": self.detection_mode,
+ }
+
+
+# ============================================================================
+# 核心检测函数
+# ============================================================================
+
+def detect_triangle(
+ ohlc_data: Dict[str, Union[List, np.ndarray]],
+ params: Optional[DetectionParams] = None,
+ include_pivots: bool = True,
+ display_window: Optional[int] = None,
+) -> DetectionResult:
+ """
+ 主检测函数
+
+ Args:
+ ohlc_data: OHLCV 数据字典 {dates, open, high, low, close, volume?}
+ params: 检测参数,默认使用推荐值
+ include_pivots: 是否包含枢轴点数据
+ display_window: 图表显示窗口大小
+
+ Returns:
+ DetectionResult: strength 为核心输出
+
+ Example:
+ >>> data = {
+ ... 'dates': ['2026-01-20', '2026-01-21', ...],
+ ... 'open': [10.1, 10.2, ...],
+ ... 'high': [10.5, 10.6, ...],
+ ... 'low': [9.9, 10.0, ...],
+ ... 'close': [10.3, 10.4, ...],
+ ... 'volume': [1000000, 1200000, ...]
+ ... }
+ >>> result = detect_triangle(data)
+ >>> print(f"强度: {result.strength}, 方向: {result.direction}")
+
+ >>> # 自定义参数
+ >>> params = DetectionParams(
+ ... window=180,
+ ... min_convergence=0.6,
+ ... )
+ >>> result = detect_triangle(data, params)
+
+ >>> # 获取 ECharts 配置
+ >>> if result.chart_data:
+ ... echarts_option = result.chart_data.to_echarts_option("股票名称")
+ """
+ # 使用默认参数
+ if params is None:
+ params = DetectionParams()
+
+ # 转换为内部参数
+ internal_params = params.to_internal_params()
+
+ # 提取数据
+ dates = ohlc_data.get('dates', [])
+ open_arr = np.array(ohlc_data.get('open', []), dtype=float)
+ high_arr = np.array(ohlc_data.get('high', []), dtype=float)
+ low_arr = np.array(ohlc_data.get('low', []), dtype=float)
+ close_arr = np.array(ohlc_data.get('close', []), dtype=float)
+ volume_arr = np.array(ohlc_data.get('volume', []), dtype=float) if 'volume' in ohlc_data else None
+
+ n = len(close_arr)
+
+ # 数据验证
+ if n < internal_params.window:
+ return _create_invalid_result(
+ "数据长度不足",
+ ohlc_data, dates, open_arr, high_arr, low_arr, close_arr, volume_arr,
+ display_window
+ )
+
+ # 过滤NaN
+ valid_mask = ~np.isnan(close_arr)
+ valid_indices = np.where(valid_mask)[0]
+
+ if len(valid_indices) < internal_params.window:
+ return _create_invalid_result(
+ "有效数据长度不足",
+ ohlc_data, dates, open_arr, high_arr, low_arr, close_arr, volume_arr,
+ display_window
+ )
+
+ # 提取检测窗口数据
+ valid_end = len(valid_indices) - 1
+ detect_start = valid_end - internal_params.window + 1
+
+ high_win = high_arr[valid_mask][detect_start:valid_end + 1]
+ low_win = low_arr[valid_mask][detect_start:valid_end + 1]
+ close_win = close_arr[valid_mask][detect_start:valid_end + 1]
+ volume_win = volume_arr[valid_mask][detect_start:valid_end + 1] if volume_arr is not None else None
+
+ # 灵活区域大小(与 pivot_lookback 一致)
+ flexible_zone = params.pivot_lookback
+
+ # 调用核心检测函数
+ result = detect_converging_triangle(
+ high=high_win,
+ low=low_win,
+ close=close_win,
+ volume=volume_win,
+ params=internal_params,
+ stock_idx=0,
+ date_idx=n - 1,
+ real_time_mode=params._realtime_mode,
+ flexible_zone=flexible_zone,
+ )
+
+ # 计算综合强度分(正=向上,负=向下,0=无效/未突破)
+ if result.is_valid:
+ if result.breakout_dir == "up":
+ strength = result.breakout_strength_up
+ direction = BreakoutDirection.UP
+ elif result.breakout_dir == "down":
+ strength = -result.breakout_strength_down # 向下为负值
+ direction = BreakoutDirection.DOWN
+ else:
+ # 形态有效但未突破,返回较大方向的强度的较小版本
+ if result.breakout_strength_up >= result.breakout_strength_down:
+ strength = result.breakout_strength_up * 0.5 # 潜在向上
+ else:
+ strength = -result.breakout_strength_down * 0.5 # 潜在向下
+ direction = BreakoutDirection.NONE
+ else:
+ strength = 0.0
+ direction = BreakoutDirection.NONE
+
+ # 构建强度分量
+ strength_components = StrengthComponents(
+ price_score=result.price_score_up if result.breakout_dir != "down" else result.price_score_down,
+ convergence_score=result.convergence_score,
+ volume_score=result.volume_score,
+ fitting_score=result.fitting_score,
+ utilization_score=result.boundary_utilization,
+ )
+
+ # 构建前端图表数据
+ chart_data = _build_chart_data(
+ dates=dates,
+ open_arr=open_arr,
+ high_arr=high_arr,
+ low_arr=low_arr,
+ close_arr=close_arr,
+ volume_arr=volume_arr,
+ valid_mask=valid_mask,
+ valid_indices=valid_indices,
+ detect_start=detect_start,
+ internal_params=internal_params,
+ result=result,
+ include_pivots=include_pivots,
+ display_window=display_window,
+ realtime_mode=params._realtime_mode,
+ flexible_zone=flexible_zone,
+ )
+
+ return DetectionResult(
+ strength=round(strength, 4),
+ is_valid=result.is_valid,
+ direction=direction,
+ strength_up=round(result.breakout_strength_up, 4),
+ strength_down=round(result.breakout_strength_down, 4),
+ strength_components=strength_components,
+ width_ratio=round(result.width_ratio, 4),
+ touches_upper=result.touches_upper,
+ touches_lower=result.touches_lower,
+ volume_confirmed=result.volume_confirmed,
+ chart_data=chart_data,
+ detection_mode="realtime" if params._realtime_mode else "standard",
+ )
+
+
+def _create_invalid_result(
+ reason: str,
+ ohlc_data: Dict,
+ dates: List,
+ open_arr: np.ndarray,
+ high_arr: np.ndarray,
+ low_arr: np.ndarray,
+ close_arr: np.ndarray,
+ volume_arr: Optional[np.ndarray],
+ display_window: Optional[int],
+) -> DetectionResult:
+ """创建无效结果(数据不足等情况)"""
+ # 构建基础图表数据(仅K线,无趋势线)
+ chart_data = _build_basic_chart_data(
+ dates=dates,
+ open_arr=open_arr,
+ high_arr=high_arr,
+ low_arr=low_arr,
+ close_arr=close_arr,
+ volume_arr=volume_arr,
+ display_window=display_window,
+ )
+
+ return DetectionResult(
+ strength=0.0,
+ is_valid=False,
+ direction=BreakoutDirection.NONE,
+ chart_data=chart_data,
+ detection_mode="invalid",
+ )
+
+
+def _build_basic_chart_data(
+ dates: List,
+ open_arr: np.ndarray,
+ high_arr: np.ndarray,
+ low_arr: np.ndarray,
+ close_arr: np.ndarray,
+ volume_arr: Optional[np.ndarray],
+ display_window: Optional[int],
+) -> ChartData:
+ """构建基础图表数据(仅K线,无趋势线)"""
+ n = len(close_arr)
+
+ # 确定显示范围
+ if display_window and display_window < n:
+ start = n - display_window
+ else:
+ start = 0
+
+ # 日期格式转换
+ display_dates = _format_dates(dates[start:])
+
+ # 构建 K 线数据(ECharts 格式:[open, close, low, high])
+ candlestick = []
+ for i in range(start, n):
+ candlestick.append([
+ float(open_arr[i]) if not np.isnan(open_arr[i]) else 0,
+ float(close_arr[i]) if not np.isnan(close_arr[i]) else 0,
+ float(low_arr[i]) if not np.isnan(low_arr[i]) else 0,
+ float(high_arr[i]) if not np.isnan(high_arr[i]) else 0,
+ ])
+
+ # 成交量
+ volumes = []
+ if volume_arr is not None:
+ for i in range(start, n):
+ volumes.append(float(volume_arr[i]) if not np.isnan(volume_arr[i]) else 0)
+
+ return ChartData(
+ candlestick=candlestick,
+ dates=display_dates,
+ volumes=volumes,
+ )
+
+
+def _build_chart_data(
+ dates: List,
+ open_arr: np.ndarray,
+ high_arr: np.ndarray,
+ low_arr: np.ndarray,
+ close_arr: np.ndarray,
+ volume_arr: Optional[np.ndarray],
+ valid_mask: np.ndarray,
+ valid_indices: np.ndarray,
+ detect_start: int,
+ internal_params: ConvergingTriangleParams,
+ result,
+ include_pivots: bool,
+ display_window: Optional[int],
+ realtime_mode: bool,
+ flexible_zone: int,
+) -> ChartData:
+ """构建完整图表数据(包含趋势线和枢轴点)"""
+ valid_end = len(valid_indices) - 1
+ n_valid = len(valid_indices)
+
+ # 确定显示范围
+ if display_window and display_window < n_valid:
+ display_start = n_valid - display_window
+ else:
+ display_start = 0
+
+ # 提取显示数据
+ display_indices = valid_indices[display_start:valid_end + 1]
+ display_dates_raw = [dates[i] for i in display_indices]
+ display_dates = _format_dates(display_dates_raw)
+
+ display_open = open_arr[valid_mask][display_start:valid_end + 1]
+ display_high = high_arr[valid_mask][display_start:valid_end + 1]
+ display_low = low_arr[valid_mask][display_start:valid_end + 1]
+ display_close = close_arr[valid_mask][display_start:valid_end + 1]
+ display_volume = volume_arr[valid_mask][display_start:valid_end + 1] if volume_arr is not None else []
+
+ # 构建 K 线数据
+ candlestick = []
+ for i in range(len(display_close)):
+ candlestick.append([
+ float(display_open[i]) if not np.isnan(display_open[i]) else 0,
+ float(display_close[i]) if not np.isnan(display_close[i]) else 0,
+ float(display_low[i]) if not np.isnan(display_low[i]) else 0,
+ float(display_high[i]) if not np.isnan(display_high[i]) else 0,
+ ])
+
+ volumes = [float(v) if not np.isnan(v) else 0 for v in display_volume] if len(display_volume) > 0 else []
+
+ # 如果没有有效形态,返回基础数据
+ if not result.is_valid:
+ return ChartData(
+ candlestick=candlestick,
+ dates=display_dates,
+ volumes=volumes,
+ )
+
+ # 计算趋势线(在检测窗口内)
+ high_win = high_arr[valid_mask][detect_start:valid_end + 1]
+ low_win = low_arr[valid_mask][detect_start:valid_end + 1]
+ close_win = close_arr[valid_mask][detect_start:valid_end + 1]
+ n_win = len(close_win)
+
+ # 计算枢轴点
+ if realtime_mode:
+ confirmed_ph, confirmed_pl, candidate_ph, candidate_pl = pivots_fractal_hybrid(
+ high_win, low_win, k=internal_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=internal_params.pivot_k)
+
+ # 拟合边界线
+ a_u, b_u, selected_ph = fit_pivot_line_dispatch(
+ pivot_indices=ph_idx,
+ pivot_values=high_win[ph_idx],
+ mode="upper",
+ method=internal_params.fitting_method,
+ all_prices=high_win,
+ window_start=0,
+ window_end=n_win - 1,
+ )
+ a_l, b_l, selected_pl = fit_pivot_line_dispatch(
+ pivot_indices=pl_idx,
+ pivot_values=low_win[pl_idx],
+ mode="lower",
+ method=internal_params.fitting_method,
+ all_prices=low_win,
+ window_start=0,
+ window_end=n_win - 1,
+ )
+
+ # 计算显示窗口中的偏移
+ triangle_offset_in_display = len(candlestick) - n_win
+
+ # 构建趋势线
+ upper_line = TrendLine(
+ start_index=triangle_offset_in_display,
+ start_price=float(a_u * 0 + b_u),
+ end_index=triangle_offset_in_display + n_win - 1,
+ end_price=float(a_u * (n_win - 1) + b_u),
+ slope=float(a_u),
+ intercept=float(b_u),
+ )
+
+ lower_line = TrendLine(
+ start_index=triangle_offset_in_display,
+ start_price=float(a_l * 0 + b_l),
+ end_index=triangle_offset_in_display + n_win - 1,
+ end_price=float(a_l * (n_win - 1) + b_l),
+ slope=float(a_l),
+ intercept=float(b_l),
+ )
+
+ # 构建枢轴点
+ pivots = []
+ if include_pivots:
+ # 高点枢轴点
+ for idx in ph_idx:
+ pivots.append(PivotPoint(
+ index=int(triangle_offset_in_display + idx),
+ price=float(high_win[idx]),
+ is_high=True,
+ is_confirmed=idx in confirmed_ph if realtime_mode else True,
+ ))
+ # 低点枢轴点
+ for idx in pl_idx:
+ pivots.append(PivotPoint(
+ index=int(triangle_offset_in_display + idx),
+ price=float(low_win[idx]),
+ is_high=False,
+ is_confirmed=idx in confirmed_pl if realtime_mode else True,
+ ))
+
+ # 计算交汇点
+ denom = a_u - a_l
+ apex_x = float((b_l - b_u) / denom) if abs(denom) > 1e-12 else None
+ if apex_x is not None:
+ apex_x = triangle_offset_in_display + apex_x
+
+ return ChartData(
+ candlestick=candlestick,
+ dates=display_dates,
+ volumes=volumes,
+ upper_line=upper_line,
+ lower_line=lower_line,
+ pivots=pivots,
+ detection_window=(triangle_offset_in_display, triangle_offset_in_display + n_win - 1),
+ apex_index=apex_x,
+ )
+
+
+def _format_dates(dates: List) -> List[str]:
+ """格式化日期列表为字符串"""
+ result = []
+ for d in dates:
+ if isinstance(d, (int, np.integer)):
+ # 整数格式如 20260120 -> "2026-01-20"
+ s = str(d)
+ if len(s) == 8:
+ result.append(f"{s[:4]}-{s[4:6]}-{s[6:]}")
+ else:
+ result.append(str(d))
+ else:
+ result.append(str(d))
+ return result
+
+
+# ============================================================================
+# 核心函数:矩阵批量检测
+# ============================================================================
+
+def detect_matrix(
+ high_mtx: np.ndarray,
+ low_mtx: np.ndarray,
+ close_mtx: np.ndarray,
+ volume_mtx: np.ndarray,
+ dates: np.ndarray,
+ tkrs: Optional[np.ndarray] = None,
+ tkrs_name: Optional[np.ndarray] = None,
+ params: Optional[DetectionParams] = None,
+ start_day: Optional[int] = None,
+ end_day: Optional[int] = None,
+ only_valid: bool = True,
+ verbose: bool = False,
+):
+ """
+ 全市场矩阵批量检测:每只股票每天都进行检测
+
+ 适用于万得全A等全市场扫描场景
+
+ Args:
+ high_mtx: 最高价矩阵 (n_stocks, n_days)
+ low_mtx: 最低价矩阵
+ close_mtx: 收盘价矩阵
+ volume_mtx: 成交量矩阵
+ dates: 日期数组 (n_days,)
+ tkrs: 股票代码数组 (n_stocks,)
+ tkrs_name: 股票名称数组 (n_stocks,)
+ params: 检测参数
+ start_day: 起始日索引,默认 window-1
+ end_day: 结束日索引,默认最后一天
+ only_valid: 只返回有效形态,默认 True
+ verbose: 打印进度
+
+ Returns:
+ DataFrame: stock_idx, stock_code, stock_name, date_idx, date,
+ strength, is_valid, direction, width_ratio
+ """
+ import pandas as pd
+ from converging_triangle import detect_converging_triangle_batch
+
+ if params is None:
+ params = DetectionParams()
+
+ internal_params = params.to_internal_params()
+
+ n_stocks, n_days = close_mtx.shape
+
+ # 计算范围
+ if start_day is None:
+ start_day = internal_params.window - 1
+ if end_day is None:
+ # 找最后有效日
+ any_valid = np.any(~np.isnan(close_mtx), axis=0)
+ valid_day_idx = np.where(any_valid)[0]
+ end_day = valid_day_idx[-1] if len(valid_day_idx) > 0 else n_days - 1
+
+ start_day = max(internal_params.window - 1, start_day)
+ end_day = min(n_days - 1, end_day)
+
+ if verbose:
+ total_points = n_stocks * (end_day - start_day + 1)
+ print(f"检测范围: {n_stocks} 只股票 × {end_day - start_day + 1} 天 = {total_points} 个检测点")
+
+ # 调用底层批量检测
+ df = detect_converging_triangle_batch(
+ open_mtx=close_mtx, # 开盘价用收盘价代替(不影响三角形检测)
+ high_mtx=high_mtx,
+ low_mtx=low_mtx,
+ close_mtx=close_mtx,
+ volume_mtx=volume_mtx,
+ params=internal_params,
+ start_day=start_day,
+ end_day=end_day,
+ only_valid=only_valid,
+ verbose=verbose,
+ real_time_mode=params._realtime_mode,
+ flexible_zone=params._pivot_lookback,
+ )
+
+ if len(df) == 0:
+ return pd.DataFrame()
+
+ # 计算带符号的 strength
+ df['strength'] = df.apply(
+ lambda row: row['breakout_strength_up'] if row['breakout_dir'] == 'up'
+ else -row['breakout_strength_down'] if row['breakout_dir'] == 'down'
+ else max(row['breakout_strength_up'], row['breakout_strength_down'])
+ if row['breakout_strength_up'] >= row['breakout_strength_down']
+ else -row['breakout_strength_down'],
+ axis=1
+ )
+
+ # 添加股票代码和名称
+ if tkrs is not None:
+ df['stock_code'] = df['stock_idx'].map(lambda x: tkrs[x] if x < len(tkrs) else '')
+ if tkrs_name is not None:
+ df['stock_name'] = df['stock_idx'].map(lambda x: tkrs_name[x] if x < len(tkrs_name) else '')
+
+ # 添加日期
+ df['date'] = df['date_idx'].map(lambda x: dates[x] if x < len(dates) else 0)
+
+ # 重命名列
+ df = df.rename(columns={
+ 'breakout_dir': 'direction',
+ })
+
+ # 选择输出列
+ output_cols = ['stock_idx', 'date_idx', 'date', 'strength', 'is_valid', 'direction', 'width_ratio']
+ if tkrs is not None:
+ output_cols.insert(1, 'stock_code')
+ if tkrs_name is not None:
+ output_cols.insert(2 if tkrs is not None else 1, 'stock_name')
+
+ # 保留存在的列
+ output_cols = [c for c in output_cols if c in df.columns]
+
+ return df[output_cols]
+
+
+# ============================================================================
+# 便捷函数
+# ============================================================================
+
+def quick_detect(
+ dates: List,
+ open_: List[float],
+ high: List[float],
+ low: List[float],
+ close: List[float],
+ volume: Optional[List[float]] = None,
+) -> float:
+ """快速检测,直接返回强度分"""
+ ohlc_data = {
+ 'dates': dates,
+ 'open': open_,
+ 'high': high,
+ 'low': low,
+ 'close': close,
+ }
+ if volume is not None:
+ ohlc_data['volume'] = volume
+
+ params = DetectionParams()
+ result = detect_triangle(ohlc_data, params, include_pivots=False)
+ return result.strength
+
+
+# ============================================================================
+# 测试/演示代码
+# ============================================================================
+
+if __name__ == "__main__":
+ print("=" * 70)
+ print("收敛三角形检测 API 示例")
+ print("=" * 70)
+
+ # 生成模拟数据(收敛形态)
+ np.random.seed(42)
+ n_days = 300
+
+ # 基础价格序列(带收敛趋势)
+ base_price = 10.0
+ prices = [base_price]
+
+ for i in range(1, n_days):
+ # 添加随机波动
+ change = np.random.normal(0, 0.02) * prices[-1]
+ # 添加收敛效果(振幅逐渐缩小)
+ decay = 1 - (i / n_days) * 0.5
+ change *= decay
+ prices.append(prices[-1] + change)
+
+ prices = np.array(prices)
+
+ # 生成 OHLC
+ dates = [f"2025-{(i//30)+1:02d}-{(i%30)+1:02d}" for i in range(n_days)]
+ opens = prices * (1 + np.random.normal(0, 0.005, n_days))
+ highs = np.maximum(prices, opens) * (1 + np.abs(np.random.normal(0, 0.01, n_days)))
+ lows = np.minimum(prices, opens) * (1 - np.abs(np.random.normal(0, 0.01, n_days)))
+ closes = prices
+ volumes = np.random.randint(1000000, 5000000, n_days).astype(float)
+
+ # 构建数据
+ ohlc_data = {
+ 'dates': dates,
+ 'open': opens.tolist(),
+ 'high': highs.tolist(),
+ 'low': lows.tolist(),
+ 'close': closes.tolist(),
+ 'volume': volumes.tolist(),
+ }
+
+ print("\n[1] 使用默认参数检测...")
+ result = detect_triangle(ohlc_data)
+ print(f" 有效形态: {result.is_valid}")
+ print(f" 强度分: {result.strength}")
+ print(f" 突破方向: {result.direction.value}")
+
+ if result.strength_components:
+ print(f" 分量: 价格={result.strength_components.price_score:.3f}, "
+ f"收敛={result.strength_components.convergence_score:.3f}, "
+ f"成交量={result.strength_components.volume_score:.3f}")
+
+ print("\n[2] 使用宽松参数检测...")
+ params_loose = DetectionParams(min_convergence=0.8, breakout_threshold=0.001)
+ result_loose = detect_triangle(ohlc_data, params_loose)
+ print(f" 有效形态: {result_loose.is_valid}")
+ print(f" 强度分: {result_loose.strength}")
+
+ print("\n[3] 快速检测...")
+ strength = quick_detect(
+ dates=dates,
+ open_=opens.tolist(),
+ high=highs.tolist(),
+ low=lows.tolist(),
+ close=closes.tolist(),
+ volume=volumes.tolist(),
+ )
+ print(f" 强度分: {strength}")
+
+ print("\n[4] 图表数据...")
+ if result.chart_data:
+ print(f" K线数量: {len(result.chart_data.candlestick)}")
+ print(f" 有上沿线: {result.chart_data.upper_line is not None}")
+ print(f" 有下沿线: {result.chart_data.lower_line is not None}")
+ print(f" 枢轴点数: {len(result.chart_data.pivots)}")
+
+ # 生成 ECharts 配置示例
+ echarts_option = result.chart_data.to_echarts_option("示例股票")
+ print(f" ECharts配置键: {list(echarts_option.keys())}")
+
+ print("\n详细文档: docs/triangle_api_reference.md")
+ print("=" * 70)