feat: 收敛三角形形态可视化验证工具

功能:
- 支持中文名称搜索股票(如"中控技术"、"英伟达")
- 支持 A 股和美股
- 趋势线从窗口起点延伸到终点(与前端一致)
- Y 轴自动调整以包含趋势线
- 支持 --window 参数(1Y/3Y/5Y/ALL)

用法:
python validate.py 中控技术 日 --window 3Y --save
python validate.py 英伟达 日 --window 3Y --save

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
褚宏光 2026-03-04 14:51:16 +08:00
commit 430511e8c4
10 changed files with 1142 additions and 0 deletions

26
.gitignore vendored Normal file
View File

@ -0,0 +1,26 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
env/
venv/
.venv/
# Output
output/*.png
output/*.jpg
# IDE
.idea/
.vscode/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Local config (optional, if you want to keep token private)
# config.py

75
CLAUDE.md Normal file
View File

@ -0,0 +1,75 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## 项目概述
收敛三角形Converging Triangle形态检测可视化验证工具。通过 HTTP API 调用远程公式执行,绘制 K 线图 + 三角形趋势线,帮助快速目测验证形态检测结果。**支持 A 股和美股**。
## 常用命令
```bash
# 安装依赖
pip install requests mplfinance matplotlib numpy pandas
# 单股验证
python validate.py <股票名或代码> [频率] [--save] [--no-show] [--date YYYYMMDD] [--mode 0-3] [--window 1Y/3Y/5Y/ALL]
# 示例
python validate.py 中远海控 # 日K弹窗显示
python validate.py 中控技术 日 --window 3Y --save # 3年K线保存图片
python validate.py 英伟达 日 --window 3Y --save # 美股也支持
python validate.py SH600519 月 --no-show --save # 月K只保存不弹窗
# 批量验证
python batch_validate.py --no-show --save
```
**参数说明**
- `freq`: 日 / 周 / 月,默认日
- `--save`: 保存 PNG 到 output/ 目录
- `--no-show`: 不弹出交互窗口
- `--mode`: 强度模式 0=等权 1=激进 2=保守 3=放量
- `--window`: K 线时间窗口 1Y/3Y/5Y/ALL默认 3Y
## 架构
```
数据流:
validate.py
① GET {SEARCH_API_URL}/smartstock/assets/search?q=<关键词>
→ 获取正确的 ticker中控技术 → SH688777英伟达 → NVDA.O
② POST {GPT_SERVER_URL}/innerServer/runOneFormula
body: { formula_str: "结果=收敛三角形详情(ticker,freq,True,mode)" }
→ response.output = MongoDB _id
③ GET {DATA_SERVER_URL}/api/timeSeries/requestIndexDetail?id=<_id>
header: Authorization: Bearer <token>
→ data.data_info.detail (含 strength/direction/chart_data)
④ GET {KLINE_API_URL}/dataSupport/getKLineDataByTicker?ticker=<ticker>&begin_date=<begin_date>
→ 获取 K 线 OHLC 数据
⑤ chart.draw_triangle_chart(detail)
→ mplfinance 绘制 K 线 + 趋势线
→ Y 轴自动调整以包含趋势线
→ output/{ticker}_{freq}K_{date}.png
```
**模块职责**
- `validate.py`: CLI 入口、资产搜索、公式执行、详情获取、K 线数据获取
- `batch_validate.py`: 批量验证,修改 `BATCH_CASES` 列表自定义验证标的
- `chart.py`: mplfinance 绘图,趋势线延伸逻辑(与前端 TechPattern.vue 一致)
- `config.py`: API 端点配置Token 过期时在此更新
## 与前端一致性
`chart.py` 中的趋势线绘制逻辑与 `D:\project\frontend_mobile\src\views\workflow\components\assetRatingV2\components\TechPattern.vue` 完全一致:
- 趋势线从 `window_start_date` 延伸到 `window_end_date`
- 使用公式数据中的 `index` 计算斜率
- Y 轴范围包含趋势线价格 + 5% 边距
## 配置
Token 过期时,更新 `config.py` 中的 `AUTH_TOKEN`

152
README.md Normal file
View File

@ -0,0 +1,152 @@
# triangle-validator
收敛三角形Converging Triangle形态检测可视化验证工具。
通过调用远程 HTTP API 执行公式并拉取结果,绘制 K 线图 + 三角形趋势线,
帮助快速目测验证形态检测结果是否准确。**无需本地数据库,无需 dunhe 虚拟环境。**
**支持 A 股和美股**(如英伟达、苹果等)。
---
## 目录结构
```
triangle-validator/
├── validate.py # 主入口:单股验证
├── batch_validate.py # 批量验证多只股票
├── chart.py # K 线 + 三角形绘图模块
├── config.py # API 端点 & 认证配置
├── requirements.txt # Python 依赖
├── output/ # 生成的图片(自动创建)
└── README.md
```
---
## 前提条件
只需要系统 Python3.8+)及两个依赖包:
```bash
pip install requests mplfinance
```
---
## 单股验证
```bash
# 基本用法日K弹出窗口
python validate.py 中远海控
# 使用中文名称(自动搜索转换为股票代码)
python validate.py 中控技术 日 --window 3Y --save
# 美股也支持
python validate.py 英伟达 日 --window 3Y --save
# 周K保存图片
python validate.py 中远海控 周 --save
# 月K不弹窗只保存
python validate.py SH600519 月 --no-show --save
# 指定截止日期
python validate.py 贵州茅台 日 --date 20260120 --save
# 使用激进模式mode=1
python validate.py 宁德时代 周 --mode 1 --save
```
### 参数说明
| 参数 | 说明 |
|------|------|
| `ticker` | 股票名称(如"中远海控"、"英伟达")或代码(如"SH601919"|
| `freq` | 频率:`日` / `周` / `月`,默认 `日` |
| `--save` | 保存 PNG 到 `output/` 目录 |
| `--no-show` | 不弹出交互窗口(适合服务器/批量场景)|
| `--date` | 截止日期,格式 `YYYYMMDD`,默认最新数据日 |
| `--mode` | 强度模式0=等权 1=激进 2=保守 3=放量,默认 0 |
| `--window` | K 线时间窗口:`1Y` / `3Y` / `5Y` / `ALL`,默认 `3Y` |
---
## 批量验证
```bash
# 默认批量(内置测试列表,日/周/月各一批)
python batch_validate.py
# 只保存,不弹窗
python batch_validate.py --no-show --save
```
修改 `batch_validate.py` 中的 `BATCH_CASES` 列表可自定义验证标的。
---
## 图表说明
生成的图表包含以下元素:
| 元素 | 颜色 | 含义 |
|------|------|------|
| 红色趋势线 | `#ef4444` | 上沿(压力线),从窗口起点延伸到终点 |
| 绿色趋势线 | `#10b981` | 下沿(支撑线),从窗口起点延伸到终点 |
| 红色虚线 | `#ef4444` | 上突破价位 |
| 绿色虚线 | `#10b981` | 下突破价位 |
标题格式:`{ticker} {频率}K 强度={score:.3f} {方向} 窗口 yyyyMMdd ~ yyyyMMdd`
---
## 数据流
```
validate.py
① GET {SEARCH_API_URL}/smartstock/assets/search?q=<关键词>
→ 获取正确的 ticker如 "中控技术" → "SH688777"
② POST {GPT_SERVER_URL}/innerServer/runOneFormula
body: { formula_str: "结果=收敛三角形详情(ticker,freq,True,mode)" }
→ response.output = MongoDB _id
③ GET {DATA_SERVER_URL}/api/timeSeries/requestIndexDetail?id=<_id>
header: Authorization: Bearer <token>
→ data.data_info.detail
├─ strength / direction / is_valid
├─ breakout_price_up / breakout_price_down
└─ chart_data
├─ upper_line / lower_line (趋势线端点)
├─ upper_pivots / lower_pivots (枢轴点)
└─ window_start_date / window_end_date
④ GET {KLINE_API_URL}/dataSupport/getKLineDataByTicker?ticker=<ticker>&begin_date=<begin_date>
→ 获取 K 线 OHLC 数据3年历史
⑤ chart.draw_triangle_chart(detail)
→ mplfinance 绘制 K 线 + 趋势线
→ Y 轴自动调整以包含趋势线
→ output/{ticker}_{freq}K_{date}.png
```
---
## 常见问题
**Q: 报错 `ModuleNotFoundError: No module named 'requests'`**
A: 执行 `pip install requests mplfinance`
**Q: 报错 `ConnectionError``404`**
A: 检查 `config.py` 中的 `GPT_SERVER_URL` / `DATA_SERVER_URL` 是否正确,以及 token 是否过期。
**Q: 公式执行失败,未返回 _id**
A: 确认 ticker 名称正确,或改用标准代码(如 `SH600519`)。
**Q: 图表中文乱码(显示方框)**
A: 安装中文字体,推荐 `Microsoft YaHei`Windows 自带)或 `SimHei`。chart.py 会自动检测并应用。
**Q: 美股 K 线数据获取失败**
A: 使用中文名称(如"英伟达")而非股票代码,程序会自动通过搜索接口获取正确的 ticker。

116
batch_validate.py Normal file
View File

@ -0,0 +1,116 @@
"""
batch_validate.py 批量验证多只股票的收敛三角形形态HTTP
用法
python batch_validate.py [--no-show] [--save]
--no-show 不弹窗直接生成所有图适合无显示器/批量场景
--save 保存图片到 output/ 目录
修改下方 BATCH_CASES 可自定义验证列表
"""
import sys
import argparse
from pathlib import Path
from validate import run_formula, fetch_detail
_THIS_DIR = Path(__file__).parent.resolve()
# ── 批量验证列表(可自由修改)───────────────────────────────────────────────────
# 格式:(ticker, freq, mode=0, date=None)
BATCH_CASES = [
# 日 K —— 知名个股
('中远海控', '', 0, None),
('贵州茅台', '', 0, None),
('宁德时代', '', 0, None),
('中芯国际', '', 0, None),
# 周 K —— 宽基指数
('沪深300', '', 0, None),
('中证500', '', 0, None),
# 月 K
('贵州茅台', '', 0, None),
('中远海控', '', 0, None),
]
# ── 单条处理 ──────────────────────────────────────────────────────────────────
def process_one(ticker: str, freq: str, mode: int, date, save: bool, show: bool) -> bool:
print(f'[batch] → {ticker!r} {freq}K ...')
try:
index_id = run_formula(ticker, freq, mode, date)
except Exception as e:
print(f'[batch] ✗ 公式执行失败: {e}')
return False
try:
detail = fetch_detail(index_id)
except Exception as e:
print(f'[batch] ✗ 读取详情失败: {e}')
return False
cd = detail.get('chart_data', {})
strength = detail.get('strength', 0)
direction = detail.get('direction', 'none')
print(
f'[batch] ✓ 强度={strength:.4f} 方向={direction} '
f'窗口={cd.get("window_start_date", "?")}~{cd.get("window_end_date", "?")}'
)
if not cd.get('klines'):
print('[batch] ✗ 没有 klines跳过绘图')
return False
from chart import draw_triangle_chart
safe_ticker = ticker.replace('/', '_').replace('\\', '_')
output_path = None
if save:
output_dir = _THIS_DIR / 'output'
fname = f'{safe_ticker}_{freq}K_{cd.get("target_date", "")}.png'
output_path = str(output_dir / fname)
try:
draw_triangle_chart(detail=detail, output_path=output_path, show=show)
except Exception as e:
print(f'[batch] ✗ 绘图失败: {e}')
return False
return True
# ── 主函数 ────────────────────────────────────────────────────────────────────
def main():
parser = argparse.ArgumentParser(description='收敛三角形批量验证HTTP 版)')
parser.add_argument('--no-show', action='store_true', help='不弹出图形窗口')
parser.add_argument('--save', action='store_true', help='保存图片到 output/')
args = parser.parse_args()
show = not args.no_show
save = args.save
if not show and not save:
print('[batch] 提示:既未开启 --save 也未弹窗,将只打印摘要。')
ok_count = 0
fail_count = 0
for case in BATCH_CASES:
ticker, freq = case[0], case[1]
mode = case[2] if len(case) > 2 else 0
date = case[3] if len(case) > 3 else None
success = process_one(ticker, freq, mode, date, save, show)
if success:
ok_count += 1
else:
fail_count += 1
print(f'\n[batch] 完成 成功={ok_count} 失败={fail_count} 总计={ok_count + fail_count}')
if __name__ == '__main__':
main()

361
chart.py Normal file
View File

@ -0,0 +1,361 @@
"""
收敛三角形可视化绘制模块
根据 chart_data (包含 klines) 绘制 K 线图 + 三角形趋势线
"""
import os
import numpy as np
import pandas as pd
import mplfinance as mpf
import matplotlib.pyplot as plt
import matplotlib.font_manager as fm
# ── 中文字体支持 ──────────────────────────────────────────────────────────────
def _setup_chinese_font():
"""尝试配置中文字体,避免乱码"""
candidates = [
'Microsoft YaHei', 'SimHei', 'PingFang SC', 'STSong',
'Noto Sans CJK SC', 'WenQuanYi Zen Hei',
]
available = {f.name for f in fm.fontManager.ttflist}
for name in candidates:
if name in available:
plt.rcParams['font.family'] = name
plt.rcParams['axes.unicode_minus'] = False
return name
# 找不到就用默认,可能出现方框
return None
_CHINESE_FONT = _setup_chinese_font()
# ── 工具函数 ──────────────────────────────────────────────────────────────────
def _date_int_to_str(d: int) -> str:
"""20250327 → '2025-03-27'"""
s = str(int(d))
return f'{s[:4]}-{s[4:6]}-{s[6:]}'
def _build_dataframe(klines: dict) -> pd.DataFrame:
"""从 klines 字典构建 mplfinance 需要的 DataFrame"""
dates_str = [_date_int_to_str(d) for d in klines['dates']]
df = pd.DataFrame(
{
'Open': [v if v is not None else np.nan for v in klines['open']],
'High': [v if v is not None else np.nan for v in klines['high']],
'Low': [v if v is not None else np.nan for v in klines['low']],
'Close': [v if v is not None else np.nan for v in klines['close']],
'Volume': [v if v is not None else np.nan for v in klines['volume']],
},
index=pd.DatetimeIndex(dates_str),
)
return df, dates_str
def _make_aline(line_pts: list):
"""
[{"date": int, "price": float}, ...] 转换为 mplfinance aline 格式的 2-端点元组
Returns:
(("2025-03-27", price1), ("2026-01-20", price2)) or None
"""
if not line_pts or len(line_pts) < 2:
return None
p1 = (_date_int_to_str(line_pts[0]['date']), line_pts[0]['price'])
p2 = (_date_int_to_str(line_pts[-1]['date']), line_pts[-1]['price'])
return (p1, p2)
def _build_pivot_arrays(pivots: list, dates_str: list, n: int) -> np.ndarray:
"""
将枢轴点列表映射为长度 n float 数组非枢轴位置为 NaN
"""
date_to_idx = {d: i for i, d in enumerate(dates_str)}
arr = np.full(n, np.nan)
for pt in pivots:
ds = _date_int_to_str(pt['date'])
idx = date_to_idx.get(ds)
if idx is not None:
arr[idx] = pt['price']
return arr
# ── 主绘图函数 ────────────────────────────────────────────────────────────────
def draw_triangle_chart(
detail: dict,
output_path: str = None,
show: bool = True,
show_pivots: bool = False, # 是否显示枢轴点(默认不显示,与前端一致)
):
"""
绘制收敛三角形 K 线图
Args:
detail: 收敛三角形详情字典 ``result.info['detail']``
output_path: 保存路径 ``'output/CSCO_日.png'``None 则不保存
show: 是否弹出交互窗口在无 GUI 环境下设 False
show_pivots: 是否显示枢轴点标记默认 False与前端一致
Returns:
output_path (便于链式调用)
"""
chart = detail.get('chart_data', {})
klines = chart.get('klines')
if not klines:
raise ValueError(
"chart_data 中没有 klines请用 include_klines=True 调用收敛三角形详情"
)
# ── 基本属性 ─────────────────────────────────
ticker = chart.get('ticker', '')
freq = chart.get('freq', 'D')
freq_label = {'D': '日K', 'W': '周K', 'M': '月K'}.get(freq, freq)
strength = detail.get('strength', 0.0)
direction = detail.get('direction', 'none')
dir_label = {'up': '↑上涨突破', 'down': '↓下跌突破', 'none': '整理中'}.get(direction, direction)
window_start = chart.get('window_start_date', '')
window_end = chart.get('window_end_date', '')
touches_upper = detail.get('touches_upper', '?')
touches_lower = detail.get('touches_lower', '?')
# ── 构建 DataFrame ─────────────────────────────
df, dates_str = _build_dataframe(klines)
n = len(df)
# ── 趋势线:从 window_start_date 延伸到 window_end_date与前端一致──────────
alines_list = []
alines_colors = []
upper_line = chart.get('upper_line', [])
lower_line = chart.get('lower_line', [])
# 将日期字符串转为集合,用于查找
date_to_idx = {d: i for i, d in enumerate(dates_str)}
window_start_str = _date_int_to_str(window_start) if window_start else None
window_end_str = _date_int_to_str(window_end) if window_end else None
# 辅助函数:查找最近的日期(与前端 getClosestDateCategory 一致)
def get_closest_date_idx(target_date: int, dates_str: list) -> int:
"""查找目标日期在 K 线数据中最接近的位置"""
target_str = _date_int_to_str(target_date)
if target_str in date_to_idx:
return date_to_idx[target_str]
# 找不到精确匹配时,找最近的日期
from datetime import datetime
target_dt = datetime.strptime(target_str, '%Y-%m-%d')
min_diff = float('inf')
closest_idx = 0
for i, d in enumerate(dates_str):
dt = datetime.strptime(d, '%Y-%m-%d')
diff = abs((dt - target_dt).days)
if diff < min_diff:
min_diff = diff
closest_idx = i
return closest_idx
# 辅助函数:计算延伸后的趋势线(与前端 getExtendedLinePoints 完全一致)
def make_extended_aline(line_pts, pivot_point, color):
"""
与前端 TechPattern.vue getExtendedLinePoints 函数逻辑完全一致
趋势线从 window_start_date 延伸到 window_end_date
"""
if not line_pts or len(line_pts) < 2:
return None
p1 = line_pts[0]
p2 = line_pts[-1]
# 检查是否有 index 字段(公式返回的数据)
has_index = 'index' in p1 and 'index' in p2 and pivot_point and 'index' in pivot_point
if has_index and window_start and window_end:
# 使用原始 index 计算斜率(与前端一致)
slope = (p2['price'] - p1['price']) / (p2['index'] - p1['index']) if p2['index'] != p1['index'] else 0
# 前端: startIdx = 0 (window_start_date 对应 index 0)
start_idx = 0
# 查找 window_end_date 对应的 index与前端一致
end_idx = p2['index']
all_points = (chart.get('upper_line', []) + chart.get('lower_line', []) +
chart.get('upper_pivots', []) + chart.get('lower_pivots', []))
for pt in all_points:
if pt.get('date') == window_end:
end_idx = pt.get('index', end_idx)
break
# 计算延伸后的价格(与前端一致)
pivot_index = pivot_point['index']
pivot_price = pivot_point['price']
start_price = pivot_price + slope * (start_idx - pivot_index)
end_price = pivot_price + slope * (end_idx - pivot_index)
# 将 window_start_date 和 window_end_date 映射到 K 线数据的索引
kline_start_idx = get_closest_date_idx(window_start, dates_str)
kline_end_idx = get_closest_date_idx(window_end, dates_str)
print(f'[chart] 趋势线延伸: {window_start_str}({kline_start_idx}) -> {window_end_str}({kline_end_idx})')
print(f'[chart] 价格: {start_price:.2f} -> {end_price:.2f}')
return (
(kline_start_idx, start_price),
(kline_end_idx, end_price)
)
# 降级处理:仅连接两点(与前端一致)
d1 = _date_int_to_str(p1['date'])
d2 = _date_int_to_str(p2['date'])
idx1 = date_to_idx.get(d1)
idx2 = date_to_idx.get(d2)
if idx1 is None or idx2 is None:
print(f'[chart] 警告: 趋势线日期不在 K 线数据中: {d1}{d2}')
return None
return (
(idx1, p1['price']),
(idx2, p2['price'])
)
# 绘制上轨线(红色)- 与前端一致
if upper_line and window_start and window_end:
upper_aline = make_extended_aline(upper_line, upper_line[0] if upper_line else None, '#ef4444')
if upper_aline:
alines_list.append(upper_aline)
alines_colors.append('#ef4444') # 红色
# 绘制下轨线(绿色)- 与前端一致
if lower_line and window_start and window_end:
lower_aline = make_extended_aline(lower_line, lower_line[0] if lower_line else None, '#10b981')
if lower_aline:
alines_list.append(lower_aline)
alines_colors.append('#10b981') # 绿色
# ── 枢轴点散点图(可选)─────────────────────────
addplots = []
if show_pivots:
upper_pivots_arr = _build_pivot_arrays(chart.get('upper_pivots', []), dates_str, n)
lower_pivots_arr = _build_pivot_arrays(chart.get('lower_pivots', []), dates_str, n)
if np.any(~np.isnan(upper_pivots_arr)):
addplots.append(mpf.make_addplot(
upper_pivots_arr, type='scatter',
markersize=100, marker='^', color='#ef4444', alpha=0.85,
))
if np.any(~np.isnan(lower_pivots_arr)):
addplots.append(mpf.make_addplot(
lower_pivots_arr, type='scatter',
markersize=100, marker='v', color='#10b981', alpha=0.85,
))
# ── 突破价位水平线 ─────────────────────────────
bp_up = detail.get('breakout_price_up')
bp_down = detail.get('breakout_price_down')
hlines_vals = []
hlines_colors = []
if bp_up and float(bp_up) > 0:
hlines_vals.append(float(bp_up))
hlines_colors.append('#ef4444')
if bp_down and float(bp_down) > 0:
hlines_vals.append(float(bp_down))
hlines_colors.append('#10b981')
# ── 标题 ────────────────────────────────────────
title = (
f'{ticker} {freq_label} '
f'强度={strength:.3f} {dir_label}\n'
f'窗口 {window_start}~{window_end} '
f'(上沿触碰={touches_upper} 下沿触碰={touches_lower})'
)
# ── 组装 plot 参数 ────────────────────────────────
_rc = {'axes.unicode_minus': False}
if _CHINESE_FONT:
_rc['font.family'] = _CHINESE_FONT
_rc['font.sans-serif'] = [_CHINESE_FONT, 'DejaVu Sans']
_style = mpf.make_mpf_style(base_mpf_style='yahoo', rc=_rc)
kwargs = dict(
type='candle',
volume=False, # 不显示成交量
title=title,
style=_style,
figsize=(18, 10),
tight_layout=True,
)
if alines_list:
kwargs['alines'] = dict(
alines=alines_list,
colors=alines_colors,
linewidths=2.0,
alpha=0.90,
)
if addplots:
kwargs['addplot'] = addplots
if hlines_vals:
kwargs['hlines'] = dict(
hlines=hlines_vals,
colors=hlines_colors,
linestyle='--',
linewidths=1.2,
alpha=0.7,
)
if output_path:
os.makedirs(os.path.dirname(os.path.abspath(output_path)), exist_ok=True)
# ── 绘图 ─────────────────────────────────────────
# 先不绘制趋势线,获取 figure 和 axes
_kwargs = {k: v for k, v in kwargs.items() if k not in ['alines', 'savefig']}
fig, axes = mpf.plot(df, **_kwargs, returnfig=True, warn_too_much_data=1000)
# 使用 matplotlib 直接在主图上绘制趋势线
# alines_list 格式: [((idx1, price1), (idx2, price2)), ...]
if alines_list and len(axes) > 0:
main_ax = axes[0] # 主图K线图
# 收集趋势线价格,用于调整 Y 轴范围(与前端一致)
trend_line_prices = []
for aline, color in zip(alines_list, alines_colors):
(idx1, p1), (idx2, p2) = aline
trend_line_prices.extend([p1, p2])
# 直接使用整数索引作为 x 坐标(与 mplfinance 内部一致)
main_ax.plot([idx1, idx2], [p1, p2], color=color, linewidth=2, alpha=0.9)
# 调整 Y 轴范围以包含趋势线(与前端一致,增加 5% 边距)
if trend_line_prices:
current_ylim = main_ax.get_ylim()
kline_min, kline_max = current_ylim
# 合并 K 线和趋势线的价格范围
all_min = min(kline_min, min(trend_line_prices))
all_max = max(kline_max, max(trend_line_prices))
price_range = all_max - all_min
# 增加 5% 边距(与前端一致)
new_min = all_min - price_range * 0.05
new_max = all_max + price_range * 0.05
main_ax.set_ylim(new_min, new_max)
print(f'[chart] Y 轴范围调整: {kline_min:.2f}~{kline_max:.2f} -> {new_min:.2f}~{new_max:.2f}')
if show:
plt.show()
if output_path:
fig.savefig(output_path, dpi=150, bbox_inches='tight')
print(f'[chart] 已保存: {output_path}')
plt.close(fig)
return output_path

28
config.py Normal file
View File

@ -0,0 +1,28 @@
"""
triangle-validator 配置文件
Token 过期时在此处更新即可
"""
# ── gpt_server (公式执行)
# 运行公式用的 API与 MCP 工具走同一路径
GPT_SERVER_URL = "http://test.guanzhao12.com:3006"
# ── timeSeries (读取详情结果)
# 读取公式执行结果 (含 chart_data / klines)
DATA_SERVER_URL = "http://test.guanzhao12.com:8081"
# ── K线数据 API (与前端一致)
# 单独获取 K 线 OHLC 数据
KLINE_API_URL = "http://test.guanzhao12.com:3000"
# ── 资产搜索 API (与前端一致)
# 搜索股票/指数等资产
SEARCH_API_URL = "http://test.guanzhao12.com:3009"
# JWT Token —— 过期后在此处替换新 Token
AUTH_TOKEN = (
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"
".eyJ1c2VySWQiOjczLCJ1c2VyTmFtZSI6Im1jcCIsImlhdCI6MTc3MjQzODg1MCwiZXhwIjoxNzczMDQzNjUwfQ"
".NGSoODRLjB1lHYL7bFGRGCRzIkzO_ebUEbqcakAhGvE"
)

5
requirements.txt Normal file
View File

@ -0,0 +1,5 @@
requests>=2.28
mplfinance>=0.12.10
matplotlib>=3.7
numpy>=1.24
pandas>=2.0

5
run.bat Normal file
View File

@ -0,0 +1,5 @@
@echo off
REM 使用 dunhe_dataServer 的 venv 运行 validate.py
REM 用法: run.bat 中远海控 [日|周|月] [--save] [--no-show] [--date 20260120]
set PYTHON=..\dunhe_dataServer\.venv\Scripts\python.exe
%PYTHON% validate.py %*

9
run.sh Normal file
View File

@ -0,0 +1,9 @@
#!/usr/bin/env bash
# 使用 dunhe_dataServer 的 venv 运行 validate.py
# 用法: ./run.sh 中远海控 [日|周|月] [--save] [--no-show] [--date 20260120]
PYTHON="../dunhe_dataServer/.venv/Scripts/python.exe"
# Linux/macOS fallback
if [ ! -f "$PYTHON" ]; then
PYTHON="../dunhe_dataServer/.venv/bin/python"
fi
"$PYTHON" validate.py "$@"

365
validate.py Normal file
View File

@ -0,0 +1,365 @@
"""
triangle-validator / validate.py
收敛三角形形态可视化验证脚本HTTP
流程
1. gpt_server POST /api/gptAgent/runFormula 执行公式 拿到 _id
2. timeSeries GET /api/timeSeries/requestIndexDetail?id=_id 拿到完整 detail klines
3. chart.draw_triangle_chart() 绘图
用法
python validate.py <股票名或代码> [频率] [--save] [--no-show] [--date 日期] [--window 窗口]
示例
python validate.py 中远海控
python validate.py 中远海控 --save
python validate.py SH600519 --no-show --save
python validate.py 贵州茅台 --date 20260120 --save
python validate.py SH688777 --window 1Y --save
python validate.py SH688777 --window ALL --save
"""
import sys
import json
import argparse
from pathlib import Path
import requests
from config import GPT_SERVER_URL, DATA_SERVER_URL, KLINE_API_URL, SEARCH_API_URL, AUTH_TOKEN
from datetime import datetime, timedelta
_THIS_DIR = Path(__file__).parent.resolve()
# ── 时间窗口计算(与前端一致)───────────────────────────────────────────────
def get_window_start_date(window: str) -> str:
"""
根据时间窗口字符串计算相对于今天的开始日期与前端 getWindowStartDate 一致
Args:
window: 时间窗口 '1Y', '3Y', '5Y', 'ALL'
Returns:
开始日期格式为 YYYYMMDD '20240130'
"""
if window == 'ALL':
return ''
today = datetime.now()
# 提取数字和单位
import re
match = re.match(r'^(\d+)([YMD])$', window, re.IGNORECASE)
if not match:
print(f'[WARN] 无效的时间窗口格式: {window}使用默认3年')
window = '3Y'
match = re.match(r'^(\d+)([YMD])$', window, re.IGNORECASE)
amount = int(match.group(1))
unit = match.group(2).upper()
if unit == 'Y': # 年
start_date = today - timedelta(days=amount * 365)
elif unit == 'M': # 月
start_date = today - timedelta(days=amount * 30)
elif unit == 'D': # 日
start_date = today - timedelta(days=amount)
else:
start_date = today
return start_date.strftime('%Y%m%d')
# ── CLI 解析 ──────────────────────────────────────────────────────────────────
def parse_args():
parser = argparse.ArgumentParser(
description='收敛三角形形态可视化验证',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=__doc__,
)
parser.add_argument('ticker', help='股票名称或代码,如:中远海控 / SH601919')
parser.add_argument(
'freq', nargs='?', default='',
choices=['', '', ''],
help='K 线频率(默认:日)',
)
parser.add_argument('--save', action='store_true', help='保存图片到 output/ 目录')
parser.add_argument('--no-show', action='store_true', help='不弹出图形窗口')
parser.add_argument('--date', type=int, default=None, metavar='YYYYMMDD',
help='截止日期,如 20260120')
parser.add_argument('--mode', type=int, default=0, choices=[0, 1, 2, 3],
help='强度模式0=等权 1=激进 2=保守 3=放量默认0')
parser.add_argument('--window', type=str, default='3Y',
choices=['1Y', '3Y', '5Y', 'ALL'],
help='K线时间窗口1Y=1年 3Y=3年 5Y=5年 ALL=全部默认3Y与前端一致')
return parser.parse_args()
# ── Step 1执行公式拿 _id ──────────────────────────────────────────────────
def run_formula(ticker: str, freq: str, mode: int, date) -> str:
"""
innerServer/runOneFormula 接口执行收敛三角形详情公式
Returns:
index_info._id (str) 直接从 response.output
"""
import uuid
if date:
formula = f'结果=收敛三角形详情({ticker},{freq},True,{mode},{date})'
else:
formula = f'结果=收敛三角形详情({ticker},{freq},True,{mode})'
url = f'{GPT_SERVER_URL}/innerServer/runOneFormula'
payload = {
'formula_str': formula,
'begin_date': 0,
'user_name': 'mcp',
'tool_name': 'triangle_validate',
'session_id': f'tv-{uuid.uuid4().hex[:8]}',
}
print(f'[validate] 执行公式: {formula}')
resp = requests.post(url, json=payload, timeout=120)
resp.raise_for_status()
body = resp.json()
_id = (body.get('response', {}) or {}).get('output')
if not _id:
_id = (
(body.get('response', {}) or {})
.get('data', {})
.get('index_info', {})
.get('_id')
)
if not _id:
raise RuntimeError(
f'公式执行失败,未返回 _id\n响应: {json.dumps(body, ensure_ascii=False)[:500]}'
)
print(f'[validate] 公式执行完成_id = {_id}')
return str(_id)
# ── Step 2读取完整 detail含 klines───────────────────────────────────────
def fetch_detail(index_id: str) -> dict:
"""
requestIndexDetail 接口读取 detail chart_data.klines
Returns:
detail dict包含 strength / chart_data / klines 等所有字段
"""
url = f'{DATA_SERVER_URL}/api/timeSeries/requestIndexDetail'
headers = {'Authorization': f'Bearer {AUTH_TOKEN}'}
params = {'id': index_id}
print(f'[validate] 读取详情: {url}?id={index_id}')
resp = requests.get(url, headers=headers, params=params, timeout=30)
resp.raise_for_status()
body = resp.json()
if body.get('code') != 0:
raise RuntimeError(f'requestIndexDetail 返回错误: {body}')
return body['data']['data_info']['detail']
# ── Step 2.4:搜索资产获取 ticker与前端一致───────────────────────────────────
def search_asset(keyword: str) -> dict:
"""
调用与前端相同的资产搜索 API
Args:
keyword: 搜索关键词 "中控技术""SH688777""英伟达"
Returns:
dict: { code, name, ticker, type } None
"""
url = f'{SEARCH_API_URL}/smartstock/assets/search'
params = {'q': keyword}
print(f'[validate] 搜索资产: {url}?q={keyword}')
resp = requests.get(url, params=params, timeout=10)
resp.raise_for_status()
body = resp.json()
if body.get('code') != 0:
raise RuntimeError(f'搜索返回错误: {body}')
items = body.get('data', {}).get('items', [])
if items:
result = items[0]
print(f'[validate] 搜索结果: {result}')
return result
else:
print(f'[validate] 搜索无结果')
return None
# ── Step 2.5:单独获取 K 线数据(与前端一致)───────────────────────────────────
def fetch_kline_data(ticker: str, begin_date: int | str = None) -> dict:
"""
调用与前端相同的 K 线数据 API
Args:
ticker: 股票代码 "SH601919"
begin_date: 开始日期 YYYYMMDD 格式 20250101
Returns:
dict: { labels: [...], open: [...], high: [...], low: [...], close: [...] }
"""
url = f'{KLINE_API_URL}/dataSupport/getKLineDataByTicker'
headers = {'Authorization': f'Bearer {AUTH_TOKEN}'}
params = {'ticker': ticker}
if begin_date:
params['begin_date'] = begin_date
print(f'[validate] 获取K线数据: {url}?ticker={ticker}&begin_date={begin_date}')
resp = requests.get(url, headers=headers, params=params, timeout=30)
resp.raise_for_status()
body = resp.json()
if body.get('code') != 0:
raise RuntimeError(f'getKLineDataByTicker 返回错误: {body}')
# 数据嵌套在 body['data'] 中
data = body.get('data', {})
print(f'[validate] K线数据获取成功{len(data.get("labels", []))}')
return data
# ── Step 3打印摘要 ──────────────────────────────────────────────────────────
def print_summary(detail: dict, ticker: str, freq: str):
cd = detail.get('chart_data', {})
strength = detail.get('strength', 0)
direction = detail.get('direction', 'none')
is_valid = detail.get('is_valid', False)
bp_up = detail.get('breakout_price_up', '-')
bp_down = detail.get('breakout_price_down', '-')
klines = cd.get('klines', {})
print(f'\n{"=" * 52}')
print(f' {ticker} {freq}K 收敛三角形')
print(f'{"=" * 52}')
print(f' 有效形态 : {is_valid}')
print(f' 综合强度 : {strength:.4f}')
print(f' 突破方向 : {direction}')
print(f' 上突破价 : {bp_up}')
print(f' 下突破价 : {bp_down}')
print(f' 上沿触碰 : {detail.get("touches_upper", "?")}')
print(f' 下沿触碰 : {detail.get("touches_lower", "?")}')
print(f' 收敛比例 : {detail.get("width_ratio", 0):.4f}')
print(f' 窗口日期 : {cd.get("window_start_date", "?")} ~ {cd.get("window_end_date", "?")}')
print(f' K 线根数 : {len(klines.get("dates", []))}')
print(f'{"=" * 52}\n')
# ── 主函数 ────────────────────────────────────────────────────────────────────
def run(args):
# 0. 搜索资产获取正确的 ticker与前端一致
search_result = search_asset(args.ticker)
if search_result:
kline_ticker = search_result.get('ticker') # 用于 K 线 API
asset_name = search_result.get('name') # 资产名称
print(f'[validate] 资产: {asset_name}, Ticker: {kline_ticker}')
else:
# 搜索失败,使用原始输入
kline_ticker = args.ticker
print(f'[WARN] 搜索失败,使用原始输入: {kline_ticker}')
# 1. 执行公式
try:
index_id = run_formula(args.ticker, args.freq, args.mode, args.date)
except Exception as e:
print(f'[ERROR] 公式执行失败: {e}')
sys.exit(1)
# 2. 读取结果
try:
detail = fetch_detail(index_id)
except Exception as e:
print(f'[ERROR] 读取详情失败: {e}')
sys.exit(1)
# 3. 摘要
print_summary(detail, args.ticker, args.freq)
cd = detail.get('chart_data', {})
# 4. 获取 K 线数据(使用搜索到的 ticker
# 根据 window 参数计算开始日期(与前端一致)
begin_date = get_window_start_date(args.window)
print(f'[validate] 时间窗口: {args.window}, 开始日期: {begin_date or "(全部)"}')
# 获取 K 线数据
klines = None
try:
kline_data = fetch_kline_data(kline_ticker, begin_date=begin_date)
# 🔧 过滤掉 None 值(非交易日),与前端 filterAndProcessKLineData 一致
raw_labels = kline_data.get('labels', [])
raw_open = kline_data.get('open', [])
raw_high = kline_data.get('high', [])
raw_low = kline_data.get('low', [])
raw_close = kline_data.get('close', [])
valid_indices = [
i for i in range(len(raw_labels))
if raw_open[i] is not None
and raw_high[i] is not None
and raw_low[i] is not None
and raw_close[i] is not None
]
if valid_indices:
# 转换为过滤后的 klines 格式
klines = {
'dates': [raw_labels[i] for i in valid_indices],
'open': [raw_open[i] for i in valid_indices],
'high': [raw_high[i] for i in valid_indices],
'low': [raw_low[i] for i in valid_indices],
'close': [raw_close[i] for i in valid_indices],
'volume': [0] * len(valid_indices), # 暂时用0填充
}
print(f'[INFO] K 线数据已获取,原始 {len(raw_labels)} 条,有效 {len(klines["dates"])}')
else:
print(f'[WARN] K 线 API 返回空数据')
except Exception as e:
print(f'[WARN] 获取 K 线数据失败: {e}')
# 如果 K 线 API 失败或返回空数据,使用公式返回的 klines
if not klines or not klines.get('dates'):
print('[INFO] 使用公式返回的 K 线数据')
klines = cd.get('klines')
if not klines or not klines.get('dates'):
print('[ERROR] 没有 K 线数据,跳过绘图。')
return
cd['klines'] = klines
# 5. 绘图
from chart import draw_triangle_chart
safe_ticker = args.ticker.replace('/', '_').replace('\\', '_')
output_path = None
if args.save:
output_dir = _THIS_DIR / 'output'
fname = f'{safe_ticker}_{args.freq}K_{cd.get("target_date", "")}.png'
output_path = str(output_dir / fname)
try:
draw_triangle_chart(
detail=detail,
output_path=output_path,
show=not args.no_show,
)
except Exception as e:
print(f'[ERROR] 绘图失败: {e}')
import traceback
traceback.print_exc()
sys.exit(1)
# ── 入口 ──────────────────────────────────────────────────────────────────────
if __name__ == '__main__':
run(parse_args())