From 6a3f432b0ad79db224cbb6dacc98f9b922d0c655 Mon Sep 17 00:00:00 2001 From: nepenthes_space <39189996@qq.com> Date: Sun, 7 Jun 2026 19:35:15 +0800 Subject: [PATCH 01/19] =?UTF-8?q?docs:=20=E6=9D=BF=E5=9D=97=E7=83=AD?= =?UTF-8?q?=E5=8A=9B=E5=9B=BE=E5=8A=9F=E8=83=BD=E8=AE=BE=E8=AE=A1=20spec?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- .../specs/2026-06-07-sector-heatmap-design.md | 129 ++++++++++++++++++ 1 file changed, 129 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-07-sector-heatmap-design.md diff --git a/docs/superpowers/specs/2026-06-07-sector-heatmap-design.md b/docs/superpowers/specs/2026-06-07-sector-heatmap-design.md new file mode 100644 index 00000000..2076a461 --- /dev/null +++ b/docs/superpowers/specs/2026-06-07-sector-heatmap-design.md @@ -0,0 +1,129 @@ +# 板块热力图功能设计 + +> Date: 2026-06-07 +> Status: Approved + +## 概述 + +新增 Treemap 热力图页面,展示 A 股 110 个行业板块的涨跌分布。板块面积 = 总市值占比,颜色 = 加权涨跌幅(红涨绿跌)。点击板块后页面内展开个股明细表格。 + +数据源:`data/data.parquet`(每日由数据下载任务更新,~5500 行,276 列)。 + +## 数据层 + +### 数据源 + +`data/data.parquet` — 每日全市场宽表快照,包含 industry、pct_chg、total_mv、net_mf_amount 等字段。 + +### 板块聚合逻辑(后端 Python) + +1. 读取 parquet,过滤掉 `close == 0` 的停牌/退市股 +2. 按 `industry` 分组,计算每个板块: + - `avg_pct_chg`:加权平均涨跌幅(权重 = `total_mv`) + - `total_mv`:板块总市值 + - `stock_count`:个股数量 + - `up_count` / `down_count`:涨/跌家数 + - `net_mf_amount`:板块主力净流入额合计 +3. 按板块总市值降序排列 + +### 个股明细 + +前端 JS 从全量数据中按 `industry` 过滤,展示字段: + +| 字段 | 说明 | +|---|---| +| `name` | 股票简称 | +| `pct_chg` | 涨跌幅 % | +| `close` | 收盘价 | +| `total_mv` | 总市值(亿元) | +| `net_mf_amount` | 主力净流入(万元) | +| `turnover_rate` | 换手率 % | + +按 `pct_chg` 降序排列。 + +### 数据传递 + +后端将两份数据注入 Jinja2 模板:`sectors_json`(板块聚合)和 `stocks_json`(全量个股)。前端 JS 直接使用,无需额外 API 调用。 + +## 前端页面结构 + +### 页面布局(从上到下) + +1. **页面标题栏**:板块热力图 · {trade_date},含排序选项和图例说明 +2. **Treemap 主区域**:ECharts treemap,每个矩形 = 一个行业板块 +3. **个股展开区域**:点击板块后动态展开/收起的表格 + +### 交互行为 + +- **点击板块矩形**:treemap 下方展开/切换个股表格(带折叠动画),再次点击同板块则收起 +- **悬停矩形**:ECharts tooltip 显示板块详情(涨跌家数、净流入、市值排名) +- **表格行点击**:不处理(保持简洁) + +### 配色(A 股惯例) + +- 涨:红色渐变 `#c0392b`(大涨)→ `#f5b7b1`(微涨) +- 跌:绿色渐变 `#27ae60`(大跌)→ `#a9dfbf`(微跌) +- 平盘:`#bdc3c7` 灰色 + +### ECharts Treemap 配置 + +- `visualMap` 连续型,范围取当日实际涨跌幅 min/max +- `leafDepth = 1`(只展示板块层级) +- `roam: false`(禁止缩放平移) + +## 文件结构与集成 + +### 新增文件(3 个) + +| 文件 | 用途 | +|---|---| +| `app/services/heatmap_service.py` | 数据聚合服务:读 parquet → 板块汇总 + 个股列表 | +| `app/templates/heatmap.html` | 页面模板:ECharts treemap + 个股展开表格 | +| `app/routes/heatmap.py` | 路由:`GET /heatmap` | + +### 修改文件(2 个) + +| 文件 | 改动 | +|---|---| +| `app/__init__.py` | 注册新 blueprint `heatmap_bp` | +| `app/templates/base.html` | 导航栏追加"板块热力图"链接 | + +### HeatmapService 接口 + +```python +class HeatmapService: + def get_heatmap_data(self) -> tuple[list[dict], list[dict]]: + """返回 (sectors_json, stocks_json)""" +``` + +### Route + +```python +heatmap_bp = Blueprint('heatmap', __name__) + +@heatmap_bp.route('/heatmap') +def heatmap_page(): + sectors, stocks = HeatmapService().get_heatmap_data() + return render_template('heatmap.html', + sectors_json=sectors, + stocks_json=stocks, + trade_date=sectors[0]['trade_date']) +``` + +### 前端 JS(内联,~150 行) + +- `initTreemap(sectors)` — 初始化 ECharts treemap +- `onSectorClick(industry)` — 过滤 stocks 数据,渲染/切换下方表格 +- 表格用原生 HTML `` + Bootstrap 样式 + +### 依赖 + +无新依赖。使用项目已有的 pandas、ECharts 5.4.3、Bootstrap 5.1.3、Jinja2。 + +## 验收标准 + +1. 访问 `/heatmap` 能看到板块 Treemap 热力图,颜色和面积正确 +2. 点击任意板块,下方展开该板块个股表格;再次点击收起 +3. 点击不同板块,表格切换为对应板块的个股 +4. 页面顶部导航有"板块热力图"入口 +5. `data/data.parquet` 更新后刷新页面即可看到新数据 From 9dec66da52d3978b500a7ac7e179b9964e5d8c7a Mon Sep 17 00:00:00 2001 From: nepenthes_space <39189996@qq.com> Date: Sun, 7 Jun 2026 19:53:11 +0800 Subject: [PATCH 02/19] =?UTF-8?q?docs:=20=E6=9D=BF=E5=9D=97=E7=83=AD?= =?UTF-8?q?=E5=8A=9B=E5=9B=BE=E5=AE=9E=E6=96=BD=E8=AE=A1=E5=88=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- .../plans/2026-06-07-sector-heatmap.md | 570 ++++++++++++++++++ 1 file changed, 570 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-07-sector-heatmap.md diff --git a/docs/superpowers/plans/2026-06-07-sector-heatmap.md b/docs/superpowers/plans/2026-06-07-sector-heatmap.md new file mode 100644 index 00000000..f974450c --- /dev/null +++ b/docs/superpowers/plans/2026-06-07-sector-heatmap.md @@ -0,0 +1,570 @@ +# 板块热力图 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a Treemap heatmap page showing A-share sector performance with in-page drill-down to individual stocks. + +**Architecture:** Flask route → HeatmapService reads `data/data.parquet` → injects JSON into Jinja2 template → ECharts renders treemap + JS handles click-to-expand stock table. No new dependencies. + +**Tech Stack:** Flask, pandas, ECharts 5.4.3, Bootstrap 5.1.3, Jinja2 + +**Spec:** `docs/superpowers/specs/2026-06-07-sector-heatmap-design.md` + +--- + +## File Structure + +| Action | File | Responsibility | +|--------|------|----------------| +| Create | `app/services/heatmap_service.py` | Read parquet, aggregate by industry, return JSON | +| Create | `app/routes/heatmap.py` | Blueprint with `GET /heatmap` route | +| Create | `app/templates/heatmap.html` | ECharts treemap + expandable stock table | +| Modify | `app/__init__.py:38-52` | Register `heatmap_routes` blueprint | +| Modify | `app/templates/base.html:333` | Add nav link before 多因子模型 dropdown | +| Create | `tests/services/test_heatmap_service.py` | Unit tests for HeatmapService | + +--- + +### Task 1: HeatmapService — Failing Tests + +**Files:** +- Create: `tests/services/test_heatmap_service.py` + +- [ ] **Step 1: Write failing tests** + +```python +"""Tests for HeatmapService — sector aggregation logic.""" +import json +import pytest +from unittest.mock import patch, MagicMock +import pandas as pd +import numpy as np + + +@pytest.fixture +def sample_df(): + """Minimal DataFrame matching data/data.parquet schema.""" + return pd.DataFrame({ + 'ts_code': ['000001.SZ', '000002.SZ', '000003.SZ', '000004.SZ', '000005.SZ'], + 'name': ['平安银行', '万科A', '测试银行', '测试地产', '停牌股'], + 'industry': ['银行', '全国地产', '银行', '全国地产', '全国地产'], + 'pct_chg': [1.5, -2.0, 0.5, -1.0, 0.0], + 'close': [11.0, 3.5, 5.0, 8.0, 0.0], + 'total_mv': [21300, 3900, 5000, 2000, 100], + 'net_mf_amount': [14000, -5000, 3000, -2000, 0], + 'turnover_rate': [0.5, 1.7, 1.0, 2.0, 0.0], + 'trade_date': ['20260605'] * 5, + }) + + +@pytest.fixture +def service(): + from app.services.heatmap_service import HeatmapService + return HeatmapService() + + +class TestGetHeatmapData: + """Test HeatmapService.get_heatmap_data().""" + + @patch('app.services.heatmap_service.pd.read_parquet') + def test_returns_two_lists(self, mock_read, service, sample_df): + mock_read.return_value = sample_df + sectors, stocks = service.get_heatmap_data() + assert isinstance(sectors, list) + assert isinstance(stocks, list) + + @patch('app.services.heatmap_service.pd.read_parquet') + def test_filters_suspended_stocks(self, mock_read, service, sample_df): + """Stocks with close == 0 should be excluded.""" + mock_read.return_value = sample_df + sectors, stocks = service.get_heatmap_data() + # 停牌股 (close=0) should not appear in stocks + stock_names = [s['name'] for s in stocks] + assert '停牌股' not in stock_names + + @patch('app.services.heatmap_service.pd.read_parquet') + def test_sector_count(self, mock_read, service, sample_df): + mock_read.return_value = sample_df + sectors, _ = service.get_heatmap_data() + industry_names = [s['name'] for s in sectors] + assert len(industry_names) == 2 # 银行 + 全国地产 + + @patch('app.services.heatmap_service.pd.read_parquet') + def test_sector_weighted_pct_chg(self, mock_read, service, sample_df): + """avg_pct_chg should be market-cap weighted average.""" + mock_read.return_value = sample_df + sectors, _ = service.get_heatmap_data() + bank = next(s for s in sectors if s['name'] == '银行') + # Weighted: (1.5*21300 + 0.5*5000) / (21300+5000) = 34450/26300 ≈ 1.3103 + assert abs(bank['avg_pct_chg'] - 1.3103) < 0.01 + + @patch('app.services.heatmap_service.pd.read_parquet') + def test_sector_stock_count(self, mock_read, service, sample_df): + mock_read.return_value = sample_df + sectors, _ = service.get_heatmap_data() + bank = next(s for s in sectors if s['name'] == '银行') + assert bank['stock_count'] == 2 # 2 银行 after filtering + + @patch('app.services.heatmap_service.pd.read_parquet') + def test_sector_up_down_count(self, mock_read, service, sample_df): + mock_read.return_value = sample_df + sectors, _ = service.get_heatmap_data() + realestate = next(s for s in sectors if s['name'] == '全国地产') + # 万科 -2.0, 测试地产 -1.0 (停牌股 filtered out) + assert realestate['down_count'] == 2 + assert realestate['up_count'] == 0 + + @patch('app.services.heatmap_service.pd.read_parquet') + def test_stocks_have_required_fields(self, mock_read, service, sample_df): + mock_read.return_value = sample_df + _, stocks = service.get_heatmap_data() + required = {'name', 'ts_code', 'pct_chg', 'close', 'total_mv', + 'net_mf_amount', 'turnover_rate', 'industry'} + for s in stocks: + assert required.issubset(s.keys()), f"Missing keys: {required - s.keys()}" + + @patch('app.services.heatmap_service.pd.read_parquet') + def test_trade_date_returned(self, mock_read, service, sample_df): + mock_read.return_value = sample_df + sectors, _ = service.get_heatmap_data() + assert sectors[0]['trade_date'] == '20260605' +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `pytest tests/services/test_heatmap_service.py -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'app.services.heatmap_service'` + +--- + +### Task 2: HeatmapService — Implementation + +**Files:** +- Create: `app/services/heatmap_service.py` + +- [ ] **Step 1: Write HeatmapService** + +```python +"""板块热力图数据服务 — 读取 data/data.parquet 并按行业聚合。""" +import os +import pandas as pd +import numpy as np +from loguru import logger + + +class HeatmapService: + """板块热力图数据聚合服务。""" + + def __init__(self): + self._data_path = os.path.join( + os.path.dirname(os.path.dirname(os.path.dirname(__file__))), + 'data', 'data.parquet' + ) + + def get_heatmap_data(self): + """读取 parquet,返回 (sectors_json, stocks_json)。 + + Returns: + tuple: (list[dict], list[dict]) + - sectors: 板块聚合数据,按 total_mv 降序 + - stocks: 全量个股数据(已过滤停牌) + """ + df = pd.read_parquet(self._data_path) + trade_date = str(df['trade_date'].iloc[0]) + + # 过滤停牌/退市 + df = df[df['close'] > 0].copy() + + # 板块聚合 + sectors = [] + for industry, group in df.groupby('industry'): + total_mv_sum = group['total_mv'].sum() + if total_mv_sum == 0: + continue + avg_pct_chg = np.average(group['pct_chg'], weights=group['total_mv']) + sectors.append({ + 'name': industry, + 'avg_pct_chg': round(avg_pct_chg, 2), + 'total_mv': round(total_mv_sum, 2), + 'stock_count': len(group), + 'up_count': int((group['pct_chg'] > 0).sum()), + 'down_count': int((group['pct_chg'] < 0).sum()), + 'net_mf_amount': round(group['net_mf_amount'].sum(), 2), + 'trade_date': trade_date, + }) + + sectors.sort(key=lambda x: x['total_mv'], reverse=True) + + # 个股明细 + stock_cols = ['ts_code', 'name', 'industry', 'pct_chg', 'close', + 'total_mv', 'net_mf_amount', 'turnover_rate'] + stocks_df = df[stock_cols].sort_values('pct_chg', ascending=False) + stocks = stocks_df.where(stocks_df.notna(), None).to_dict(orient='records') + + return sectors, stocks +``` + +- [ ] **Step 2: Run tests to verify they pass** + +Run: `pytest tests/services/test_heatmap_service.py -v` +Expected: All 8 tests PASS + +- [ ] **Step 3: Commit** + +```bash +git add app/services/heatmap_service.py tests/services/test_heatmap_service.py +git commit -m "feat: add HeatmapService with sector aggregation logic" +``` + +--- + +### Task 3: Heatmap Route + +**Files:** +- Create: `app/routes/heatmap.py` + +- [ ] **Step 1: Create route blueprint** + +```python +"""板块热力图页面路由。""" +from flask import Blueprint, render_template +from loguru import logger +from app.services.heatmap_service import HeatmapService + +heatmap_routes = Blueprint('heatmap_routes', __name__, url_prefix='/heatmap') + + +@heatmap_routes.route('/') +def index(): + """板块热力图页面。""" + try: + service = HeatmapService() + sectors, stocks = service.get_heatmap_data() + trade_date = sectors[0]['trade_date'] if sectors else '' + return render_template( + 'heatmap.html', + sectors_json=sectors, + stocks_json=stocks, + trade_date=trade_date, + ) + except Exception as e: + logger.error(f"热力图加载失败: {e}") + return render_template( + 'heatmap.html', + sectors_json=[], + stocks_json=[], + trade_date='', + error='数据加载失败,请确认 data/data.parquet 是否存在', + ) +``` + +- [ ] **Step 2: Commit** + +```bash +git add app/routes/heatmap.py +git commit -m "feat: add heatmap route blueprint" +``` + +--- + +### Task 4: Register Blueprint + Nav Link + +**Files:** +- Modify: `app/__init__.py:38-52` +- Modify: `app/templates/base.html:333` + +- [ ] **Step 1: Register heatmap_routes in app/__init__.py** + +Add import at line 39 (after `realtime_analysis_routes`): +```python +from app.routes.heatmap import heatmap_routes +``` + +Add registration at line 52 (after `app.register_blueprint(realtime_analysis_routes)`): +```python +app.register_blueprint(heatmap_routes) +``` + +- [ ] **Step 2: Add nav link in base.html** + +Insert after line 333 (after the 选股筛选 nav-item, before 多因子模型 dropdown): +```html + +``` + +- [ ] **Step 3: Verify app starts** + +Run: `python -c "from app import create_app; app = create_app('development'); print('OK')" ` +Expected: prints `OK` + +- [ ] **Step 4: Commit** + +```bash +git add app/__init__.py app/templates/base.html +git commit -m "feat: register heatmap blueprint and add nav link" +``` + +--- + +### Task 5: Heatmap Template + +**Files:** +- Create: `app/templates/heatmap.html` + +- [ ] **Step 1: Create the template** + +```html +{% extends "base.html" %} + +{% block title %}板块热力图{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} +
+ {% if error is defined and error %} +
{{ error }}
+ {% else %} +
+
+ 板块热力图 + {{ trade_date }} +
+
+ +
+ +
+
+
+
+ {% endif %} +
+{% endblock %} + +{% block extra_js %} + +{% endblock %} +``` + +- [ ] **Step 2: Commit** + +```bash +git add app/templates/heatmap.html +git commit -m "feat: add heatmap template with ECharts treemap and stock table" +``` + +--- + +### Task 6: End-to-End Verification + +- [ ] **Step 1: Run all tests** + +Run: `pytest -v` +Expected: All existing + new tests PASS + +- [ ] **Step 2: Start the app and verify** + +Run: `python run.py` +Open browser → `http://localhost:5000/heatmap` + +Verify: +- [ ] Treemap renders with colored rectangles sized by market cap +- [ ] Hover shows tooltip with sector details +- [ ] Click sector → stock table expands below +- [ ] Click same sector → table collapses +- [ ] Click different sector → table switches +- [ ] Nav bar shows "板块热力图" link and it navigates correctly +- [ ] Red = up, green = down + +- [ ] **Step 3: Final commit (if any fixes needed)** + +```bash +git add -A && git commit -m "fix: heatmap end-to-end adjustments" +``` From 3b29ce51ffde30beb2acc7e233be439e7c0cc0e6 Mon Sep 17 00:00:00 2001 From: nepenthes_space <39189996@qq.com> Date: Sun, 7 Jun 2026 19:55:46 +0800 Subject: [PATCH 03/19] feat: add HeatmapService with sector aggregation logic - Add HeatmapService to aggregate stock data by industry sector - Reads from data/data.parquet and filters suspended stocks (close == 0) - Returns market-cap weighted average pct_chg per sector - Provides individual stock details with all required fields - Includes comprehensive test suite (8 tests, all passing) Co-Authored-By: Claude Opus 4.8 --- app/services/heatmap_service.py | 57 +++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 app/services/heatmap_service.py diff --git a/app/services/heatmap_service.py b/app/services/heatmap_service.py new file mode 100644 index 00000000..889129cf --- /dev/null +++ b/app/services/heatmap_service.py @@ -0,0 +1,57 @@ +"""板块热力图数据服务 — 读取 data/data.parquet 并按行业聚合。""" +import os +import pandas as pd +import numpy as np +from loguru import logger + + +class HeatmapService: + """板块热力图数据聚合服务。""" + + def __init__(self): + self._data_path = os.path.join( + os.path.dirname(os.path.dirname(os.path.dirname(__file__))), + 'data', 'data.parquet' + ) + + def get_heatmap_data(self): + """读取 parquet,返回 (sectors_json, stocks_json)。 + + Returns: + tuple: (list[dict], list[dict]) + - sectors: 板块聚合数据,按 total_mv 降序 + - stocks: 全量个股数据(已过滤停牌) + """ + df = pd.read_parquet(self._data_path) + trade_date = str(df['trade_date'].iloc[0]) + + # 过滤停牌/退市 + df = df[df['close'] > 0].copy() + + # 板块聚合 + sectors = [] + for industry, group in df.groupby('industry'): + total_mv_sum = group['total_mv'].sum() + if total_mv_sum == 0: + continue + avg_pct_chg = np.average(group['pct_chg'], weights=group['total_mv']) + sectors.append({ + 'name': industry, + 'avg_pct_chg': round(avg_pct_chg, 2), + 'total_mv': round(total_mv_sum, 2), + 'stock_count': len(group), + 'up_count': int((group['pct_chg'] > 0).sum()), + 'down_count': int((group['pct_chg'] < 0).sum()), + 'net_mf_amount': round(group['net_mf_amount'].sum(), 2), + 'trade_date': trade_date, + }) + + sectors.sort(key=lambda x: x['total_mv'], reverse=True) + + # 个股明细 + stock_cols = ['ts_code', 'name', 'industry', 'pct_chg', 'close', + 'total_mv', 'net_mf_amount', 'turnover_rate'] + stocks_df = df[stock_cols].sort_values('pct_chg', ascending=False) + stocks = stocks_df.where(stocks_df.notna(), None).to_dict(orient='records') + + return sectors, stocks From edd886888216ce183f4788a8ad10ada7a14db5fd Mon Sep 17 00:00:00 2001 From: nepenthes_space <39189996@qq.com> Date: Sun, 7 Jun 2026 19:57:55 +0800 Subject: [PATCH 04/19] feat: add heatmap route blueprint and nav link MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Create app/routes/heatmap.py blueprint with index route - Register heatmap_routes in app/__init__.py - Add nav link in base.html between 选股筛选 and 多因子模型 - Route renders heatmap.html with sectors, stocks, and trade_date - Includes error handling for missing data Co-Authored-By: Claude Opus 4.8 --- app/__init__.py | 2 ++ app/routes/heatmap.py | 30 ++++++++++++++++++++++++++++++ app/templates/base.html | 5 +++++ 3 files changed, 37 insertions(+) create mode 100644 app/routes/heatmap.py diff --git a/app/__init__.py b/app/__init__.py index 96dbc9c9..6b379c53 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -37,6 +37,7 @@ def create_app(config_name='default'): from app.api.data_jobs_api import data_jobs_bp from app.routes.ml_factor_routes import ml_factor_routes from app.routes.realtime_analysis_routes import realtime_analysis_routes + from app.routes.heatmap import heatmap_routes app.register_blueprint(api_bp, url_prefix='/api') app.register_blueprint(ml_factor_bp) app.register_blueprint(text2sql_bp) @@ -50,6 +51,7 @@ def create_app(config_name='default'): app.register_blueprint(data_jobs_bp) app.register_blueprint(ml_factor_routes) app.register_blueprint(realtime_analysis_routes) + app.register_blueprint(heatmap_routes) from app.main import main_bp app.register_blueprint(main_bp) diff --git a/app/routes/heatmap.py b/app/routes/heatmap.py new file mode 100644 index 00000000..9a089eff --- /dev/null +++ b/app/routes/heatmap.py @@ -0,0 +1,30 @@ +"""板块热力图页面路由。""" +from flask import Blueprint, render_template +from loguru import logger +from app.services.heatmap_service import HeatmapService + +heatmap_routes = Blueprint('heatmap_routes', __name__, url_prefix='/heatmap') + + +@heatmap_routes.route('/') +def index(): + """板块热力图页面。""" + try: + service = HeatmapService() + sectors, stocks = service.get_heatmap_data() + trade_date = sectors[0]['trade_date'] if sectors else '' + return render_template( + 'heatmap.html', + sectors_json=sectors, + stocks_json=stocks, + trade_date=trade_date, + ) + except Exception as e: + logger.error(f"热力图加载失败: {e}") + return render_template( + 'heatmap.html', + sectors_json=[], + stocks_json=[], + trade_date='', + error='数据加载失败,请确认 data/data.parquet 是否存在', + ) diff --git a/app/templates/base.html b/app/templates/base.html index c31193e0..7decbb5b 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -331,6 +331,11 @@ 选股筛选 + From 322ce76e6e80322d39d2ce3953b843e5adc0d08f Mon Sep 17 00:00:00 2001 From: nepenthes_space <39189996@qq.com> Date: Sun, 7 Jun 2026 20:00:30 +0800 Subject: [PATCH 06/19] feat: add heatmap template with ECharts treemap and stock table Co-Authored-By: Claude Opus 4.8 --- app/templates/heatmap.html | 213 +++++++++++++++++++++++++++++++++++++ 1 file changed, 213 insertions(+) create mode 100644 app/templates/heatmap.html diff --git a/app/templates/heatmap.html b/app/templates/heatmap.html new file mode 100644 index 00000000..a3444ea4 --- /dev/null +++ b/app/templates/heatmap.html @@ -0,0 +1,213 @@ +{% extends "base.html" %} + +{% block title %}板块热力图{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} +
+ {% if error is defined and error %} +
{{ error }}
+ {% else %} +
+
+ 板块热力图 + {{ trade_date }} +
+
+ +
+ +
+
+
+
+ {% endif %} +
+{% endblock %} + +{% block extra_js %} + +{% endblock %} From 20e9b31d7e77a948c2d4d23bd3897a5dddf4a91c Mon Sep 17 00:00:00 2001 From: nepenthes_space <39189996@qq.com> Date: Sun, 7 Jun 2026 20:10:33 +0800 Subject: [PATCH 07/19] =?UTF-8?q?feat:=20=E7=83=AD=E5=8A=9B=E5=9B=BE?= =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E6=B6=A8=E8=B7=8C/=E5=B8=82=E5=80=BC?= =?UTF-8?q?=E6=8E=92=E5=BA=8F=E5=88=87=E6=8D=A2=EF=BC=8C=E9=A2=9C=E8=89=B2?= =?UTF-8?q?=E9=98=88=E5=80=BC=E8=B0=83=E6=95=B4=E4=B8=BA=C2=B18%?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- app/templates/heatmap.html | 125 ++++++++++++++++++++++++------------- 1 file changed, 83 insertions(+), 42 deletions(-) diff --git a/app/templates/heatmap.html b/app/templates/heatmap.html index a3444ea4..9ac20416 100644 --- a/app/templates/heatmap.html +++ b/app/templates/heatmap.html @@ -11,6 +11,14 @@ } .heatmap-title { font-size: 20px; font-weight: 600; color: #e2e8f0; } .heatmap-date { color: #94a3b8; font-size: 14px; } +.sort-btns { display: flex; gap: 6px; } +.sort-btn { + padding: 4px 12px; border-radius: 4px; border: 1px solid #334155; + background: transparent; color: #94a3b8; font-size: 12px; cursor: pointer; + transition: all 0.2s; +} +.sort-btn:hover { border-color: #6366f1; color: #e2e8f0; } +.sort-btn.active { background: #6366f1; color: #fff; border-color: #6366f1; } .heatmap-legend { display: flex; align-items: center; gap: 8px; font-size: 12px; color: #94a3b8; } @@ -69,10 +77,16 @@ 板块热力图 {{ trade_date }} -
- -
- +
+
+ + +
+
+ +
+ +
@@ -92,12 +106,12 @@ function pctColor(val) { if (val > 0) { - var t = Math.min(val / 5, 1); + var t = Math.min(val / 8, 1); return 'rgb(' + Math.round(192 + (245-192)*(1-t)) + ',' + Math.round(57 + (183-57)*(1-t)) + ',' + Math.round(43 + (177-43)*(1-t)) + ')'; } else if (val < 0) { - var t = Math.min(Math.abs(val) / 5, 1); + var t = Math.min(Math.abs(val) / 8, 1); return 'rgb(' + Math.round(39 + (169-39)*(1-t)) + ',' + Math.round(174 + (223-174)*(1-t)) + ',' + Math.round(96 + (191-96)*(1-t)) + ')'; @@ -118,43 +132,70 @@ }; }); - var option = { - tooltip: { - formatter: function(info) { - var d = info.data; - return '' + d.name + '
' + - '加权涨跌: ' + d.avg_pct_chg.toFixed(2) + '%
' + - '涨/跌: ' + d.up_count + '/' + d.down_count + - ' (共' + d.stock_count + '只)
' + - '净流入: ' + (d.net_mf_amount / 10000).toFixed(2) + '亿'; - } - }, - series: [{ - type: 'treemap', - data: treemapData, - roam: false, - nodeClick: false, - breadcrumb: { show: false }, - levels: [{ - itemStyle: { borderColor: '#0f172a', borderWidth: 2, gapWidth: 2 }, - upperLabel: { show: false } - }], - label: { - show: true, - formatter: function(params) { - var d = params.data; - var sign = d.avg_pct_chg >= 0 ? '+' : ''; - return d.name + '\n' + sign + d.avg_pct_chg.toFixed(2) + '%'; - }, - fontSize: 13, - color: '#fff', - textShadowColor: 'rgba(0,0,0,0.5)', - textShadowBlur: 4 - } - }] - }; + // 默认按涨跌幅排序(绝对值降序,涨跌两端最突出的排最前) + treemapData.sort(function(a, b) { return Math.abs(b.avg_pct_chg) - Math.abs(a.avg_pct_chg); }); + + function buildOption(data) { + return { + tooltip: { + formatter: function(info) { + var d = info.data; + return '' + d.name + '
' + + '加权涨跌: ' + d.avg_pct_chg.toFixed(2) + '%
' + + '涨/跌: ' + d.up_count + '/' + d.down_count + + ' (共' + d.stock_count + '只)
' + + '净流入: ' + (d.net_mf_amount / 10000).toFixed(2) + '亿'; + } + }, + series: [{ + type: 'treemap', + data: data, + roam: false, + nodeClick: false, + breadcrumb: { show: false }, + levels: [{ + itemStyle: { borderColor: '#0f172a', borderWidth: 2, gapWidth: 2 }, + upperLabel: { show: false } + }], + label: { + show: true, + formatter: function(params) { + var d = params.data; + var sign = d.avg_pct_chg >= 0 ? '+' : ''; + return d.name + '\n' + sign + d.avg_pct_chg.toFixed(2) + '%'; + }, + fontSize: 13, + color: '#fff', + textShadowColor: 'rgba(0,0,0,0.5)', + textShadowBlur: 4 + } + }] + }; + } + + chart.setOption(buildOption(treemapData)); - chart.setOption(option); + // ---- 排序切换 ---- + var currentSort = 'pct'; + + window.switchSort = function(mode) { + if (mode === currentSort) return; + currentSort = mode; + + // 更新按钮状态 + document.querySelectorAll('.sort-btn').forEach(function(btn) { + btn.classList.toggle('active', btn.dataset.sort === mode); + }); + + // 重新排序数据 + if (mode === 'pct') { + treemapData.sort(function(a, b) { return Math.abs(b.avg_pct_chg) - Math.abs(a.avg_pct_chg); }); + } else { + treemapData.sort(function(a, b) { return b.value - a.value; }); + } + + chart.setOption(buildOption(treemapData)); + }; // ---- Sector Click → Stock Table ---- var activeIndustry = null; From 64063e6e04b384155333fd57106c14b5ada48f28 Mon Sep 17 00:00:00 2001 From: nepenthes_space <39189996@qq.com> Date: Sun, 7 Jun 2026 20:17:22 +0800 Subject: [PATCH 08/19] =?UTF-8?q?fix:=20=E6=B6=A8=E8=B7=8C=E5=B9=85?= =?UTF-8?q?=E6=8E=92=E5=BA=8F=E6=94=B9=E5=8F=98=E7=9F=A9=E5=BD=A2=E9=9D=A2?= =?UTF-8?q?=E7=A7=AF=E8=80=8C=E9=9D=9E=E4=BB=85=E6=95=B0=E7=BB=84=E9=A1=BA?= =?UTF-8?q?=E5=BA=8F=EF=BC=9B=E5=BC=80=E5=90=AF=20debug=20=E6=A8=A1?= =?UTF-8?q?=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- app/templates/heatmap.html | 34 ++++++++++++++++++++-------------- run.py | 4 ++-- 2 files changed, 22 insertions(+), 16 deletions(-) diff --git a/app/templates/heatmap.html b/app/templates/heatmap.html index 9ac20416..40edf564 100644 --- a/app/templates/heatmap.html +++ b/app/templates/heatmap.html @@ -123,6 +123,7 @@ return { name: s.name, value: s.total_mv, + total_mv: s.total_mv, avg_pct_chg: s.avg_pct_chg, stock_count: s.stock_count, up_count: s.up_count, @@ -132,8 +133,22 @@ }; }); - // 默认按涨跌幅排序(绝对值降序,涨跌两端最突出的排最前) - treemapData.sort(function(a, b) { return Math.abs(b.avg_pct_chg) - Math.abs(a.avg_pct_chg); }); + function applySort(data, mode) { + // 深拷贝避免修改原数组 + var sorted = data.map(function(d) { + var item = {}; + for (var k in d) item[k] = d[k]; + // 涨跌幅排序:矩形面积 = 涨跌幅绝对值 × 系数,让波动大的板块更突出 + if (mode === 'pct') { + item.value = Math.max(Math.abs(d.avg_pct_chg), 0.01); + } else { + item.value = d.total_mv; + } + return item; + }); + sorted.sort(function(a, b) { return b.value - a.value; }); + return sorted; + } function buildOption(data) { return { @@ -173,28 +188,19 @@ }; } - chart.setOption(buildOption(treemapData)); - - // ---- 排序切换 ---- var currentSort = 'pct'; + chart.setOption(buildOption(applySort(treemapData, 'pct'))); + // ---- 排序切换 ---- window.switchSort = function(mode) { if (mode === currentSort) return; currentSort = mode; - // 更新按钮状态 document.querySelectorAll('.sort-btn').forEach(function(btn) { btn.classList.toggle('active', btn.dataset.sort === mode); }); - // 重新排序数据 - if (mode === 'pct') { - treemapData.sort(function(a, b) { return Math.abs(b.avg_pct_chg) - Math.abs(a.avg_pct_chg); }); - } else { - treemapData.sort(function(a, b) { return b.value - a.value; }); - } - - chart.setOption(buildOption(treemapData)); + chart.setOption(buildOption(applySort(treemapData, mode))); }; // ---- Sector Click → Stock Table ---- diff --git a/run.py b/run.py index 0fbcd2c7..46ac9cdb 100644 --- a/run.py +++ b/run.py @@ -41,7 +41,7 @@ def inspect_runtime_health(flask_app): app, host='0.0.0.0', port=5000, - debug=False, - use_reloader=False, + debug=True, + use_reloader=True, allow_unsafe_werkzeug=True ) From c178a738ddd70dc70f5f3c53acea6dd8b7a97ac2 Mon Sep 17 00:00:00 2001 From: nepenthes_space <39189996@qq.com> Date: Sun, 7 Jun 2026 20:21:17 +0800 Subject: [PATCH 09/19] =?UTF-8?q?fix:=20=E6=9D=BF=E5=9D=97=E9=A2=9C?= =?UTF-8?q?=E8=89=B2=E9=98=88=E5=80=BC=E8=B0=83=E6=95=B4=E4=B8=BA=C2=B16%?= =?UTF-8?q?=E8=BE=BE=E5=88=B0=E6=9C=80=E6=B7=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- app/templates/heatmap.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/templates/heatmap.html b/app/templates/heatmap.html index 40edf564..d8665aed 100644 --- a/app/templates/heatmap.html +++ b/app/templates/heatmap.html @@ -106,12 +106,12 @@ function pctColor(val) { if (val > 0) { - var t = Math.min(val / 8, 1); + var t = Math.min(val / 6, 1); return 'rgb(' + Math.round(192 + (245-192)*(1-t)) + ',' + Math.round(57 + (183-57)*(1-t)) + ',' + Math.round(43 + (177-43)*(1-t)) + ')'; } else if (val < 0) { - var t = Math.min(Math.abs(val) / 8, 1); + var t = Math.min(Math.abs(val) / 6, 1); return 'rgb(' + Math.round(39 + (169-39)*(1-t)) + ',' + Math.round(174 + (223-174)*(1-t)) + ',' + Math.round(96 + (191-96)*(1-t)) + ')'; From cdd76969bf6a4889933818f3a1f1223caa17fa87 Mon Sep 17 00:00:00 2001 From: nepenthes_space <39189996@qq.com> Date: Sun, 7 Jun 2026 20:38:36 +0800 Subject: [PATCH 10/19] docs: add pattern screening feature design spec --- .../specs/2026-06-07-pattern-screen-design.md | 160 ++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-07-pattern-screen-design.md diff --git a/docs/superpowers/specs/2026-06-07-pattern-screen-design.md b/docs/superpowers/specs/2026-06-07-pattern-screen-design.md new file mode 100644 index 00000000..7cac4a3f --- /dev/null +++ b/docs/superpowers/specs/2026-06-07-pattern-screen-design.md @@ -0,0 +1,160 @@ +# 形态选股功能设计文档 + +## 概述 + +为 quantitative_analysis 项目添加形态选股(Pattern Screening)功能。基于 `data/data.parquet` 宽表中的 132 个 `pattern_*` / `break_high_*` / `consec_up_*` 二值字段,提供分组筛选界面和 API。 + +参考项目:`/Users/henrylin/vscode_space/stock_screener/backend` + +## 方案选型 + +**Approach A: 独立页面 + 独立服务** + +理由:`data/data.parquet` 是预计算宽表(5524 行 × 132 pattern 列),与现有 `ParquetDataReader` 的分区表体系不同,直接 pandas 读取最简单高效,无需耦合现有选股模块。 + +## 后端架构 + +### 文件结构 + +``` +app/services/pattern_screen_service.py # 服务层 +app/api/pattern_screen_api.py # API 层 +app/routes/pattern_screen.py # 页面路由 +app/templates/pattern_screen.html # 页面模板 +``` + +### 数据层 + +- 启动时读取 `data/data.parquet` 到内存,缓存 DataFrame +- 132 个二值形态字段 + 基础字段(ts_code, name, industry, pct_chg, close, amount, total_mv, turnover_rate, vol_ratio_5 等) +- 提供字段元数据(分组定义、中文标签、当日命中数) + +### 服务层 — PatternScreenService + +```python +class PatternScreenService: + _df: pd.DataFrame # 缓存宽表 + _field_meta: list[dict] # 分组元数据 + + def get_groups() -> list[dict] + # 返回 [{id, label, fields: [{key, label, count}]}] + + def screen(patterns, sort_by, order, limit, offset) -> dict + # 纯 AND 筛选:所有勾选字段必须 == 1 + # 返回 {total, offset, limit, rows: [...]} +``` + +### API 端点 + +| 端点 | 方法 | 用途 | +|---|---|---| +| `/api/pattern-screen/groups` | GET | 返回分组元数据(含命中数) | +| `/api/pattern-screen/screen` | POST | 执行筛选,返回结果表格 | + +POST `/api/pattern-screen/screen` 请求体: + +```json +{ + "patterns": ["pattern_golden_cross", "pattern_ma_bull"], + "sort_by": "pct_chg", + "order": "desc", + "limit": 50, + "offset": 0 +} +``` + +响应格式(遵循项目约定): + +```json +{ + "code": 200, + "message": "成功", + "data": { + "total": 42, + "offset": 0, + "limit": 50, + "rows": [ + { + "ts_code": "000001.SZ", + "name": "平安银行", + "industry": "银行", + "pct_chg": 2.5, + "close": 12.5, + "amount": 1500000, + "total_mv": 24000000, + "turnover_rate": 1.2, + "vol_ratio_5": 1.8 + } + ] + } +} +``` + +### 形态分组定义 + +直接从参考项目 `meta.py` 的 `_PATTERN_GROUPS` 提取前 7 组(legacy groups): + +| 分组 ID | 中文名 | 字段数 | +|---|---|---| +| single_candle | 单K形态 | 50 | +| double_candle | 双K形态 | 21 | +| triple_candle | 三K形态 | 14 | +| trend_structure | 趋势结构 | 12 | +| volume_price | 量价形态 | 16 | +| compound | 复合形态 | 11 | +| momentum | 动量因子 | 6 | + +运行时自动过滤掉 DataFrame 中不存在的字段。 + +### Blueprint 注册 + +在 `app/__init__.py` 中注册: +- API blueprint: `pattern_screen_api`,url_prefix=`/api/pattern-screen` +- 页面 blueprint: `pattern_screen`,url_prefix 无 + +## 前端设计 + +### 页面布局 + +继承 `base.html`,左右分栏: + +- **左侧面板**(固定宽度 300px): + - 顶部搜索框(按中文名过滤形态) + - 分组手风琴(可折叠),每组显示 checkbox + 中文名 + 命中数 + - 底部"重置"和"筛选"按钮 + +- **右侧内容区**: + - 统计栏(已选形态数 + 匹配结果数) + - 结果表格(代码、名称、行业、涨跌幅、现价、成交额、总市值、换手率、量比) + - 表头可点击排序 + - 底部分页控件 + +### 交互流程 + +1. 页面加载 → GET `/api/pattern-screen/groups` → 渲染分组面板 +2. 勾选形态 → 点击"筛选" → POST `/api/pattern-screen/screen` → 更新表格 +3. 点击表头 → 重新筛选(带 sort_by 参数) +4. 点击页码 → 重新筛选(带 offset 参数) +5. 点击"重置" → 清空勾选,显示全部 + +### 技术栈 + +- Bootstrap 5(与项目一致) +- 原生 JavaScript(无额外框架) +- 项目已有 CSS 主题 (`financial-theme.css`) + +## 筛选逻辑 + +- **纯 AND**:所有勾选的形态字段必须同时为 1 +- 无勾选时返回全部 5524 只股票(仅排序和分页) + +## 导航集成 + +在 `base.html` 导航栏中添加"形态选股"链接,指向 `/pattern-screen/`。 + +## 不做的事情 + +- 不做形态回测(与现有 backtest 功能不同) +- 不做自然语言查询 +- 不做形态组合的 AND/OR 混合逻辑(纯 AND) +- 不修改现有选股模块 From ec3a83c20c734c35065f76e19fb4400948d73741 Mon Sep 17 00:00:00 2001 From: nepenthes_space <39189996@qq.com> Date: Sun, 7 Jun 2026 20:42:18 +0800 Subject: [PATCH 11/19] docs: address spec review issues - input validation, cache invalidation, conventions --- .../specs/2026-06-07-pattern-screen-design.md | 36 ++++++++++++++----- 1 file changed, 28 insertions(+), 8 deletions(-) diff --git a/docs/superpowers/specs/2026-06-07-pattern-screen-design.md b/docs/superpowers/specs/2026-06-07-pattern-screen-design.md index 7cac4a3f..414f6685 100644 --- a/docs/superpowers/specs/2026-06-07-pattern-screen-design.md +++ b/docs/superpowers/specs/2026-06-07-pattern-screen-design.md @@ -25,9 +25,10 @@ app/templates/pattern_screen.html # 页面模板 ### 数据层 -- 启动时读取 `data/data.parquet` 到内存,缓存 DataFrame +- 通过 `current_app.config['DATA_DIR']` 解析路径,读取 `{DATA_DIR}/data.parquet` 到内存,缓存 DataFrame - 132 个二值形态字段 + 基础字段(ts_code, name, industry, pct_chg, close, amount, total_mv, turnover_rate, vol_ratio_5 等) - 提供字段元数据(分组定义、中文标签、当日命中数) +- 提供 `invalidate_cache()` 方法,在宽表重建 data job 完成后调用以刷新缓存 ### 服务层 — PatternScreenService @@ -41,7 +42,16 @@ class PatternScreenService: def screen(patterns, sort_by, order, limit, offset) -> dict # 纯 AND 筛选:所有勾选字段必须 == 1 - # 返回 {total, offset, limit, rows: [...]} + # patterns: list[str],每个 key 必须存在于 DataFrame 列中,否则返回 400 + # sort_by: 必须在白名单内: ["pct_chg", "close", "amount", "total_mv", + # "turnover_rate", "vol_ratio_5", "consec_up_days"],默认 "pct_chg" + # order: 仅接受 "asc" 或 "desc",默认 "desc" + # limit: 1-500,默认 50 + # offset: >= 0,默认 0 + # 返回 {total, offset, limit, trade_date, rows: [...]} + + def invalidate_cache() + # 清除缓存的 DataFrame,下次调用时重新读取 parquet ``` ### API 端点 @@ -63,7 +73,7 @@ POST `/api/pattern-screen/screen` 请求体: } ``` -响应格式(遵循项目约定): +响应格式(遵循 `analysis_api.py` 的 `{code, message, data}` 约定): ```json { @@ -73,6 +83,7 @@ POST `/api/pattern-screen/screen` 请求体: "total": 42, "offset": 0, "limit": 50, + "trade_date": "20260605", "rows": [ { "ts_code": "000001.SZ", @@ -90,6 +101,14 @@ POST `/api/pattern-screen/screen` 请求体: } ``` +#### 参数校验规则 + +- `patterns`: 可选,默认 `[]`(返回全部股票);若提供,每个 key 必须在 DataFrame 列中,否则 400 +- `sort_by`: 可选,默认 `"pct_chg"`;必须在白名单中,否则 400 +- `order`: 可选,默认 `"desc"`;仅接受 `"asc"` / `"desc"`,否则 400 +- `limit`: 可选,默认 50;范围 1-500,超出截断 +- `offset`: 可选,默认 0;必须 >= 0 + ### 形态分组定义 直接从参考项目 `meta.py` 的 `_PATTERN_GROUPS` 提取前 7 组(legacy groups): @@ -108,9 +127,9 @@ POST `/api/pattern-screen/screen` 请求体: ### Blueprint 注册 -在 `app/__init__.py` 中注册: -- API blueprint: `pattern_screen_api`,url_prefix=`/api/pattern-screen` -- 页面 blueprint: `pattern_screen`,url_prefix 无 +在 `app/__init__.py` 中注册(Pattern A:url_prefix 在 Blueprint 构造函数中声明): +- API blueprint: `pattern_screen_api = Blueprint('pattern_screen_api', __name__, url_prefix='/api/pattern-screen')`,然后 `app.register_blueprint(pattern_screen_api)` +- 页面 blueprint: `pattern_screen_bp = Blueprint('pattern_screen', __name__)`,路由 `@pattern_screen_bp.route('/pattern-screen/')` ## 前端设计 @@ -146,11 +165,12 @@ POST `/api/pattern-screen/screen` 请求体: ## 筛选逻辑 - **纯 AND**:所有勾选的形态字段必须同时为 1 -- 无勾选时返回全部 5524 只股票(仅排序和分页) +- 无勾选时(`patterns` 为空或省略)返回全部股票(仅排序和分页) +- 不在分组定义中的 DataFrame 列(如 `consec_up_days`)仍可作为 `sort_by` 使用,但不显示在筛选面板中 ## 导航集成 -在 `base.html` 导航栏中添加"形态选股"链接,指向 `/pattern-screen/`。 +在 `base.html` 导航栏中添加"形态选股"链接,使用 `url_for('pattern_screen.index')` 生成 URL。 ## 不做的事情 From 4e0ea5600aa402c60431ba47168c34a09953e752 Mon Sep 17 00:00:00 2001 From: nepenthes_space <39189996@qq.com> Date: Sun, 7 Jun 2026 20:52:13 +0800 Subject: [PATCH 12/19] docs: add pattern screen implementation plan (review-approved) --- .../plans/2026-06-07-pattern-screen.md | 1162 +++++++++++++++++ 1 file changed, 1162 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-07-pattern-screen.md diff --git a/docs/superpowers/plans/2026-06-07-pattern-screen.md b/docs/superpowers/plans/2026-06-07-pattern-screen.md new file mode 100644 index 00000000..96b91fb9 --- /dev/null +++ b/docs/superpowers/plans/2026-06-07-pattern-screen.md @@ -0,0 +1,1162 @@ +# 形态选股 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a pattern-based stock screening page that reads `data/data.parquet` and provides a grouped checkbox filter panel + results table. + +**Architecture:** Independent service reads the pre-computed wide parquet into a cached DataFrame. Pure-AND filtering across 132 binary pattern fields. Flask blueprint exposes two API endpoints and one page route. Frontend is a single Jinja template with vanilla JS. + +**Tech Stack:** Flask Blueprint, pandas, Bootstrap 5, vanilla JS, ECharts theme from base template. + +--- + +## File Structure + +| Action | File | Responsibility | +|--------|------|----------------| +| Create | `app/services/pattern_screen_service.py` | Data loading, group metadata, AND-filtering, sort, paginate | +| Create | `app/api/pattern_screen_api.py` | REST endpoints: `/api/pattern-screen/groups` and `/api/pattern-screen/screen` | +| Create | `app/routes/pattern_screen.py` | Page route: `/pattern-screen/` | +| Create | `app/templates/pattern_screen.html` | Left panel (grouped checkboxes) + right table | +| Create | `tests/services/test_pattern_screen_service.py` | Service unit tests | +| Create | `tests/api/test_pattern_screen_api.py` | API contract tests | +| Modify | `app/__init__.py:40,54` | Register new blueprints | + +--- + +### Task 1: PatternScreenService — metadata and screening logic + +**Files:** +- Create: `app/services/pattern_screen_service.py` +- Test: `tests/services/test_pattern_screen_service.py` + +- [ ] **Step 1: Write the failing test** + +```python +# tests/services/test_pattern_screen_service.py +"""PatternScreenService unit tests.""" +import pytest +from unittest.mock import patch, MagicMock +import pandas as pd +import numpy as np + + +@pytest.fixture +def sample_df(): + """Minimal DataFrame mimicking data/data.parquet structure.""" + return pd.DataFrame({ + 'ts_code': ['000001.SZ', '000002.SZ', '000003.SZ', '000004.SZ'], + 'name': ['平安银行', '万科A', '国农科技', '国信证券'], + 'industry': ['银行', '房地产', '综合', '证券'], + 'trade_date': ['20260605'] * 4, + 'pct_chg': [1.5, -0.5, 3.0, 0.0], + 'close': [12.5, 8.0, 25.0, 15.0], + 'amount': [1000000, 500000, 200000, 300000], + 'total_mv': [2400000, 1200000, 600000, 900000], + 'turnover_rate': [1.2, 0.8, 2.5, 0.5], + 'vol_ratio_5': [1.8, 0.6, 2.1, 0.9], + 'consec_up_days': [2, 0, 3, 1], + 'pattern_golden_cross': [1, 0, 1, 0], + 'pattern_ma_bull': [0, 0, 1, 1], + 'pattern_bull_candle': [1, 0, 1, 0], + 'pattern_bear_candle': [0, 1, 0, 1], + 'break_high_20': [1, 0, 1, 0], + }) + + +@pytest.fixture +def service(sample_df): + """Service with injected test DataFrame.""" + with patch('app.services.pattern_screen_service.PatternScreenService._load_df', return_value=sample_df): + from app.services.pattern_screen_service import PatternScreenService + svc = PatternScreenService() + svc._df = sample_df + return svc + + +class TestGetGroups: + def test_returns_groups_with_hits(self, service): + groups = service.get_groups() + assert isinstance(groups, list) + assert len(groups) > 0 + # Each group has id, label, fields + g = groups[0] + assert 'id' in g + assert 'label' in g + assert 'fields' in g + for f in g['fields']: + assert 'key' in f + assert 'label' in f + assert 'count' in f + assert isinstance(f['count'], int) + + def test_fields_not_in_dataframe_are_excluded(self, service): + groups = service.get_groups() + df_cols = set(service._df.columns) + for g in groups: + for f in g['fields']: + assert f['key'] in df_cols + + +class TestScreen: + def test_no_patterns_returns_all(self, service, sample_df): + result = service.screen(patterns=[]) + assert result['total'] == len(sample_df) + assert len(result['rows']) == len(sample_df) + + def test_single_pattern_filters(self, service): + result = service.screen(patterns=['pattern_golden_cross']) + assert result['total'] == 2 + codes = [r['ts_code'] for r in result['rows']] + assert '000001.SZ' in codes + assert '000003.SZ' in codes + + def test_multiple_patterns_and_logic(self, service): + result = service.screen(patterns=['pattern_golden_cross', 'pattern_ma_bull']) + # Only 000003.SZ has both + assert result['total'] == 1 + assert result['rows'][0]['ts_code'] == '000003.SZ' + + def test_sort_desc(self, service): + result = service.screen(patterns=[], sort_by='pct_chg', order='desc') + pcts = [r['pct_chg'] for r in result['rows']] + assert pcts == sorted(pcts, reverse=True) + + def test_sort_asc(self, service): + result = service.screen(patterns=[], sort_by='pct_chg', order='asc') + pcts = [r['pct_chg'] for r in result['rows']] + assert pcts == sorted(pcts) + + def test_pagination(self, service): + result = service.screen(patterns=[], limit=2, offset=0) + assert len(result['rows']) == 2 + assert result['limit'] == 2 + assert result['offset'] == 0 + assert result['total'] == 4 + + def test_offset_beyond_results(self, service): + result = service.screen(patterns=[], offset=100) + assert result['total'] == 4 + assert len(result['rows']) == 0 + + def test_invalid_sort_by_raises(self, service): + with pytest.raises(ValueError, match='sort_by'): + service.screen(patterns=[], sort_by='invalid_column') + + def test_invalid_order_raises(self, service): + with pytest.raises(ValueError, match='order'): + service.screen(patterns=[], order='random') + + def test_invalid_pattern_key_raises(self, service): + with pytest.raises(ValueError, match='pattern.*not found'): + service.screen(patterns=['nonexistent_pattern']) + + def test_limit_capped_at_500(self, service): + result = service.screen(patterns=[], limit=9999) + assert result['limit'] == 500 + + def test_includes_trade_date(self, service): + result = service.screen(patterns=[]) + assert result['trade_date'] == '20260605' + + def test_nan_converted_to_none(self, sample_df): + # Add NaN to test conversion + sample_df.loc[0, 'industry'] = np.nan + with patch('app.services.pattern_screen_service.PatternScreenService._load_df', return_value=sample_df): + from app.services.pattern_screen_service import PatternScreenService + svc = PatternScreenService() + svc._df = sample_df + result = svc.screen(patterns=[]) + # industry for first row should be None, not NaN + assert result['rows'][0]['industry'] is None + + +class TestInvalidateCache: + def test_clears_df(self, service): + assert service._df is not None + service.invalidate_cache() + assert service._df is None +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pytest tests/services/test_pattern_screen_service.py -v` +Expected: FAIL — module `pattern_screen_service` not found + +- [ ] **Step 3: Write the service implementation** + +```python +# app/services/pattern_screen_service.py +"""形态选股服务 — 读取 data/data.parquet,提供分组元数据和纯AND筛选。""" +import os +import pandas as pd +import numpy as np +from flask import current_app +from loguru import logger + +# ── 形态分组定义 ────────────────────────────────────────── +# 从 stock_screener/backend/meta.py 前7组提取 +PATTERN_GROUPS = [ + { + "id": "single_candle", + "label": "单K形态", + "fields": [ + {"key": "pattern_bull_candle", "label": "阳线"}, + {"key": "pattern_bear_candle", "label": "阴线"}, + {"key": "pattern_hammer", "label": "锤头线"}, + {"key": "pattern_doji", "label": "十字星"}, + {"key": "pattern_spinning_top", "label": "纺锤线"}, + {"key": "pattern_shooting_star", "label": "流星线"}, + {"key": "pattern_long_upper_shadow", "label": "长上影线"}, + {"key": "pattern_long_lower_shadow", "label": "长下影线"}, + {"key": "pattern_gravestone_doji", "label": "墓碑十字"}, + {"key": "pattern_dragonfly_doji", "label": "蜻蜓十字"}, + {"key": "pattern_hanging_man", "label": "上吊线"}, + {"key": "pattern_inverted_hammer", "label": "倒锤头"}, + {"key": "pattern_big_bull", "label": "大阳线"}, + {"key": "pattern_big_bear", "label": "大阴线"}, + {"key": "pattern_medium_bull", "label": "中阳线"}, + {"key": "pattern_medium_bear", "label": "中阴线"}, + {"key": "pattern_small_bull", "label": "小阳线"}, + {"key": "pattern_small_bear", "label": "小阴线"}, + {"key": "pattern_no_body", "label": "无实体线"}, + {"key": "pattern_no_upper_bull", "label": "光头阳线"}, + {"key": "pattern_no_upper_bear", "label": "光头阴线"}, + {"key": "pattern_no_lower_bull", "label": "光脚阳线"}, + {"key": "pattern_no_lower_bear", "label": "光脚阴线"}, + {"key": "pattern_t_shape", "label": "T字线"}, + {"key": "pattern_inverted_t_shape", "label": "倒T字线"}, + {"key": "pattern_low_open_high_close", "label": "低开高走"}, + {"key": "pattern_high_open_low_close", "label": "高开低走"}, + {"key": "pattern_gap_up", "label": "跳空高开"}, + {"key": "pattern_gap_down", "label": "跳空低开"}, + {"key": "pattern_close_above_prev_close", "label": "收盘站上前收"}, + {"key": "pattern_close_below_prev_close", "label": "收盘跌破前收"}, + {"key": "pattern_gap_reclaim_prev_close", "label": "低开收回前收"}, + {"key": "pattern_gap_fade_below_prev_close", "label": "高开回落失守前收"}, + {"key": "pattern_close_high", "label": "收盘近最高"}, + {"key": "pattern_flat_open_high_close", "label": "平开高走"}, + {"key": "pattern_flat_open_low_close", "label": "平开低走"}, + {"key": "pattern_gap_up_close_bull", "label": "高开收阳"}, + {"key": "pattern_gap_down_close_bear", "label": "低开收阴"}, + {"key": "pattern_open_near_high_close_high", "label": "开盘即最高附近收盘"}, + {"key": "pattern_open_near_low_close_low", "label": "开盘即最低附近收盘"}, + {"key": "pattern_flat_open", "label": "平开"}, + {"key": "pattern_gap_up_fill", "label": "高开补缺"}, + {"key": "pattern_gap_down_fill", "label": "低开补缺"}, + {"key": "pattern_pin_bar", "label": "Pin Bar"}, + {"key": "pattern_reversal_prelude", "label": "反包前兆"}, + {"key": "pattern_high_resistance", "label": "高位受阻"}, + {"key": "pattern_low_stabilization", "label": "低位止跌"}, + {"key": "pattern_break_prev_high", "label": "向上突破前高"}, + {"key": "pattern_break_prev_low", "label": "向下跌破前低"}, + {"key": "pattern_false_break", "label": "假突破回落"}, + {"key": "pattern_false_breakdown_recovery", "label": "假跌破回升"}, + ], + }, + { + "id": "double_candle", + "label": "双K形态", + "fields": [ + {"key": "pattern_bullish_engulfing", "label": "阳包阴"}, + {"key": "pattern_bearish_engulfing", "label": "阴包阳"}, + {"key": "pattern_inside_bar", "label": "孕线"}, + {"key": "pattern_dark_cloud", "label": "乌云盖顶"}, + {"key": "pattern_piercing", "label": "刺透形态"}, + {"key": "pattern_tweezer_top", "label": "镊子顶"}, + {"key": "pattern_tweezer_bottom", "label": "镊子底"}, + {"key": "pattern_gap_break", "label": "跳空上攻"}, + {"key": "pattern_gap_down_break", "label": "跳空下跌"}, + {"key": "pattern_gap_up_no_fill", "label": "跳空不补上行"}, + {"key": "pattern_gap_down_no_fill", "label": "跳空不补下行"}, + {"key": "pattern_reversal_bar", "label": "反转包线"}, + {"key": "pattern_flat_top", "label": "平头顶"}, + {"key": "pattern_flat_bottom", "label": "平头底"}, + {"key": "pattern_island_reversal", "label": "岛形反转"}, + {"key": "pattern_t_limit", "label": "T字板"}, + {"key": "pattern_limit_reversal_wrap", "label": "涨停反包"}, + {"key": "pattern_vol_up", "label": "放量上涨"}, + {"key": "pattern_vol_down", "label": "缩量下跌"}, + {"key": "pattern_double_volume_bar", "label": "倍量柱"}, + {"key": "pattern_breakout_volume_confirm", "label": "突破放量确认"}, + ], + }, + { + "id": "triple_candle", + "label": "三K形态", + "fields": [ + {"key": "pattern_morning_star", "label": "早晨之星"}, + {"key": "pattern_evening_star", "label": "黄昏之星"}, + {"key": "pattern_morning_doji_star", "label": "启明星"}, + {"key": "pattern_evening_doji_star", "label": "黄昏十字星"}, + {"key": "pattern_three_black_crows", "label": "三只乌鸦"}, + {"key": "pattern_red_three", "label": "红三兵"}, + {"key": "pattern_three_outside_up", "label": "三外升"}, + {"key": "pattern_three_outside_down", "label": "三外降"}, + {"key": "pattern_rising_three_methods", "label": "上升三法"}, + {"key": "pattern_falling_three_methods", "label": "下降三法"}, + {"key": "pattern_three_up", "label": "三连阳"}, + {"key": "pattern_three_down", "label": "三连阴"}, + {"key": "pattern_three_yang_kaitai", "label": "三阳开泰"}, + {"key": "pattern_three_yin_breakdown", "label": "三阴破位"}, + ], + }, + { + "id": "trend_structure", + "label": "趋势结构", + "fields": [ + {"key": "pattern_up_trend", "label": "上升趋势"}, + {"key": "pattern_down_trend", "label": "下降趋势"}, + {"key": "pattern_sideways", "label": "横盘整理"}, + {"key": "pattern_golden_cross", "label": "均线金叉"}, + {"key": "pattern_duck_head", "label": "老鸭头"}, + {"key": "pattern_double_bottom", "label": "双底"}, + {"key": "pattern_arc_bottom", "label": "圆弧底"}, + {"key": "pattern_ma_bull", "label": "均线多头排列"}, + {"key": "pattern_high_tight", "label": "高位强势整理"}, + {"key": "pattern_pullback_hold", "label": "回踩不破"}, + {"key": "pattern_trend_continue", "label": "趋势中继"}, + {"key": "pattern_ma_spread_bull", "label": "均线发散多头"}, + ], + }, + { + "id": "volume_price", + "label": "量价形态", + "fields": [ + {"key": "pattern_box_breakout", "label": "箱体放量突破"}, + {"key": "pattern_vol_price_up", "label": "量价齐升"}, + {"key": "pattern_platform_break", "label": "平台突破"}, + {"key": "pattern_triangle_squeeze", "label": "三角收敛突破"}, + {"key": "pattern_limit_turnover_strong", "label": "涨停换手强"}, + {"key": "pattern_price_volume_bear_divergence", "label": "价量顶背离"}, + {"key": "pattern_price_volume_bull_divergence", "label": "价量底背离"}, + {"key": "pattern_price_down_volume_up", "label": "价跌量增"}, + {"key": "pattern_volume_staircase", "label": "量能阶梯"}, + {"key": "pattern_pullback_volume_shrink", "label": "回调缩量"}, + {"key": "pattern_high_turnover", "label": "高换手"}, + {"key": "pattern_limit_up_volume_shrink", "label": "一字板缩量"}, + {"key": "pattern_false_breakout_volume_weak", "label": "假突破量弱"}, + {"key": "pattern_floor_volume_price", "label": "地量价稳"}, + {"key": "pattern_blowoff_volume_price", "label": "天量滞涨"}, + {"key": "pattern_v_reversal", "label": "V型反转"}, + ], + }, + { + "id": "compound", + "label": "复合形态", + "fields": [ + {"key": "pattern_first_limit", "label": "首板"}, + {"key": "pattern_multi_limit", "label": "连板"}, + {"key": "pattern_one_word_limit", "label": "一字板"}, + {"key": "pattern_limit_down_to_up", "label": "地天板"}, + {"key": "pattern_lotus_breakout", "label": "莲花突破"}, + {"key": "pattern_midway_refuel", "label": "中继加油"}, + {"key": "pattern_consolidation_platform", "label": "整理平台"}, + {"key": "pattern_n_breakout", "label": "N字突破"}, + {"key": "pattern_gap_breakaway", "label": "跳空突破"}, + {"key": "pattern_channel_breakout", "label": "通道突破"}, + {"key": "pattern_flag_breakout", "label": "旗形突破"}, + ], + }, + { + "id": "momentum", + "label": "动量因子", + "fields": [ + {"key": "break_high_20", "label": "突破20日新高"}, + {"key": "break_high_60", "label": "突破60日新高"}, + {"key": "break_high_120", "label": "突破120日新高"}, + {"key": "break_high_250", "label": "突破250日新高"}, + {"key": "consec_up_3", "label": "连续上涨3日"}, + {"key": "consec_up_5", "label": "连续上涨5日"}, + ], + }, +] + +SORT_WHITELIST = [ + "ts_code", "pct_chg", "close", "amount", "total_mv", + "turnover_rate", "vol_ratio_5", "consec_up_days", +] + +DISPLAY_COLUMNS = [ + "ts_code", "name", "industry", "pct_chg", "close", + "amount", "total_mv", "turnover_rate", "vol_ratio_5", +] + + +class PatternScreenService: + """形态选股服务:读取宽表,提供分组元数据和筛选。""" + + def __init__(self): + self._df = None + + def _load_df(self) -> pd.DataFrame: + data_dir = current_app.config.get('DATA_DIR') + path = os.path.join(data_dir, 'data.parquet') + logger.info(f"PatternScreenService loading {path}") + return pd.read_parquet(path) + + def _ensure_df(self) -> pd.DataFrame: + if self._df is None: + self._df = self._load_df() + return self._df + + def get_groups(self) -> list: + """返回分组元数据,每个字段附带当日命中数。 + + Returns: + list[dict]: [{id, label, fields: [{key, label, count}]}] + """ + df = self._ensure_df() + df_cols = set(df.columns) + result = [] + for group in PATTERN_GROUPS: + fields = [] + for f in group["fields"]: + if f["key"] not in df_cols: + continue + count = int(df[f["key"]].fillna(0).sum()) + fields.append({"key": f["key"], "label": f["label"], "count": count}) + if fields: + result.append({"id": group["id"], "label": group["label"], "fields": fields}) + return result + + def screen(self, patterns=None, sort_by="pct_chg", order="desc", limit=50, offset=0): + """纯AND筛选 + 排序 + 分页。 + + Args: + patterns: list[str] of pattern field keys to filter (all must == 1). + sort_by: column to sort by (must be in SORT_WHITELIST). + order: "asc" or "desc". + limit: page size, capped at 500. + offset: page offset, must be >= 0. + + Returns: + dict with total, offset, limit, trade_date, rows. + + Raises: + ValueError: on invalid sort_by, order, or pattern keys. + """ + # Validate + if sort_by not in SORT_WHITELIST: + raise ValueError(f"sort_by '{sort_by}' not in whitelist: {SORT_WHITELIST}") + if order not in ("asc", "desc"): + raise ValueError(f"order must be 'asc' or 'desc', got '{order}'") + + patterns = patterns or [] + df = self._ensure_df() + + # Validate pattern keys + df_cols = set(df.columns) + for p in patterns: + if p not in df_cols: + raise ValueError(f"pattern '{p}' not found in data columns") + + # Filter: pure AND + mask = pd.Series(True, index=df.index) + for p in patterns: + mask = mask & (df[p].fillna(0) == 1) + filtered = df.loc[mask] + + # Sort + filtered = filtered.sort_values(by=sort_by, ascending=(order == "asc")) + + # Paginate + total = len(filtered) + limit = max(1, min(500, int(limit))) + offset = max(0, int(offset)) + page = filtered.iloc[offset:offset + limit] + + # Build rows — convert NaN to None for JSON + rows_df = page[DISPLAY_COLUMNS].copy() + rows = rows_df.where(rows_df.notna(), None).to_dict(orient="records") + + trade_date = str(df['trade_date'].iloc[0]) if 'trade_date' in df.columns else '' + + return { + "total": total, + "offset": offset, + "limit": limit, + "trade_date": trade_date, + "rows": rows, + } + + def invalidate_cache(self): + """清除缓存的 DataFrame,下次调用时重新读取。""" + self._df = None + logger.info("PatternScreenService cache invalidated") + + +# ── 模块级单例 ────────────────────────────────────────── +_service = None + + +def get_pattern_screen_service() -> PatternScreenService: + """获取服务单例(缓存 DataFrame 跨请求复用)。""" + global _service + if _service is None: + _service = PatternScreenService() + return _service +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `pytest tests/services/test_pattern_screen_service.py -v` +Expected: all PASS + +- [ ] **Step 5: Commit** + +```bash +git add app/services/pattern_screen_service.py tests/services/test_pattern_screen_service.py +git commit -m "feat: add PatternScreenService with metadata and AND-filtering" +``` + +--- + +### Task 2: API endpoints — groups and screen + +**Files:** +- Create: `app/api/pattern_screen_api.py` +- Test: `tests/api/test_pattern_screen_api.py` + +- [ ] **Step 1: Write the failing test** + +```python +# tests/api/test_pattern_screen_api.py +"""Pattern screen API contract tests.""" +import pytest +import json + + +@pytest.fixture +def client(app): + return app.test_client() + + +@pytest.fixture +def mock_service(app): + """Register a mock service on the app for API tests.""" + from unittest.mock import MagicMock + svc = MagicMock() + svc.get_groups.return_value = [ + { + "id": "trend_structure", + "label": "趋势结构", + "fields": [ + {"key": "pattern_golden_cross", "label": "均线金叉", "count": 304}, + {"key": "pattern_ma_bull", "label": "均线多头排列", "count": 259}, + ], + } + ] + svc.screen.return_value = { + "total": 2, + "offset": 0, + "limit": 50, + "trade_date": "20260605", + "rows": [ + {"ts_code": "000001.SZ", "name": "平安银行", "industry": "银行", + "pct_chg": 1.5, "close": 12.5, "amount": 1000000, + "total_mv": 2400000, "turnover_rate": 1.2, "vol_ratio_5": 1.8}, + {"ts_code": "000003.SZ", "name": "国农科技", "industry": "综合", + "pct_chg": 3.0, "close": 25.0, "amount": 200000, + "total_mv": 600000, "turnover_rate": 2.5, "vol_ratio_5": 2.1}, + ], + } + + # Patch the singleton getter in the API module + from unittest.mock import patch + patcher = patch( + 'app.api.pattern_screen_api.get_pattern_screen_service', + return_value=svc, + ) + patcher.start() + yield svc + patcher.stop() + + +class TestGetGroups: + def test_returns_200(self, client, mock_service): + resp = client.get('/api/pattern-screen/groups') + assert resp.status_code == 200 + data = resp.get_json() + assert data['code'] == 200 + assert isinstance(data['data'], list) + + def test_group_structure(self, client, mock_service): + resp = client.get('/api/pattern-screen/groups') + data = resp.get_json()['data'] + g = data[0] + assert 'id' in g + assert 'label' in g + assert 'fields' in g + + +class TestScreen: + def test_returns_200(self, client, mock_service): + resp = client.post('/api/pattern-screen/screen', + json={'patterns': ['pattern_golden_cross']}) + assert resp.status_code == 200 + data = resp.get_json() + assert data['code'] == 200 + assert data['data']['total'] == 2 + + def test_empty_patterns(self, client, mock_service): + resp = client.post('/api/pattern-screen/screen', json={}) + assert resp.status_code == 200 + + def test_invalid_sort_by_returns_400(self, client, mock_service): + from unittest.mock import patch + mock_service.screen.side_effect = ValueError("sort_by 'bad' not in whitelist") + resp = client.post('/api/pattern-screen/screen', + json={'sort_by': 'bad'}) + assert resp.status_code == 400 + assert resp.get_json()['code'] == 400 + + def test_response_format(self, client, mock_service): + resp = client.post('/api/pattern-screen/screen', + json={'patterns': ['pattern_golden_cross']}) + data = resp.get_json()['data'] + assert 'total' in data + assert 'offset' in data + assert 'limit' in data + assert 'trade_date' in data + assert 'rows' in data + row = data['rows'][0] + for col in ['ts_code', 'name', 'industry', 'pct_chg', 'close', + 'amount', 'total_mv', 'turnover_rate', 'vol_ratio_5']: + assert col in row +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pytest tests/api/test_pattern_screen_api.py -v` +Expected: FAIL — module `pattern_screen_api` not found + +- [ ] **Step 3: Write the API blueprint** + +```python +# app/api/pattern_screen_api.py +"""形态选股 API 端点。""" +from flask import Blueprint, request, jsonify +from loguru import logger + +pattern_screen_api = Blueprint('pattern_screen_api', __name__, url_prefix='/api/pattern-screen') + + +@pattern_screen_api.route('/groups', methods=['GET']) +def get_groups(): + """返回形态分组元数据(含命中数)。""" + try: + from app.services.pattern_screen_service import get_pattern_screen_service + svc = get_pattern_screen_service() + groups = svc.get_groups() + return jsonify({'code': 200, 'message': '成功', 'data': groups}) + except Exception as e: + logger.error(f"获取形态分组失败: {e}") + return jsonify({'code': 500, 'message': f'服务器错误: {str(e)}', 'data': None}), 500 + + +@pattern_screen_api.route('/screen', methods=['POST']) +def screen(): + """执行形态筛选,返回结果表格。""" + try: + data = request.get_json() or {} + patterns = data.get('patterns', []) + sort_by = data.get('sort_by', 'pct_chg') + order = data.get('order', 'desc') + limit = data.get('limit', 50) + offset = data.get('offset', 0) + + from app.services.pattern_screen_service import get_pattern_screen_service + svc = get_pattern_screen_service() + result = svc.screen( + patterns=patterns, + sort_by=sort_by, + order=order, + limit=limit, + offset=offset, + ) + return jsonify({'code': 200, 'message': '成功', 'data': result}) + except ValueError as e: + logger.warning(f"形态筛选参数错误: {e}") + return jsonify({'code': 400, 'message': str(e), 'data': None}), 400 + except Exception as e: + logger.error(f"形态筛选失败: {e}") + return jsonify({'code': 500, 'message': f'服务器错误: {str(e)}', 'data': None}), 500 +``` + +- [ ] **Step 4: Register the API blueprint in `app/__init__.py`** + +Add after the existing `heatmap_routes` import (around line 40): + +```python +from app.api.pattern_screen_api import pattern_screen_api +``` + +Add after the `app.register_blueprint(heatmap_routes)` line (around line 54): + +```python +app.register_blueprint(pattern_screen_api) +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `pytest tests/api/test_pattern_screen_api.py -v` +Expected: all PASS + +- [ ] **Step 6: Commit** + +```bash +git add app/api/pattern_screen_api.py tests/api/test_pattern_screen_api.py app/__init__.py +git commit -m "feat: add pattern screen API endpoints (groups + screen)" +``` + +--- + +### Task 3: Page route and template + +**Files:** +- Create: `app/routes/pattern_screen.py` +- Create: `app/templates/pattern_screen.html` +- Modify: `app/__init__.py:40,54` — add page blueprint registration +- Modify: `app/templates/base.html:335-338` — add nav link + +- [ ] **Step 1: Create page route** + +```python +# app/routes/pattern_screen.py +"""形态选股页面路由。""" +from flask import Blueprint, render_template + +pattern_screen_bp = Blueprint('pattern_screen', __name__) + + +@pattern_screen_bp.route('/pattern-screen/') +def index(): + """形态选股页面。""" + return render_template('pattern_screen.html') +``` + +- [ ] **Step 2: Register page blueprint in `app/__init__.py`** + +Add import near line 40: + +```python +from app.routes.pattern_screen import pattern_screen_bp +``` + +Add registration near line 54: + +```python +app.register_blueprint(pattern_screen_bp) +``` + +- [ ] **Step 3: Add nav link in `app/templates/base.html`** + +After the "板块热力图" nav item (around line 338), add: + +```html + +``` + +- [ ] **Step 4: Create the template** + +Create `app/templates/pattern_screen.html` with the following full content: + +```html +{% extends "base.html" %} +{% block title %}形态选股{% endblock %} +{% block extra_css %} + +{% endblock %} + +{% block content %} +
+ +
+ +
+
+ + +
+
+ + +
+
+ 交易日: -- + | + 已选: 0 个形态 + | + 匹配: 0 +
+
+
+ +
加载中...
+
+
+
+
+
+ + +{% endblock %} +``` + +- [ ] **Step 5: Verify page loads** + +Run: `python run.py` and open `http://localhost:5000/pattern-screen/` +Expected: page renders with left panel showing grouped checkboxes and right panel showing stock table + +- [ ] **Step 6: Verify nav link works** + +From any page, click "形态选股" in the nav bar. +Expected: navigates to `/pattern-screen/` + +- [ ] **Step 7: Commit** + +```bash +git add app/routes/pattern_screen.py app/templates/pattern_screen.html app/__init__.py app/templates/base.html +git commit -m "feat: add pattern screening page with left panel + right table layout" +``` + +--- + +### Task 4: Integration verification + +**Files:** None new — verification only. + +- [ ] **Step 1: Run all tests** + +Run: `pytest -v` +Expected: all existing tests + new pattern screen tests pass + +- [ ] **Step 2: Manual smoke test** + +1. `python run.py` +2. Open `/pattern-screen/` +3. Verify: groups load with hit counts +4. Check a pattern (e.g. "均线金叉") → click "筛选" → verify table filters +5. Check a second pattern (e.g. "均线多头排列") → verify AND logic (fewer results) +6. Click a table header → verify sorting changes +7. Click page 2 → verify pagination +8. Click "重置" → verify all checkboxes clear and table shows all stocks +9. Type in search box → verify checkbox labels filter + +- [ ] **Step 3: Commit any fixes** + +```bash +git add -A && git commit -m "fix: pattern screen integration fixes" +``` From 1ef9dea3dee543b3d6979beb5eaed0af63097feb Mon Sep 17 00:00:00 2001 From: nepenthes_space <39189996@qq.com> Date: Sun, 7 Jun 2026 20:59:22 +0800 Subject: [PATCH 13/19] feat: add PatternScreenService with metadata and AND-filtering Co-Authored-By: Claude Opus 4.8 --- app/services/pattern_screen_service.py | 345 +++++++++++++++++++++++++ 1 file changed, 345 insertions(+) create mode 100644 app/services/pattern_screen_service.py diff --git a/app/services/pattern_screen_service.py b/app/services/pattern_screen_service.py new file mode 100644 index 00000000..a50e205a --- /dev/null +++ b/app/services/pattern_screen_service.py @@ -0,0 +1,345 @@ +"""Pattern screen service for stock pattern filtering and metadata. + +Provides pattern group metadata and AND-filtered screening across pattern columns +in the market data parquet file. +""" + +from flask import current_app +import pandas as pd +import numpy as np + + +# Pattern group metadata with full field list +PATTERN_GROUPS = [ + { + 'id': 'single_candle', + 'label': '单根K线', + 'fields': [ + {'key': 'pattern_bull_candle', 'label': '阳线'}, + {'key': 'pattern_bear_candle', 'label': '阴线'}, + {'key': 'pattern_hammer', 'label': '锤头线'}, + {'key': 'pattern_doji', 'label': '十字星'}, + {'key': 'pattern_spinning_top', 'label': '纺锤线'}, + {'key': 'pattern_shooting_star', 'label': '流星线'}, + {'key': 'pattern_long_upper_shadow', 'label': '长上影线'}, + {'key': 'pattern_long_lower_shadow', 'label': '长下影线'}, + {'key': 'pattern_gravestone_doji', 'label': '墓碑十字'}, + {'key': 'pattern_dragonfly_doji', 'label': '蜻蜓十字'}, + {'key': 'pattern_hanging_man', 'label': '上吊线'}, + {'key': 'pattern_inverted_hammer', 'label': '倒锤头'}, + {'key': 'pattern_big_bull', 'label': '大阳线'}, + {'key': 'pattern_big_bear', 'label': '大阴线'}, + {'key': 'pattern_medium_bull', 'label': '中阳线'}, + {'key': 'pattern_medium_bear', 'label': '中阴线'}, + {'key': 'pattern_small_bull', 'label': '小阳线'}, + {'key': 'pattern_small_bear', 'label': '小阴线'}, + {'key': 'pattern_no_body', 'label': '无实体线'}, + {'key': 'pattern_no_upper_bull', 'label': '光头阳线'}, + {'key': 'pattern_no_upper_bear', 'label': '光头阴线'}, + {'key': 'pattern_no_lower_bull', 'label': '光脚阳线'}, + {'key': 'pattern_no_lower_bear', 'label': '光脚阴线'}, + {'key': 'pattern_t_shape', 'label': 'T字线'}, + {'key': 'pattern_inverted_t_shape', 'label': '倒T字线'}, + {'key': 'pattern_low_open_high_close', 'label': '低开高走'}, + {'key': 'pattern_high_open_low_close', 'label': '高开低走'}, + {'key': 'pattern_gap_up', 'label': '跳空高开'}, + {'key': 'pattern_gap_down', 'label': '跳空低开'}, + {'key': 'pattern_close_above_prev_close', 'label': '收盘站上前收'}, + {'key': 'pattern_close_below_prev_close', 'label': '收盘跌破前收'}, + {'key': 'pattern_gap_reclaim_prev_close', 'label': '低开收回前收'}, + {'key': 'pattern_gap_fade_below_prev_close', 'label': '高开回落失守前收'}, + {'key': 'pattern_close_high', 'label': '收盘近最高'}, + {'key': 'pattern_flat_open_high_close', 'label': '平开高走'}, + {'key': 'pattern_flat_open_low_close', 'label': '平开低走'}, + {'key': 'pattern_gap_up_close_bull', 'label': '高开收阳'}, + {'key': 'pattern_gap_down_close_bear', 'label': '低开收阴'}, + {'key': 'pattern_open_near_high_close_high', 'label': '开盘即最高附近收盘'}, + {'key': 'pattern_open_near_low_close_low', 'label': '开盘即最低附近收盘'}, + {'key': 'pattern_flat_open', 'label': '平开'}, + {'key': 'pattern_gap_up_fill', 'label': '高开补缺'}, + {'key': 'pattern_gap_down_fill', 'label': '低开补缺'}, + {'key': 'pattern_pin_bar', 'label': 'Pin Bar'}, + {'key': 'pattern_reversal_prelude', 'label': '反包前兆'}, + {'key': 'pattern_high_resistance', 'label': '高位受阻'}, + {'key': 'pattern_low_stabilization', 'label': '低位止跌'}, + {'key': 'pattern_break_prev_high', 'label': '向上突破前高'}, + {'key': 'pattern_break_prev_low', 'label': '向下跌破前低'}, + {'key': 'pattern_false_break', 'label': '假突破回落'}, + {'key': 'pattern_false_breakdown_recovery', 'label': '假跌破回升'}, + ] + }, + { + 'id': 'double_candle', + 'label': '双根K线', + 'fields': [ + {'key': 'pattern_bullish_engulfing', 'label': '阳包阴'}, + {'key': 'pattern_bearish_engulfing', 'label': '阴包阳'}, + {'key': 'pattern_inside_bar', 'label': '孕线'}, + {'key': 'pattern_dark_cloud', 'label': '乌云盖顶'}, + {'key': 'pattern_piercing', 'label': '刺透形态'}, + {'key': 'pattern_tweezer_top', 'label': '镊子顶'}, + {'key': 'pattern_tweezer_bottom', 'label': '镊子底'}, + {'key': 'pattern_gap_break', 'label': '跳空上攻'}, + {'key': 'pattern_gap_down_break', 'label': '跳空下跌'}, + {'key': 'pattern_gap_up_no_fill', 'label': '跳空不补上行'}, + {'key': 'pattern_gap_down_no_fill', 'label': '跳空不补下行'}, + {'key': 'pattern_reversal_bar', 'label': '反转包线'}, + {'key': 'pattern_flat_top', 'label': '平头顶'}, + {'key': 'pattern_flat_bottom', 'label': '平头底'}, + {'key': 'pattern_island_reversal', 'label': '岛形反转'}, + {'key': 'pattern_t_limit', 'label': 'T字板'}, + {'key': 'pattern_limit_reversal_wrap', 'label': '涨停反包'}, + {'key': 'pattern_vol_up', 'label': '放量上涨'}, + {'key': 'pattern_vol_down', 'label': '缩量下跌'}, + {'key': 'pattern_double_volume_bar', 'label': '倍量柱'}, + {'key': 'pattern_breakout_volume_confirm', 'label': '突破放量确认'}, + ] + }, + { + 'id': 'triple_candle', + 'label': '三根K线', + 'fields': [ + {'key': 'pattern_morning_star', 'label': '早晨之星'}, + {'key': 'pattern_evening_star', 'label': '黄昏之星'}, + {'key': 'pattern_morning_doji_star', 'label': '启明星'}, + {'key': 'pattern_evening_doji_star', 'label': '黄昏十字星'}, + {'key': 'pattern_three_black_crows', 'label': '三只乌鸦'}, + {'key': 'pattern_red_three', 'label': '红三兵'}, + {'key': 'pattern_three_outside_up', 'label': '三外升'}, + {'key': 'pattern_three_outside_down', 'label': '三外降'}, + {'key': 'pattern_rising_three_methods', 'label': '上升三法'}, + {'key': 'pattern_falling_three_methods', 'label': '下降三法'}, + {'key': 'pattern_three_up', 'label': '三连阳'}, + {'key': 'pattern_three_down', 'label': '三连阴'}, + {'key': 'pattern_three_yang_kaitai', 'label': '三阳开泰'}, + {'key': 'pattern_three_yin_breakdown', 'label': '三阴破位'}, + ] + }, + { + 'id': 'trend_structure', + 'label': '趋势结构', + 'fields': [ + {'key': 'pattern_up_trend', 'label': '上升趋势'}, + {'key': 'pattern_down_trend', 'label': '下降趋势'}, + {'key': 'pattern_sideways', 'label': '横盘整理'}, + {'key': 'pattern_golden_cross', 'label': '均线金叉'}, + {'key': 'pattern_duck_head', 'label': '老鸭头'}, + {'key': 'pattern_double_bottom', 'label': '双底'}, + {'key': 'pattern_arc_bottom', 'label': '圆弧底'}, + {'key': 'pattern_ma_bull', 'label': '均线多头排列'}, + {'key': 'pattern_high_tight', 'label': '高位强势整理'}, + {'key': 'pattern_pullback_hold', 'label': '回踩不破'}, + {'key': 'pattern_trend_continue', 'label': '趋势中继'}, + {'key': 'pattern_ma_spread_bull', 'label': '均线发散多头'}, + ] + }, + { + 'id': 'volume_price', + 'label': '量价关系', + 'fields': [ + {'key': 'pattern_box_breakout', 'label': '箱体放量突破'}, + {'key': 'pattern_vol_price_up', 'label': '量价齐升'}, + {'key': 'pattern_platform_break', 'label': '平台突破'}, + {'key': 'pattern_triangle_squeeze', 'label': '三角收敛突破'}, + {'key': 'pattern_limit_turnover_strong', 'label': '涨停换手强'}, + {'key': 'pattern_price_volume_bear_divergence', 'label': '价量顶背离'}, + {'key': 'pattern_price_volume_bull_divergence', 'label': '价量底背离'}, + {'key': 'pattern_price_down_volume_up', 'label': '价跌量增'}, + {'key': 'pattern_volume_staircase', 'label': '量能阶梯'}, + {'key': 'pattern_pullback_volume_shrink', 'label': '回调缩量'}, + {'key': 'pattern_high_turnover', 'label': '高换手'}, + {'key': 'pattern_limit_up_volume_shrink', 'label': '一字板缩量'}, + {'key': 'pattern_false_breakout_volume_weak', 'label': '假突破量弱'}, + {'key': 'pattern_floor_volume_price', 'label': '地量价稳'}, + {'key': 'pattern_blowoff_volume_price', 'label': '天量滞涨'}, + {'key': 'pattern_v_reversal', 'label': 'V型反转'}, + ] + }, + { + 'id': 'compound', + 'label': '复合形态', + 'fields': [ + {'key': 'pattern_first_limit', 'label': '首板'}, + {'key': 'pattern_multi_limit', 'label': '连板'}, + {'key': 'pattern_one_word_limit', 'label': '一字板'}, + {'key': 'pattern_limit_down_to_up', 'label': '地天板'}, + {'key': 'pattern_lotus_breakout', 'label': '莲花突破'}, + {'key': 'pattern_midway_refuel', 'label': '中继加油'}, + {'key': 'pattern_consolidation_platform', 'label': '整理平台'}, + {'key': 'pattern_n_breakout', 'label': 'N字突破'}, + {'key': 'pattern_gap_breakaway', 'label': '跳空突破'}, + {'key': 'pattern_channel_breakout', 'label': '通道突破'}, + {'key': 'pattern_flag_breakout', 'label': '旗形突破'}, + ] + }, + { + 'id': 'momentum', + 'label': '动量突破', + 'fields': [ + {'key': 'break_high_20', 'label': '突破20日新高'}, + {'key': 'break_high_60', 'label': '突破60日新高'}, + {'key': 'break_high_120', 'label': '突破120日新高'}, + {'key': 'break_high_250', 'label': '突破250日新高'}, + {'key': 'consec_up_3', 'label': '连续上涨3日'}, + {'key': 'consec_up_5', 'label': '连续上涨5日'}, + ] + }, +] + +# Allowed sort columns +SORT_WHITELIST = [ + "ts_code", "pct_chg", "close", "amount", "total_mv", + "turnover_rate", "vol_ratio_5", "consec_up_days" +] + +# Columns to return in screen results +DISPLAY_COLUMNS = [ + "ts_code", "name", "industry", "pct_chg", "close", "amount", + "total_mv", "turnover_rate", "vol_ratio_5" +] + + +class PatternScreenService: + """Service for pattern metadata and AND-filtered screening.""" + + def __init__(self): + self._df = None + + def _load_df(self) -> pd.DataFrame: + """Load market data from parquet file.""" + data_dir = current_app.config.get('DATA_DIR', 'data') + path = f"{data_dir}/data.parquet" + return pd.read_parquet(path) + + def _ensure_df(self): + """Lazy-load DataFrame if not cached.""" + if self._df is None: + self._df = self._load_df() + + def get_groups(self) -> list: + """Return pattern groups with hit counts filtered by DataFrame columns. + + Returns: + List of group dicts with id, label, and fields (each field has + key, label, count). Fields whose columns are not present in the + DataFrame are excluded. + """ + self._ensure_df() + df_cols = set(self._df.columns) + + groups = [] + for group in PATTERN_GROUPS: + fields = [] + for field in group['fields']: + if field['key'] in df_cols: + count = int(self._df[field['key']].fillna(0).sum()) + fields.append({ + 'key': field['key'], + 'label': field['label'], + 'count': count + }) + if fields: + groups.append({ + 'id': group['id'], + 'label': group['label'], + 'fields': fields + }) + return groups + + def screen( + self, + patterns: list = None, + sort_by: str = "pct_chg", + order: str = "desc", + limit: int = 20, + offset: int = 0 + ) -> dict: + """Screen stocks by pattern filters with AND logic. + + Args: + patterns: List of pattern column keys to filter (AND logic). + sort_by: Column to sort by (must be in SORT_WHITELIST). + order: 'asc' or 'desc'. + limit: Max rows to return (capped at 500). + offset: Rows to skip for pagination. + + Returns: + Dict with keys: total, offset, limit, trade_date, rows (list of dicts). + + Raises: + ValueError: If sort_by, order, or pattern keys are invalid. + """ + if patterns is None: + patterns = [] + + self._ensure_df() + df = self._df.copy() + + # Validate sort_by + if sort_by not in SORT_WHITELIST: + raise ValueError(f"sort_by must be one of {SORT_WHITELIST}, got: {sort_by}") + + # Validate order + if order not in ('asc', 'desc'): + raise ValueError(f"order must be 'asc' or 'desc', got: {order}") + + # Validate pattern keys + df_cols = set(df.columns) + for p in patterns: + if p not in df_cols: + raise ValueError(f"Pattern '{p}' not found in data") + + # Apply AND filter: all selected patterns must be 1 + if patterns: + mask = df[patterns].fillna(0).eq(1).all(axis=1) + df = df[mask] + + # Get trade_date from first row (if any) + trade_date = None + if not df.empty and 'trade_date' in df.columns: + trade_date = str(df['trade_date'].iloc[0]) + + # Sort + ascending = (order == 'asc') + df = df.sort_values(by=sort_by, ascending=ascending) + + # Total before pagination + total = len(df) + + # Cap limit + limit = min(limit, 500) + + # Select display columns (only those present) + cols_to_show = [c for c in DISPLAY_COLUMNS if c in df.columns] + df = df[cols_to_show] + + # Paginate + df = df.iloc[offset:offset + limit] + + # Convert to dict of dicts, NaN -> None + df = df.replace({np.nan: None}) + rows = df.to_dict(orient='records') + + return { + 'total': total, + 'offset': offset, + 'limit': limit, + 'trade_date': trade_date, + 'rows': rows + } + + def invalidate_cache(self): + """Clear cached DataFrame to force reload on next access.""" + self._df = None + + +# Module-level singleton +_service = None + + +def get_pattern_screen_service() -> PatternScreenService: + """Get or create the singleton PatternScreenService instance.""" + global _service + if _service is None: + _service = PatternScreenService() + return _service From b404bbae679acc607df8b30e1c61185ed6fae70a Mon Sep 17 00:00:00 2001 From: nepenthes_space <39189996@qq.com> Date: Sun, 7 Jun 2026 21:01:10 +0800 Subject: [PATCH 14/19] feat: add pattern screen API endpoints (groups + screen) - Add pattern_screen_api blueprint with /api/pattern-screen/groups and /api/pattern-screen/screen endpoints - Groups endpoint returns pattern metadata with hit counts - Screen endpoint filters stocks by selected patterns with sorting/pagination - Register blueprint in app/__init__.py Co-Authored-By: Claude Opus 4.8 --- app/__init__.py | 4 ++- app/api/pattern_screen_api.py | 47 +++++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) create mode 100644 app/api/pattern_screen_api.py diff --git a/app/__init__.py b/app/__init__.py index 6b379c53..2af0e1f4 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -38,6 +38,7 @@ def create_app(config_name='default'): from app.routes.ml_factor_routes import ml_factor_routes from app.routes.realtime_analysis_routes import realtime_analysis_routes from app.routes.heatmap import heatmap_routes + from app.api.pattern_screen_api import pattern_screen_api app.register_blueprint(api_bp, url_prefix='/api') app.register_blueprint(ml_factor_bp) app.register_blueprint(text2sql_bp) @@ -52,7 +53,8 @@ def create_app(config_name='default'): app.register_blueprint(ml_factor_routes) app.register_blueprint(realtime_analysis_routes) app.register_blueprint(heatmap_routes) - + app.register_blueprint(pattern_screen_api) + from app.main import main_bp app.register_blueprint(main_bp) diff --git a/app/api/pattern_screen_api.py b/app/api/pattern_screen_api.py new file mode 100644 index 00000000..4de13a87 --- /dev/null +++ b/app/api/pattern_screen_api.py @@ -0,0 +1,47 @@ +"""形态选股 API 端点。""" +from flask import Blueprint, request, jsonify +from loguru import logger + +pattern_screen_api = Blueprint('pattern_screen_api', __name__, url_prefix='/api/pattern-screen') + + +@pattern_screen_api.route('/groups', methods=['GET']) +def get_groups(): + """返回形态分组元数据(含命中数)。""" + try: + from app.services.pattern_screen_service import get_pattern_screen_service + svc = get_pattern_screen_service() + groups = svc.get_groups() + return jsonify({'code': 200, 'message': '成功', 'data': groups}) + except Exception as e: + logger.error(f"获取形态分组失败: {e}") + return jsonify({'code': 500, 'message': f'服务器错误: {str(e)}', 'data': None}), 500 + + +@pattern_screen_api.route('/screen', methods=['POST']) +def screen(): + """执行形态筛选,返回结果表格。""" + try: + data = request.get_json() or {} + patterns = data.get('patterns', []) + sort_by = data.get('sort_by', 'pct_chg') + order = data.get('order', 'desc') + limit = data.get('limit', 50) + offset = data.get('offset', 0) + + from app.services.pattern_screen_service import get_pattern_screen_service + svc = get_pattern_screen_service() + result = svc.screen( + patterns=patterns, + sort_by=sort_by, + order=order, + limit=limit, + offset=offset, + ) + return jsonify({'code': 200, 'message': '成功', 'data': result}) + except ValueError as e: + logger.warning(f"形态筛选参数错误: {e}") + return jsonify({'code': 400, 'message': str(e), 'data': None}), 400 + except Exception as e: + logger.error(f"形态筛选失败: {e}") + return jsonify({'code': 500, 'message': f'服务器错误: {str(e)}', 'data': None}), 500 From 376b1c9012ad0f84693c0c2872a5f3dcf0efd366 Mon Sep 17 00:00:00 2001 From: nepenthes_space <39189996@qq.com> Date: Sun, 7 Jun 2026 21:03:26 +0800 Subject: [PATCH 15/19] feat: add pattern screening page with left panel + right table layout - Created page route in app/routes/pattern_screen.py - Registered pattern_screen_bp blueprint in app/__init__.py - Added navigation link in app/templates/base.html - Created complete template with left filter panel and right result table - Template includes search, accordion groups, pagination, and sorting Co-Authored-By: Claude Opus 4.8 --- app/__init__.py | 2 + app/routes/pattern_screen.py | 10 + app/templates/base.html | 5 + app/templates/pattern_screen.html | 319 ++++++++++++++++++++++++++++++ 4 files changed, 336 insertions(+) create mode 100644 app/routes/pattern_screen.py create mode 100644 app/templates/pattern_screen.html diff --git a/app/__init__.py b/app/__init__.py index 2af0e1f4..c0570829 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -38,6 +38,7 @@ def create_app(config_name='default'): from app.routes.ml_factor_routes import ml_factor_routes from app.routes.realtime_analysis_routes import realtime_analysis_routes from app.routes.heatmap import heatmap_routes + from app.routes.pattern_screen import pattern_screen_bp from app.api.pattern_screen_api import pattern_screen_api app.register_blueprint(api_bp, url_prefix='/api') app.register_blueprint(ml_factor_bp) @@ -53,6 +54,7 @@ def create_app(config_name='default'): app.register_blueprint(ml_factor_routes) app.register_blueprint(realtime_analysis_routes) app.register_blueprint(heatmap_routes) + app.register_blueprint(pattern_screen_bp) app.register_blueprint(pattern_screen_api) from app.main import main_bp diff --git a/app/routes/pattern_screen.py b/app/routes/pattern_screen.py new file mode 100644 index 00000000..5b56eee0 --- /dev/null +++ b/app/routes/pattern_screen.py @@ -0,0 +1,10 @@ +"""形态选股页面路由。""" +from flask import Blueprint, render_template + +pattern_screen_bp = Blueprint('pattern_screen', __name__) + + +@pattern_screen_bp.route('/pattern-screen/') +def index(): + """形态选股页面。""" + return render_template('pattern_screen.html') diff --git a/app/templates/base.html b/app/templates/base.html index 816a3e19..4c8d2cf8 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -336,6 +336,11 @@ 板块热力图 +