Compare commits

...

6 Commits

Author SHA1 Message Date
yang a3292c3dd9 新增二板历史角度 2026-05-17 13:19:18 +08:00
yang 507b79f257 1、新增首页合计详情页;
2、新增索引和缓存;
2026-05-14 11:19:36 +08:00
yang 49cd4f85df Merge pull request '项目优化' (#7) from main into master
Reviewed-on: #7
2026-05-13 21:56:21 +08:00
yang 2f36adcb43 项目优化 2026-05-13 21:55:29 +08:00
yang 94e8a4f770 Merge pull request '彻底解决 sql 兼容性问题' (#6) from main into master
Reviewed-on: #6
2026-05-13 21:26:54 +08:00
yang 50b10fa880 彻底解决 sql 兼容性问题 2026-05-13 21:26:22 +08:00
26 changed files with 995 additions and 522 deletions
+6 -1
View File
@@ -10,4 +10,9 @@
# 依赖目录 (如果使用 vendor 模式)
# vendor/
config.yaml
# 本地运行配置,包含数据库账号密码,不提交到仓库
/config.yaml
/config.yml
/config/config.yaml
/config/config.yml
+42
View File
@@ -0,0 +1,42 @@
# level
## 本地配置
服务启动时会读取项目根目录的 `config.yaml`,也兼容读取 `config/config.yaml`。该文件包含数据库账号密码,已加入 `.gitignore`,不要提交到 Git 仓库。
配置格式如下:
```yaml
database:
user: "your_mysql_user"
password: "your_mysql_password"
host: "localhost"
port: 3306
dbname: "level"
charset: "utf8mb4"
collation: "utf8mb4_unicode_ci"
server:
port: 18080
```
如果 `config.yaml` 已经被远程仓库跟踪过,仅添加 `.gitignore` 不会自动停止跟踪,需要执行:
```bash
git rm --cached config.yaml
git commit -m "chore: stop tracking local config"
```
这只会从 Git 索引中移除配置文件,本地文件仍会保留。若远程历史里已经包含真实密码,需要同时更换相关密码;如需彻底清理 Git 历史,可再用 `git filter-repo` 或 BFG 处理。
## 数据库 DDL
当前代码使用的表结构保存在 `database/ddl/current_tables.sql`。这些表包括:
- `trade_calendar`
- `stock_daily`
- `stock_basic`
- `limit_list_d`
- `stock_limit_combined_data`
该文件由本地 `level` 库执行 `SHOW CREATE TABLE` 导出。
+9
View File
@@ -1,6 +1,7 @@
package main
import (
"github.com/gin-gonic/gin"
"gorm.io/driver/mysql"
"gorm.io/gorm"
"level_go/internal/api"
@@ -12,6 +13,8 @@ import (
// main 是服务启动入口,负责完成配置加载、数据库连接、依赖组装和 HTTP 路由启动。
func main() {
gin.SetMode(gin.ReleaseMode)
// 初始化配置文件,后续数据库 DSN 和服务端口都从全局配置中读取。
config.InitConfig()
@@ -21,6 +24,12 @@ func main() {
if err != nil {
log.Fatal("数据库连接失败:", err)
}
sqlDB, err := db.DB()
if err != nil {
log.Fatal("获取数据库连接池失败:", err)
}
sqlDB.SetMaxIdleConns(20)
sqlDB.SetMaxOpenConns(100)
// 依赖注入:Service 持有数据库连接,Handler 持有对应 Service。
commonSrv := &service.CommonService{DB: db}
-22
View File
@@ -1,22 +0,0 @@
## 阿里云数据库
#database:
# user: "yangfan"
# password: "aA%8023321088"
# host: "8.159.129.156"
# port: 10836
# dbname: "level"
# charset: "utf8mb4"
# collation: "utf8mb4_unicode_ci"
# 本地数据库
database:
user: "yangfan"
password: "admin"
host: "localhost"
port: 3306
dbname: "level"
charset: "utf8mb4"
collation: "utf8mb4_unicode_ci"
server:
port: 18080
+3
View File
@@ -0,0 +1,3 @@
ALTER TABLE `stock_limit_combined_data`
ADD INDEX `idx_ts_code_date_status` (`ts_code`, `trade_date`, `final_status`),
ADD INDEX `idx_trade_status_code` (`trade_date`, `final_status`, `ts_code`);
@@ -1,4 +1,51 @@
ALTER DATABASE `level` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- DDL exported from local MySQL with SHOW CREATE TABLE.
-- Database: level
CREATE TABLE `trade_calendar` (
`cal_date` date NOT NULL,
`exchange` varchar(10) COLLATE utf8mb4_unicode_ci NOT NULL,
`is_open` tinyint DEFAULT NULL,
`pretrade_date` date DEFAULT NULL,
PRIMARY KEY (`cal_date`,`exchange`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE `stock_daily` (
`id` bigint NOT NULL AUTO_INCREMENT,
`ts_code` varchar(20) COLLATE utf8mb4_unicode_ci NOT NULL,
`symbol` varchar(10) COLLATE utf8mb4_unicode_ci NOT NULL,
`trade_date` date NOT NULL,
`open` decimal(16,4) DEFAULT NULL,
`high` decimal(16,4) DEFAULT NULL,
`low` decimal(16,4) DEFAULT NULL,
`close` decimal(16,4) DEFAULT NULL,
`pre_close` decimal(16,4) DEFAULT NULL,
`change_val` decimal(16,4) DEFAULT NULL,
`pct_chg` decimal(10,4) DEFAULT NULL,
`vol` bigint DEFAULT NULL,
`amount` bigint DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_code_date` (`ts_code`,`trade_date`)
) ENGINE=InnoDB AUTO_INCREMENT=9372644 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE `stock_basic` (
`ts_code` text COLLATE utf8mb4_unicode_ci,
`symbol` text COLLATE utf8mb4_unicode_ci,
`name` text COLLATE utf8mb4_unicode_ci,
`area` text COLLATE utf8mb4_unicode_ci,
`industry` text COLLATE utf8mb4_unicode_ci,
`fullname` text COLLATE utf8mb4_unicode_ci,
`enname` text COLLATE utf8mb4_unicode_ci,
`cnspell` text COLLATE utf8mb4_unicode_ci,
`market` text COLLATE utf8mb4_unicode_ci,
`exchange` text COLLATE utf8mb4_unicode_ci,
`curr_type` text COLLATE utf8mb4_unicode_ci,
`list_status` text COLLATE utf8mb4_unicode_ci,
`list_date` text COLLATE utf8mb4_unicode_ci,
`delist_date` text COLLATE utf8mb4_unicode_ci,
`is_hs` text COLLATE utf8mb4_unicode_ci,
`act_name` text COLLATE utf8mb4_unicode_ci,
`act_ent_type` text COLLATE utf8mb4_unicode_ci
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE `limit_list_d` (
`id` bigint NOT NULL AUTO_INCREMENT,
@@ -25,54 +72,13 @@ CREATE TABLE `limit_list_d` (
KEY `idx_trade_date` (`trade_date`),
KEY `idx_ts_code` (`ts_code`),
KEY `idx_limit` (`limit`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE `stock_basic` (
`ts_code` text COLLATE utf8mb4_unicode_ci,
`symbol` text COLLATE utf8mb4_unicode_ci,
`name` text COLLATE utf8mb4_unicode_ci,
`area` text COLLATE utf8mb4_unicode_ci,
`industry` text COLLATE utf8mb4_unicode_ci,
`fullname` text COLLATE utf8mb4_unicode_ci,
`enname` text COLLATE utf8mb4_unicode_ci,
`cnspell` text COLLATE utf8mb4_unicode_ci,
`market` text COLLATE utf8mb4_unicode_ci,
`exchange` text COLLATE utf8mb4_unicode_ci,
`curr_type` text COLLATE utf8mb4_unicode_ci,
`list_status` text COLLATE utf8mb4_unicode_ci,
`list_date` text COLLATE utf8mb4_unicode_ci,
`delist_date` text COLLATE utf8mb4_unicode_ci,
`is_hs` text COLLATE utf8mb4_unicode_ci,
`act_name` text COLLATE utf8mb4_unicode_ci,
`act_ent_type` text COLLATE utf8mb4_unicode_ci
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE `stock_daily` (
`id` bigint NOT NULL AUTO_INCREMENT,
`ts_code` varchar(20) COLLATE utf8mb4_unicode_ci NOT NULL,
`symbol` varchar(10) COLLATE utf8mb4_unicode_ci NOT NULL,
`trade_date` date NOT NULL,
`open` decimal(16,4) DEFAULT NULL,
`high` decimal(16,4) DEFAULT NULL,
`low` decimal(16,4) DEFAULT NULL,
`close` decimal(16,4) DEFAULT NULL,
`pre_close` decimal(16,4) DEFAULT NULL,
`change_val` decimal(16,4) DEFAULT NULL,
`pct_chg` decimal(10,4) DEFAULT NULL,
`vol` bigint DEFAULT NULL,
`amount` bigint DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_code_date` (`ts_code`,`trade_date`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
) ENGINE=InnoDB AUTO_INCREMENT=154380 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE `stock_limit_combined_data` (
`id` bigint NOT NULL AUTO_INCREMENT,
`trade_date` date NOT NULL COMMENT '交易日期',
`symbol` varchar(10) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '股票代码,以stock_daily为准',
`ts_code` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'TS股票代码,以stock_daily为准',
`ts_code` varchar(20) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'TS股票代码,以stock_daily为准',
`name` varchar(50) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '股票名称,以stock_daily关联基础表为准',
`final_status` tinyint NOT NULL COMMENT '融合状态:1-涨停,2-炸板',
`source_count` int NOT NULL DEFAULT '0' COMMENT '命中的数据源数量',
@@ -104,46 +110,7 @@ CREATE TABLE `stock_limit_combined_data` (
UNIQUE KEY `uniq_date_code` (`trade_date`,`ts_code`),
KEY `idx_trade_date` (`trade_date`),
KEY `idx_symbol` (`symbol`),
KEY `idx_trade_date_status` (`trade_date`,`final_status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE `trade_calendar` (
`cal_date` date NOT NULL,
`exchange` varchar(10) COLLATE utf8mb4_unicode_ci NOT NULL,
`is_open` tinyint DEFAULT NULL,
`pretrade_date` date DEFAULT NULL,
PRIMARY KEY (`cal_date`,`exchange`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE `stock_limit_data` (
`id` bigint NOT NULL AUTO_INCREMENT,
`trade_date` date NOT NULL COMMENT '交易日期',
`symbol` varchar(10) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '股票代码',
`ts_code` varchar(20) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'TS股票代码',
`name` varchar(50) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '股票名称',
`status` tinyint NOT NULL COMMENT '状态:1-涨停,2-炸板',
`limit_price` decimal(16,4) DEFAULT NULL COMMENT '计算出的理论涨停价',
`close` decimal(16,4) DEFAULT NULL COMMENT '实际收盘价',
`vol` bigint DEFAULT NULL COMMENT '成交量',
`market` varchar(20) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '所属板块',
`ak_code` varchar(10) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'AKShare-代码',
`ak_name` varchar(50) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'AKShare-名称',
`ak_pct_chg` decimal(10,4) DEFAULT NULL COMMENT 'AKShare-涨跌幅',
`ak_latest_price` decimal(16,4) DEFAULT NULL COMMENT 'AKShare-最新价',
`ak_amount` decimal(24,4) DEFAULT NULL COMMENT 'AKShare-成交额',
`ak_float_market_value` decimal(24,4) DEFAULT NULL COMMENT 'AKShare-流通市值',
`ak_total_market_value` decimal(24,4) DEFAULT NULL COMMENT 'AKShare-总市值',
`ak_turnover_rate` decimal(10,4) DEFAULT NULL COMMENT 'AKShare-换手率',
`ak_seal_amount` decimal(24,4) DEFAULT NULL COMMENT 'AKShare-封板资金',
`ak_first_limit_time` varchar(20) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'AKShare-首次封板时间',
`ak_last_limit_time` varchar(20) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'AKShare-最后封板时间',
`ak_break_limit_count` int DEFAULT NULL COMMENT 'AKShare-炸板次数',
`ak_limit_up_stat` varchar(50) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'AKShare-涨停统计',
`ak_limit_up_days` int DEFAULT NULL COMMENT 'AKShare-连板数',
`ak_industry` varchar(100) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'AKShare-所属行业',
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_date_code` (`trade_date`,`ts_code`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
KEY `idx_trade_date_status` (`trade_date`,`final_status`),
KEY `idx_ts_code_date_status` (`ts_code`,`trade_date`,`final_status`),
KEY `idx_trade_status_code` (`trade_date`,`final_status`,`ts_code`)
) ENGINE=InnoDB AUTO_INCREMENT=167035 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+3 -1
View File
@@ -4,7 +4,7 @@
- 服务前缀:`/api/v1`
- 响应格式:`application/json`
- 当前项目接口总数:11
- 当前项目接口总数:13
## 2. 接口列表
@@ -17,6 +17,8 @@
| 涨停低开分组 | `GET` | `/api/v1/limit-board/open-lower-groups` | 获取查询日和前一交易日均涨停的股票,并按是否低于前一交易日收盘价分组 | [limit-board-open-lower-groups.md](./limit-board-open-lower-groups.md) |
| 涨停开盘阈值分组 | `GET` | `/api/v1/limit-board/open-threshold` | 获取查询日和前一交易日均涨停的股票,并按开盘价是否低于前一交易日收盘价 101% 分组 | [limit-board-open-threshold.md](./limit-board-open-threshold.md) |
| 二板早盘次日盈利 | `GET` | `/api/v1/limit-board/second-board-profit` | 分页查询 T-2 无涨停、T-1 涨停、T 涨停的次日盈利明细 | [download-second-board-profit-csv.md](./download-second-board-profit-csv.md) |
| 二板获利综合 | `GET` | `/api/v1/limit-board/second-board-profit-summary` | 汇总每只个股 2020 年至今 09:31:00-09:59:59 二板后的次日最大获利,并按累计获利倒序排序 | [second-board-profit-summary.md](./second-board-profit-summary.md) |
| 个股二板获利详情 | `GET` | `/api/v1/limit-board/second-board-profit-detail` | 查询单只个股每次 09:31:00-09:59:59 二板后的次日最大获利 | [second-board-profit-summary.md](./second-board-profit-summary.md) |
| 二板日历 | `GET` | `/api/v1/limit-board/second-cal` | 按交易日统计二板数量、封板/炸板数量和 T+1 盘中盈利概率 | [second-cal.md](./second-cal.md) |
| 炸板次日表现 | `GET` | `/api/v1/limit-board/break-next-performance` | 分页查询 T-2 未涨停、T-1 涨停、T 日炸板股票及 T+1 表现 | [limit-board-break-next-performance.md](./limit-board-break-next-performance.md) |
| 下载 | `GET` | `/api/v1/download/limit-board/second-board-profit-csv` | 下载筛选条件范围内的二板早盘次日盈利 CSV 明细 | [download-second-board-profit-csv.md](./download-second-board-profit-csv.md) |
+1 -1
View File
@@ -19,7 +19,7 @@
| `tOpenGainLt101` | `bool` | 否 | 无 | 筛选 T 日开盘价是否小于 T-1 收盘价的 101%,支持别名 `tOpenLessThan101`、`t_open_gain_lt_101` |
| `breakTimeBefore1100` | `bool` | 否 | 无 | 筛选 T 日炸板时间是否在 `11:00:00` 前,空炸板时间按 `true` 处理,支持别名 `tBreakTimeBefore1100`、`break_time_before_1100` |
兼容 `start_date`、`end_date` 作为日期参数别名。
兼容 `start_date`、`end_date`、`trade_start_date`、`trade_end_date` 作为日期参数别名。
示例:
-19
View File
@@ -1,19 +0,0 @@
ALTER DATABASE `level` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
ALTER TABLE `limit_list_d`
CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
ALTER TABLE `stock_basic`
CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
ALTER TABLE `stock_daily`
CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
ALTER TABLE `stock_limit_combined_data`
CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
ALTER TABLE `trade_calendar`
CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
ALTER TABLE `stock_limit_data`
CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
+230
View File
@@ -0,0 +1,230 @@
# 二板获利综合与个股详情接口文档
## 1. 接口说明
本页包含两个二板获利统计接口:
- 二板获利综合:汇总每只个股 2020 年至今早盘二板后的 T+1 最大获利,并按累计获利从高到低排序。
- 个股二板获利详情:查询单只个股每次早盘二板后的 T+1 最大获利情况。
接口当前读取缓存表,不再在请求时实时联表计算:
- `second_board_profit_detail_cache`:保存每次命中的二板详情和 T+1 最大获利。
- `second_board_profit_summary_cache`:保存完整缓存范围内按个股聚合后的综合数据。
业务筛选基础条件:
- T-2 交易日无涨停。
- T-1 交易日涨停。
- T 交易日再次触及涨停价,T 日可为封板或炸板。
- T 日二板首次涨停时间在 `09:31:00` 到 `09:59:59` 之间,包含起止时间。
## 2. 接口地址
| 接口名称 | 请求方式 | 请求路径 | 说明 |
| --- | --- | --- | --- |
| 二板获利综合 | `GET` | `/api/v1/limit-board/second-board-profit-summary` | 汇总每只个股 2020 年至今 09:31:00-09:59:59 二板后的次日最大获利 |
| 个股二板获利详情 | `GET` | `/api/v1/limit-board/second-board-profit-detail` | 查询单只个股每次 09:31:00-09:59:59 二板后的次日最大获利 |
## 3. 统计规则
- 统计口径复用二板日历接口:T-2 未涨停、T-1 涨停、T 日再次触及涨停价。
- T+1 最大获利百分比为 `next_high_profit_pct`,按 `(T+1最高价 - T日最高价) / T日最高价 * 100` 计算并保留两位小数。
- 二板获利综合接口按个股累计 `next_high_profit_pct`,得到 `total_next_high_profit_pct`,并按该字段从高到低排序。
- 没有 T+1 最高价或 T 日最高价的数据仍会计入二板次数,但不参与获利累计和平均值计算。
- 当请求日期范围覆盖整张明细缓存表时,二板获利综合接口直接读取 `second_board_profit_summary_cache`。
- 当请求日期范围小于明细缓存表覆盖范围时,二板获利综合接口从 `second_board_profit_detail_cache` 按日期范围动态聚合。
- 个股二板获利详情接口始终从 `second_board_profit_detail_cache` 按日期范围和股票代码查询。
## 4. 缓存刷新
缓存表需要由离线脚本或人工任务刷新。接口只返回缓存表中已经落地的数据;如果最新交易日或历史补数未写入缓存表,接口不会临时回退到实时联表计算。
当前校验结果:
- `second_board_profit_detail_cache` 当前覆盖 `2020-01-06` 到 `2026-05-14`。
- 在该覆盖范围内,缓存明细和原实时计算口径数量一致、键一致、字段一致。
- 默认查询范围 `2020-01-01` 到当天如果覆盖了缓存表之外的日期,需要先刷新缓存表,否则接口只返回已落地缓存数据。
## 5. 请求参数
两个接口都支持以下日期参数:
| 参数名 | 类型 | 必填 | 默认值 | 示例 | 说明 |
| --- | --- | --- | --- | --- | --- |
| `tradeStartDate` | `string` | 否 | `2020-01-01` | `2020-01-01` | T 日交易开始日期,格式 `YYYY-MM-DD` |
| `tradeEndDate` | `string` | 否 | 当天 | `2026-05-16` | T 日交易结束日期,格式 `YYYY-MM-DD` |
个股二板获利详情接口还需要传入股票代码,`tsCode` 和 `symbol` 二选一:
| 参数名 | 类型 | 必填 | 默认值 | 示例 | 说明 |
| --- | --- | --- | --- | --- | --- |
| `tsCode` | `string` | 否 | 空 | `000001.SZ` | TuShare 股票代码,优先使用 |
| `symbol` | `string` | 否 | 空 | `000001` | 市场展示股票代码;未传 `tsCode` 时使用 |
兼容说明:日期参数兼容 `trade_start_date`、`start_date`、`trade_end_date`、`end_date`;`symbol` 兼容别名 `code`。
## 6. 请求示例
二板获利综合:
```http
GET /api/v1/limit-board/second-board-profit-summary
```
指定日期范围的二板获利综合:
```http
GET /api/v1/limit-board/second-board-profit-summary?tradeStartDate=2020-01-01&tradeEndDate=2026-05-16
```
个股二板获利详情:
```http
GET /api/v1/limit-board/second-board-profit-detail?tsCode=000001.SZ
```
## 7. 二板获利综合响应
| 字段名 | 类型 | 说明 |
| --- | --- | --- |
| `trade_start_date` | `string` | 查询起始 T 日 |
| `trade_end_date` | `string` | 查询结束 T 日 |
| `start_time` | `string` | 固定筛选起点,当前为 `09:31:00` |
| `end_time` | `string` | 固定筛选终点,当前为 `09:59:59` |
| `total` | `int` | 命中的个股数量 |
| `list` | `array` | 按累计获利从高到低排序的个股汇总数据 |
`list[]` 字段:
| 字段名 | 类型 | 说明 |
| --- | --- | --- |
| `symbol` | `string` | 股票代码 |
| `ts_code` | `string` | TuShare 股票代码 |
| `name` | `string` | 股票简称 |
| `market` | `string` | 所属板块 |
| `industry` | `string` | 所属行业 |
| `second_board_count` | `int` | 09:31:00-09:59:59 期间命中的二板次数 |
| `limit_count` | `int` | 二板封板次数 |
| `break_count` | `int` | 二板炸板次数 |
| `profit_data_count` | `int` | 具备 T+1 最大获利百分比的次数 |
| `total_next_high_profit_pct` | `number` | T+1 最大获利百分比累计值 |
| `average_next_high_profit_pct` | `number` | T+1 最大获利百分比平均值 |
| `first_trade_date` | `string` | 首次命中交易日 |
| `last_trade_date` | `string` | 最近命中交易日 |
响应示例:
```json
{
"trade_start_date": "2020-01-01",
"trade_end_date": "2026-05-16",
"start_time": "09:31:00",
"end_time": "09:59:59",
"total": 1,
"list": [
{
"symbol": "000001",
"ts_code": "000001.SZ",
"name": "示例股份",
"market": "主板",
"industry": "银行",
"second_board_count": 3,
"limit_count": 2,
"break_count": 1,
"profit_data_count": 3,
"total_next_high_profit_pct": 12.36,
"average_next_high_profit_pct": 4.12,
"first_trade_date": "2021-03-18",
"last_trade_date": "2026-04-24"
}
]
}
```
## 8. 个股二板获利详情响应
| 字段名 | 类型 | 说明 |
| --- | --- | --- |
| `trade_start_date` | `string` | 查询起始 T 日 |
| `trade_end_date` | `string` | 查询结束 T 日 |
| `start_time` | `string` | 固定筛选起点,当前为 `09:31:00` |
| `end_time` | `string` | 固定筛选终点,当前为 `09:59:59` |
| `symbol` | `string` | 股票代码 |
| `ts_code` | `string` | TuShare 股票代码 |
| `name` | `string` | 股票简称 |
| `market` | `string` | 所属板块 |
| `industry` | `string` | 所属行业 |
| `second_board_count` | `int` | 命中的二板次数 |
| `profit_data_count` | `int` | 具备 T+1 最大获利百分比的次数 |
| `total_next_high_profit_pct` | `number` | T+1 最大获利百分比累计值 |
| `average_next_high_profit_pct` | `number` | T+1 最大获利百分比平均值 |
| `list` | `array` | 每次二板获利详情,字段同二板日历接口 `SecondCalStock` |
`list[]` 关键字段:
| 字段名 | 类型 | 说明 |
| --- | --- | --- |
| `trade_date` | `string` | T 日交易日 |
| `previous_trade_date` | `string` | T-1 交易日 |
| `previous_two_trade_date` | `string` | T-2 交易日 |
| `next_trade_date` | `string` | T+1 交易日 |
| `status` | `string` | `limit` 表示封板,`break` 表示炸板 |
| `first_limit_time` | `string` | T 日二板首次涨停时间,固定落在 `09:31:00` 到 `09:59:59` |
| `t_high` | `number \| null` | T 日最高价 |
| `next_high` | `number \| null` | T+1 最高价 |
| `next_intraday_profitable` | `boolean \| null` | T+1 最高价是否高于 T 日最高价 |
| `next_high_profit_pct` | `number \| null` | T+1 最高价相对 T 日最高价的最大获利百分比 |
响应示例:
```json
{
"trade_start_date": "2020-01-01",
"trade_end_date": "2026-05-16",
"start_time": "09:31:00",
"end_time": "09:59:59",
"symbol": "000001",
"ts_code": "000001.SZ",
"name": "示例股份",
"market": "主板",
"industry": "银行",
"second_board_count": 1,
"profit_data_count": 1,
"total_next_high_profit_pct": 4.12,
"average_next_high_profit_pct": 4.12,
"list": [
{
"trade_date": "2026-04-24",
"previous_trade_date": "2026-04-23",
"previous_two_trade_date": "2026-04-22",
"next_trade_date": "2026-04-27",
"symbol": "000001",
"ts_code": "000001.SZ",
"name": "示例股份",
"market": "主板",
"industry": "银行",
"previous_close": 10,
"limit_price": 11,
"t_high": 11,
"t_close": 11,
"t_pct_chg": 10,
"status": "limit",
"first_limit_time": "09:35:12",
"open_times": 0,
"next_high": 11.45,
"next_close": 11.2,
"next_intraday_profitable": true,
"next_high_profit_pct": 4.09
}
]
}
```
## 9. 错误响应
```json
{
"error": "参数错误"
}
```
+29
View File
@@ -63,6 +63,10 @@ GET /api/v1/limit-board/second-cal?tradeStartDate=2026-05-07&tradeEndDate=2026-0
### 4.2 list[].stocks 明细字段
`list[].stocks` 保留当日全部二板明细。
`list[].total_stocks` 为当日涨停和炸板合并后的明细列表,字段结构与 `stocks` 相同;该列表会剔除 `first_limit_time` 为空的数据,并按 `first_limit_time` 升序排序,时间相同时按 `ts_code` 升序排序。
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `trade_date` | `string` | T 日 |
@@ -121,6 +125,31 @@ GET /api/v1/limit-board/second-cal?tradeStartDate=2026-05-07&tradeEndDate=2026-0
"break_next_data_count": 3,
"break_next_profit_count": 3,
"break_next_profit_rate": 100,
"total_stocks": [
{
"trade_date": "2026-05-07",
"previous_trade_date": "2026-05-06",
"previous_two_trade_date": "2026-04-30",
"next_trade_date": "2026-05-08",
"symbol": "000001",
"ts_code": "000001.SZ",
"name": "示例股份",
"market": "主板",
"industry": "银行",
"previous_close": 10,
"limit_price": 11,
"t_high": 11,
"t_close": 11,
"t_pct_chg": 10,
"status": "limit",
"first_limit_time": "09:35:12",
"open_times": 0,
"next_high": 11.2,
"next_close": 11.1,
"next_intraday_profitable": true,
"next_high_profit_pct": 1.82
}
],
"stocks": [
{
"trade_date": "2026-05-07",
+12 -2
View File
@@ -112,8 +112,8 @@ func parseSecondCalRequest(c *gin.Context) (model.SecondCalRequest, error) {
// includePagination 为 false 时跳过分页参数,供 CSV 导出复用同一套日期和过滤条件解析逻辑。
func parseLimitBreakNextPerformanceRequest(c *gin.Context, includePagination bool) (model.LimitBreakNextPerformanceRequest, error) {
req := model.LimitBreakNextPerformanceRequest{
TradeStartDate: strings.TrimSpace(c.DefaultQuery("tradeStartDate", c.Query("start_date"))),
TradeEndDate: strings.TrimSpace(c.DefaultQuery("tradeEndDate", c.Query("end_date"))),
TradeStartDate: firstNonEmptyQuery(c, "tradeStartDate", "trade_start_date", "start_date"),
TradeEndDate: firstNonEmptyQuery(c, "tradeEndDate", "trade_end_date", "end_date"),
Page: 1,
PageSize: 20,
}
@@ -159,6 +159,16 @@ func parseLimitBreakNextPerformanceRequest(c *gin.Context, includePagination boo
return req, nil
}
// firstNonEmptyQuery 按优先级读取第一个非空查询参数。
func firstNonEmptyQuery(c *gin.Context, keys ...string) string {
for _, key := range keys {
if value := strings.TrimSpace(c.Query(key)); value != "" {
return value
}
}
return ""
}
// parseOptionalBoolQuery 从多个兼容参数名中读取可选布尔值。
//
// 返回 nil 表示用户没有传入该过滤项;返回 *bool 表示需要按 true/false 过滤。
+22 -18
View File
@@ -11,7 +11,6 @@ import (
"time"
"github.com/gin-gonic/gin"
"level_go/internal/dbcompat"
)
const (
@@ -337,16 +336,21 @@ func validateSecondProfitDateRange(startDate, endDate string) error {
// SQL 先找出“前一日涨停、前两日未涨停、当日继续涨停”的股票,再补齐 T+1 行情;
// Go 代码负责应用动态过滤条件并计算开盘、最高、最低、收盘相对 T 日收盘的收益率。
func (h *LimitBoardHandler) loadSecondProfitRows(req secondProfitQuery) ([]secondProfitCSVRow, error) {
seeds, err := dbcompat.LoadSSEOpenCalendarSeeds(h.Service.DB, req.TradeStartDate, req.TradeEndDate)
if err != nil {
return nil, err
}
if len(seeds) == 0 {
return []secondProfitCSVRow{}, nil
}
calendarTable, calendarArgs := dbcompat.CalendarSeedTable(seeds)
query := `
WITH calendar AS (
SELECT
cal_date AS trade_date,
LAG(cal_date) OVER (ORDER BY cal_date) AS previous_trade_date,
LAG(cal_date, 2) OVER (ORDER BY cal_date) AS previous_two_trade_date,
LEAD(cal_date) OVER (ORDER BY cal_date) AS next_trade_date
FROM trade_calendar
WHERE exchange = 'SSE' AND is_open = 1
),
calendar_window AS (
SELECT *
FROM calendar
WHERE trade_date BETWEEN ? AND ?
)
SELECT
DATE_FORMAT(c.trade_date, '%Y-%m-%d') AS trade_date,
DATE_FORMAT(c.previous_trade_date, '%Y-%m-%d') AS previous_trade_date,
@@ -374,28 +378,28 @@ SELECT
nd.high AS next_high,
nd.low AS next_low,
nd.close AS next_close
FROM (` + calendarTable + `) c
FROM calendar_window c
INNER JOIN stock_limit_combined_data l
ON l.trade_date = c.trade_date AND l.final_status = 1
INNER JOIN stock_limit_combined_data pl
ON ` + dbcompat.TsCodeEqual("pl.ts_code", "l.ts_code") + ` AND pl.trade_date = c.previous_trade_date AND pl.final_status = 1
ON pl.ts_code = l.ts_code AND pl.trade_date = c.previous_trade_date AND pl.final_status = 1
LEFT JOIN stock_limit_combined_data ppl
ON ` + dbcompat.TsCodeEqual("ppl.ts_code", "l.ts_code") + ` AND ppl.trade_date = c.previous_two_trade_date AND ppl.final_status = 1
ON ppl.ts_code = l.ts_code AND ppl.trade_date = c.previous_two_trade_date AND ppl.final_status = 1
LEFT JOIN stock_daily d
ON ` + dbcompat.TsCodeEqual("l.ts_code", "d.ts_code") + ` AND d.trade_date = l.trade_date
ON l.ts_code = d.ts_code AND d.trade_date = l.trade_date
LEFT JOIN stock_daily pd
ON ` + dbcompat.TsCodeEqual("l.ts_code", "pd.ts_code") + ` AND pd.trade_date = c.previous_trade_date
ON l.ts_code = pd.ts_code AND pd.trade_date = c.previous_trade_date
LEFT JOIN stock_daily ppd
ON ` + dbcompat.TsCodeEqual("l.ts_code", "ppd.ts_code") + ` AND ppd.trade_date = c.previous_two_trade_date
ON l.ts_code = ppd.ts_code AND ppd.trade_date = c.previous_two_trade_date
LEFT JOIN stock_daily nd
ON ` + dbcompat.TsCodeEqual("l.ts_code", "nd.ts_code") + ` AND nd.trade_date = c.next_trade_date
ON l.ts_code = nd.ts_code AND nd.trade_date = c.next_trade_date
WHERE c.previous_trade_date IS NOT NULL
AND c.previous_two_trade_date IS NOT NULL
AND ppl.ts_code IS NULL
ORDER BY c.trade_date ASC, l.ts_code ASC`
var rawRows []secondProfitCSVRow
if err := h.Service.DB.Raw(query, calendarArgs...).Scan(&rawRows).Error; err != nil {
if err := h.Service.DB.Raw(query, req.TradeStartDate, req.TradeEndDate).Scan(&rawRows).Error; err != nil {
return nil, err
}
@@ -0,0 +1,67 @@
package api
import (
"errors"
"net/http"
"time"
"github.com/gin-gonic/gin"
"level_go/internal/model"
)
// GetSecondBoardProfitSummary 返回 2020 年至今二板早盘股票的 T+1 最大获利汇总。
func (h *LimitBoardHandler) GetSecondBoardProfitSummary(c *gin.Context) {
req, err := parseSecondBoardProfitRequest(c, false)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
resp, err := h.Service.GetSecondBoardProfitSummary(req)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, resp)
}
// GetSecondBoardProfitDetail 返回单只股票二板早盘后的 T+1 最大获利详情。
func (h *LimitBoardHandler) GetSecondBoardProfitDetail(c *gin.Context) {
req, err := parseSecondBoardProfitRequest(c, true)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
resp, err := h.Service.GetSecondBoardProfitDetail(req)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, resp)
}
// parseSecondBoardProfitRequest 解析二板早盘获利综合和详情接口参数。
func parseSecondBoardProfitRequest(c *gin.Context, requireCode bool) (model.SecondBoardProfitRequest, error) {
req := model.SecondBoardProfitRequest{
TradeStartDate: firstNonEmptyQuery(c, "tradeStartDate", "trade_start_date", "start_date"),
TradeEndDate: firstNonEmptyQuery(c, "tradeEndDate", "trade_end_date", "end_date"),
TsCode: firstNonEmptyQuery(c, "tsCode", "ts_code"),
Symbol: firstNonEmptyQuery(c, "symbol", "code"),
}
if req.TradeStartDate == "" {
req.TradeStartDate = defaultSecondProfitStartDate
}
if req.TradeEndDate == "" {
req.TradeEndDate = time.Now().Format("2006-01-02")
}
if err := validateSecondProfitDateRange(req.TradeStartDate, req.TradeEndDate); err != nil {
return req, err
}
if requireCode && req.TsCode == "" && req.Symbol == "" {
return req, errors.New("tsCode 或 symbol 参数不能为空")
}
return req, nil
}
-147
View File
@@ -1,147 +0,0 @@
package dbcompat
import (
"strings"
"gorm.io/gorm"
)
// CalendarSeed is a MySQL-version-neutral replacement for LAG/LEAD over the
// trade calendar. Keeping the adjacent dates in Go avoids depending on MySQL 8.
type CalendarSeed struct {
TradeDate string
PreviousTradeDate string
PreviousTwoTradeDate string
NextTradeDate string
}
type calendarDateRow struct {
TradeDate string `gorm:"column:trade_date"`
}
type columnCollationRow struct {
CollationName string `gorm:"column:collation_name"`
}
// LoadSSEOpenCalendarSeeds loads all SSE open dates and builds the date window
// needed by range queries. The returned seeds are limited to [startDate, endDate].
func LoadSSEOpenCalendarSeeds(db *gorm.DB, startDate, endDate string) ([]CalendarSeed, error) {
var rows []calendarDateRow
if err := db.Table("trade_calendar").
Select("DATE_FORMAT(cal_date, '%Y-%m-%d') AS trade_date").
Where("exchange = ? AND is_open = ?", "SSE", 1).
Order("cal_date ASC").
Scan(&rows).Error; err != nil {
return nil, err
}
seeds := make([]CalendarSeed, 0)
for i, row := range rows {
if row.TradeDate < startDate || row.TradeDate > endDate {
continue
}
seed := CalendarSeed{TradeDate: row.TradeDate}
if i > 0 {
seed.PreviousTradeDate = rows[i-1].TradeDate
}
if i > 1 {
seed.PreviousTwoTradeDate = rows[i-2].TradeDate
}
if i+1 < len(rows) {
seed.NextTradeDate = rows[i+1].TradeDate
}
seeds = append(seeds, seed)
}
return seeds, nil
}
// CalendarSeedTable converts calendar seeds into a derived table that works on
// MySQL 5.7, MySQL 8, and MariaDB without CTE/window-function support.
func CalendarSeedTable(seeds []CalendarSeed) (string, []any) {
if len(seeds) == 0 {
return `SELECT
CAST(NULL AS DATE) AS trade_date,
CAST(NULL AS DATE) AS previous_trade_date,
CAST(NULL AS DATE) AS previous_two_trade_date,
CAST(NULL AS DATE) AS next_trade_date
WHERE 1 = 0`, nil
}
parts := make([]string, 0, len(seeds))
args := make([]any, 0, len(seeds)*4)
for _, seed := range seeds {
parts = append(parts, `SELECT
CAST(? AS DATE) AS trade_date,
CAST(? AS DATE) AS previous_trade_date,
CAST(? AS DATE) AS previous_two_trade_date,
CAST(? AS DATE) AS next_trade_date`)
args = append(args,
nullableDate(seed.TradeDate),
nullableDate(seed.PreviousTradeDate),
nullableDate(seed.PreviousTwoTradeDate),
nullableDate(seed.NextTradeDate),
)
}
return strings.Join(parts, "\nUNION ALL\n"), args
}
// TsCodeEqual keeps joins free of hard-coded COLLATE clauses. This avoids
// binding SQL to MySQL-8-only collations while preserving index-friendly joins.
func TsCodeEqual(left, right string) string {
return left + " = " + right
}
// ColumnCollation returns the current database collation for one column.
func ColumnCollation(db *gorm.DB, tableName, columnName string) (string, error) {
var row columnCollationRow
err := db.Raw(`
SELECT COLLATION_NAME AS collation_name
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = ?
AND COLUMN_NAME = ?
LIMIT 1`, tableName, columnName).Scan(&row).Error
if err != nil {
return "", err
}
return row.CollationName, nil
}
// TsCodeEqualUnicode compares a utf8mb4_unicode_ci column with a column that
// may be utf8mb4_0900_ai_ci. utf8mb4_unicode_ci exists on both MySQL 5.7 and 8.
func TsCodeEqualUnicode(left, right string) string {
return left + " = " + right + " COLLATE utf8mb4_unicode_ci"
}
// TsCodeEqualRightCollation compares two stock-code columns using a collation
// known to exist on the connected database. This avoids MySQL-version-specific
// literals while preserving the fast side of joins such as stock_daily.ts_code.
func TsCodeEqualRightCollation(left, right, collation string) string {
if !safeCollationName(collation) {
return TsCodeEqual(left, right)
}
return left + " = " + right + " COLLATE " + collation
}
func safeCollationName(collation string) bool {
if collation == "" {
return false
}
for _, r := range collation {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' {
continue
}
return false
}
return true
}
func nullableDate(date string) any {
if strings.TrimSpace(date) == "" {
return nil
}
return date
}
+54
View File
@@ -21,6 +21,14 @@ type SecondCalRequest struct {
TradeEndDate string `form:"tradeEndDate" json:"trade_end_date"` // 查询结束 T 日。
}
// SecondBoardProfitRequest 是二板获利综合和详情接口的日期范围请求。
type SecondBoardProfitRequest struct {
TradeStartDate string `form:"tradeStartDate" json:"trade_start_date"` // 查询起始 T 日,默认 2020-01-01。
TradeEndDate string `form:"tradeEndDate" json:"trade_end_date"` // 查询结束 T 日,默认当天。
TsCode string `form:"tsCode" json:"ts_code"` // 可选 TuShare 股票代码,详情接口必填。
Symbol string `form:"symbol" json:"symbol"` // 可选市场股票代码,详情接口可替代 tsCode。
}
// LimitBoardStock 表示单只涨停股在涨停概览中的展示数据。
type LimitBoardStock struct {
Symbol string `json:"symbol"` // 市场展示用股票代码。
@@ -165,6 +173,51 @@ type SecondCalStock struct {
NextHighProfitPct *float64 `json:"next_high_profit_pct"` // T+1 最高价相对 T 日最高价的收益率。
}
// SecondBoardProfitSummaryItem 表示单只股票二板后的次日最大获利汇总。
type SecondBoardProfitSummaryItem struct {
Symbol string `json:"symbol"` // 股票代码。
TsCode string `json:"ts_code"` // TuShare 股票代码。
Name string `json:"name"` // 股票简称。
Market string `json:"market"` // 所属板块。
Industry string `json:"industry"` // 所属行业。
SecondBoardCount int `json:"second_board_count"` // 09:31:00-09:59:59 期间命中的二板次数。
LimitCount int `json:"limit_count"` // 二板封板次数。
BreakCount int `json:"break_count"` // 二板炸板次数。
ProfitDataCount int `json:"profit_data_count"` // 具备 T+1 最大获利百分比的次数。
TotalNextHighProfitPct float64 `json:"total_next_high_profit_pct"` // T+1 最大获利百分比累计值。
AverageNextHighProfitPct float64 `json:"average_next_high_profit_pct"` // T+1 最大获利百分比平均值。
FirstTradeDate string `json:"first_trade_date"` // 首次命中交易日。
LastTradeDate string `json:"last_trade_date"` // 最近命中交易日。
}
// SecondBoardProfitSummaryResponse 是二板获利综合接口响应。
type SecondBoardProfitSummaryResponse struct {
TradeStartDate string `json:"trade_start_date"` // 查询起始 T 日。
TradeEndDate string `json:"trade_end_date"` // 查询结束 T 日。
StartTime string `json:"start_time"` // 二板首次涨停时间筛选起点。
EndTime string `json:"end_time"` // 二板首次涨停时间筛选终点。
Total int `json:"total"` // 返回的股票数量。
List []SecondBoardProfitSummaryItem `json:"list"` // 按累计获利从高到低排序。
}
// SecondBoardProfitDetailResponse 是单只股票二板获利详情接口响应。
type SecondBoardProfitDetailResponse struct {
TradeStartDate string `json:"trade_start_date"` // 查询起始 T 日。
TradeEndDate string `json:"trade_end_date"` // 查询结束 T 日。
StartTime string `json:"start_time"` // 二板首次涨停时间筛选起点。
EndTime string `json:"end_time"` // 二板首次涨停时间筛选终点。
Symbol string `json:"symbol"` // 股票代码。
TsCode string `json:"ts_code"` // TuShare 股票代码。
Name string `json:"name"` // 股票简称。
Market string `json:"market"` // 所属板块。
Industry string `json:"industry"` // 所属行业。
SecondBoardCount int `json:"second_board_count"` // 命中二板次数。
ProfitDataCount int `json:"profit_data_count"` // 具备 T+1 最大获利百分比的次数。
TotalNextHighProfitPct float64 `json:"total_next_high_profit_pct"` // T+1 最大获利百分比累计值。
AverageNextHighProfitPct float64 `json:"average_next_high_profit_pct"` // T+1 最大获利百分比平均值。
List []SecondCalStock `json:"list"` // 每次二板获利详情。
}
// LimitOpenCompareGroup 是开盘价低于前收接口的分组结果。
type LimitOpenCompareGroup struct {
Key string `json:"key"` // 分组唯一标识。
@@ -261,6 +314,7 @@ type SecondCalDay struct {
BreakNextProfitCount int `json:"break_next_profit_count"` // 当日炸板股 T+1 盘中盈利数量。
BreakNextProfitRate float64 `json:"break_next_profit_rate"` // 当日炸板股 T+1 盘中盈利概率,百分比。
Stocks []SecondCalStock `json:"stocks"` // 当日二板股票明细。
TotalStocks []SecondCalStock `json:"total_stocks"` // 涨停和炸板合并后按首次涨停时间排序的明细。
}
// SecondCalResponse 是二板日历接口响应。
+115
View File
@@ -0,0 +1,115 @@
package router
import (
"bytes"
"net/http"
"strings"
"sync"
"time"
"github.com/gin-gonic/gin"
)
const (
responseCacheTTL = 5 * time.Minute
responseCacheMaxBody = 4 << 20
responseCacheMaxKeys = 512
)
type responseCacheEntry struct {
status int
contentType string
body []byte
expiresAt time.Time
}
type responseCacheStore struct {
mu sync.RWMutex
items map[string]responseCacheEntry
}
func newResponseCacheStore() *responseCacheStore {
return &responseCacheStore{items: make(map[string]responseCacheEntry)}
}
func (s *responseCacheStore) get(key string) (responseCacheEntry, bool) {
now := time.Now()
s.mu.RLock()
entry, ok := s.items[key]
s.mu.RUnlock()
if !ok {
return responseCacheEntry{}, false
}
if now.After(entry.expiresAt) {
s.mu.Lock()
if current, exists := s.items[key]; exists && now.After(current.expiresAt) {
delete(s.items, key)
}
s.mu.Unlock()
return responseCacheEntry{}, false
}
return entry, true
}
func (s *responseCacheStore) set(key string, entry responseCacheEntry) {
s.mu.Lock()
if len(s.items) >= responseCacheMaxKeys {
now := time.Now()
for cacheKey, current := range s.items {
if now.After(current.expiresAt) || len(s.items) >= responseCacheMaxKeys {
delete(s.items, cacheKey)
}
}
}
s.items[key] = entry
s.mu.Unlock()
}
type cachedResponseWriter struct {
gin.ResponseWriter
body *bytes.Buffer
}
func (w cachedResponseWriter) Write(data []byte) (int, error) {
w.body.Write(data)
return w.ResponseWriter.Write(data)
}
func cacheableGETResponseMiddleware(store *responseCacheStore) gin.HandlerFunc {
return func(c *gin.Context) {
if c.Request.Method != http.MethodGet || strings.HasPrefix(c.Request.URL.Path, "/api/v1/download/") {
c.Next()
return
}
key := c.Request.URL.RequestURI()
if entry, ok := store.get(key); ok {
c.Header("Content-Type", entry.contentType)
c.Data(entry.status, entry.contentType, entry.body)
return
}
body := bytes.NewBuffer(nil)
writer := cachedResponseWriter{ResponseWriter: c.Writer, body: body}
c.Writer = writer
c.Next()
status := c.Writer.Status()
if status != http.StatusOK || body.Len() == 0 || body.Len() > responseCacheMaxBody {
return
}
contentType := c.Writer.Header().Get("Content-Type")
if !strings.Contains(contentType, "application/json") {
return
}
store.set(key, responseCacheEntry{
status: status,
contentType: contentType,
body: append([]byte(nil), body.Bytes()...),
expiresAt: time.Now().Add(responseCacheTTL),
})
}
}
+5 -1
View File
@@ -9,7 +9,8 @@ import (
//
// 函数集中注册跨域中间件和 v1 版本接口,调用方只需要注入各业务 Handler。
func NewRouter(commonApi *api.CommonHandler, stockApi *api.StockHandler, limitBoardApi *api.LimitBoardHandler) *gin.Engine {
r := gin.Default()
r := gin.New()
r.Use(gin.Recovery())
// 跨域中间件允许前端从任意来源访问接口,并暴露 CSV 下载需要的 Content-Disposition。
r.Use(func(c *gin.Context) {
@@ -23,6 +24,7 @@ func NewRouter(commonApi *api.CommonHandler, stockApi *api.StockHandler, limitBo
}
c.Next()
})
r.Use(cacheableGETResponseMiddleware(newResponseCacheStore()))
v1 := r.Group("/api/v1")
{
@@ -37,6 +39,8 @@ func NewRouter(commonApi *api.CommonHandler, stockApi *api.StockHandler, limitBo
limitBoard.GET("/open-threshold", limitBoardApi.GetOpenThresholdGroups)
limitBoard.GET("/open-threshold-groups", limitBoardApi.GetOpenThresholdGroups)
limitBoard.GET("/second-board-profit", limitBoardApi.ListSecondBoardProfit)
limitBoard.GET("/second-board-profit-summary", limitBoardApi.GetSecondBoardProfitSummary)
limitBoard.GET("/second-board-profit-detail", limitBoardApi.GetSecondBoardProfitDetail)
limitBoard.GET("/second-cal", limitBoardApi.GetSecondCal)
limitBoard.GET("/break-next-performance", limitBoardApi.GetBreakNextPerformance)
}
@@ -5,7 +5,6 @@ import (
"strconv"
"strings"
"level_go/internal/dbcompat"
"level_go/internal/model"
)
@@ -105,16 +104,21 @@ func (s *LimitBoardService) ListLimitBreakNextPerformanceStocks(req model.LimitB
//
// 口径:前一交易日涨停、前两交易日未涨停、当前交易日 final_status = 2。
func (s *LimitBoardService) loadLimitBreakNextPerformanceRows(startDate, endDate string) ([]limitBreakNextPerformanceRow, error) {
seeds, err := dbcompat.LoadSSEOpenCalendarSeeds(s.DB, startDate, endDate)
if err != nil {
return nil, err
}
if len(seeds) == 0 {
return []limitBreakNextPerformanceRow{}, nil
}
calendarTable, calendarArgs := dbcompat.CalendarSeedTable(seeds)
query := `
WITH calendar AS (
SELECT
cal_date AS trade_date,
LAG(cal_date) OVER (ORDER BY cal_date) AS previous_trade_date,
LAG(cal_date, 2) OVER (ORDER BY cal_date) AS previous_two_trade_date,
LEAD(cal_date) OVER (ORDER BY cal_date) AS next_trade_date
FROM trade_calendar
WHERE exchange = 'SSE' AND is_open = 1
),
calendar_window AS (
SELECT *
FROM calendar
WHERE trade_date BETWEEN ? AND ?
)
SELECT *
FROM (
SELECT
@@ -143,21 +147,21 @@ FROM (
nd.high AS next_high,
nd.low AS next_low,
nd.close AS next_close
FROM (` + calendarTable + `) c
FROM calendar_window c
INNER JOIN stock_limit_combined_data l
ON l.trade_date = c.trade_date AND l.final_status = 2
INNER JOIN stock_limit_combined_data pl
ON ` + dbcompat.TsCodeEqual("pl.ts_code", "l.ts_code") + ` AND pl.trade_date = c.previous_trade_date AND pl.final_status = 1
ON pl.ts_code = l.ts_code AND pl.trade_date = c.previous_trade_date AND pl.final_status = 1
LEFT JOIN stock_limit_combined_data ppl
ON ` + dbcompat.TsCodeEqual("ppl.ts_code", "l.ts_code") + ` AND ppl.trade_date = c.previous_two_trade_date AND ppl.final_status = 1
ON ppl.ts_code = l.ts_code AND ppl.trade_date = c.previous_two_trade_date AND ppl.final_status = 1
LEFT JOIN stock_daily d
ON ` + dbcompat.TsCodeEqual("d.ts_code", "l.ts_code") + ` AND d.trade_date = c.trade_date
ON d.ts_code = l.ts_code AND d.trade_date = c.trade_date
LEFT JOIN stock_daily pd
ON ` + dbcompat.TsCodeEqual("pd.ts_code", "l.ts_code") + ` AND pd.trade_date = c.previous_trade_date
ON pd.ts_code = l.ts_code AND pd.trade_date = c.previous_trade_date
LEFT JOIN stock_daily nd
ON ` + dbcompat.TsCodeEqual("nd.ts_code", "l.ts_code") + ` AND nd.trade_date = c.next_trade_date
ON nd.ts_code = l.ts_code AND nd.trade_date = c.next_trade_date
LEFT JOIN stock_basic sb
ON ` + dbcompat.TsCodeEqualUnicode("sb.ts_code", "l.ts_code") + `
ON sb.ts_code = l.ts_code
WHERE c.previous_trade_date IS NOT NULL
AND c.previous_two_trade_date IS NOT NULL
AND ppl.ts_code IS NULL
@@ -167,7 +171,7 @@ WHERE previous_close IS NOT NULL
ORDER BY trade_date ASC, ts_code ASC`
var rows []limitBreakNextPerformanceRow
err = s.DB.Raw(query, calendarArgs...).Scan(&rows).Error
err := s.DB.Raw(query, startDate, endDate).Scan(&rows).Error
return rows, err
}
@@ -6,7 +6,6 @@ import (
"strconv"
"strings"
"level_go/internal/dbcompat"
"level_go/internal/model"
)
@@ -147,7 +146,7 @@ func (s *LimitBoardService) loadLimitOpenCompareStocks(tradeDate, previousTradeD
(? <> '' AND NOT EXISTS (
SELECT 1
FROM stock_limit_combined_data ppl
WHERE ` + dbcompat.TsCodeEqual("ppl.ts_code", "l.ts_code") + ` AND ppl.trade_date = ? AND ppl.final_status = 1
WHERE ppl.ts_code = l.ts_code AND ppl.trade_date = ? AND ppl.final_status = 1
)) AS is_first_to_second_board,
COALESCE(l.calc_limit_price, 0) AS limit_price,
COALESCE(l.tushare_pct_chg, d.pct_chg, 0) AS pct_chg,
@@ -165,11 +164,11 @@ func (s *LimitBoardService) loadLimitOpenCompareStocks(tradeDate, previousTradeD
l.calc_vol AS vol
FROM stock_limit_combined_data l
LEFT JOIN stock_daily d
ON ` + dbcompat.TsCodeEqual("l.ts_code", "d.ts_code") + ` AND d.trade_date = l.trade_date
ON l.ts_code = d.ts_code AND d.trade_date = l.trade_date
LEFT JOIN stock_daily pd
ON ` + dbcompat.TsCodeEqual("l.ts_code", "pd.ts_code") + ` AND pd.trade_date = ?
ON l.ts_code = pd.ts_code AND pd.trade_date = ?
INNER JOIN stock_limit_combined_data pl
ON ` + dbcompat.TsCodeEqual("pl.ts_code", "l.ts_code") + ` AND pl.trade_date = ? AND pl.final_status = 1
ON pl.ts_code = l.ts_code AND pl.trade_date = ? AND pl.final_status = 1
WHERE l.trade_date = ? AND l.final_status = 1
ORDER BY d.pct_chg DESC, l.ts_code ASC`
@@ -3,7 +3,6 @@ package service
import (
"sort"
"level_go/internal/dbcompat"
"level_go/internal/model"
)
@@ -94,11 +93,11 @@ func (s *LimitBoardService) loadLimitOpenThresholdStocks(tradeDate, previousTrad
l.calc_vol AS vol
FROM stock_limit_combined_data l
LEFT JOIN stock_daily d
ON ` + dbcompat.TsCodeEqual("l.ts_code", "d.ts_code") + ` AND d.trade_date = l.trade_date
ON l.ts_code = d.ts_code AND d.trade_date = l.trade_date
LEFT JOIN stock_daily pd
ON ` + dbcompat.TsCodeEqual("l.ts_code", "pd.ts_code") + ` AND pd.trade_date = ?
ON l.ts_code = pd.ts_code AND pd.trade_date = ?
INNER JOIN stock_limit_combined_data pl
ON ` + dbcompat.TsCodeEqual("pl.ts_code", "l.ts_code") + ` AND pl.trade_date = ? AND pl.final_status = 1
ON pl.ts_code = l.ts_code AND pl.trade_date = ? AND pl.final_status = 1
WHERE l.trade_date = ? AND l.final_status = 1
ORDER BY d.pct_chg DESC, l.ts_code ASC`
@@ -3,7 +3,6 @@ package service
import (
"sort"
"level_go/internal/dbcompat"
"level_go/internal/model"
)
@@ -147,7 +146,7 @@ func (s *LimitBoardService) loadCurrentLimitStocks(tradeDate string) ([]model.Li
l.calc_vol AS vol
FROM stock_limit_combined_data l
LEFT JOIN stock_daily d
ON ` + dbcompat.TsCodeEqual("l.ts_code", "d.ts_code") + ` AND d.trade_date = l.trade_date
ON l.ts_code = d.ts_code AND d.trade_date = l.trade_date
WHERE l.trade_date = ? AND l.final_status = 1
ORDER BY d.pct_chg DESC, l.ts_code ASC`
@@ -0,0 +1,257 @@
package service // 声明 service 包,保持与涨停板服务层一致。
import ( // 引入本文件需要的标准库和项目包。
"strings" // 用于清理请求中的股票代码空格。
"level_go/internal/model" // 引入接口请求和响应模型。
)
const ( // 定义二板获利缓存接口使用的常量。
secondBoardProfitStartTime = "09:31:00" // 固定的二板首次涨停时间筛选起点。
secondBoardProfitEndTime = "09:59:59" // 固定的二板首次涨停时间筛选终点。
)
type secondBoardProfitCacheRange struct { // 表示明细缓存表当前覆盖的交易日期范围。
StartDate string `gorm:"column:start_date"` // 缓存明细最早交易日,格式 YYYY-MM-DD。
EndDate string `gorm:"column:end_date"` // 缓存明细最晚交易日,格式 YYYY-MM-DD。
}
type secondBoardProfitDetailCacheRow struct { // 表示从二板获利详情缓存表扫描出的单行数据。
TradeDate string `gorm:"column:trade_date"` // T 日交易日。
PreviousTradeDate string `gorm:"column:previous_trade_date"` // T-1 交易日。
PreviousTwoTradeDate string `gorm:"column:previous_two_trade_date"` // T-2 交易日。
NextTradeDate string `gorm:"column:next_trade_date"` // T+1 交易日。
Symbol string `gorm:"column:symbol"` // 股票代码。
TsCode string `gorm:"column:ts_code"` // TuShare 股票代码。
Name string `gorm:"column:name"` // 股票简称。
Market string `gorm:"column:market"` // 所属板块。
Industry string `gorm:"column:industry"` // 所属行业。
PreviousClose *float64 `gorm:"column:previous_close"` // T-1 收盘价。
LimitPrice *float64 `gorm:"column:limit_price"` // T 日涨停价。
THigh *float64 `gorm:"column:t_high"` // T 日最高价。
TClose *float64 `gorm:"column:t_close"` // T 日收盘价。
TPctChg *float64 `gorm:"column:t_pct_chg"` // T 日涨跌幅。
Status string `gorm:"column:status"` // 二板状态,limit 表示封板,break 表示炸板。
FirstLimitTime string `gorm:"column:first_limit_time"` // T 日二板首次涨停时间。
OpenTimes *int `gorm:"column:open_times"` // T 日开板次数。
NextHigh *float64 `gorm:"column:next_high"` // T+1 最高价。
NextClose *float64 `gorm:"column:next_close"` // T+1 收盘价。
NextIntradayProfitable *int `gorm:"column:next_intraday_profitable"` // T+1 盘中是否盈利,数据库中为 0/1/null。
NextHighProfitPct *float64 `gorm:"column:next_high_profit_pct"` // T+1 最大获利百分比。
}
// GetSecondBoardProfitSummary 返回二板获利综合数据。
func (s *LimitBoardService) GetSecondBoardProfitSummary(req model.SecondBoardProfitRequest) (model.SecondBoardProfitSummaryResponse, error) { // 定义二板获利综合服务方法。
resp := model.SecondBoardProfitSummaryResponse{ // 初始化响应结构。
TradeStartDate: req.TradeStartDate, // 回显查询起始交易日。
TradeEndDate: req.TradeEndDate, // 回显查询结束交易日。
StartTime: secondBoardProfitStartTime, // 回显固定时间筛选起点。
EndTime: secondBoardProfitEndTime, // 回显固定时间筛选终点。
List: []model.SecondBoardProfitSummaryItem{}, // 初始化空列表,避免 JSON 返回 null。
}
useSummaryCache, err := s.shouldUseSecondBoardProfitSummaryCache(req) // 判断当前日期范围是否可以直接使用汇总缓存表。
if err != nil { // 判断缓存范围查询是否失败。
return resp, err // 返回已初始化响应和数据库错误。
}
if useSummaryCache { // 请求范围覆盖整张明细缓存表时,直接读取预聚合汇总缓存表。
return s.loadSecondBoardProfitSummaryCache(resp) // 从 summary cache 加载结果并返回。
}
return s.loadSecondBoardProfitSummaryFromDetail(req, resp) // 窄日期范围从 detail cache 动态聚合,保持日期筛选语义。
}
// GetSecondBoardProfitDetail 返回单只股票二板获利详情数据。
func (s *LimitBoardService) GetSecondBoardProfitDetail(req model.SecondBoardProfitRequest) (model.SecondBoardProfitDetailResponse, error) { // 定义个股二板详情服务方法。
resp := model.SecondBoardProfitDetailResponse{ // 初始化响应结构。
TradeStartDate: req.TradeStartDate, // 回显查询起始交易日。
TradeEndDate: req.TradeEndDate, // 回显查询结束交易日。
StartTime: secondBoardProfitStartTime, // 回显固定时间筛选起点。
EndTime: secondBoardProfitEndTime, // 回显固定时间筛选终点。
Symbol: strings.TrimSpace(req.Symbol), // 回显请求中的股票代码。
TsCode: strings.TrimSpace(req.TsCode), // 回显请求中的 TuShare 股票代码。
List: []model.SecondCalStock{}, // 初始化空列表,避免 JSON 返回 null。
}
rows, err := s.loadSecondBoardProfitDetailRows(req) // 从详情缓存表读取个股明细行。
if err != nil { // 判断数据库查询是否失败。
return resp, err // 返回已初始化响应和数据库错误。
}
for _, row := range rows { // 遍历缓存明细行并构建响应。
stock := buildSecondBoardProfitStockFromCache(row) // 将缓存行转换为原接口复用的股票明细结构。
if resp.SecondBoardCount == 0 { // 第一条命中记录用于补齐个股基础信息。
resp.Symbol = stock.Symbol // 设置响应股票代码。
resp.TsCode = stock.TsCode // 设置响应 TuShare 股票代码。
resp.Name = stock.Name // 设置响应股票简称。
resp.Market = stock.Market // 设置响应所属板块。
resp.Industry = stock.Industry // 设置响应所属行业。
}
resp.SecondBoardCount++ // 累加命中的二板次数。
if stock.NextHighProfitPct != nil { // 只统计具备 T+1 最大获利百分比的数据。
resp.ProfitDataCount++ // 累加具备收益数据的次数。
resp.TotalNextHighProfitPct = roundSecondCalPct(resp.TotalNextHighProfitPct + *stock.NextHighProfitPct) // 累加最大获利百分比。
}
resp.List = append(resp.List, stock) // 追加单次二板详情。
}
if resp.ProfitDataCount > 0 { // 判断是否存在可计算平均值的数据。
resp.AverageNextHighProfitPct = roundSecondCalPct(resp.TotalNextHighProfitPct / float64(resp.ProfitDataCount)) // 计算平均最大获利百分比。
}
return resp, nil // 返回详情响应。
}
// shouldUseSecondBoardProfitSummaryCache 判断是否可以使用预聚合汇总缓存表。
func (s *LimitBoardService) shouldUseSecondBoardProfitSummaryCache(req model.SecondBoardProfitRequest) (bool, error) { // 定义汇总缓存使用判断函数。
cacheRange, err := s.loadSecondBoardProfitCacheRange() // 查询明细缓存表当前覆盖日期范围。
if err != nil { // 判断缓存范围查询是否失败。
return false, err // 查询失败时返回错误。
}
if cacheRange.StartDate == "" || cacheRange.EndDate == "" { // 判断缓存表是否为空。
return false, nil // 缓存为空时不能使用汇总缓存。
}
return req.TradeStartDate <= cacheRange.StartDate && req.TradeEndDate >= cacheRange.EndDate, nil // 请求覆盖缓存全范围时使用汇总缓存。
}
// loadSecondBoardProfitCacheRange 查询二板获利详情缓存表的日期覆盖范围。
func (s *LimitBoardService) loadSecondBoardProfitCacheRange() (secondBoardProfitCacheRange, error) { // 定义缓存日期范围查询函数。
// 定义查询缓存范围 SQL。
query := `
SELECT
COALESCE(DATE_FORMAT(MIN(trade_date), '%Y-%m-%d'), '') AS start_date,
COALESCE(DATE_FORMAT(MAX(trade_date), '%Y-%m-%d'), '') AS end_date
FROM second_board_profit_detail_cache` // 从明细缓存表读取最小和最大交易日。
var cacheRange secondBoardProfitCacheRange // 声明缓存范围结果变量。
err := s.DB.Raw(query).Scan(&cacheRange).Error // 执行 SQL 并扫描结果。
return cacheRange, err // 返回缓存范围和可能的错误。
}
// loadSecondBoardProfitSummaryCache 从预聚合汇总缓存表读取完整汇总数据。
func (s *LimitBoardService) loadSecondBoardProfitSummaryCache(resp model.SecondBoardProfitSummaryResponse) (model.SecondBoardProfitSummaryResponse, error) { // 定义汇总缓存读取函数。
// 定义汇总缓存查询 SQL。
query := `
SELECT
symbol,
ts_code,
name,
market,
industry,
second_board_count,
limit_count,
break_count,
profit_data_count,
total_next_high_profit_pct,
average_next_high_profit_pct,
COALESCE(DATE_FORMAT(first_trade_date, '%Y-%m-%d'), '') AS first_trade_date,
COALESCE(DATE_FORMAT(last_trade_date, '%Y-%m-%d'), '') AS last_trade_date
FROM second_board_profit_summary_cache
ORDER BY total_next_high_profit_pct DESC, profit_data_count DESC, ts_code ASC` // 按累计获利、数据次数和股票代码稳定排序。
if err := s.DB.Raw(query).Scan(&resp.List).Error; err != nil { // 执行汇总缓存查询。
return resp, err // 查询失败时返回错误。
}
resp.Total = len(resp.List) // 设置返回股票数量。
return resp, nil // 返回汇总响应。
}
// loadSecondBoardProfitSummaryFromDetail 从明细缓存表按日期范围动态聚合汇总数据。
func (s *LimitBoardService) loadSecondBoardProfitSummaryFromDetail(req model.SecondBoardProfitRequest, resp model.SecondBoardProfitSummaryResponse) (model.SecondBoardProfitSummaryResponse, error) { // 定义明细聚合函数。
// 定义日期范围聚合 SQL。
query := `
SELECT
MAX(symbol) AS symbol,
ts_code,
MAX(name) AS name,
MAX(market) AS market,
MAX(industry) AS industry,
COUNT(*) AS second_board_count,
SUM(CASE WHEN status = 'limit' THEN 1 ELSE 0 END) AS limit_count,
SUM(CASE WHEN status = 'break' THEN 1 ELSE 0 END) AS break_count,
COUNT(next_high_profit_pct) AS profit_data_count,
ROUND(SUM(COALESCE(next_high_profit_pct, 0)), 2) AS total_next_high_profit_pct,
COALESCE(ROUND(SUM(COALESCE(next_high_profit_pct, 0)) / NULLIF(COUNT(next_high_profit_pct), 0), 2), 0) AS average_next_high_profit_pct,
COALESCE(DATE_FORMAT(MIN(trade_date), '%Y-%m-%d'), '') AS first_trade_date,
COALESCE(DATE_FORMAT(MAX(trade_date), '%Y-%m-%d'), '') AS last_trade_date
FROM second_board_profit_detail_cache
WHERE trade_date BETWEEN ? AND ?
GROUP BY ts_code
ORDER BY total_next_high_profit_pct DESC, profit_data_count DESC, ts_code ASC` // 在明细缓存表内按请求日期范围聚合。
if err := s.DB.Raw(query, req.TradeStartDate, req.TradeEndDate).Scan(&resp.List).Error; err != nil { // 执行聚合查询。
return resp, err // 查询失败时返回错误。
}
resp.Total = len(resp.List) // 设置返回股票数量。
return resp, nil // 返回汇总响应。
}
// loadSecondBoardProfitDetailRows 从明细缓存表读取单只股票详情。
func (s *LimitBoardService) loadSecondBoardProfitDetailRows(req model.SecondBoardProfitRequest) ([]secondBoardProfitDetailCacheRow, error) { // 定义明细缓存查询函数。
tsCode := strings.TrimSpace(req.TsCode) // 清理 TuShare 股票代码参数。
symbol := strings.TrimSpace(req.Symbol) // 清理市场股票代码参数。
// 定义明细缓存查询 SQL。
query := `
SELECT
DATE_FORMAT(trade_date, '%Y-%m-%d') AS trade_date,
DATE_FORMAT(previous_trade_date, '%Y-%m-%d') AS previous_trade_date,
DATE_FORMAT(previous_two_trade_date, '%Y-%m-%d') AS previous_two_trade_date,
COALESCE(DATE_FORMAT(next_trade_date, '%Y-%m-%d'), '') AS next_trade_date,
symbol,
ts_code,
name,
market,
industry,
previous_close,
limit_price,
t_high,
t_close,
t_pct_chg,
status,
first_limit_time,
open_times,
next_high,
next_close,
next_intraday_profitable,
next_high_profit_pct
FROM second_board_profit_detail_cache
WHERE trade_date BETWEEN ? AND ?` // 先按日期范围过滤明细缓存。
args := []interface{}{req.TradeStartDate, req.TradeEndDate} // 初始化 SQL 参数为日期范围。
if tsCode != "" { // 优先按 TuShare 股票代码查询。
query += " AND ts_code = ?" // 追加 TuShare 股票代码条件。
args = append(args, tsCode) // 追加 TuShare 股票代码参数。
} else { // 未传 TuShare 股票代码时使用市场股票代码。
query += " AND symbol = ?" // 追加市场股票代码条件。
args = append(args, symbol) // 追加市场股票代码参数。
}
query += " ORDER BY trade_date ASC, ts_code ASC" // 追加稳定排序条件。
var rows []secondBoardProfitDetailCacheRow // 声明明细缓存扫描结果。
err := s.DB.Raw(query, args...).Scan(&rows).Error // 执行 SQL 并扫描结果。
return rows, err // 返回明细行和可能的错误。
}
// buildSecondBoardProfitStockFromCache 将缓存行转换为接口复用的二板股票明细模型。
func buildSecondBoardProfitStockFromCache(row secondBoardProfitDetailCacheRow) model.SecondCalStock { // 定义缓存行到响应模型的转换函数。
item := model.SecondCalStock{ // 初始化二板股票明细。
TradeDate: row.TradeDate, // 设置 T 日交易日。
PreviousTradeDate: row.PreviousTradeDate, // 设置 T-1 交易日。
PreviousTwoTradeDate: row.PreviousTwoTradeDate, // 设置 T-2 交易日。
NextTradeDate: row.NextTradeDate, // 设置 T+1 交易日。
Symbol: row.Symbol, // 设置股票代码。
TsCode: row.TsCode, // 设置 TuShare 股票代码。
Name: row.Name, // 设置股票简称。
Market: row.Market, // 设置所属板块。
Industry: row.Industry, // 设置所属行业。
PreviousClose: row.PreviousClose, // 设置 T-1 收盘价。
LimitPrice: row.LimitPrice, // 设置 T 日涨停价。
THigh: row.THigh, // 设置 T 日最高价。
TClose: row.TClose, // 设置 T 日收盘价。
TPctChg: row.TPctChg, // 设置 T 日涨跌幅。
Status: row.Status, // 设置二板状态。
FirstLimitTime: row.FirstLimitTime, // 设置首次涨停时间。
OpenTimes: row.OpenTimes, // 设置开板次数。
NextHigh: row.NextHigh, // 设置 T+1 最高价。
NextClose: row.NextClose, // 设置 T+1 收盘价。
NextHighProfitPct: row.NextHighProfitPct, // 设置 T+1 最大获利百分比。
}
if row.NextIntradayProfitable != nil { // 判断缓存中是否存在盈利布尔数据。
nextIntradayProfitable := *row.NextIntradayProfitable != 0 // 将 0/1 转换为 bool。
item.NextIntradayProfitable = &nextIntradayProfitable // 设置 T+1 盘中是否盈利。
}
return item // 返回转换后的股票明细。
}
+55 -33
View File
@@ -2,8 +2,9 @@ package service
import (
"math"
"sort"
"strings"
"level_go/internal/dbcompat"
"level_go/internal/model"
)
@@ -58,6 +59,7 @@ func (s *LimitBoardService) GetSecondCal(req model.SecondCalRequest) (model.Seco
PreviousTwoTradeDate: day.PreviousTwoTradeDate,
NextTradeDate: day.NextTradeDate,
Stocks: []model.SecondCalStock{},
TotalStocks: []model.SecondCalStock{},
})
}
@@ -88,19 +90,29 @@ func (s *LimitBoardService) GetSecondCal(req model.SecondCalRequest) (model.Seco
// loadSecondCalDays 查询日期范围内的开市交易日和相邻交易日。
func (s *LimitBoardService) loadSecondCalDays(startDate, endDate string) ([]secondCalDaySeed, map[string]int, error) {
seeds, err := dbcompat.LoadSSEOpenCalendarSeeds(s.DB, startDate, endDate)
if err != nil {
query := `
WITH calendar AS (
SELECT
cal_date AS trade_date,
LAG(cal_date) OVER (ORDER BY cal_date) AS previous_trade_date,
LAG(cal_date, 2) OVER (ORDER BY cal_date) AS previous_two_trade_date,
LEAD(cal_date) OVER (ORDER BY cal_date) AS next_trade_date
FROM trade_calendar
WHERE exchange = 'SSE' AND is_open = 1
)
SELECT
DATE_FORMAT(trade_date, '%Y-%m-%d') AS trade_date,
COALESCE(DATE_FORMAT(previous_trade_date, '%Y-%m-%d'), '') AS previous_trade_date,
COALESCE(DATE_FORMAT(previous_two_trade_date, '%Y-%m-%d'), '') AS previous_two_trade_date,
COALESCE(DATE_FORMAT(next_trade_date, '%Y-%m-%d'), '') AS next_trade_date
FROM calendar
WHERE trade_date BETWEEN ? AND ?
ORDER BY trade_date ASC`
var days []secondCalDaySeed
if err := s.DB.Raw(query, startDate, endDate).Scan(&days).Error; err != nil {
return nil, nil, err
}
days := make([]secondCalDaySeed, 0, len(seeds))
for _, seed := range seeds {
days = append(days, secondCalDaySeed{
TradeDate: seed.TradeDate,
PreviousTradeDate: seed.PreviousTradeDate,
PreviousTwoTradeDate: seed.PreviousTwoTradeDate,
NextTradeDate: seed.NextTradeDate,
})
}
// 构建交易日到响应数组下标的映射,后续聚合明细时可以 O(1) 定位。
dayIndex := make(map[string]int, len(days))
@@ -112,20 +124,21 @@ func (s *LimitBoardService) loadSecondCalDays(startDate, endDate string) ([]seco
// loadSecondCalRows 查询 T-1 涨停、T-2 未涨停、T 日再次触及涨停价的二板股票。
func (s *LimitBoardService) loadSecondCalRows(startDate, endDate string) ([]secondCalRow, error) {
seeds, err := dbcompat.LoadSSEOpenCalendarSeeds(s.DB, startDate, endDate)
if err != nil {
return nil, err
}
if len(seeds) == 0 {
return []secondCalRow{}, nil
}
calendarTable, calendarArgs := dbcompat.CalendarSeedTable(seeds)
stockDailyTsCodeCollation, err := dbcompat.ColumnCollation(s.DB, "stock_daily", "ts_code")
if err != nil {
return nil, err
}
query := `
WITH calendar AS (
SELECT
cal_date AS trade_date,
LAG(cal_date) OVER (ORDER BY cal_date) AS previous_trade_date,
LAG(cal_date, 2) OVER (ORDER BY cal_date) AS previous_two_trade_date,
LEAD(cal_date) OVER (ORDER BY cal_date) AS next_trade_date
FROM trade_calendar
WHERE exchange = 'SSE' AND is_open = 1
),
calendar_window AS (
SELECT *
FROM calendar
WHERE trade_date BETWEEN ? AND ?
)
SELECT
DATE_FORMAT(trade_date, '%Y-%m-%d') AS trade_date,
DATE_FORMAT(previous_trade_date, '%Y-%m-%d') AS previous_trade_date,
@@ -190,21 +203,21 @@ FROM (
END AS open_times,
nd.high AS next_high,
nd.close AS next_close
FROM (` + calendarTable + `) c
FROM calendar_window c
INNER JOIN limit_list_d pl
ON pl.trade_date = c.previous_trade_date AND pl.` + "`limit`" + ` = 'U'
LEFT JOIN limit_list_d ppl
ON ` + dbcompat.TsCodeEqual("ppl.ts_code", "pl.ts_code") + ` AND ppl.trade_date = c.previous_two_trade_date AND ppl.` + "`limit`" + ` = 'U'
ON ppl.ts_code = pl.ts_code AND ppl.trade_date = c.previous_two_trade_date AND ppl.` + "`limit`" + ` = 'U'
INNER JOIN stock_daily d
ON ` + dbcompat.TsCodeEqualRightCollation("d.ts_code", "pl.ts_code", stockDailyTsCodeCollation) + ` AND d.trade_date = c.trade_date
ON d.ts_code = pl.ts_code AND d.trade_date = c.trade_date
LEFT JOIN stock_daily nd
ON ` + dbcompat.TsCodeEqualRightCollation("nd.ts_code", "pl.ts_code", stockDailyTsCodeCollation) + ` AND nd.trade_date = c.next_trade_date
ON nd.ts_code = pl.ts_code AND nd.trade_date = c.next_trade_date
LEFT JOIN limit_list_d tlu
ON ` + dbcompat.TsCodeEqual("tlu.ts_code", "pl.ts_code") + ` AND tlu.trade_date = c.trade_date AND tlu.` + "`limit`" + ` = 'U'
ON tlu.ts_code = pl.ts_code AND tlu.trade_date = c.trade_date AND tlu.` + "`limit`" + ` = 'U'
LEFT JOIN limit_list_d tlz
ON ` + dbcompat.TsCodeEqual("tlz.ts_code", "pl.ts_code") + ` AND tlz.trade_date = c.trade_date AND tlz.` + "`limit`" + ` = 'Z'
ON tlz.ts_code = pl.ts_code AND tlz.trade_date = c.trade_date AND tlz.` + "`limit`" + ` = 'Z'
LEFT JOIN stock_basic sb
ON ` + dbcompat.TsCodeEqual("sb.ts_code", "pl.ts_code") + `
ON sb.ts_code = pl.ts_code
WHERE c.previous_trade_date IS NOT NULL
AND c.previous_two_trade_date IS NOT NULL
AND ppl.ts_code IS NULL
@@ -219,7 +232,7 @@ WHERE previous_close IS NOT NULL
ORDER BY trade_date ASC, ts_code ASC`
var rows []secondCalRow
err = s.DB.Raw(query, calendarArgs...).Scan(&rows).Error
err := s.DB.Raw(query, startDate, endDate).Scan(&rows).Error
return rows, err
}
@@ -288,10 +301,19 @@ func accumulateSecondCalDay(day *model.SecondCalDay, item model.SecondCalStock)
}
}
day.Stocks = append(day.Stocks, item)
if strings.TrimSpace(item.FirstLimitTime) != "" {
day.TotalStocks = append(day.TotalStocks, item)
}
}
// finalizeSecondCalDay 计算单个交易日的概率字段。
func finalizeSecondCalDay(day *model.SecondCalDay) {
sort.SliceStable(day.TotalStocks, func(i, j int) bool {
if day.TotalStocks[i].FirstLimitTime == day.TotalStocks[j].FirstLimitTime {
return day.TotalStocks[i].TsCode < day.TotalStocks[j].TsCode
}
return day.TotalStocks[i].FirstLimitTime < day.TotalStocks[j].FirstLimitTime
})
day.NextIntradayProfitRate = secondCalRate(day.NextIntradayProfitCount, day.NextTradeDataCount)
day.LimitNextProfitRate = secondCalRate(day.LimitNextProfitCount, day.LimitNextDataCount)
day.BreakNextProfitRate = secondCalRate(day.BreakNextProfitCount, day.BreakNextDataCount)
+1 -2
View File
@@ -5,7 +5,6 @@ import (
"sort"
"gorm.io/gorm"
"level_go/internal/dbcompat"
"level_go/internal/model"
)
@@ -46,7 +45,7 @@ func (s *StockService) AnalyzePerformance(startDate, endDate string) ([]model.An
// 查询 startDate 当日涨停股,并优先使用涨停合并表中的计算最高价。
query := `SELECT l.ts_code, l.name, d.open, COALESCE(l.calc_high, d.high, 0) AS high FROM stock_limit_combined_data l
LEFT JOIN stock_daily d ON ` + dbcompat.TsCodeEqual("l.ts_code", "d.ts_code") + ` AND l.trade_date = d.trade_date
LEFT JOIN stock_daily d ON l.ts_code = d.ts_code AND l.trade_date = d.trade_date
WHERE l.trade_date = ? AND l.final_status = 1`
if err := s.DB.Raw(query, startDate).Scan(&limits).Error; err != nil {
-159
View File
@@ -1,159 +0,0 @@
macbook pro 本地
-- auto-generated definition
create table stock_basic
(
ts_code text null,
symbol text null,
name text null,
area text null,
industry text null,
fullname text null,
enname text null,
cnspell text null,
market text null,
exchange text null,
curr_type text null,
list_status text null,
list_date text null,
delist_date text null,
is_hs text null,
act_name text null,
act_ent_type text null
);
-- auto-generated definition
create table stock_daily
(
id bigint auto_increment
primary key,
ts_code varchar(20) not null,
symbol varchar(10) not null,
trade_date date not null,
open decimal(16, 4) null,
high decimal(16, 4) null,
low decimal(16, 4) null,
close decimal(16, 4) null,
pre_close decimal(16, 4) null,
change_val decimal(16, 4) null,
pct_chg decimal(10, 4) null,
vol bigint null,
amount bigint null,
constraint uniq_code_date
unique (ts_code, trade_date)
)
charset = utf8mb4;
-- auto-generated definition
create table stock_limit_combined_data
(
id bigint auto_increment
primary key,
trade_date date not null comment '交易日期',
symbol varchar(10) not null comment '股票代码,以stock_daily为准',
ts_code varchar(20) not null comment 'TS股票代码,以stock_daily为准',
name varchar(50) not null comment '股票名称,以stock_daily关联基础表为准',
final_status tinyint not null comment '融合状态:1-涨停,2-炸板',
source_count int default 0 not null comment '命中的数据源数量',
source_flags varchar(50) null comment '命中的数据源:calc,tushare',
calc_status tinyint null comment '本地行情计算状态:1-涨停,2-炸板',
calc_limit_price decimal(16, 4) null comment '本地行情计算涨停价',
calc_high decimal(16, 4) null comment 'stock_daily最高价',
calc_close decimal(16, 4) null comment 'stock_daily收盘价',
calc_pre_close decimal(16, 4) null comment 'stock_daily前收盘价',
calc_vol bigint null comment 'stock_daily成交量',
market varchar(20) null comment '所属板块',
tushare_limit_type varchar(1) null comment 'Tushare-D跌停U涨停Z炸板',
tushare_industry varchar(100) null comment 'Tushare-所属行业',
tushare_name varchar(50) null comment 'Tushare-名称',
tushare_close decimal(16, 4) null comment 'Tushare-收盘价',
tushare_pct_chg decimal(10, 4) null comment 'Tushare-涨跌幅',
tushare_amount decimal(24, 4) null comment 'Tushare-成交额',
tushare_limit_amount decimal(24, 4) null comment 'Tushare-板上成交金额',
tushare_float_mv decimal(24, 4) null comment 'Tushare-流通市值',
tushare_total_mv decimal(24, 4) null comment 'Tushare-总市值',
tushare_turnover_ratio decimal(10, 4) null comment 'Tushare-换手率',
tushare_fd_amount decimal(24, 4) null comment 'Tushare-封单金额',
tushare_first_time varchar(20) null comment 'Tushare-首次封板时间',
tushare_last_time varchar(20) null comment 'Tushare-最后封板时间',
tushare_open_times int null comment 'Tushare-炸板/开板次数',
tushare_up_stat varchar(50) null comment 'Tushare-涨停统计',
tushare_limit_times int null comment 'Tushare-连板数',
constraint uniq_date_code
unique (trade_date, ts_code)
);
create index idx_symbol
on stock_limit_combined_data (symbol);
create index idx_trade_date
on stock_limit_combined_data (trade_date);
create index idx_trade_date_status
on stock_limit_combined_data (trade_date, final_status);
-- auto-generated definition
create table trade_calendar
(
cal_date date not null,
exchange varchar(10) not null,
is_open tinyint null,
pretrade_date date null,
primary key (cal_date, exchange)
)
charset = utf8mb4;
阿里云
-- auto-generated definition
create table stock_basic
(
ts_code text null,
symbol text null,
name text null,
area text null,
industry text null,
fullname text null,
enname text null,
cnspell text null,
market text null,
exchange text null,
curr_type text null,
list_status text null,
list_date text null,
delist_date text null,
is_hs text null,
act_name text null,
act_ent_type text null
);
-- auto-generated definition
create table stock_daily
(
id bigint auto_increment
primary key,
ts_code varchar(20) not null,
symbol varchar(10) not null,
trade_date date not null,
open decimal(16, 4) null,
high decimal(16, 4) null,
low decimal(16, 4) null,
close decimal(16, 4) null,
pre_close decimal(16, 4) null,
change_val decimal(16, 4) null,
pct_chg decimal(10, 4) null,
vol bigint null,
amount bigint null,
constraint uniq_code_date
unique (ts_code, trade_date)
);
-- auto-generated definition
create table trade_calendar
(
cal_date date not null,
exchange varchar(10) not null,
is_open tinyint null,
pretrade_date date null,
primary key (cal_date, exchange)
)
charset = utf8mb4;