Compare commits

...

15 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
yang 61cc29d7b3 适配mysql 2026-05-13 17:46:03 +08:00
yang 81dc55baaf 11点涨停逻辑变更为10点 2026-05-12 17:23:53 +08:00
yang faf535d889 Merge pull request '新增首页' (#5) from main into master
Reviewed-on: #5
2026-05-12 00:38:23 +08:00
yang d4bb849f79 新增首页 2026-05-12 00:36:25 +08:00
yang 8504838ccb 新增注释 2026-05-09 17:50:40 +08:00
yang d5c053e59d 1、新增二板炸板情况统计;
2、更新项目结构;
2026-05-09 16:59:45 +08:00
yang 9ea4b61ee3 新增T-1日开盘涨跌幅字段 2026-05-07 17:26:37 +08:00
yang 844981a312 优化sql 2026-05-07 14:32:48 +08:00
yang 373f7553cd Merge pull request '接口优化' (#4) from main into master
Reviewed-on: #4
2026-05-07 00:25:15 +08:00
32 changed files with 3245 additions and 774 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` 导出。
+15 -5
View File
@@ -1,27 +1,37 @@
package main
import (
"github.com/gin-gonic/gin"
"gorm.io/driver/mysql"
"gorm.io/gorm"
"level_go/internal/api"
"level_go/internal/router"
"level_go/internal/service"
"level_go/pkg/config" // 引入配置包
"level_go/pkg/config"
"log"
)
// main 是服务启动入口,负责完成配置加载、数据库连接、依赖组装和 HTTP 路由启动。
func main() {
// 1. 初始化配置
gin.SetMode(gin.ReleaseMode)
// 初始化配置文件,后续数据库 DSN 和服务端口都从全局配置中读取。
config.InitConfig()
// 2. 使用配置中的 DSN 连接数据库
// 使用配置中的 MySQL DSN 建立 GORM 连接;连接失败时直接终止进程,避免服务在不可用状态下启动。
dsn := config.GetDSN()
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
if err != nil {
log.Fatal("数据库连接失败:", err)
}
sqlDB, err := db.DB()
if err != nil {
log.Fatal("获取数据库连接池失败:", err)
}
sqlDB.SetMaxIdleConns(20)
sqlDB.SetMaxOpenConns(100)
// 3. 依赖注入
// 依赖注入:Service 持有数据库连接,Handler 持有对应 Service。
commonSrv := &service.CommonService{DB: db}
stockSrv := &service.StockService{DB: db}
limitBoardSrv := &service.LimitBoardService{DB: db}
@@ -30,7 +40,7 @@ func main() {
stockHandler := &api.StockHandler{Service: stockSrv}
limitBoardHandler := api.NewLimitBoardHandler(limitBoardSrv)
// 4. 启动路由
// 创建 Gin 路由并启动 HTTP 服务。
r := router.NewRouter(commonHandler, stockHandler, limitBoardHandler)
port := config.GetServerPort()
-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`);
+116
View File
@@ -0,0 +1,116 @@
-- 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,
`trade_date` date NOT NULL COMMENT '交易日期',
`ts_code` varchar(20) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '股票代码',
`industry` varchar(100) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '所属行业',
`name` varchar(50) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '股票名称',
`close` decimal(16,4) DEFAULT NULL COMMENT '收盘价',
`pct_chg` decimal(10,4) DEFAULT NULL COMMENT '涨跌幅',
`amount` decimal(24,4) DEFAULT NULL COMMENT '成交额',
`limit_amount` decimal(24,4) DEFAULT NULL COMMENT '板上成交金额',
`float_mv` decimal(24,4) DEFAULT NULL COMMENT '流通市值',
`total_mv` decimal(24,4) DEFAULT NULL COMMENT '总市值',
`turnover_ratio` decimal(10,4) DEFAULT NULL COMMENT '换手率',
`fd_amount` decimal(24,4) DEFAULT NULL COMMENT '封单金额',
`first_time` varchar(20) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '首次封板时间',
`last_time` varchar(20) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '最后封板时间',
`open_times` int DEFAULT NULL COMMENT '打开次数',
`up_stat` varchar(50) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '涨停统计',
`limit_times` int DEFAULT NULL COMMENT '连板数',
`limit` varchar(1) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'D跌停 U涨停 Z炸板',
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_trade_code_limit` (`trade_date`,`ts_code`,`limit`),
KEY `idx_trade_date` (`trade_date`),
KEY `idx_ts_code` (`ts_code`),
KEY `idx_limit` (`limit`)
) 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) 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 '命中的数据源数量',
`source_flags` varchar(50) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '命中的数据源:calc,tushare',
`calc_status` tinyint DEFAULT NULL COMMENT '本地行情计算状态:1-涨停,2-炸板',
`calc_limit_price` decimal(16,4) DEFAULT NULL COMMENT '本地行情计算涨停价',
`calc_high` decimal(16,4) DEFAULT NULL COMMENT 'stock_daily最高价',
`calc_close` decimal(16,4) DEFAULT NULL COMMENT 'stock_daily收盘价',
`calc_pre_close` decimal(16,4) DEFAULT NULL COMMENT 'stock_daily前收盘价',
`calc_vol` bigint DEFAULT NULL COMMENT 'stock_daily成交量',
`market` varchar(20) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '所属板块',
`tushare_limit_type` varchar(1) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'Tushare-D跌停U涨停Z炸板',
`tushare_industry` varchar(100) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'Tushare-所属行业',
`tushare_name` varchar(50) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'Tushare-名称',
`tushare_close` decimal(16,4) DEFAULT NULL COMMENT 'Tushare-收盘价',
`tushare_pct_chg` decimal(10,4) DEFAULT NULL COMMENT 'Tushare-涨跌幅',
`tushare_amount` decimal(24,4) DEFAULT NULL COMMENT 'Tushare-成交额',
`tushare_limit_amount` decimal(24,4) DEFAULT NULL COMMENT 'Tushare-板上成交金额',
`tushare_float_mv` decimal(24,4) DEFAULT NULL COMMENT 'Tushare-流通市值',
`tushare_total_mv` decimal(24,4) DEFAULT NULL COMMENT 'Tushare-总市值',
`tushare_turnover_ratio` decimal(10,4) DEFAULT NULL COMMENT 'Tushare-换手率',
`tushare_fd_amount` decimal(24,4) DEFAULT NULL COMMENT 'Tushare-封单金额',
`tushare_first_time` varchar(20) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'Tushare-首次封板时间',
`tushare_last_time` varchar(20) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'Tushare-最后封板时间',
`tushare_open_times` int DEFAULT NULL COMMENT 'Tushare-炸板/开板次数',
`tushare_up_stat` varchar(50) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'Tushare-涨停统计',
`tushare_limit_times` int DEFAULT NULL COMMENT 'Tushare-连板数',
PRIMARY KEY (`id`),
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`),
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;
+6 -1
View File
@@ -4,7 +4,7 @@
- 服务前缀:`/api/v1`
- 响应格式:`application/json`
- 当前项目接口总数:8
- 当前项目接口总数:13
## 2. 接口列表
@@ -17,7 +17,12 @@
| 涨停低开分组 | `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) |
| 下载 | `GET` | `/api/v1/download/limit-board/break-next-performance-csv` | 下载筛选条件范围内的炸板次日表现 CSV 明细 | [limit-board-break-next-performance.md](./limit-board-break-next-performance.md) |
## 3. 错误响应约定
+6
View File
@@ -95,7 +95,9 @@ GET /api/v1/download/limit-board/second-board-profit-csv?tradeStartDate=2026-04-
| `name` | `string` | `股票简称` | 股票简称 |
| `market` | `string` | `市场` | 市场信息 |
| `industry` | `string` | `行业` | 所属行业 |
| `previous_open_pct_chg` | `number \| null` | `T-1日开盘涨跌幅%` | T-1 日开盘价相对 T-2 收盘价的涨跌幅百分比 |
| `open_pct_chg` | `number \| null` | `T日开盘涨跌幅%` | T 日开盘价相对 T-1 收盘价的涨跌幅百分比 |
| `next_open_pct_chg` | `number \| null` | `T+1日开盘涨跌幅%` | T+1 日开盘价相对 T 日收盘价的涨跌幅百分比 |
| `next_open_profitable` | `string` | `T+1开盘是否盈利` | T+1 开盘收益大于 0 时为 `是`,否则为 `否`;无次日数据时为 `无次日数据` |
| `next_close_profitable` | `string` | `T+1收盘是否盈利` | T+1 收盘收益大于 0 时为 `是`,否则为 `否`;无次日数据时为 `无次日数据` |
| `next_intraday_profitable` | `string` | `T+1盘中是否可盈利` | T+1 最高收益大于 0 时为 `是`,否则为 `否`;无次日数据时为 `无次日数据` |
@@ -132,7 +134,9 @@ GET /api/v1/download/limit-board/second-board-profit-csv?tradeStartDate=2026-04-
"name": "示例股份",
"market": "主板",
"industry": "软件开发",
"previous_open_pct_chg": -1.25,
"open_pct_chg": 1.36,
"next_open_pct_chg": 2.38,
"next_open_profitable": "是",
"next_close_profitable": "否",
"next_intraday_profitable": "是",
@@ -164,7 +168,9 @@ CSV 字段顺序:
| `股票简称` | 股票简称 |
| `市场` | 市场信息 |
| `行业` | 所属行业 |
| `T-1日开盘涨跌幅%` | T-1 日开盘价相对 T-2 收盘价的涨跌幅百分比 |
| `T日开盘涨跌幅%` | T 日开盘价相对 T-1 收盘价的涨跌幅百分比 |
| `T+1日开盘涨跌幅%` | T+1 日开盘价相对 T 日收盘价的涨跌幅百分比 |
| `T+1开盘是否盈利` | T+1 开盘收益大于 0 时为 `是`,否则为 `否`;无次日数据时为 `无次日数据` |
| `T+1收盘是否盈利` | T+1 收盘收益大于 0 时为 `是`,否则为 `否`;无次日数据时为 `无次日数据` |
| `T+1盘中是否可盈利` | T+1 最高收益大于 0 时为 `是`,否则为 `否`;无次日数据时为 `无次日数据` |
+160
View File
@@ -0,0 +1,160 @@
# 炸板次日表现接口文档
## 1. 基本信息
- 接口名称:炸板次日表现
- 请求方法:`GET`
- 请求路径:`/api/v1/limit-board/break-next-performance`
- 接口用途:按 T 日范围查询 T-2 未涨停、T-1 涨停、T 日触及涨停价但收盘未封住的炸板股票,并返回 T+1 交易日表现。
- 导出路径:`/api/v1/download/limit-board/break-next-performance-csv`
## 2. 请求参数
| 参数 | 类型 | 必填 | 默认值 | 说明 |
| --- | --- | --- | --- | --- |
| `tradeStartDate` | `string` | 是 | 无 | T 日起始日期,格式 `YYYY-MM-DD` |
| `tradeEndDate` | `string` | 是 | 无 | T 日结束日期,格式 `YYYY-MM-DD` |
| `page` | `int` | 否 | `1` | 页码 |
| `pageSize` | `int` | 否 | `20` | 每页数量 |
| `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`、`trade_start_date`、`trade_end_date` 作为日期参数别名。
示例:
```http
GET /api/v1/limit-board/break-next-performance?tradeStartDate=2026-05-07&tradeEndDate=2026-05-07&tOpenGainLt101=true&breakTimeBefore1100=true&page=1&pageSize=20
```
导出示例:
```http
GET /api/v1/download/limit-board/break-next-performance-csv?tradeStartDate=2026-05-07&tradeEndDate=2026-05-07&tOpenGainLt101=true&breakTimeBefore1100=true
```
## 3. 统计规则
- 以 `trade_calendar` 中 `exchange = 'SSE'` 且 `is_open = 1` 的记录推导 T-2、T-1、T、T+1 交易日。
- 样本池来自 `stock_limit_combined_data`:T 日必须为炸板记录,即 `final_status = 2`;T-1 必须为涨停记录,即 `final_status = 1`;T-2 同一股票不能存在 `final_status = 1`。
- T 日和 T+1 日 OHLC 来自 `stock_daily`。
- 股票板块来自 `stock_limit_combined_data.market`。
- T 日开盘涨幅以 T-1 收盘价为基准计算。
- 如果 T 日有 Tushare 炸板记录,则返回具体的 Tushare 炸板时间 `tushare_first_time` 并格式化为 `HH:MM:SS`;无 Tushare 炸板记录或无具体时间时 `first_time` 为空。
- T 日炸板时间为空时,`break_time_before_1100` 默认返回 `true`。
- T+1 开盘涨幅、最高获利、最大亏损、收盘涨跌幅均以 T 日收盘价为基准计算。
- T+1 盘中是否可以获利表示:T+1 盘中最高价是否能弥补 T 日最高价买入后到 T 日收盘价的差价,按 `next_high >= t_high` 判断;缺少 T+1 最高价、T 日最高价或 T 日收盘价时返回 `null`。
## 4. 响应字段
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `trade_start_date` | `string` | 查询起始 T 日 |
| `trade_end_date` | `string` | 查询结束 T 日 |
| `page` | `int` | 当前页 |
| `page_size` | `int` | 每页数量 |
| `total` | `int` | 命中总数 |
| `total_pages` | `int` | 总页数 |
| `summary.break_count` | `int` | T 日炸板股票总数 |
| `summary.next_trade_data_count` | `int` | 有 T+1 行情数据的股票数 |
| `list` | `array` | 明细列表 |
明细核心字段:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `trade_date` | `string` | T 日 |
| `ts_code` / `symbol` / `name` | `string` | 股票标识 |
| `market` / `industry` | `string` | 板块和行业 |
| `t_open` / `t_high` / `t_close` | `number \| null` | T 日行情 |
| `t_open_pct_chg` | `number \| null` | T 日开盘涨幅,基于 T-1 收盘价 |
| `t_open_gain_lt_101` | `boolean` | T 日开盘价是否小于 T-1 收盘价的 101% |
| `first_time` | `string` | T 日 Tushare 炸板时间,格式化为 `HH:MM:SS`,无 Tushare 炸板记录或无具体时间时为空 |
| `break_time_before_1100` | `boolean` | T 日炸板时间是否在 `11:00:00` 前;空炸板时间默认为 `true` |
| `has_tushare_break_data` | `boolean` | T 日是否存在 Tushare 炸板记录,即 `tushare_limit_type = 'Z'` |
| `next_open` / `next_high` / `next_low` / `next_close` | `number \| null` | T+1 行情 |
| `next_open_pct_chg` | `number \| null` | T+1 开盘涨幅,基于 T 日收盘价 |
| `next_intraday_profitable` | `boolean \| null` | T+1 盘中是否可以获利,按 T+1 最高价是否能弥补 T 日最高价买入后到 T 日收盘价的差价判断,即 `next_high >= t_high` |
| `next_high_profit_pct` | `number \| null` | T+1 最高获利百分比,基于 T 日收盘价 |
| `next_low_loss_pct` | `number \| null` | T+1 最大亏损百分比,基于 T 日收盘价 |
| `next_close_pct_chg` | `number \| null` | T+1 收盘涨跌幅,基于 T 日收盘价 |
## 5. 响应示例
```json
{
"trade_start_date": "2026-05-07",
"trade_end_date": "2026-05-07",
"page": 1,
"page_size": 20,
"total": 3,
"total_pages": 1,
"summary": {
"break_count": 3,
"next_trade_data_count": 3
},
"list": [
{
"trade_date": "2026-05-07",
"symbol": "000060",
"ts_code": "000060.SZ",
"name": "中金岭南",
"market": "主板",
"industry": "铅锌",
"t_open": 7.71,
"t_open_pct_chg": 3.77,
"t_open_gain_lt_101": false,
"t_high": 8.17,
"t_close": 8.08,
"t_pct_chg": 8.7483,
"first_time": "13:55:30",
"break_time_before_1100": false,
"has_tushare_break_data": true,
"next_open": 7.81,
"next_high": 8.23,
"next_low": 7.77,
"next_close": 7.95,
"next_open_pct_chg": -3.34,
"next_intraday_profitable": true,
"next_high_profit_pct": 1.86,
"next_low_loss_pct": -3.84,
"next_close_pct_chg": -1.61
}
]
}
```
## 6. CSV 导出
- 响应类型:`text/csv; charset=utf-8`
- 响应头:`Content-Disposition: attachment; filename="limit_break_next_performance_YYYYMMDD_YYYYMMDD_daily_detail.csv"`
- 文件包含 UTF-8 BOM,便于 Excel 直接打开中文列名。
- 导出接口复用列表接口的日期、`tOpenGainLt101`、`breakTimeBefore1100` 筛选条件,不使用分页参数。
列顺序:
| 列名 | 来源字段 |
| --- | --- |
| `T日` | `trade_date` |
| `股票代码` | `ts_code` |
| `股票简称` | `name` |
| `T涨跌幅` | `t_pct_chg` |
| `T开盘<1%` | `t_open_gain_lt_101`,导出为 `是`/`否` |
| `tushare炸板时间` | `first_time` |
| `11点前炸板` | `break_time_before_1100`,导出为 `是`/`否` |
| `T+1盘中可盈利` | `next_intraday_profitable`,导出为 `是`/`否`/`无次日数据` |
| `T+1最高获利` | `next_high_profit_pct` |
| `T+1开盘涨幅` | `next_open_pct_chg` |
| `T+1收盘涨跌` | `next_close_pct_chg` |
| `市场` | `market` |
| `行业` | `industry` |
| `T开盘` | `t_open` |
| `T开盘涨幅` | `t_open_pct_chg` |
| `T最高` | `t_high` |
| `T收盘` | `t_close` |
| `tushare是否炸板` | `has_tushare_break_data`,导出为 `是`/`否` |
| `T+1开盘` | `next_open` |
| `T+1最高` | `next_high` |
| `T+1最低` | `next_low` |
| `T+1收盘` | `next_close` |
| `T+1最大亏损` | `next_low_loss_pct` |
+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": "参数错误"
}
```
+181
View File
@@ -0,0 +1,181 @@
# 二板日历接口文档
## 1. 基本信息
- 接口名称:二板日历
- 请求方法:`GET`
- 请求路径:`/api/v1/limit-board/second-cal`
- 接口用途:按交易日统计每日二板数量、二板封板数量、二板炸板数量,以及二板股票 T+1 盘中盈利概率。
## 2. 请求参数
| 参数 | 类型 | 必填 | 默认值 | 说明 |
| --- | --- | --- | --- | --- |
| `tradeStartDate` | `string` | 是 | 无 | T 日起始日期,格式 `YYYY-MM-DD` |
| `tradeEndDate` | `string` | 是 | 无 | T 日结束日期,格式 `YYYY-MM-DD` |
兼容 `start_date`、`end_date` 作为日期参数别名。
请求示例:
```http
GET /api/v1/limit-board/second-cal?tradeStartDate=2026-05-07&tradeEndDate=2026-05-11
```
## 3. 统计规则
- 以 `trade_calendar` 中 `exchange = 'SSE'` 且 `is_open = 1` 的记录推导 T-2、T-1、T、T+1 交易日。
- T-1 涨停样本来自 `limit_list_d`,条件为 `limit = 'U'`。
- T-2 同一股票不能在 `limit_list_d` 中存在 `limit = 'U'` 的记录。
- 股票所属板块和行业来自 `stock_basic.market`、`stock_basic.industry`。
- T 日行情来自 `stock_daily`,当 T 日最高价 `high` 触及按 T-1 收盘价和板块涨跌幅规则计算的涨停价时,计为二板。
- 涨停价比例按板块计算:北交所 30%,创业板/科创板 20%,其余名称包含 ST 的股票 5%,其他股票 10%。
- T 日 `close` 与 `high` 一致时,计为二板封板,`status = "limit"`。
- T 日 `close` 小于 `high` 时,计为二板炸板,`status = "break"`。
- `first_limit_time` 和 `open_times` 来自 T 日二板记录,封板取 `limit_list_d.limit = 'U'` 记录,炸板取 `limit_list_d.limit = 'Z'` 记录;时间格式化为 `HH:MM:SS`。
- T+1 盘中盈利统一按 `T+1 high > T high` 判断,概率分母只统计具备 T 日最高价和 T+1 最高价的数据。
## 4. 响应字段
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `trade_start_date` | `string` | 查询起始 T 日 |
| `trade_end_date` | `string` | 查询结束 T 日 |
| `summary` | `object` | 日期范围汇总统计 |
| `list` | `array` | 每个交易日的二板日历统计 |
### 4.1 summary/list 单日统计字段
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `second_board_count` | `int` | 二板股票数量 |
| `limit_count` | `int` | 二板封板数量 |
| `break_count` | `int` | 二板炸板数量 |
| `next_trade_data_count` | `int` | 具备 T+1 盈利判断数据的数量 |
| `next_intraday_profit_count` | `int` | T+1 盘中盈利数量 |
| `next_intraday_profit_rate` | `number` | T+1 盘中盈利概率,百分比 |
| `limit_next_data_count` | `int` | 封板股中具备 T+1 盈利判断数据的数量 |
| `limit_next_profit_count` | `int` | 封板股 T+1 盘中盈利数量 |
| `limit_next_profit_rate` | `number` | 封板股 T+1 盘中盈利概率,百分比 |
| `break_next_data_count` | `int` | 炸板股中具备 T+1 盈利判断数据的数量 |
| `break_next_profit_count` | `int` | 炸板股 T+1 盘中盈利数量 |
| `break_next_profit_rate` | `number` | 炸板股 T+1 盘中盈利概率,百分比 |
### 4.2 list[].stocks 明细字段
`list[].stocks` 保留当日全部二板明细。
`list[].total_stocks` 为当日涨停和炸板合并后的明细列表,字段结构与 `stocks` 相同;该列表会剔除 `first_limit_time` 为空的数据,并按 `first_limit_time` 升序排序,时间相同时按 `ts_code` 升序排序。
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `trade_date` | `string` | T 日 |
| `previous_trade_date` | `string` | T-1 交易日 |
| `previous_two_trade_date` | `string` | T-2 交易日 |
| `next_trade_date` | `string` | T+1 交易日 |
| `symbol` / `ts_code` / `name` | `string` | 股票标识 |
| `market` / `industry` | `string` | 板块和行业 |
| `previous_close` | `number \| null` | T-1 收盘价 |
| `limit_price` | `number \| null` | T 日涨停价 |
| `t_high` / `t_close` | `number \| null` | T 日最高价和收盘价 |
| `t_pct_chg` | `number \| null` | T 日收盘涨跌幅 |
| `status` | `string` | `limit` 表示封板,`break` 表示炸板 |
| `first_limit_time` | `string` | T 日二板首次涨停时间,格式化为 `HH:MM:SS`;无数据时为空字符串 |
| `open_times` | `int \| null` | T 日二板开板次数 |
| `next_high` / `next_close` | `number \| null` | T+1 最高价和收盘价 |
| `next_intraday_profitable` | `boolean \| null` | T+1 盘中是否盈利 |
| `next_high_profit_pct` | `number \| null` | T+1 最高价相对 T 日最高价的收益率 |
## 5. 响应示例
```json
{
"trade_start_date": "2026-05-07",
"trade_end_date": "2026-05-11",
"summary": {
"trade_day_count": 3,
"second_board_count": 51,
"limit_count": 41,
"break_count": 10,
"next_trade_data_count": 35,
"next_intraday_profit_count": 34,
"next_intraday_profit_rate": 97.14,
"limit_next_data_count": 29,
"limit_next_profit_count": 28,
"limit_next_profit_rate": 96.55,
"break_next_data_count": 6,
"break_next_profit_count": 6,
"break_next_profit_rate": 100
},
"list": [
{
"trade_date": "2026-05-07",
"previous_trade_date": "2026-05-06",
"previous_two_trade_date": "2026-04-30",
"next_trade_date": "2026-05-08",
"second_board_count": 19,
"limit_count": 16,
"break_count": 3,
"next_trade_data_count": 19,
"next_intraday_profit_count": 18,
"next_intraday_profit_rate": 94.74,
"limit_next_data_count": 16,
"limit_next_profit_count": 15,
"limit_next_profit_rate": 93.75,
"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",
"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
}
]
}
]
}
```
+3 -1
View File
@@ -6,10 +6,12 @@ import (
"net/http"
)
// CommonHandler 承载通用基础数据接口。
type CommonHandler struct {
Service *service.CommonService
Service *service.CommonService // 通用数据服务,负责访问交易日历等基础表。
}
// GetTradeCalendar 返回上交所开市日期列表和最新开市日期。
func (h *CommonHandler) GetTradeCalendar(c *gin.Context) {
resp, err := h.Service.GetTradeCalendar()
if err != nil {
@@ -0,0 +1,133 @@
package api
import (
"encoding/csv"
"fmt"
"net/http"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"level_go/internal/model"
)
// DownloadBreakNextPerformanceCSV 导出炸板股 T+1 表现明细 CSV。
//
// 查询参数与 GetBreakNextPerformance 共用解析逻辑,但导出时不分页,直接输出全部命中记录。
func (h *LimitBoardHandler) DownloadBreakNextPerformanceCSV(c *gin.Context) {
req, err := parseLimitBreakNextPerformanceRequest(c, false)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
rows, _, err := h.Service.ListLimitBreakNextPerformanceStocks(req)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
filename := fmt.Sprintf("limit_break_next_performance_%s_%s_daily_detail.csv",
strings.ReplaceAll(req.TradeStartDate, "-", ""),
strings.ReplaceAll(req.TradeEndDate, "-", ""),
)
c.Header("Content-Type", "text/csv; charset=utf-8")
c.Header("Content-Disposition", `attachment; filename="`+filename+`"`)
c.Status(http.StatusOK)
if _, err := c.Writer.Write([]byte{0xEF, 0xBB, 0xBF}); err != nil {
return
}
writer := csv.NewWriter(c.Writer)
defer writer.Flush()
_ = writer.Write(breakNextPerformanceCSVHeaders())
for _, row := range rows {
if err := writer.Write(breakNextPerformanceCSVRecord(row)); err != nil {
return
}
}
}
// breakNextPerformanceCSVHeaders 返回 CSV 表头,顺序必须与 breakNextPerformanceCSVRecord 保持一致。
func breakNextPerformanceCSVHeaders() []string {
return []string{
"T日",
"股票代码",
"股票简称",
"T涨跌幅",
"T开盘<1%",
"tushare炸板时间",
"11点前炸板",
"T+1盘中可盈利",
"T+1最高获利",
"T+1开盘涨幅",
"T+1收盘涨跌",
"市场",
"行业",
"T开盘",
"T开盘涨幅",
"T最高",
"T收盘",
"tushare是否炸板",
"T+1开盘",
"T+1最高",
"T+1最低",
"T+1收盘",
"T+1最大亏损",
}
}
// breakNextPerformanceCSVRecord 把单条炸板表现记录转换为 CSV 字段。
func breakNextPerformanceCSVRecord(row model.LimitBreakNextPerformanceStock) []string {
return []string{
row.TradeDate,
row.TsCode,
row.Name,
breakNextPerformanceFormatFloatPtr(row.TPctChg),
breakNextPerformanceBoolLabel(row.TOpenGainLt101),
row.FirstTime,
breakNextPerformanceBoolLabel(row.BreakTimeBefore1100),
breakNextPerformanceBoolPtrLabel(row.NextIntradayProfitable),
breakNextPerformanceFormatFloatPtr(row.NextHighProfitPct),
breakNextPerformanceFormatFloatPtr(row.NextOpenPctChg),
breakNextPerformanceFormatFloatPtr(row.NextClosePctChg),
row.Market,
row.Industry,
breakNextPerformanceFormatFloatPtr(row.TOpen),
breakNextPerformanceFormatFloatPtr(row.TOpenPctChg),
breakNextPerformanceFormatFloatPtr(row.THigh),
breakNextPerformanceFormatFloatPtr(row.TClose),
breakNextPerformanceBoolLabel(row.HasTushareBreakData),
breakNextPerformanceFormatFloatPtr(row.NextOpen),
breakNextPerformanceFormatFloatPtr(row.NextHigh),
breakNextPerformanceFormatFloatPtr(row.NextLow),
breakNextPerformanceFormatFloatPtr(row.NextClose),
breakNextPerformanceFormatFloatPtr(row.NextLowLossPct),
}
}
// breakNextPerformanceFormatFloatPtr 格式化可空浮点数,nil 输出为空字段。
func breakNextPerformanceFormatFloatPtr(value *float64) string {
if value == nil {
return ""
}
return strconv.FormatFloat(*value, 'f', -1, 64)
}
// breakNextPerformanceBoolLabel 把布尔值转换为中文是否标签。
func breakNextPerformanceBoolLabel(value bool) string {
if value {
return "是"
}
return "否"
}
// breakNextPerformanceBoolPtrLabel 格式化可空布尔值,用于区分无次日行情和明确的是/否。
func breakNextPerformanceBoolPtrLabel(value *bool) string {
if value == nil {
return "无次日数据"
}
return breakNextPerformanceBoolLabel(*value)
}
+145 -1
View File
@@ -1,21 +1,27 @@
package api
import (
"errors"
"net/http"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"level_go/internal/model"
"level_go/internal/service"
)
// LimitBoardHandler 承载涨停板相关 HTTP 接口。
type LimitBoardHandler struct {
Service *service.LimitBoardService
Service *service.LimitBoardService // 涨停板服务,封装统计、分组和导出逻辑。
}
// NewLimitBoardHandler 创建涨停板接口 Handler。
func NewLimitBoardHandler(s *service.LimitBoardService) *LimitBoardHandler {
return &LimitBoardHandler{Service: s}
}
// GetOverview 返回指定交易日的涨停股连板概览。
func (h *LimitBoardHandler) GetOverview(c *gin.Context) {
var req model.LimitBoardRequest
if err := c.ShouldBindQuery(&req); err != nil || req.TradeDate == "" {
@@ -32,6 +38,7 @@ func (h *LimitBoardHandler) GetOverview(c *gin.Context) {
c.JSON(http.StatusOK, resp)
}
// GetOpenLowerGroups 返回昨日涨停且今日继续涨停股票的开盘对比分组。
func (h *LimitBoardHandler) GetOpenLowerGroups(c *gin.Context) {
var req model.LimitBoardRequest
if err := c.ShouldBindQuery(&req); err != nil || req.TradeDate == "" {
@@ -48,6 +55,143 @@ func (h *LimitBoardHandler) GetOpenLowerGroups(c *gin.Context) {
c.JSON(http.StatusOK, resp)
}
// GetBreakNextPerformance 返回首板后次日炸板股票的 T+1 表现分页结果。
func (h *LimitBoardHandler) GetBreakNextPerformance(c *gin.Context) {
req, err := parseLimitBreakNextPerformanceRequest(c, true)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
resp, err := h.Service.GetLimitBreakNextPerformance(req)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, resp)
}
// GetSecondCal 返回日期范围内的二板日历统计。
func (h *LimitBoardHandler) GetSecondCal(c *gin.Context) {
req, err := parseSecondCalRequest(c)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
resp, err := h.Service.GetSecondCal(req)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, resp)
}
// parseSecondCalRequest 解析二板日历接口的日期范围参数。
func parseSecondCalRequest(c *gin.Context) (model.SecondCalRequest, error) {
req := model.SecondCalRequest{
TradeStartDate: strings.TrimSpace(c.DefaultQuery("tradeStartDate", c.Query("start_date"))),
TradeEndDate: strings.TrimSpace(c.DefaultQuery("tradeEndDate", c.Query("end_date"))),
}
if req.TradeStartDate == "" {
return req, errors.New("tradeStartDate 参数不能为空")
}
if req.TradeEndDate == "" {
return req, errors.New("tradeEndDate 参数不能为空")
}
if err := validateSecondProfitDateRange(req.TradeStartDate, req.TradeEndDate); err != nil {
return req, err
}
return req, nil
}
// parseLimitBreakNextPerformanceRequest 解析炸板次日表现接口的查询参数。
//
// includePagination 为 false 时跳过分页参数,供 CSV 导出复用同一套日期和过滤条件解析逻辑。
func parseLimitBreakNextPerformanceRequest(c *gin.Context, includePagination bool) (model.LimitBreakNextPerformanceRequest, error) {
req := model.LimitBreakNextPerformanceRequest{
TradeStartDate: firstNonEmptyQuery(c, "tradeStartDate", "trade_start_date", "start_date"),
TradeEndDate: firstNonEmptyQuery(c, "tradeEndDate", "trade_end_date", "end_date"),
Page: 1,
PageSize: 20,
}
if req.TradeStartDate == "" {
return req, errors.New("tradeStartDate 参数不能为空")
}
if req.TradeEndDate == "" {
return req, errors.New("tradeEndDate 参数不能为空")
}
if err := validateSecondProfitDateRange(req.TradeStartDate, req.TradeEndDate); err != nil {
return req, err
}
if includePagination {
pageText := strings.TrimSpace(c.DefaultQuery("page", "1"))
page, err := strconv.Atoi(pageText)
if err != nil || page <= 0 {
return req, errors.New("page 必须为正整数")
}
req.Page = page
pageSizeText := strings.TrimSpace(c.DefaultQuery("pageSize", "20"))
pageSize, err := strconv.Atoi(pageSizeText)
if err != nil || pageSize <= 0 {
return req, errors.New("pageSize 必须为正整数")
}
req.PageSize = pageSize
}
tOpenGainLt101, err := parseOptionalBoolQuery(c, "tOpenGainLt101", "tOpenLessThan101", "t_open_gain_lt_101")
if err != nil {
return req, errors.New("tOpenGainLt101 必须为布尔值")
}
req.TOpenGainLt101 = tOpenGainLt101
breakTimeBefore1100, err := parseOptionalBoolQuery(c, "breakTimeBefore1100", "tBreakTimeBefore1100", "break_time_before_1100")
if err != nil {
return req, errors.New("breakTimeBefore1100 必须为布尔值")
}
req.BreakTimeBefore1100 = breakTimeBefore1100
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 过滤。
func parseOptionalBoolQuery(c *gin.Context, keys ...string) (*bool, error) {
for _, key := range keys {
valueText, exists := c.GetQuery(key)
if !exists {
continue
}
valueText = strings.TrimSpace(valueText)
if valueText == "" {
continue
}
value, err := strconv.ParseBool(valueText)
if err != nil {
return nil, err
}
return &value, nil
}
return nil, nil
}
// GetOpenThresholdGroups 返回昨日涨停股今日开盘是否低于前收 101% 的分组。
func (h *LimitBoardHandler) GetOpenThresholdGroups(c *gin.Context) {
var req model.LimitBoardRequest
if err := c.ShouldBindQuery(&req); err != nil || req.TradeDate == "" {
+93 -16
View File
@@ -14,10 +14,15 @@ import (
)
const (
// defaultSecondProfitStartDate 是二板收益统计的默认起始日期。
defaultSecondProfitStartDate = "2020-01-01"
defaultSecondProfitEndDate = "2026-12-31"
// defaultSecondProfitEndDate 是二板收益统计的默认结束日期。
defaultSecondProfitEndDate = "2026-12-31"
)
// secondProfitCSVRow 是二板收益查询的内部行结构。
//
// 前半部分字段直接由 SQL 扫描得到,后半部分字段在 loadSecondProfitRows 中根据行情计算。
type secondProfitCSVRow struct {
TradeDate string `gorm:"column:trade_date"`
PreviousTradeDate string `gorm:"column:previous_trade_date"`
@@ -28,6 +33,8 @@ type secondProfitCSVRow struct {
Name string `gorm:"column:name"`
Market string `gorm:"column:market"`
Industry string `gorm:"column:industry"`
PreviousOpen sql.NullFloat64 `gorm:"column:previous_open"`
PreviousTwoClose sql.NullFloat64 `gorm:"column:previous_two_close"`
Open sql.NullFloat64 `gorm:"column:open"`
Close sql.NullFloat64 `gorm:"column:close_price"`
LimitPrice float64 `gorm:"column:limit_price"`
@@ -44,6 +51,7 @@ type secondProfitCSVRow struct {
NextLow sql.NullFloat64 `gorm:"column:next_low"`
NextClose sql.NullFloat64 `gorm:"column:next_close"`
OpenPreviousCloseRatio float64
PreviousOpenPctChg sql.NullFloat64
OpenPctChg sql.NullFloat64
NextOpenReturnPct sql.NullFloat64
NextHighReturnPct sql.NullFloat64
@@ -54,6 +62,7 @@ type secondProfitCSVRow struct {
NextIntradayProfitable string
}
// secondProfitQuery 保存二板收益接口和 CSV 导出共用的查询条件。
type secondProfitQuery struct {
TradeStartDate string
TradeEndDate string
@@ -71,22 +80,29 @@ type secondProfitQuery struct {
PageSize int
}
// secondProfitCompareFilter 表示多个比较条件的组合过滤器。
//
// 当前逻辑使用 AND 关系:所有 Conditions 均满足时才认为匹配。
type secondProfitCompareFilter struct {
Conditions []secondProfitCompareCondition
}
// secondProfitCompareCondition 表示一个形如 value < 10 或 value >= 09:30 的比较条件。
type secondProfitCompareCondition struct {
Op string
Value float64
Op string // 比较操作符,支持 <、>、<=、>=、=。
Value float64 // 参与比较的标准化数值;时间会转换为秒数。
}
// secondProfitListItem 是二板收益列表接口返回给前端的单行数据。
type secondProfitListItem struct {
TradeDate string `json:"trade_date"`
TsCode string `json:"ts_code"`
Name string `json:"name"`
Market string `json:"market"`
Industry string `json:"industry"`
PreviousOpenPctChg *float64 `json:"previous_open_pct_chg"`
OpenPctChg *float64 `json:"open_pct_chg"`
NextOpenPctChg *float64 `json:"next_open_pct_chg"`
NextOpenProfitable string `json:"next_open_profitable"`
NextCloseProfitable string `json:"next_close_profitable"`
NextIntradayProfitable string `json:"next_intraday_profitable"`
@@ -99,6 +115,9 @@ type secondProfitListItem struct {
Amount *float64 `json:"amount"`
}
// ListSecondBoardProfit 返回二板收益明细的分页 JSON 数据。
//
// 与 CSV 导出使用同一套查询和筛选逻辑,区别是这里会按 page/pageSize 截取结果。
func (h *LimitBoardHandler) ListSecondBoardProfit(c *gin.Context) {
req, err := parseSecondProfitQuery(c, true)
if err != nil {
@@ -151,6 +170,9 @@ func (h *LimitBoardHandler) ListSecondBoardProfit(c *gin.Context) {
})
}
// DownloadSecondBoardProfitCSV 导出二板收益明细 CSV。
//
// 输出 UTF-8 BOM,便于 Excel 直接打开中文表头和内容。
func (h *LimitBoardHandler) DownloadSecondBoardProfitCSV(c *gin.Context) {
req, err := parseSecondProfitQuery(c, false)
if err != nil {
@@ -187,6 +209,9 @@ func (h *LimitBoardHandler) DownloadSecondBoardProfitCSV(c *gin.Context) {
}
}
// parseSecondProfitQuery 解析二板收益接口查询参数。
//
// includePagination 为 false 时跳过分页解析,供 CSV 导出复用筛选条件。
func parseSecondProfitQuery(c *gin.Context, includePagination bool) (secondProfitQuery, error) {
startDate := c.DefaultQuery("tradeStartDate", c.DefaultQuery("start_date", defaultSecondProfitStartDate))
endDate := c.DefaultQuery("tradeEndDate", c.DefaultQuery("end_date", defaultSecondProfitEndDate))
@@ -256,6 +281,9 @@ func parseSecondProfitQuery(c *gin.Context, includePagination bool) (secondProfi
return req, nil
}
// secondProfitFilterParts 兼容两种过滤参数格式。
//
// 既支持 limitUpTimeFilterType1/Value1 这种具名参数,也支持 limitUpTimeFilter[]=... 数组格式。
func secondProfitFilterParts(c *gin.Context, base string) (string, string, string, string) {
type1 := strings.TrimSpace(c.Query(base + "Type1"))
value1 := strings.TrimSpace(c.Query(base + "Value1"))
@@ -279,6 +307,7 @@ func secondProfitFilterParts(c *gin.Context, base string) (string, string, strin
return "", "", "", ""
}
// secondProfitFilterArrayValue 安全读取数组参数中的指定位置。
func secondProfitFilterArrayValue(values []string, index int) string {
if index >= len(values) {
return ""
@@ -286,6 +315,7 @@ func secondProfitFilterArrayValue(values []string, index int) string {
return strings.TrimSpace(values[index])
}
// validateSecondProfitDateRange 校验查询日期格式和先后顺序。
func validateSecondProfitDateRange(startDate, endDate string) error {
start, err := time.Parse("2006-01-02", startDate)
if err != nil {
@@ -301,27 +331,38 @@ func validateSecondProfitDateRange(startDate, endDate string) error {
return nil
}
// loadSecondProfitRows 查询并计算二板收益明细。
//
// SQL 先找出“前一日涨停、前两日未涨停、当日继续涨停”的股票,再补齐 T+1 行情;
// Go 代码负责应用动态过滤条件并计算开盘、最高、最低、收盘相对 T 日收盘的收益率。
func (h *LimitBoardHandler) loadSecondProfitRows(req secondProfitQuery) ([]secondProfitCSVRow, error) {
query := `
WITH open_calendar AS (
WITH calendar AS (
SELECT
DATE_FORMAT(cal_date, '%Y-%m-%d') AS trade_date,
LAG(DATE_FORMAT(cal_date, '%Y-%m-%d'), 1) OVER (ORDER BY cal_date) AS previous_trade_date,
LAG(DATE_FORMAT(cal_date, '%Y-%m-%d'), 2) OVER (ORDER BY cal_date) AS previous_two_trade_date,
LEAD(DATE_FORMAT(cal_date, '%Y-%m-%d'), 1) OVER (ORDER BY cal_date) AS next_trade_date
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
c.trade_date,
c.previous_trade_date,
c.previous_two_trade_date,
COALESCE(c.next_trade_date, '') AS next_trade_date,
DATE_FORMAT(c.trade_date, '%Y-%m-%d') AS trade_date,
DATE_FORMAT(c.previous_trade_date, '%Y-%m-%d') AS previous_trade_date,
DATE_FORMAT(c.previous_two_trade_date, '%Y-%m-%d') AS previous_two_trade_date,
COALESCE(DATE_FORMAT(c.next_trade_date, '%Y-%m-%d'), '') AS next_trade_date,
l.ts_code,
COALESCE(l.symbol, '') AS symbol,
COALESCE(l.name, '') AS name,
COALESCE(l.market, '') AS market,
COALESCE(l.tushare_industry, '') AS industry,
pd.open AS previous_open,
COALESCE(pd.pre_close, ppd.close) AS previous_two_close,
d.open,
COALESCE(l.calc_close, l.tushare_close, d.close) AS close_price,
COALESCE(l.calc_limit_price, 0) AS limit_price,
@@ -337,7 +378,7 @@ SELECT
nd.high AS next_high,
nd.low AS next_low,
nd.close AS next_close
FROM open_calendar 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
@@ -348,10 +389,11 @@ LEFT JOIN stock_daily d
ON l.ts_code = d.ts_code AND d.trade_date = l.trade_date
LEFT JOIN stock_daily pd
ON l.ts_code = pd.ts_code AND pd.trade_date = c.previous_trade_date
LEFT JOIN stock_daily ppd
ON l.ts_code = ppd.ts_code AND ppd.trade_date = c.previous_two_trade_date
LEFT JOIN stock_daily nd
ON l.ts_code = nd.ts_code AND nd.trade_date = c.next_trade_date
WHERE c.trade_date BETWEEN ? AND ?
AND c.previous_trade_date IS NOT NULL
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`
@@ -363,6 +405,7 @@ ORDER BY c.trade_date ASC, l.ts_code ASC`
rows := make([]secondProfitCSVRow, 0, len(rawRows))
for _, row := range rawRows {
// 首次涨停时间过滤在内存中执行,便于兼容 0930、93000、09:30:00 等多种时间格式。
if req.LimitUpTimeRule.Enabled() {
limitTimeValue, ok := secondProfitTimeSeconds(row.FirstLimitTime.String)
if !ok || !req.LimitUpTimeRule.Match(float64(limitTimeValue)) {
@@ -370,6 +413,7 @@ ORDER BY c.trade_date ASC, l.ts_code ASC`
}
}
// 开盘涨跌幅依赖前收价格,只有行情完整且前收大于 0 时才计算。
if row.Open.Valid && row.PreviousClose.Valid && row.PreviousClose.Float64 > 0 {
row.OpenPreviousCloseRatio = row.Open.Float64 / row.PreviousClose.Float64
row.OpenPctChg = sql.NullFloat64{
@@ -377,10 +421,17 @@ ORDER BY c.trade_date ASC, l.ts_code ASC`
Valid: true,
}
}
if row.PreviousOpen.Valid && row.PreviousTwoClose.Valid && row.PreviousTwoClose.Float64 > 0 {
row.PreviousOpenPctChg = sql.NullFloat64{
Float64: secondProfitRound2((row.PreviousOpen.Float64 - row.PreviousTwoClose.Float64) / row.PreviousTwoClose.Float64 * 100),
Valid: true,
}
}
if req.OpenPctChgRule.Enabled() && (!row.OpenPctChg.Valid || !req.OpenPctChgRule.Match(row.OpenPctChg.Float64)) {
continue
}
// T+1 收益统一以 T 日收盘价为基准,缺失的次日行情会保留为 NullFloat64。
if row.Close.Valid && row.Close.Float64 > 0 {
row.NextOpenReturnPct = secondProfitPctReturn(row.NextOpen, row.Close.Float64)
row.NextHighReturnPct = secondProfitPctReturn(row.NextHigh, row.Close.Float64)
@@ -395,16 +446,19 @@ ORDER BY c.trade_date ASC, l.ts_code ASC`
return rows, nil
}
// secondProfitCSVHeaders 返回二板收益 CSV 表头。
func secondProfitCSVHeaders() []string {
return []string{
"交易日", "股票代码", "股票简称", "市场", "行业",
"T日开盘涨跌幅%",
"T-1日开盘涨跌幅%",
"T日开盘涨跌幅%", "T+1日开盘涨跌幅%",
"T+1开盘是否盈利", "T+1收盘是否盈利", "T+1盘中是否可盈利",
"T+1最高盈利%", "T+1最低盈利%", "开板次数", "换手率%",
"首次涨停时间", "封单额", "成交额",
}
}
// secondProfitCSVRecord 把内部行结构转换为 CSV 字段。
func secondProfitCSVRecord(row secondProfitCSVRow) []string {
return []string{
row.TradeDate,
@@ -412,7 +466,9 @@ func secondProfitCSVRecord(row secondProfitCSVRow) []string {
row.Name,
row.Market,
row.Industry,
secondProfitFormatNullFloat(row.PreviousOpenPctChg),
secondProfitFormatNullFloat(row.OpenPctChg),
secondProfitFormatNullFloat(row.NextOpenReturnPct),
row.NextOpenProfitable,
row.NextCloseProfitable,
row.NextIntradayProfitable,
@@ -426,6 +482,7 @@ func secondProfitCSVRecord(row secondProfitCSVRow) []string {
}
}
// secondProfitBuildItem 把内部行结构转换为 JSON 列表项。
func secondProfitBuildItem(row secondProfitCSVRow) secondProfitListItem {
return secondProfitListItem{
TradeDate: row.TradeDate,
@@ -433,7 +490,9 @@ func secondProfitBuildItem(row secondProfitCSVRow) secondProfitListItem {
Name: row.Name,
Market: row.Market,
Industry: row.Industry,
PreviousOpenPctChg: secondProfitNullFloatPtr(row.PreviousOpenPctChg),
OpenPctChg: secondProfitNullFloatPtr(row.OpenPctChg),
NextOpenPctChg: secondProfitNullFloatPtr(row.NextOpenReturnPct),
NextOpenProfitable: row.NextOpenProfitable,
NextCloseProfitable: row.NextCloseProfitable,
NextIntradayProfitable: row.NextIntradayProfitable,
@@ -447,6 +506,7 @@ func secondProfitBuildItem(row secondProfitCSVRow) secondProfitListItem {
}
}
// parseSecondProfitCompareFilter 根据两组操作符和值构造比较过滤器。
func parseSecondProfitCompareFilter(op1, value1, op2, value2 string, parseValue func(string) (float64, error), fieldName string) (secondProfitCompareFilter, error) {
filter := secondProfitCompareFilter{}
if err := appendSecondProfitCompareCondition(&filter, op1, value1, parseValue, fieldName, "第一组"); err != nil {
@@ -458,6 +518,7 @@ func parseSecondProfitCompareFilter(op1, value1, op2, value2 string, parseValue
return filter, nil
}
// appendSecondProfitCompareCondition 校验并追加一个比较条件。
func appendSecondProfitCompareCondition(filter *secondProfitCompareFilter, op, rawValue string, parseValue func(string) (float64, error), fieldName, groupName string) error {
op = strings.TrimSpace(op)
rawValue = strings.TrimSpace(rawValue)
@@ -478,6 +539,7 @@ func appendSecondProfitCompareCondition(filter *secondProfitCompareFilter, op, r
return nil
}
// isSecondProfitCompareOp 判断操作符是否在白名单内。
func isSecondProfitCompareOp(op string) bool {
switch op {
case "<", ">", "<=", ">=", "=":
@@ -487,10 +549,12 @@ func isSecondProfitCompareOp(op string) bool {
}
}
// Enabled 表示过滤器中是否存在有效条件。
func (f secondProfitCompareFilter) Enabled() bool {
return len(f.Conditions) > 0
}
// Match 判断输入值是否满足过滤器中的所有条件。
func (f secondProfitCompareFilter) Match(value float64) bool {
if !f.Enabled() {
return true
@@ -503,6 +567,7 @@ func (f secondProfitCompareFilter) Match(value float64) bool {
return true
}
// Match 判断输入值是否满足单个比较条件。
func (c secondProfitCompareCondition) Match(value float64) bool {
switch c.Op {
case "<":
@@ -520,6 +585,7 @@ func (c secondProfitCompareCondition) Match(value float64) bool {
}
}
// parseSecondProfitTimeValue 把时间字符串转换为当天秒数。
func parseSecondProfitTimeValue(value string) (float64, error) {
seconds, ok := secondProfitTimeSeconds(value)
if !ok {
@@ -528,10 +594,12 @@ func parseSecondProfitTimeValue(value string) (float64, error) {
return float64(seconds), nil
}
// parseSecondProfitFloatValue 解析普通浮点过滤值。
func parseSecondProfitFloatValue(value string) (float64, error) {
return strconv.ParseFloat(strings.TrimSpace(value), 64)
}
// secondProfitTimeSeconds 兼容多种涨停时间格式并转换为当天秒数。
func secondProfitTimeSeconds(limitTime string) (int, bool) {
timeText := strings.TrimSpace(limitTime)
if timeText == "" {
@@ -576,6 +644,7 @@ func secondProfitTimeSeconds(limitTime string) (int, bool) {
return hour*3600 + minute*60 + second, true
}
// secondProfitFormatLimitTime 把紧凑时间格式格式化为 HH:MM:SS。
func secondProfitFormatLimitTime(value string) string {
timeText := strings.TrimSpace(value)
if timeText == "" || strings.Contains(timeText, ":") {
@@ -593,6 +662,7 @@ func secondProfitFormatLimitTime(value string) string {
return timeText[0:2] + ":" + timeText[2:4] + ":" + timeText[4:6]
}
// secondProfitPctReturn 计算相对基准价的百分比收益。
func secondProfitPctReturn(value sql.NullFloat64, base float64) sql.NullFloat64 {
if !value.Valid || base == 0 {
return sql.NullFloat64{}
@@ -600,6 +670,7 @@ func secondProfitPctReturn(value sql.NullFloat64, base float64) sql.NullFloat64
return sql.NullFloat64{Float64: secondProfitRound2((value.Float64 - base) / base * 100), Valid: true}
}
// secondProfitLabel 根据收益是否大于 0 输出中文盈利标签。
func secondProfitLabel(value sql.NullFloat64) string {
if !value.Valid {
return "无次日数据"
@@ -610,14 +681,17 @@ func secondProfitLabel(value sql.NullFloat64) string {
return "否"
}
// secondProfitRound2 保留两位小数。
func secondProfitRound2(value float64) float64 {
return math.Round(value*100) / 100
}
// secondProfitFormatFloat 使用最短十进制格式输出浮点数。
func secondProfitFormatFloat(value float64) string {
return strconv.FormatFloat(value, 'f', -1, 64)
}
// secondProfitFormatNullFloat 格式化可空浮点数,空值输出为空字符串。
func secondProfitFormatNullFloat(value sql.NullFloat64) string {
if !value.Valid {
return ""
@@ -625,6 +699,7 @@ func secondProfitFormatNullFloat(value sql.NullFloat64) string {
return secondProfitFormatFloat(value.Float64)
}
// secondProfitFormatNullInt 格式化可空整数,空值输出为空字符串。
func secondProfitFormatNullInt(value sql.NullInt64) string {
if !value.Valid {
return ""
@@ -632,6 +707,7 @@ func secondProfitFormatNullInt(value sql.NullInt64) string {
return strconv.FormatInt(value.Int64, 10)
}
// secondProfitNullFloatPtr 把 sql.NullFloat64 转换为 JSON 友好的 *float64。
func secondProfitNullFloatPtr(value sql.NullFloat64) *float64 {
if !value.Valid {
return nil
@@ -640,6 +716,7 @@ func secondProfitNullFloatPtr(value sql.NullFloat64) *float64 {
return &v
}
// secondProfitNullIntPtr 把 sql.NullInt64 转换为 JSON 友好的 *int64。
func secondProfitNullIntPtr(value sql.NullInt64) *int64 {
if !value.Valid {
return nil
@@ -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
}
+7 -2
View File
@@ -7,15 +7,19 @@ import (
"net/http"
)
// StockHandler 承载股票行情和走势分析接口。
type StockHandler struct {
Service *service.StockService
Service *service.StockService // 股票服务,封装行情查询和区间分析逻辑。
}
// NewStockHandler 创建股票接口 Handler。
func NewStockHandler(s *service.StockService) *StockHandler {
return &StockHandler{Service: s}
}
// GetKlineData 获取指定股票从2024-09-24至今的K线
// GetKlineData 获取指定股票的历史 K 线数据。
//
// 股票代码来自路径参数 :code,返回按交易日升序排列的 stock_daily 记录。
func (h *StockHandler) GetKlineData(c *gin.Context) {
code := c.Param("code")
if code == "" {
@@ -32,6 +36,7 @@ func (h *StockHandler) GetKlineData(c *gin.Context) {
c.JSON(http.StatusOK, data)
}
// Analyze 根据前端传入的起止日期分析涨停后走势。
func (h *StockHandler) Analyze(c *gin.Context) {
var req model.AnalyzeRequest
if err := c.ShouldBindJSON(&req); err != nil {
+9 -6
View File
@@ -2,16 +2,19 @@ package model
import "time"
// TradeCalendar 映射交易日历表,用于判断某天是否为指定交易所的开市日。
type TradeCalendar struct {
CalDate time.Time `gorm:"column:cal_date;primaryKey"`
Exchange string `gorm:"column:exchange;primaryKey"`
IsOpen int `gorm:"column:is_open"`
PretradeDate time.Time `gorm:"column:pretrade_date"`
CalDate time.Time `gorm:"column:cal_date;primaryKey"` // 日历日期。
Exchange string `gorm:"column:exchange;primaryKey"` // 交易所代码,例如 SSE。
IsOpen int `gorm:"column:is_open"` // 是否开市,1 表示开市。
PretradeDate time.Time `gorm:"column:pretrade_date"` // 上一个交易日。
}
// TableName 指定 GORM 查询时使用 trade_calendar 表。
func (TradeCalendar) TableName() string { return "trade_calendar" }
// CalendarResponse 是交易日历接口响应。
type CalendarResponse struct {
OpenDates []string `json:"open_dates"`
MaxDate string `json:"max_date"`
OpenDates []string `json:"open_dates"` // 所有开市日期,格式 YYYY-MM-DD。
MaxDate string `json:"max_date"` // 当前返回结果中的最大开市日期。
}
+320 -119
View File
@@ -1,162 +1,363 @@
package model
// LimitBoardRequest 是按交易日查询涨停板数据的通用请求。
type LimitBoardRequest struct {
TradeDate string `form:"trade_date" json:"trade_date"`
TradeDate string `form:"trade_date" json:"trade_date"` // 目标交易日,格式 YYYY-MM-DD。
}
// LimitBreakNextPerformanceRequest 是查询炸板股次日表现的请求。
type LimitBreakNextPerformanceRequest struct {
TradeStartDate string `form:"tradeStartDate" json:"trade_start_date"` // 查询起始交易日。
TradeEndDate string `form:"tradeEndDate" json:"trade_end_date"` // 查询结束交易日。
Page int `form:"page" json:"page"` // 当前页码,从 1 开始。
PageSize int `form:"pageSize" json:"page_size"` // 每页数量。
TOpenGainLt101 *bool `json:"t_open_gain_lt_101,omitempty"` // 可选过滤:T 日开盘是否低于前收 101%。
BreakTimeBefore1100 *bool `json:"break_time_before_1100,omitempty"` // 可选过滤:炸板时间是否早于 11:00。
}
// SecondCalRequest 是二板日历接口的日期范围请求。
type SecondCalRequest struct {
TradeStartDate string `form:"tradeStartDate" json:"trade_start_date"` // 查询起始 T 日。
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"`
TsCode string `json:"ts_code"`
Name string `json:"name"`
Market string `json:"market"`
TradeDate string `json:"trade_date"`
Close float64 `json:"close"`
LimitPrice float64 `json:"limit_price"`
PctChg float64 `json:"pct_chg"`
AkAmount *float64 `json:"ak_amount"`
AkFloatMarketValue *float64 `json:"ak_float_market_value"`
AkTotalMarketValue *float64 `json:"ak_total_market_value"`
AkTurnoverRate *float64 `json:"ak_turnover_rate"`
AkSealAmount *float64 `json:"ak_seal_amount"`
AkFirstLimitTime *string `json:"ak_first_limit_time"`
AkLastLimitTime *string `json:"ak_last_limit_time"`
AkBreakLimitCount *int `json:"ak_break_limit_count"`
AkLimitUpStat *string `json:"ak_limit_up_stat"`
AkLimitUpDays *int `json:"ak_limit_up_days"`
AkIndustry *string `json:"ak_industry"`
Vol *int64 `json:"vol"`
BoardCount int `json:"board_count"`
BoardLabel string `json:"board_label"`
DisplayStatus string `json:"display_status"`
Symbol string `json:"symbol"` // 市场展示用股票代码。
TsCode string `json:"ts_code"` // TuShare 股票代码。
Name string `json:"name"` // 股票简称。
Market string `json:"market"` // 所属市场。
TradeDate string `json:"trade_date"` // 交易日。
Close float64 `json:"close"` // 收盘价。
LimitPrice float64 `json:"limit_price"` // 涨停价。
PctChg float64 `json:"pct_chg"` // 当日涨跌幅。
AkAmount *float64 `json:"ak_amount"` // 成交额,数据库为空时返回 null。
AkFloatMarketValue *float64 `json:"ak_float_market_value"` // 流通市值。
AkTotalMarketValue *float64 `json:"ak_total_market_value"` // 总市值。
AkTurnoverRate *float64 `json:"ak_turnover_rate"` // 换手率。
AkSealAmount *float64 `json:"ak_seal_amount"` // 封单额。
AkFirstLimitTime *string `json:"ak_first_limit_time"` // 首次涨停时间。
AkLastLimitTime *string `json:"ak_last_limit_time"` // 最后涨停时间。
AkBreakLimitCount *int `json:"ak_break_limit_count"` // 开板次数。
AkLimitUpStat *string `json:"ak_limit_up_stat"` // 连板状态原始描述。
AkLimitUpDays *int `json:"ak_limit_up_days"` // 连续涨停天数。
AkIndustry *string `json:"ak_industry"` // 行业。
Vol *int64 `json:"vol"` // 成交量。
BoardCount int `json:"board_count"` // 计算出的连板数。
BoardLabel string `json:"board_label"` // 连板标签,例如 二板。
DisplayStatus string `json:"display_status"` // 前端展示状态,当前与 BoardLabel 保持一致。
}
// LimitOpenCompareStock 表示“今日涨停且昨日涨停”的开盘价对比数据。
type LimitOpenCompareStock struct {
Symbol string `json:"symbol"`
TsCode string `json:"ts_code"`
Name string `json:"name"`
Market string `json:"market"`
TradeDate string `json:"trade_date"`
HasOpen bool `json:"-"`
Open float64 `json:"open"`
Close float64 `json:"close"`
PreviousTradeDate string `json:"previous_trade_date"`
PreviousTwoTradeDate string `json:"previous_two_trade_date"`
HasPreviousClose bool `json:"-"`
PreviousClose float64 `json:"previous_close"`
IsFirstToSecondBoard bool `json:"is_first_to_second_board"`
IsFirstSecondBeforeEleven bool `json:"is_first_second_before_eleven"`
HighlightLabel string `json:"highlight_label"`
LimitPrice float64 `json:"limit_price"`
PctChg float64 `json:"pct_chg"`
AkAmount *float64 `json:"ak_amount"`
AkFloatMarketValue *float64 `json:"ak_float_market_value"`
AkTotalMarketValue *float64 `json:"ak_total_market_value"`
AkTurnoverRate *float64 `json:"ak_turnover_rate"`
AkSealAmount *float64 `json:"ak_seal_amount"`
AkFirstLimitTime *string `json:"ak_first_limit_time"`
AkLastLimitTime *string `json:"ak_last_limit_time"`
AkBreakLimitCount *int `json:"ak_break_limit_count"`
AkLimitUpStat *string `json:"ak_limit_up_stat"`
AkLimitUpDays *int `json:"ak_limit_up_days"`
AkIndustry *string `json:"ak_industry"`
Vol *int64 `json:"vol"`
Symbol string `json:"symbol"` // 市场展示用股票代码。
TsCode string `json:"ts_code"` // TuShare 股票代码。
Name string `json:"name"` // 股票简称。
Market string `json:"market"` // 所属市场。
TradeDate string `json:"trade_date"` // 查询交易日。
HasOpen bool `json:"-"` // 数据库是否存在今日开盘价,内部计算使用。
Open float64 `json:"open"` // 今日开盘价。
Close float64 `json:"close"` // 今日收盘价。
PreviousTradeDate string `json:"previous_trade_date"` // 上一个交易日。
PreviousTwoTradeDate string `json:"previous_two_trade_date"` // 上上个交易日。
HasPreviousClose bool `json:"-"` // 数据库是否存在前收价,内部计算使用。
PreviousClose float64 `json:"previous_close"` // 上一个交易日收盘价。
IsFirstToSecondBoard bool `json:"is_first_to_second_board"` // 是否属于首板进二板。
IsFirstSecondBeforeEleven bool `json:"is_first_second_before_eleven"` // 首板进二板且 11 点前封板。
HighlightLabel string `json:"highlight_label"` // 前端高亮标签。
LimitPrice float64 `json:"limit_price"` // 今日涨停价。
PctChg float64 `json:"pct_chg"` // 今日涨跌幅。
AkAmount *float64 `json:"ak_amount"` // 成交额。
AkFloatMarketValue *float64 `json:"ak_float_market_value"` // 流通市值。
AkTotalMarketValue *float64 `json:"ak_total_market_value"` // 总市值。
AkTurnoverRate *float64 `json:"ak_turnover_rate"` // 换手率。
AkSealAmount *float64 `json:"ak_seal_amount"` // 封单额。
AkFirstLimitTime *string `json:"ak_first_limit_time"` // 首次涨停时间。
AkLastLimitTime *string `json:"ak_last_limit_time"` // 最后涨停时间。
AkBreakLimitCount *int `json:"ak_break_limit_count"` // 开板次数。
AkLimitUpStat *string `json:"ak_limit_up_stat"` // 连板状态原始描述。
AkLimitUpDays *int `json:"ak_limit_up_days"` // 连续涨停天数。
AkIndustry *string `json:"ak_industry"` // 行业。
Vol *int64 `json:"vol"` // 成交量。
}
// LimitOpenThresholdStock 表示昨日涨停股在今日开盘阈值分组中的单股数据。
type LimitOpenThresholdStock struct {
Symbol string `json:"symbol"`
TsCode string `json:"ts_code"`
Name string `json:"name"`
Market string `json:"market"`
TradeDate string `json:"trade_date"`
HasOpen bool `json:"-"`
Open float64 `json:"open"`
Close float64 `json:"close"`
PreviousTradeDate string `json:"previous_trade_date"`
HasPreviousClose bool `json:"-"`
PreviousClose float64 `json:"previous_close"`
OpenThresholdPrice float64 `json:"open_threshold_price"`
OpenPreviousCloseRatio float64 `json:"open_previous_close_ratio"`
LimitPrice float64 `json:"limit_price"`
PctChg float64 `json:"pct_chg"`
AkAmount *float64 `json:"ak_amount"`
AkFloatMarketValue *float64 `json:"ak_float_market_value"`
AkTotalMarketValue *float64 `json:"ak_total_market_value"`
AkTurnoverRate *float64 `json:"ak_turnover_rate"`
AkSealAmount *float64 `json:"ak_seal_amount"`
AkFirstLimitTime *string `json:"ak_first_limit_time"`
AkLastLimitTime *string `json:"ak_last_limit_time"`
AkBreakLimitCount *int `json:"ak_break_limit_count"`
AkLimitUpStat *string `json:"ak_limit_up_stat"`
AkLimitUpDays *int `json:"ak_limit_up_days"`
AkIndustry *string `json:"ak_industry"`
Vol *int64 `json:"vol"`
Symbol string `json:"symbol"` // 市场展示用股票代码。
TsCode string `json:"ts_code"` // TuShare 股票代码。
Name string `json:"name"` // 股票简称。
Market string `json:"market"` // 所属市场。
TradeDate string `json:"trade_date"` // 查询交易日。
HasOpen bool `json:"-"` // 数据库是否存在今日开盘价,内部计算使用。
Open float64 `json:"open"` // 今日开盘价。
Close float64 `json:"close"` // 今日收盘价。
PreviousTradeDate string `json:"previous_trade_date"` // 上一个交易日。
HasPreviousClose bool `json:"-"` // 数据库是否存在前收价,内部计算使用。
PreviousClose float64 `json:"previous_close"` // 上一个交易日收盘价。
OpenThresholdPrice float64 `json:"open_threshold_price"` // 前收价乘以阈值比例后的开盘阈值。
OpenPreviousCloseRatio float64 `json:"open_previous_close_ratio"` // 今日开盘价与前收价的比值。
LimitPrice float64 `json:"limit_price"` // 今日涨停价。
PctChg float64 `json:"pct_chg"` // 今日涨跌幅。
AkAmount *float64 `json:"ak_amount"` // 成交额。
AkFloatMarketValue *float64 `json:"ak_float_market_value"` // 流通市值。
AkTotalMarketValue *float64 `json:"ak_total_market_value"` // 总市值。
AkTurnoverRate *float64 `json:"ak_turnover_rate"` // 换手率。
AkSealAmount *float64 `json:"ak_seal_amount"` // 封单额。
AkFirstLimitTime *string `json:"ak_first_limit_time"` // 首次涨停时间。
AkLastLimitTime *string `json:"ak_last_limit_time"` // 最后涨停时间。
AkBreakLimitCount *int `json:"ak_break_limit_count"` // 开板次数。
AkLimitUpStat *string `json:"ak_limit_up_stat"` // 连板状态原始描述。
AkLimitUpDays *int `json:"ak_limit_up_days"` // 连续涨停天数。
AkIndustry *string `json:"ak_industry"` // 行业。
Vol *int64 `json:"vol"` // 成交量。
}
// LimitBreakNextPerformanceStock 表示首板后次日炸板股票的 T+1 表现。
type LimitBreakNextPerformanceStock struct {
TradeDate string `json:"trade_date"` // T 日交易日,即炸板发生日。
Symbol string `json:"symbol"` // 市场展示用股票代码。
TsCode string `json:"ts_code"` // TuShare 股票代码。
Name string `json:"name"` // 股票简称。
Market string `json:"market"` // 所属市场。
Industry string `json:"industry"` // 行业。
TOpen *float64 `json:"t_open"` // T 日开盘价。
TOpenPctChg *float64 `json:"t_open_pct_chg"` // T 日开盘相对前收的涨跌幅。
TOpenGainLt101 bool `json:"t_open_gain_lt_101"` // T 日开盘价是否低于前收 101%。
THigh *float64 `json:"t_high"` // T 日最高价。
TClose *float64 `json:"t_close"` // T 日收盘价。
TPctChg *float64 `json:"t_pct_chg"` // T 日涨跌幅。
FirstTime string `json:"first_time"` // TuShare 首次炸板/涨停时间,已格式化为 HH:MM:SS。
BreakTimeBefore1100 bool `json:"break_time_before_1100"` // 炸板相关时间是否早于 11:00。
HasTushareBreakData bool `json:"has_tushare_break_data"` // TuShare 是否明确标记为炸板。
NextOpen *float64 `json:"next_open"` // T+1 开盘价。
NextHigh *float64 `json:"next_high"` // T+1 最高价。
NextLow *float64 `json:"next_low"` // T+1 最低价。
NextClose *float64 `json:"next_close"` // T+1 收盘价。
NextOpenPctChg *float64 `json:"next_open_pct_chg"` // T+1 开盘相对 T 日收盘的收益率。
NextIntradayProfitable *bool `json:"next_intraday_profitable"` // T+1 最高价是否覆盖 T 日最高价。
NextHighProfitPct *float64 `json:"next_high_profit_pct"` // T+1 最高价相对 T 日收盘的最大收益率。
NextLowLossPct *float64 `json:"next_low_loss_pct"` // T+1 最低价相对 T 日收盘的最大回撤率。
NextClosePctChg *float64 `json:"next_close_pct_chg"` // T+1 收盘相对 T 日收盘的收益率。
}
// SecondCalStock 表示二板日历中单只二板股票的 T 日和 T+1 表现。
type SecondCalStock struct {
TradeDate string `json:"trade_date"` // T 日交易日。
PreviousTradeDate string `json:"previous_trade_date"` // T-1 交易日。
PreviousTwoTradeDate string `json:"previous_two_trade_date"` // T-2 交易日。
NextTradeDate string `json:"next_trade_date"` // T+1 交易日。
Symbol string `json:"symbol"` // 股票代码。
TsCode string `json:"ts_code"` // TuShare 股票代码。
Name string `json:"name"` // 股票简称。
Market string `json:"market"` // 所属板块,来自 stock_basic.market。
Industry string `json:"industry"` // 所属行业,来自 stock_basic.industry。
PreviousClose *float64 `json:"previous_close"` // T-1 收盘价。
LimitPrice *float64 `json:"limit_price"` // 按 T-1 收盘价和板块涨跌幅规则计算的 T 日涨停价。
THigh *float64 `json:"t_high"` // T 日最高价。
TClose *float64 `json:"t_close"` // T 日收盘价。
TPctChg *float64 `json:"t_pct_chg"` // T 日收盘涨跌幅。
Status string `json:"status"` // T 日二板状态:limit 表示封板,break 表示炸板。
FirstLimitTime string `json:"first_limit_time"` // T 日二板首次涨停时间,格式化为 HH:MM:SS。
OpenTimes *int `json:"open_times"` // T 日二板开板次数。
NextHigh *float64 `json:"next_high"` // T+1 日最高价。
NextClose *float64 `json:"next_close"` // T+1 日收盘价。
NextIntradayProfitable *bool `json:"next_intraday_profitable"` // T+1 盘中最高价是否高于 T 日最高价。
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"`
Label string `json:"label"`
Count int `json:"count"`
Stocks []LimitOpenCompareStock `json:"stocks"`
Key string `json:"key"` // 分组唯一标识。
Label string `json:"label"` // 分组展示名称。
Count int `json:"count"` // 分组内股票数量。
Stocks []LimitOpenCompareStock `json:"stocks"` // 分组内股票列表。
}
// LimitOpenThresholdGroup 是开盘价阈值接口的分组结果。
type LimitOpenThresholdGroup struct {
Key string `json:"key"`
Label string `json:"label"`
Count int `json:"count"`
Stocks []LimitOpenThresholdStock `json:"stocks"`
Key string `json:"key"` // 分组唯一标识。
Label string `json:"label"` // 分组展示名称。
Count int `json:"count"` // 分组内股票数量。
Stocks []LimitOpenThresholdStock `json:"stocks"` // 分组内股票列表。
}
// LimitOpenCompareSummary 汇总昨日涨停股今日开盘表现。
type LimitOpenCompareSummary struct {
LimitCount int `json:"limit_count"`
OpenLowerPreviousCloseCount int `json:"open_lower_previous_close_count"`
OthersCount int `json:"others_count"`
FirstToSecondBoardCount int `json:"first_to_second_board_count"`
FirstSecondBeforeElevenCount int `json:"first_second_before_eleven_count"`
LimitCount int `json:"limit_count"` // 昨日涨停且今日继续涨停的股票总数。
OpenLowerPreviousCloseCount int `json:"open_lower_previous_close_count"` // 今日开盘低于昨日收盘的股票数量。
OthersCount int `json:"others_count"` // 未进入低开分组的其他股票数量。
FirstToSecondBoardCount int `json:"first_to_second_board_count"` // 首板进二板股票数量。
FirstSecondBeforeElevenCount int `json:"first_second_before_eleven_count"` // 首板进二板且 11 点前封板数量。
}
// LimitOpenThresholdSummary 汇总开盘阈值分组统计。
type LimitOpenThresholdSummary struct {
LimitCount int `json:"limit_count"`
OpenWithinThresholdCount int `json:"open_within_threshold_count"`
OpenAtOrAboveThresholdCount int `json:"open_at_or_above_threshold_count"`
ThresholdRatio float64 `json:"threshold_ratio"`
LimitCount int `json:"limit_count"` // 昨日涨停且今日继续涨停的股票总数。
OpenWithinThresholdCount int `json:"open_within_threshold_count"` // 今日开盘低于阈值的股票数量。
OpenAtOrAboveThresholdCount int `json:"open_at_or_above_threshold_count"` // 今日开盘达到或超过阈值的股票数量。
ThresholdRatio float64 `json:"threshold_ratio"` // 阈值比例,例如 1.01 表示前收 101%。
}
// LimitBreakNextPerformanceSummary 汇总炸板股次日表现统计。
type LimitBreakNextPerformanceSummary struct {
BreakCount int `json:"break_count"` // 命中过滤条件的炸板股票数量。
NextTradeDataCount int `json:"next_trade_data_count"` // 具备 T+1 行情数据的股票数量。
}
// SecondCalSummary 汇总日期范围内所有二板日历指标。
type SecondCalSummary struct {
TradeDayCount int `json:"trade_day_count"` // 返回的交易日数量。
SecondBoardCount int `json:"second_board_count"` // 触及二板涨停价的股票总数。
LimitCount int `json:"limit_count"` // 二板中封住涨停的股票数量。
BreakCount int `json:"break_count"` // 二板中炸板的股票数量。
NextTradeDataCount int `json:"next_trade_data_count"` // 具备 T+1 最高价和 T 日最高价的股票数量。
NextIntradayProfitCount int `json:"next_intraday_profit_count"` // T+1 最高价高于 T 日最高价的股票数量。
NextIntradayProfitRate float64 `json:"next_intraday_profit_rate"` // T+1 盘中盈利概率,百分比。
LimitNextDataCount int `json:"limit_next_data_count"` // T 日封板股票中具备 T+1 盈利判断数据的数量。
LimitNextProfitCount int `json:"limit_next_profit_count"` // T 日封板股票中 T+1 盘中盈利数量。
LimitNextProfitRate float64 `json:"limit_next_profit_rate"` // T 日封板股票的 T+1 盘中盈利概率,百分比。
BreakNextDataCount int `json:"break_next_data_count"` // T 日炸板股票中具备 T+1 盈利判断数据的数量。
BreakNextProfitCount int `json:"break_next_profit_count"` // T 日炸板股票中 T+1 盘中盈利数量。
BreakNextProfitRate float64 `json:"break_next_profit_rate"` // T 日炸板股票的 T+1 盘中盈利概率,百分比。
}
// LimitOpenCompareResponse 是开盘价低于前收接口响应。
type LimitOpenCompareResponse struct {
TradeDate string `json:"trade_date"`
PreviousTradeDate string `json:"previous_trade_date"`
PreviousTwoTradeDate string `json:"previous_two_trade_date"`
Summary LimitOpenCompareSummary `json:"summary"`
Groups []LimitOpenCompareGroup `json:"groups"`
TradeDate string `json:"trade_date"` // 查询交易日。
PreviousTradeDate string `json:"previous_trade_date"` // 上一个交易日。
PreviousTwoTradeDate string `json:"previous_two_trade_date"` // 上上个交易日。
Summary LimitOpenCompareSummary `json:"summary"` // 汇总统计。
Groups []LimitOpenCompareGroup `json:"groups"` // 固定分组结果。
}
// LimitBreakNextPerformanceResponse 是炸板股次日表现接口响应。
type LimitBreakNextPerformanceResponse struct {
TradeStartDate string `json:"trade_start_date"` // 查询起始交易日。
TradeEndDate string `json:"trade_end_date"` // 查询结束交易日。
Page int `json:"page"` // 当前页码。
PageSize int `json:"page_size"` // 每页数量。
Total int `json:"total"` // 命中过滤条件的总记录数。
TotalPages int `json:"total_pages"` // 总页数。
Summary LimitBreakNextPerformanceSummary `json:"summary"` // 汇总统计。
List []LimitBreakNextPerformanceStock `json:"list"` // 当前页记录。
}
// SecondCalDay 表示二板日历中单个交易日的聚合统计。
type SecondCalDay struct {
TradeDate string `json:"trade_date"` // T 日交易日。
PreviousTradeDate string `json:"previous_trade_date"` // T-1 交易日。
PreviousTwoTradeDate string `json:"previous_two_trade_date"` // T-2 交易日。
NextTradeDate string `json:"next_trade_date"` // T+1 交易日。
SecondBoardCount int `json:"second_board_count"` // 当日触及二板涨停价的股票数量。
LimitCount int `json:"limit_count"` // 当日二板封板数量。
BreakCount int `json:"break_count"` // 当日二板炸板数量。
NextTradeDataCount int `json:"next_trade_data_count"` // 当日具备 T+1 盈利判断数据的股票数量。
NextIntradayProfitCount int `json:"next_intraday_profit_count"` // 当日 T+1 最高价高于 T 日最高价数量。
NextIntradayProfitRate float64 `json:"next_intraday_profit_rate"` // 当日 T+1 盘中盈利概率,百分比。
LimitNextDataCount int `json:"limit_next_data_count"` // 当日封板股中具备 T+1 盈利判断数据的数量。
LimitNextProfitCount int `json:"limit_next_profit_count"` // 当日封板股 T+1 盘中盈利数量。
LimitNextProfitRate float64 `json:"limit_next_profit_rate"` // 当日封板股 T+1 盘中盈利概率,百分比。
BreakNextDataCount int `json:"break_next_data_count"` // 当日炸板股中具备 T+1 盈利判断数据的数量。
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 是二板日历接口响应。
type SecondCalResponse struct {
TradeStartDate string `json:"trade_start_date"` // 查询起始 T 日。
TradeEndDate string `json:"trade_end_date"` // 查询结束 T 日。
Summary SecondCalSummary `json:"summary"` // 日期范围汇总统计。
List []SecondCalDay `json:"list"` // 按交易日升序排列的日历统计。
}
// LimitOpenThresholdResponse 是开盘价阈值分组接口响应。
type LimitOpenThresholdResponse struct {
TradeDate string `json:"trade_date"`
PreviousTradeDate string `json:"previous_trade_date"`
Summary LimitOpenThresholdSummary `json:"summary"`
Groups []LimitOpenThresholdGroup `json:"groups"`
TradeDate string `json:"trade_date"` // 查询交易日。
PreviousTradeDate string `json:"previous_trade_date"` // 上一个交易日。
Summary LimitOpenThresholdSummary `json:"summary"` // 汇总统计。
Groups []LimitOpenThresholdGroup `json:"groups"` // 固定分组结果。
}
// LimitBoardGroup 是涨停概览按连板数聚合后的分组。
type LimitBoardGroup struct {
BoardCount int `json:"board_count"`
Label string `json:"label"`
Count int `json:"count"`
Stocks []LimitBoardStock `json:"stocks"`
BoardCount int `json:"board_count"` // 连板数。
Label string `json:"label"` // 连板展示标签。
Count int `json:"count"` // 分组股票数量。
Stocks []LimitBoardStock `json:"stocks"` // 分组股票列表。
}
// LimitBoardSummary 汇总涨停概览的核心统计。
type LimitBoardSummary struct {
MaxBoardCount int `json:"max_board_count"`
LimitCount int `json:"limit_count"`
GroupCount int `json:"group_count"`
MaxBoardCount int `json:"max_board_count"` // 当日最高连板数。
LimitCount int `json:"limit_count"` // 当日涨停股票数量。
GroupCount int `json:"group_count"` // 连板分组数量。
}
// LimitBoardDailyMax 表示某个交易日的最高连板数。
type LimitBoardDailyMax struct {
TradeDate string `json:"trade_date"`
MaxBoardCount int `json:"max_board_count"`
TradeDate string `json:"trade_date"` // 交易日。
MaxBoardCount int `json:"max_board_count"` // 当日最高连板数。
}
// LimitBoardResponse 是涨停概览接口响应。
type LimitBoardResponse struct {
TradeDate string `json:"trade_date"`
Summary LimitBoardSummary `json:"summary"`
RecentMaxBoardStats []LimitBoardDailyMax `json:"recent_max_board_stats"`
Groups []LimitBoardGroup `json:"groups"`
TradeDate string `json:"trade_date"` // 查询交易日。
Summary LimitBoardSummary `json:"summary"` // 当日概览统计。
RecentMaxBoardStats []LimitBoardDailyMax `json:"recent_max_board_stats"` // 最近窗口内每日最高连板数。
Groups []LimitBoardGroup `json:"groups"` // 按连板数倒序排列的股票分组。
}
+17 -17
View File
@@ -2,30 +2,30 @@ package model
import "time"
// StockDaily 对应数据库中的 stock_daily 表
// StockDaily 对应数据库中的 stock_daily 表,保存单只股票每日行情。
type StockDaily struct {
TsCode string `gorm:"column:ts_code" json:"ts_code"`
TradeDate time.Time `gorm:"column:trade_date" json:"trade_date"`
Open float64 `gorm:"column:open" json:"open"`
High float64 `gorm:"column:high" json:"high"`
Low float64 `gorm:"column:low" json:"low"`
Close float64 `gorm:"column:close" json:"close"`
PctChg float64 `gorm:"column:pct_chg" json:"pct_chg"`
Vol float64 `gorm:"column:vol" json:"vol"`
TsCode string `gorm:"column:ts_code" json:"ts_code"` // 股票代码,通常为 TuShare ts_code。
TradeDate time.Time `gorm:"column:trade_date" json:"trade_date"` // 交易日期。
Open float64 `gorm:"column:open" json:"open"` // 开盘价。
High float64 `gorm:"column:high" json:"high"` // 最高价。
Low float64 `gorm:"column:low" json:"low"` // 最低价。
Close float64 `gorm:"column:close" json:"close"` // 收盘价。
PctChg float64 `gorm:"column:pct_chg" json:"pct_chg"` // 当日涨跌幅。
Vol float64 `gorm:"column:vol" json:"vol"` // 成交量。
}
// AnalyzeRequest 用于接收前端 POST 请求的参数
// AnalyzeRequest 用于接收前端 POST 请求的分析区间参数。
type AnalyzeRequest struct {
StartDate string `json:"start_date"`
EndDate string `json:"end_date"`
StartDate string `json:"start_date"` // 起始交易日。
EndDate string `json:"end_date"` // 结束交易日。
}
// AnalysisResponse 用于向前端返回分析结果
// AnalysisResponse 用于向前端返回涨停后走势分析结果。
type AnalysisResponse struct {
Code string `json:"code"`
Name string `json:"name"`
DayOpen float64 `json:"day_open"`
DayHigh float64 `json:"day_high"`
Code string `json:"code"` // 股票代码。
Name string `json:"name"` // 股票简称。
DayOpen float64 `json:"day_open"` // 涨停日开盘价。
DayHigh float64 `json:"day_high"` // 涨停日最高价。
MaxHigh float64 `json:"max_high"` // 逻辑最高点(用于计算降幅)
MaxDate string `json:"max_date"` // 逻辑最高点日期
OverallMaxHigh float64 `json:"overall_max_high"` // 区间绝对最高点
+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),
})
}
}
+12 -2
View File
@@ -5,10 +5,14 @@ import (
"level_go/internal/api"
)
// NewRouter 创建并配置 API 路由。
//
// 函数集中注册跨域中间件和 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) {
c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, GET, OPTIONS")
@@ -20,6 +24,7 @@ func NewRouter(commonApi *api.CommonHandler, stockApi *api.StockHandler, limitBo
}
c.Next()
})
r.Use(cacheableGETResponseMiddleware(newResponseCacheStore()))
v1 := r.Group("/api/v1")
{
@@ -34,11 +39,16 @@ 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)
}
download := v1.Group("/download")
{
download.GET("/limit-board/second-board-profit-csv", limitBoardApi.DownloadSecondBoardProfitCSV)
download.GET("/limit-board/break-next-performance-csv", limitBoardApi.DownloadBreakNextPerformanceCSV)
}
}
return r
+4 -1
View File
@@ -5,10 +5,12 @@ import (
"level_go/internal/model"
)
// CommonService 提供通用基础数据查询能力。
type CommonService struct {
DB *gorm.DB
DB *gorm.DB // 数据库连接。
}
// GetTradeCalendar 查询上交所全部开市日期,并返回最新开市日期。
func (s *CommonService) GetTradeCalendar() (model.CalendarResponse, error) {
var results []struct {
DateStr string `gorm:"column:ds"`
@@ -24,6 +26,7 @@ func (s *CommonService) GetTradeCalendar() (model.CalendarResponse, error) {
return resp, err
}
// 按日期升序扫描,因此循环中最后一次赋值即为最大开市日期。
for _, r := range results {
resp.OpenDates = append(resp.OpenDates, r.DateStr)
resp.MaxDate = r.DateStr
@@ -0,0 +1,292 @@
package service
import (
"math"
"strconv"
"strings"
"level_go/internal/model"
)
// limitBreakNextPerformanceRow 是炸板股次日表现查询的数据库扫描结构。
type limitBreakNextPerformanceRow struct {
TradeDate string `gorm:"column:trade_date"`
PreviousTradeDate string `gorm:"column:previous_trade_date"`
PreviousTwoTradeDate string `gorm:"column:previous_two_trade_date"`
NextTradeDate string `gorm:"column:next_trade_date"`
Symbol string `gorm:"column:symbol"`
TsCode string `gorm:"column:ts_code"`
Name string `gorm:"column:name"`
Market string `gorm:"column:market"`
Industry string `gorm:"column:industry"`
PreviousClose *float64 `gorm:"column:previous_close"`
TPreClose *float64 `gorm:"column:t_pre_close"`
TOpen *float64 `gorm:"column:t_open"`
THigh *float64 `gorm:"column:t_high"`
TClose *float64 `gorm:"column:t_close"`
TPctChg *float64 `gorm:"column:t_pct_chg"`
FirstTime *string `gorm:"column:first_time"`
HasTushareBreakData bool `gorm:"column:has_tushare_break_data"`
NextOpen *float64 `gorm:"column:next_open"`
NextHigh *float64 `gorm:"column:next_high"`
NextLow *float64 `gorm:"column:next_low"`
NextClose *float64 `gorm:"column:next_close"`
}
// GetLimitBreakNextPerformance 返回炸板股次日表现的分页响应。
//
// 该方法负责分页和汇总统计,实际查询与单股指标计算由 ListLimitBreakNextPerformanceStocks 完成。
func (s *LimitBoardService) GetLimitBreakNextPerformance(req model.LimitBreakNextPerformanceRequest) (model.LimitBreakNextPerformanceResponse, error) {
resp := model.LimitBreakNextPerformanceResponse{
TradeStartDate: req.TradeStartDate,
TradeEndDate: req.TradeEndDate,
Page: req.Page,
PageSize: req.PageSize,
List: []model.LimitBreakNextPerformanceStock{},
}
items, nextTradeDataCount, err := s.ListLimitBreakNextPerformanceStocks(req)
if err != nil {
return resp, err
}
resp.Total = len(items)
if resp.Total > 0 {
resp.TotalPages = (resp.Total + req.PageSize - 1) / req.PageSize
}
resp.Summary = model.LimitBreakNextPerformanceSummary{
BreakCount: resp.Total,
NextTradeDataCount: nextTradeDataCount,
}
start := (req.Page - 1) * req.PageSize
if start > resp.Total {
start = resp.Total
}
end := start + req.PageSize
if end > resp.Total {
end = resp.Total
}
resp.List = items[start:end]
return resp, nil
}
// ListLimitBreakNextPerformanceStocks 查询并计算全部命中的炸板股次日表现。
//
// 返回值中的 int 表示拥有任意 T+1 行情字段的记录数量。
func (s *LimitBoardService) ListLimitBreakNextPerformanceStocks(req model.LimitBreakNextPerformanceRequest) ([]model.LimitBreakNextPerformanceStock, int, error) {
rows, err := s.loadLimitBreakNextPerformanceRows(req.TradeStartDate, req.TradeEndDate)
if err != nil {
return nil, 0, err
}
items := make([]model.LimitBreakNextPerformanceStock, 0, len(rows))
nextTradeDataCount := 0
for _, row := range rows {
item := buildLimitBreakNextPerformanceStock(row)
// 可选布尔过滤在指标计算后执行,便于复用同一批基础查询结果。
if req.TOpenGainLt101 != nil && item.TOpenGainLt101 != *req.TOpenGainLt101 {
continue
}
if req.BreakTimeBefore1100 != nil && item.BreakTimeBefore1100 != *req.BreakTimeBefore1100 {
continue
}
if item.NextOpen != nil || item.NextHigh != nil || item.NextLow != nil || item.NextClose != nil {
nextTradeDataCount++
}
items = append(items, item)
}
return items, nextTradeDataCount, nil
}
// loadLimitBreakNextPerformanceRows 查询“首板后次日炸板”的候选股票和 T+1 行情。
//
// 口径:前一交易日涨停、前两交易日未涨停、当前交易日 final_status = 2。
func (s *LimitBoardService) loadLimitBreakNextPerformanceRows(startDate, endDate string) ([]limitBreakNextPerformanceRow, error) {
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
DATE_FORMAT(c.trade_date, '%Y-%m-%d') AS trade_date,
DATE_FORMAT(c.previous_trade_date, '%Y-%m-%d') AS previous_trade_date,
DATE_FORMAT(c.previous_two_trade_date, '%Y-%m-%d') AS previous_two_trade_date,
COALESCE(DATE_FORMAT(c.next_trade_date, '%Y-%m-%d'), '') AS next_trade_date,
l.symbol,
l.ts_code,
l.name,
COALESCE(NULLIF(l.market, ''), '') AS market,
COALESCE(NULLIF(l.tushare_industry, ''), NULLIF(sb.industry, ''), '') AS industry,
COALESCE(d.pre_close, l.calc_pre_close, pd.close, pl.calc_close, pl.tushare_close) AS previous_close,
COALESCE(d.pre_close, l.calc_pre_close, pd.close, pl.calc_close, pl.tushare_close) AS t_pre_close,
d.open AS t_open,
d.high AS t_high,
COALESCE(l.calc_close, l.tushare_close, d.close) AS t_close,
COALESCE(l.tushare_pct_chg, d.pct_chg) AS t_pct_chg,
CASE
WHEN l.tushare_limit_type = 'Z'
THEN NULLIF(NULLIF(TRIM(l.tushare_first_time), ''), 'None')
ELSE NULL
END AS first_time,
(l.tushare_limit_type = 'Z') AS has_tushare_break_data,
nd.open AS next_open,
nd.high AS next_high,
nd.low AS next_low,
nd.close AS next_close
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 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 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 d.ts_code = l.ts_code AND d.trade_date = c.trade_date
LEFT JOIN stock_daily pd
ON pd.ts_code = l.ts_code AND pd.trade_date = c.previous_trade_date
LEFT JOIN stock_daily nd
ON nd.ts_code = l.ts_code AND nd.trade_date = c.next_trade_date
LEFT JOIN stock_basic sb
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
) candidate
WHERE previous_close IS NOT NULL
AND t_close IS NOT NULL
ORDER BY trade_date ASC, ts_code ASC`
var rows []limitBreakNextPerformanceRow
err := s.DB.Raw(query, startDate, endDate).Scan(&rows).Error
return rows, err
}
// buildLimitBreakNextPerformanceStock 根据数据库行计算前端需要的派生指标。
func buildLimitBreakNextPerformanceStock(row limitBreakNextPerformanceRow) model.LimitBreakNextPerformanceStock {
item := model.LimitBreakNextPerformanceStock{
TradeDate: row.TradeDate,
Symbol: row.Symbol,
TsCode: row.TsCode,
Name: row.Name,
Market: row.Market,
Industry: row.Industry,
TOpen: row.TOpen,
THigh: row.THigh,
TClose: row.TClose,
TPctChg: row.TPctChg,
HasTushareBreakData: row.HasTushareBreakData,
NextOpen: row.NextOpen,
NextHigh: row.NextHigh,
NextLow: row.NextLow,
NextClose: row.NextClose,
}
if row.FirstTime != nil {
item.FirstTime = formatLimitTime(*row.FirstTime)
}
item.BreakTimeBefore1100 = isLimitBreakTimeBeforeTen(item.FirstTime)
if row.TPreClose != nil && *row.TPreClose > 0 {
// T 日开盘涨幅以 T 日前收为基准,低于 101% 用于筛选低开/小幅高开炸板。
item.TOpenPctChg = pctChangePtr(row.TOpen, *row.TPreClose)
if row.TOpen != nil {
item.TOpenGainLt101 = *row.TOpen < *row.TPreClose*1.01
}
}
if row.TClose != nil && *row.TClose > 0 {
// T+1 的开高低收收益均以 T 日收盘价作为基准。
item.NextOpenPctChg = pctChangePtr(row.NextOpen, *row.TClose)
item.NextHighProfitPct = pctChangePtr(row.NextHigh, *row.TClose)
item.NextLowLossPct = pctChangePtr(row.NextLow, *row.TClose)
item.NextClosePctChg = pctChangePtr(row.NextClose, *row.TClose)
if row.NextHigh != nil && row.THigh != nil {
// 是否盘中可盈利按 T+1 最高价能否覆盖 T 日最高买入成本判断。
nextIntradayProfitable := *row.NextHigh >= *row.THigh
item.NextIntradayProfitable = &nextIntradayProfitable
}
}
return item
}
// pctChangePtr 计算相对基准价的百分比变化,输入缺失时返回 nil。
func pctChangePtr(value *float64, base float64) *float64 {
if value == nil || base == 0 {
return nil
}
result := roundLimitBreakPct((*value - base) / base * 100)
return &result
}
// roundLimitBreakPct 保留两位小数。
func roundLimitBreakPct(value float64) float64 {
return math.Round(value*100) / 100
}
// formatLimitTime 将 TuShare 紧凑时间格式转换为 HH:MM:SS。
func formatLimitTime(value string) string {
timeText := strings.TrimSpace(value)
if timeText == "" || strings.EqualFold(timeText, "none") || strings.EqualFold(timeText, "null") {
return ""
}
if strings.Contains(timeText, ":") {
return timeText
}
switch len(timeText) {
case 4:
timeText += "00"
case 5:
timeText = "0" + timeText
case 6:
default:
return value
}
return timeText[0:2] + ":" + timeText[2:4] + ":" + timeText[4:6]
}
// isLimitBreakTimeBeforeTen 判断炸板时间是否早于 10:00。
//
// 空时间在当前业务中视为满足条件,保持历史接口行为。
func isLimitBreakTimeBeforeTen(limitTime string) bool {
if strings.TrimSpace(limitTime) == "" {
return true
}
timeText := formatLimitTime(limitTime)
parts := strings.Split(timeText, ":")
if len(parts) < 2 || len(parts) > 3 {
return false
}
hour, err := strconv.Atoi(parts[0])
if err != nil {
return false
}
minute, err := strconv.Atoi(parts[1])
if err != nil {
return false
}
second := 0
if len(parts) == 3 {
second, err = strconv.Atoi(parts[2])
if err != nil {
return false
}
}
if hour < 0 || hour > 23 || minute < 0 || minute > 59 || second < 0 || second > 59 {
return false
}
return hour < 10
}
@@ -0,0 +1,219 @@
package service
import (
"fmt"
"sort"
"strconv"
"strings"
"level_go/internal/model"
)
// GetLimitOpenCompareGroups 返回昨日涨停且今日继续涨停股票的开盘对比分组。
//
// 重点识别两类股票:今日开盘低于前收的股票,以及首板进二板且 11 点前首次涨停的股票。
func (s *LimitBoardService) GetLimitOpenCompareGroups(tradeDate string) (model.LimitOpenCompareResponse, error) {
resp := model.LimitOpenCompareResponse{TradeDate: tradeDate}
tradeDates, dateIndex, err := s.loadTradeCalendar(tradeDate)
if err != nil {
return resp, err
}
if dateIndex == -1 {
resp.Groups = buildLimitOpenCompareGroups(nil, nil)
return resp, nil
}
previousTradeDate := ""
if dateIndex > 0 {
previousTradeDate = tradeDates[dateIndex-1]
}
resp.PreviousTradeDate = previousTradeDate
previousTwoTradeDate := ""
if dateIndex > 1 {
previousTwoTradeDate = tradeDates[dateIndex-2]
}
resp.PreviousTwoTradeDate = previousTwoTradeDate
stocks, err := s.loadLimitOpenCompareStocks(tradeDate, previousTradeDate, previousTwoTradeDate)
if err != nil {
return resp, err
}
openLowerStocks := make([]model.LimitOpenCompareStock, 0)
otherStocks := make([]model.LimitOpenCompareStock, 0)
firstToSecondBoardCount := 0
firstSecondBeforeElevenCount := 0
for _, stock := range stocks {
// previousTwoTradeDate 未涨停、previousTradeDate 涨停、tradeDate 涨停时,认为是首板进二板。
if stock.IsFirstToSecondBoard {
stock.HighlightLabel = "首板进二板"
firstToSecondBoardCount++
}
isOpenLower := previousTradeDate != "" && stock.HasOpen && stock.HasPreviousClose && stock.Open < stock.PreviousClose
if isOpenLower && stock.IsFirstToSecondBoard && isBeforeEleven(stock.AkFirstLimitTime) {
// 首板进二板且 11 点前封板的低开股,前端会用更强提示标签展示。
stock.IsFirstSecondBeforeEleven = true
stock.HighlightLabel = "首二一一"
firstSecondBeforeElevenCount++
}
if isOpenLower {
openLowerStocks = append(openLowerStocks, stock)
continue
}
otherStocks = append(otherStocks, stock)
}
resp.Summary = model.LimitOpenCompareSummary{
LimitCount: len(stocks),
OpenLowerPreviousCloseCount: len(openLowerStocks),
OthersCount: len(otherStocks),
FirstToSecondBoardCount: firstToSecondBoardCount,
FirstSecondBeforeElevenCount: firstSecondBeforeElevenCount,
}
resp.Groups = buildLimitOpenCompareGroups(openLowerStocks, otherStocks)
return resp, nil
}
// isBeforeEleven 判断涨停时间是否早于 11:00。
//
// 兼容 HH:MM:SS、930、0930、93000、093000 等时间格式。
func isBeforeEleven(limitTime *string) bool {
if limitTime == nil || *limitTime == "" {
return false
}
timeText := strings.TrimSpace(*limitTime)
hour := 0
minute := 0
second := 0
if strings.Contains(timeText, ":") {
if n, _ := fmt.Sscanf(timeText, "%d:%d:%d", &hour, &minute, &second); n < 2 {
return false
}
} else {
if len(timeText) < 4 {
return false
}
switch len(timeText) {
case 4:
timeText += "00"
case 5:
timeText = "0" + timeText
case 6:
default:
return false
}
var err error
hour, err = strconv.Atoi(timeText[0:2])
if err != nil {
return false
}
minute, err = strconv.Atoi(timeText[2:4])
if err != nil {
return false
}
second, err = strconv.Atoi(timeText[4:6])
if err != nil {
return false
}
}
if hour < 0 || hour > 23 || minute < 0 || minute > 59 || second < 0 || second > 59 {
return false
}
return hour < 11
}
// loadLimitOpenCompareStocks 查询昨日涨停且今日继续涨停的股票,并带出今日开盘和昨日收盘。
func (s *LimitBoardService) loadLimitOpenCompareStocks(tradeDate, previousTradeDate, previousTwoTradeDate string) ([]model.LimitOpenCompareStock, error) {
var stocks []model.LimitOpenCompareStock
query := `SELECT
l.symbol,
l.ts_code,
l.name,
COALESCE(l.market, '') AS market,
DATE_FORMAT(l.trade_date, '%Y-%m-%d') AS trade_date,
d.open IS NOT NULL AS has_open,
COALESCE(d.open, 0) AS open,
COALESCE(l.calc_close, l.tushare_close, d.close, 0) AS close,
? AS previous_trade_date,
? AS previous_two_trade_date,
pd.close IS NOT NULL AS has_previous_close,
COALESCE(pd.close, 0) AS previous_close,
(? <> '' AND NOT EXISTS (
SELECT 1
FROM stock_limit_combined_data ppl
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,
l.tushare_amount AS ak_amount,
l.tushare_float_mv AS ak_float_market_value,
l.tushare_total_mv AS ak_total_market_value,
l.tushare_turnover_ratio AS ak_turnover_rate,
l.tushare_fd_amount AS ak_seal_amount,
l.tushare_first_time AS ak_first_limit_time,
l.tushare_last_time AS ak_last_limit_time,
l.tushare_open_times AS ak_break_limit_count,
l.tushare_up_stat AS ak_limit_up_stat,
l.tushare_limit_times AS ak_limit_up_days,
l.tushare_industry AS ak_industry,
l.calc_vol AS vol
FROM stock_limit_combined_data l
LEFT JOIN stock_daily d
ON l.ts_code = d.ts_code AND d.trade_date = l.trade_date
LEFT JOIN stock_daily pd
ON l.ts_code = pd.ts_code AND pd.trade_date = ?
INNER JOIN stock_limit_combined_data pl
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`
if previousTradeDate == "" {
return stocks, nil
}
err := s.DB.Raw(query, previousTradeDate, previousTwoTradeDate, previousTwoTradeDate, previousTwoTradeDate, previousTradeDate, previousTradeDate, tradeDate).Scan(&stocks).Error
return stocks, err
}
// buildLimitOpenCompareGroups 构造“开盘低于前收”和“其他涨停股”两个固定分组。
func buildLimitOpenCompareGroups(openLowerStocks, otherStocks []model.LimitOpenCompareStock) []model.LimitOpenCompareGroup {
if openLowerStocks == nil {
openLowerStocks = []model.LimitOpenCompareStock{}
}
if otherStocks == nil {
otherStocks = []model.LimitOpenCompareStock{}
}
sortLimitOpenCompareStocks(openLowerStocks)
sortLimitOpenCompareStocks(otherStocks)
return []model.LimitOpenCompareGroup{
{
Key: "open_lower_previous_close",
Label: "开盘低于前收",
Count: len(openLowerStocks),
Stocks: openLowerStocks,
},
{
Key: "others",
Label: "其他涨停股",
Count: len(otherStocks),
Stocks: otherStocks,
},
}
}
// sortLimitOpenCompareStocks 按涨跌幅降序、股票代码升序稳定展示分组内股票。
func sortLimitOpenCompareStocks(stocks []model.LimitOpenCompareStock) {
sort.Slice(stocks, func(i, j int) bool {
if stocks[i].PctChg == stocks[j].PctChg {
return stocks[i].TsCode < stocks[j].TsCode
}
return stocks[i].PctChg > stocks[j].PctChg
})
}
@@ -0,0 +1,148 @@
package service
import (
"sort"
"level_go/internal/model"
)
// GetLimitOpenThresholdGroups 返回昨日涨停股今日开盘是否低于前收 101% 的分组。
func (s *LimitBoardService) GetLimitOpenThresholdGroups(tradeDate string) (model.LimitOpenThresholdResponse, error) {
resp := model.LimitOpenThresholdResponse{TradeDate: tradeDate}
// thresholdRatio 是开盘价阈值,1.01 表示前收的 101%。
const thresholdRatio = 1.01
tradeDates, dateIndex, err := s.loadTradeCalendar(tradeDate)
if err != nil {
return resp, err
}
if dateIndex == -1 {
resp.Summary.ThresholdRatio = thresholdRatio
resp.Groups = buildLimitOpenThresholdGroups(nil, nil)
return resp, nil
}
previousTradeDate := ""
if dateIndex > 0 {
previousTradeDate = tradeDates[dateIndex-1]
}
resp.PreviousTradeDate = previousTradeDate
stocks, err := s.loadLimitOpenThresholdStocks(tradeDate, previousTradeDate)
if err != nil {
return resp, err
}
withinThresholdStocks := make([]model.LimitOpenThresholdStock, 0)
atOrAboveThresholdStocks := make([]model.LimitOpenThresholdStock, 0)
for _, stock := range stocks {
// 前收价格有效时才计算阈值价格和开盘/前收比值,避免除零和误导性数据。
if stock.HasPreviousClose && stock.PreviousClose > 0 {
stock.OpenThresholdPrice = stock.PreviousClose * thresholdRatio
}
if stock.HasOpen && stock.HasPreviousClose && stock.PreviousClose > 0 {
stock.OpenPreviousCloseRatio = stock.Open / stock.PreviousClose
}
if stock.HasOpen && stock.HasPreviousClose && stock.Open < stock.OpenThresholdPrice {
withinThresholdStocks = append(withinThresholdStocks, stock)
continue
}
atOrAboveThresholdStocks = append(atOrAboveThresholdStocks, stock)
}
resp.Summary = model.LimitOpenThresholdSummary{
LimitCount: len(stocks),
OpenWithinThresholdCount: len(withinThresholdStocks),
OpenAtOrAboveThresholdCount: len(atOrAboveThresholdStocks),
ThresholdRatio: thresholdRatio,
}
resp.Groups = buildLimitOpenThresholdGroups(withinThresholdStocks, atOrAboveThresholdStocks)
return resp, nil
}
// loadLimitOpenThresholdStocks 查询昨日涨停且今日继续涨停的股票,并补齐今日开盘和前收。
func (s *LimitBoardService) loadLimitOpenThresholdStocks(tradeDate, previousTradeDate string) ([]model.LimitOpenThresholdStock, error) {
var stocks []model.LimitOpenThresholdStock
query := `SELECT
l.symbol,
l.ts_code,
l.name,
COALESCE(l.market, '') AS market,
DATE_FORMAT(l.trade_date, '%Y-%m-%d') AS trade_date,
d.open IS NOT NULL AS has_open,
COALESCE(d.open, 0) AS open,
COALESCE(l.calc_close, l.tushare_close, d.close, 0) AS close,
? AS previous_trade_date,
(pl.calc_close IS NOT NULL OR pl.tushare_close IS NOT NULL OR pd.close IS NOT NULL) AS has_previous_close,
COALESCE(pl.calc_close, pl.tushare_close, pd.close, 0) AS previous_close,
COALESCE(l.calc_limit_price, 0) AS limit_price,
COALESCE(l.tushare_pct_chg, d.pct_chg, 0) AS pct_chg,
l.tushare_amount AS ak_amount,
l.tushare_float_mv AS ak_float_market_value,
l.tushare_total_mv AS ak_total_market_value,
l.tushare_turnover_ratio AS ak_turnover_rate,
l.tushare_fd_amount AS ak_seal_amount,
l.tushare_first_time AS ak_first_limit_time,
l.tushare_last_time AS ak_last_limit_time,
l.tushare_open_times AS ak_break_limit_count,
l.tushare_up_stat AS ak_limit_up_stat,
l.tushare_limit_times AS ak_limit_up_days,
l.tushare_industry AS ak_industry,
l.calc_vol AS vol
FROM stock_limit_combined_data l
LEFT JOIN stock_daily d
ON l.ts_code = d.ts_code AND d.trade_date = l.trade_date
LEFT JOIN stock_daily pd
ON l.ts_code = pd.ts_code AND pd.trade_date = ?
INNER JOIN stock_limit_combined_data pl
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`
if previousTradeDate == "" {
return stocks, nil
}
err := s.DB.Raw(query, previousTradeDate, previousTradeDate, previousTradeDate, tradeDate).Scan(&stocks).Error
return stocks, err
}
// buildLimitOpenThresholdGroups 构造低于阈值和达到/超过阈值两个固定分组。
func buildLimitOpenThresholdGroups(withinThresholdStocks, atOrAboveThresholdStocks []model.LimitOpenThresholdStock) []model.LimitOpenThresholdGroup {
if withinThresholdStocks == nil {
withinThresholdStocks = []model.LimitOpenThresholdStock{}
}
if atOrAboveThresholdStocks == nil {
atOrAboveThresholdStocks = []model.LimitOpenThresholdStock{}
}
sortLimitOpenThresholdStocks(withinThresholdStocks)
sortLimitOpenThresholdStocks(atOrAboveThresholdStocks)
return []model.LimitOpenThresholdGroup{
{
Key: "open_within_previous_close_101",
Label: "开盘价低于前收101%",
Count: len(withinThresholdStocks),
Stocks: withinThresholdStocks,
},
{
Key: "open_at_or_above_previous_close_101",
Label: "开盘价大于等于前收101%",
Count: len(atOrAboveThresholdStocks),
Stocks: atOrAboveThresholdStocks,
},
}
}
// sortLimitOpenThresholdStocks 按涨跌幅降序、股票代码升序稳定展示分组内股票。
func sortLimitOpenThresholdStocks(stocks []model.LimitOpenThresholdStock) {
sort.Slice(stocks, func(i, j int) bool {
if stocks[i].PctChg == stocks[j].PctChg {
return stocks[i].TsCode < stocks[j].TsCode
}
return stocks[i].PctChg > stocks[j].PctChg
})
}
@@ -0,0 +1,242 @@
package service
import (
"sort"
"level_go/internal/model"
)
// limitBoardHistoryRow 是连板计算所需的最小历史涨停记录。
type limitBoardHistoryRow struct {
TsCode string `gorm:"column:ts_code"` // 股票代码。
TradeDate string `gorm:"column:trade_date"` // 涨停交易日。
}
// GetLimitBoardOverview 返回指定交易日的涨停连板概览。
//
// 结果包括最近 30 个交易日最高连板数、当日涨停股按连板数分组后的列表和汇总统计。
func (s *LimitBoardService) GetLimitBoardOverview(tradeDate string) (model.LimitBoardResponse, error) {
resp := model.LimitBoardResponse{TradeDate: tradeDate}
tradeDates, dateIndex, err := s.loadTradeCalendar(tradeDate)
if err != nil {
return resp, err
}
if dateIndex == -1 {
return resp, nil
}
resp.RecentMaxBoardStats, err = s.buildRecentMaxBoardStats(tradeDates, dateIndex)
if err != nil {
return resp, err
}
limitStocks, err := s.loadCurrentLimitStocks(tradeDate)
if err != nil {
return resp, err
}
if len(limitStocks) == 0 {
return resp, nil
}
historyMap, err := s.loadLimitHistory(limitStocks, tradeDate)
if err != nil {
return resp, err
}
groupMap, maxBoardCount := buildLimitBoardGroups(limitStocks, tradeDates, dateIndex, historyMap)
resp.Groups = sortLimitBoardGroups(groupMap)
resp.Summary = model.LimitBoardSummary{
MaxBoardCount: maxBoardCount,
LimitCount: len(limitStocks),
GroupCount: len(resp.Groups),
}
return resp, nil
}
// buildRecentMaxBoardStats 计算最近最多 30 个交易日的每日最高连板数。
func (s *LimitBoardService) buildRecentMaxBoardStats(tradeDates []string, dateIndex int) ([]model.LimitBoardDailyMax, error) {
startIndex := 0
if dateIndex >= 29 {
startIndex = dateIndex - 29
}
windowDates := tradeDates[startIndex : dateIndex+1]
if len(windowDates) == 0 {
return []model.LimitBoardDailyMax{}, nil
}
var history []limitBoardHistoryRow
if err := s.DB.Table(stockLimitCombinedTable).
Select("ts_code, DATE_FORMAT(trade_date, '%Y-%m-%d') AS trade_date").
Where("trade_date BETWEEN ? AND ? AND final_status = ?", windowDates[0], windowDates[len(windowDates)-1], 1).
Order("ts_code ASC, trade_date ASC").
Scan(&history).Error; err != nil {
return nil, err
}
dateByCode := make(map[string]map[string]struct{})
for _, item := range history {
if _, ok := dateByCode[item.TsCode]; !ok {
dateByCode[item.TsCode] = make(map[string]struct{})
}
dateByCode[item.TsCode][item.TradeDate] = struct{}{}
}
maxBoardByDate := make(map[string]int, len(windowDates))
for _, tradeDate := range windowDates {
maxBoardByDate[tradeDate] = 0
}
// 对每只股票在窗口内逐日回看连续涨停天数,并更新当天最高连板数。
for _, limitDates := range dateByCode {
for idx, tradeDate := range windowDates {
if _, ok := limitDates[tradeDate]; !ok {
continue
}
boardCount := 0
for j := idx; j >= 0; j-- {
if _, ok := limitDates[windowDates[j]]; !ok {
break
}
boardCount++
}
if boardCount > maxBoardByDate[tradeDate] {
maxBoardByDate[tradeDate] = boardCount
}
}
}
stats := make([]model.LimitBoardDailyMax, 0, len(windowDates))
for _, tradeDate := range windowDates {
stats = append(stats, model.LimitBoardDailyMax{
TradeDate: tradeDate,
MaxBoardCount: maxBoardByDate[tradeDate],
})
}
return stats, nil
}
// loadCurrentLimitStocks 查询指定交易日所有最终状态为涨停的股票。
func (s *LimitBoardService) loadCurrentLimitStocks(tradeDate string) ([]model.LimitBoardStock, error) {
var stocks []model.LimitBoardStock
query := `SELECT
l.symbol,
l.ts_code,
l.name,
COALESCE(l.market, '') AS market,
DATE_FORMAT(l.trade_date, '%Y-%m-%d') AS trade_date,
COALESCE(l.calc_close, l.tushare_close, d.close, 0) AS close,
COALESCE(l.calc_limit_price, 0) AS limit_price,
COALESCE(l.tushare_pct_chg, d.pct_chg, 0) AS pct_chg,
l.tushare_amount AS ak_amount,
l.tushare_float_mv AS ak_float_market_value,
l.tushare_total_mv AS ak_total_market_value,
l.tushare_turnover_ratio AS ak_turnover_rate,
l.tushare_fd_amount AS ak_seal_amount,
l.tushare_first_time AS ak_first_limit_time,
l.tushare_last_time AS ak_last_limit_time,
l.tushare_open_times AS ak_break_limit_count,
l.tushare_up_stat AS ak_limit_up_stat,
l.tushare_limit_times AS ak_limit_up_days,
l.tushare_industry AS ak_industry,
l.calc_vol AS vol
FROM stock_limit_combined_data l
LEFT JOIN stock_daily d
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`
err := s.DB.Raw(query, tradeDate).Scan(&stocks).Error
return stocks, err
}
// loadLimitHistory 查询指定股票集合在 tradeDate 之前及当天的涨停历史。
func (s *LimitBoardService) loadLimitHistory(stocks []model.LimitBoardStock, tradeDate string) (map[string]map[string]struct{}, error) {
codes := make([]string, 0, len(stocks))
for _, stock := range stocks {
codes = append(codes, stock.TsCode)
}
var history []limitBoardHistoryRow
if err := s.DB.Table(stockLimitCombinedTable).
Select("ts_code, DATE_FORMAT(trade_date, '%Y-%m-%d') AS trade_date").
Where("ts_code IN ? AND trade_date <= ? AND final_status = ?", codes, tradeDate, 1).
Scan(&history).Error; err != nil {
return nil, err
}
historyMap := make(map[string]map[string]struct{}, len(codes))
for _, item := range history {
if _, ok := historyMap[item.TsCode]; !ok {
historyMap[item.TsCode] = make(map[string]struct{})
}
historyMap[item.TsCode][item.TradeDate] = struct{}{}
}
return historyMap, nil
}
// buildLimitBoardGroups 计算每只股票的连板数,并按连板数构造分组映射。
func buildLimitBoardGroups(stocks []model.LimitBoardStock, tradeDates []string, dateIndex int, historyMap map[string]map[string]struct{}) (map[int][]model.LimitBoardStock, int) {
groupMap := make(map[int][]model.LimitBoardStock)
maxBoardCount := 0
for i := range stocks {
boardCount := calcBoardCount(tradeDates, dateIndex, historyMap[stocks[i].TsCode])
stocks[i].BoardCount = boardCount
stocks[i].BoardLabel = boardLabel(boardCount)
stocks[i].DisplayStatus = boardLabel(boardCount)
if boardCount > maxBoardCount {
maxBoardCount = boardCount
}
groupMap[boardCount] = append(groupMap[boardCount], stocks[i])
}
return groupMap, maxBoardCount
}
// calcBoardCount 从目标交易日向前回溯,直到遇到非涨停日为止,得到连续涨停天数。
func calcBoardCount(tradeDates []string, dateIndex int, limitDates map[string]struct{}) int {
boardCount := 0
for i := dateIndex; i >= 0; i-- {
if _, ok := limitDates[tradeDates[i]]; !ok {
break
}
boardCount++
}
return boardCount
}
// sortLimitBoardGroups 将连板分组按连板数倒序排列,组内按涨跌幅降序排列。
func sortLimitBoardGroups(groupMap map[int][]model.LimitBoardStock) []model.LimitBoardGroup {
boardCounts := make([]int, 0, len(groupMap))
for boardCount := range groupMap {
boardCounts = append(boardCounts, boardCount)
}
sort.Sort(sort.Reverse(sort.IntSlice(boardCounts)))
groups := make([]model.LimitBoardGroup, 0, len(boardCounts))
for _, boardCount := range boardCounts {
stocks := groupMap[boardCount]
sort.Slice(stocks, func(i, j int) bool {
if stocks[i].PctChg == stocks[j].PctChg {
return stocks[i].TsCode < stocks[j].TsCode
}
return stocks[i].PctChg > stocks[j].PctChg
})
groups = append(groups, model.LimitBoardGroup{
BoardCount: boardCount,
Label: boardLabel(boardCount),
Count: len(stocks),
Stocks: stocks,
})
}
return groups
}
+7 -562
View File
@@ -2,25 +2,19 @@ package service
import (
"fmt"
"sort"
"strconv"
"strings"
"gorm.io/gorm"
"level_go/internal/model"
)
// LimitBoardService 提供涨停板概览、分组、收益统计等业务能力。
type LimitBoardService struct {
DB *gorm.DB
DB *gorm.DB // 数据库连接。
}
// stockLimitCombinedTable 是涨停合并数据表名,集中定义便于多个查询复用。
const stockLimitCombinedTable = "stock_limit_combined_data"
type limitBoardHistoryRow struct {
TsCode string `gorm:"column:ts_code"`
TradeDate string `gorm:"column:trade_date"`
}
// boardLabel 把连续涨停天数转换为中文连板标签。
func boardLabel(boardCount int) string {
switch boardCount {
case 1:
@@ -46,272 +40,9 @@ func boardLabel(boardCount int) string {
}
}
func (s *LimitBoardService) GetLimitBoardOverview(tradeDate string) (model.LimitBoardResponse, error) {
resp := model.LimitBoardResponse{TradeDate: tradeDate}
tradeDates, dateIndex, err := s.loadTradeCalendar(tradeDate)
if err != nil {
return resp, err
}
if dateIndex == -1 {
return resp, nil
}
resp.RecentMaxBoardStats, err = s.buildRecentMaxBoardStats(tradeDates, dateIndex)
if err != nil {
return resp, err
}
limitStocks, err := s.loadCurrentLimitStocks(tradeDate)
if err != nil {
return resp, err
}
if len(limitStocks) == 0 {
return resp, nil
}
historyMap, err := s.loadLimitHistory(limitStocks, tradeDate)
if err != nil {
return resp, err
}
groupMap, maxBoardCount := buildLimitBoardGroups(limitStocks, tradeDates, dateIndex, historyMap)
resp.Groups = sortLimitBoardGroups(groupMap)
resp.Summary = model.LimitBoardSummary{
MaxBoardCount: maxBoardCount,
LimitCount: len(limitStocks),
GroupCount: len(resp.Groups),
}
return resp, nil
}
func (s *LimitBoardService) GetLimitOpenCompareGroups(tradeDate string) (model.LimitOpenCompareResponse, error) {
resp := model.LimitOpenCompareResponse{TradeDate: tradeDate}
tradeDates, dateIndex, err := s.loadTradeCalendar(tradeDate)
if err != nil {
return resp, err
}
if dateIndex == -1 {
resp.Groups = buildLimitOpenCompareGroups(nil, nil)
return resp, nil
}
previousTradeDate := ""
if dateIndex > 0 {
previousTradeDate = tradeDates[dateIndex-1]
}
resp.PreviousTradeDate = previousTradeDate
previousTwoTradeDate := ""
if dateIndex > 1 {
previousTwoTradeDate = tradeDates[dateIndex-2]
}
resp.PreviousTwoTradeDate = previousTwoTradeDate
stocks, err := s.loadLimitOpenCompareStocks(tradeDate, previousTradeDate, previousTwoTradeDate)
if err != nil {
return resp, err
}
openLowerStocks := make([]model.LimitOpenCompareStock, 0)
otherStocks := make([]model.LimitOpenCompareStock, 0)
firstToSecondBoardCount := 0
firstSecondBeforeElevenCount := 0
for _, stock := range stocks {
if stock.IsFirstToSecondBoard {
stock.HighlightLabel = "首板进二板"
firstToSecondBoardCount++
}
isOpenLower := previousTradeDate != "" && stock.HasOpen && stock.HasPreviousClose && stock.Open < stock.PreviousClose
if isOpenLower && stock.IsFirstToSecondBoard && isBeforeEleven(stock.AkFirstLimitTime) {
stock.IsFirstSecondBeforeEleven = true
stock.HighlightLabel = "首二一一"
firstSecondBeforeElevenCount++
}
if isOpenLower {
openLowerStocks = append(openLowerStocks, stock)
continue
}
otherStocks = append(otherStocks, stock)
}
resp.Summary = model.LimitOpenCompareSummary{
LimitCount: len(stocks),
OpenLowerPreviousCloseCount: len(openLowerStocks),
OthersCount: len(otherStocks),
FirstToSecondBoardCount: firstToSecondBoardCount,
FirstSecondBeforeElevenCount: firstSecondBeforeElevenCount,
}
resp.Groups = buildLimitOpenCompareGroups(openLowerStocks, otherStocks)
return resp, nil
}
func (s *LimitBoardService) GetLimitOpenThresholdGroups(tradeDate string) (model.LimitOpenThresholdResponse, error) {
resp := model.LimitOpenThresholdResponse{TradeDate: tradeDate}
const thresholdRatio = 1.01
tradeDates, dateIndex, err := s.loadTradeCalendar(tradeDate)
if err != nil {
return resp, err
}
if dateIndex == -1 {
resp.Summary.ThresholdRatio = thresholdRatio
resp.Groups = buildLimitOpenThresholdGroups(nil, nil)
return resp, nil
}
previousTradeDate := ""
if dateIndex > 0 {
previousTradeDate = tradeDates[dateIndex-1]
}
resp.PreviousTradeDate = previousTradeDate
stocks, err := s.loadLimitOpenThresholdStocks(tradeDate, previousTradeDate)
if err != nil {
return resp, err
}
withinThresholdStocks := make([]model.LimitOpenThresholdStock, 0)
atOrAboveThresholdStocks := make([]model.LimitOpenThresholdStock, 0)
for _, stock := range stocks {
if stock.HasPreviousClose && stock.PreviousClose > 0 {
stock.OpenThresholdPrice = stock.PreviousClose * thresholdRatio
}
if stock.HasOpen && stock.HasPreviousClose && stock.PreviousClose > 0 {
stock.OpenPreviousCloseRatio = stock.Open / stock.PreviousClose
}
if stock.HasOpen && stock.HasPreviousClose && stock.Open < stock.OpenThresholdPrice {
withinThresholdStocks = append(withinThresholdStocks, stock)
continue
}
atOrAboveThresholdStocks = append(atOrAboveThresholdStocks, stock)
}
resp.Summary = model.LimitOpenThresholdSummary{
LimitCount: len(stocks),
OpenWithinThresholdCount: len(withinThresholdStocks),
OpenAtOrAboveThresholdCount: len(atOrAboveThresholdStocks),
ThresholdRatio: thresholdRatio,
}
resp.Groups = buildLimitOpenThresholdGroups(withinThresholdStocks, atOrAboveThresholdStocks)
return resp, nil
}
func isBeforeEleven(limitTime *string) bool {
if limitTime == nil || *limitTime == "" {
return false
}
timeText := strings.TrimSpace(*limitTime)
hour := 0
minute := 0
second := 0
if strings.Contains(timeText, ":") {
if n, _ := fmt.Sscanf(timeText, "%d:%d:%d", &hour, &minute, &second); n < 2 {
return false
}
} else {
if len(timeText) < 4 {
return false
}
switch len(timeText) {
case 4:
timeText += "00"
case 5:
timeText = "0" + timeText
case 6:
default:
return false
}
var err error
hour, err = strconv.Atoi(timeText[0:2])
if err != nil {
return false
}
minute, err = strconv.Atoi(timeText[2:4])
if err != nil {
return false
}
second, err = strconv.Atoi(timeText[4:6])
if err != nil {
return false
}
}
if hour < 0 || hour > 23 || minute < 0 || minute > 59 || second < 0 || second > 59 {
return false
}
return hour < 11
}
func (s *LimitBoardService) buildRecentMaxBoardStats(tradeDates []string, dateIndex int) ([]model.LimitBoardDailyMax, error) {
startIndex := 0
if dateIndex >= 29 {
startIndex = dateIndex - 29
}
windowDates := tradeDates[startIndex : dateIndex+1]
if len(windowDates) == 0 {
return []model.LimitBoardDailyMax{}, nil
}
var history []limitBoardHistoryRow
if err := s.DB.Table(stockLimitCombinedTable).
Select("ts_code, DATE_FORMAT(trade_date, '%Y-%m-%d') AS trade_date").
Where("trade_date BETWEEN ? AND ? AND final_status = ?", windowDates[0], windowDates[len(windowDates)-1], 1).
Order("ts_code ASC, trade_date ASC").
Scan(&history).Error; err != nil {
return nil, err
}
dateByCode := make(map[string]map[string]struct{})
for _, item := range history {
if _, ok := dateByCode[item.TsCode]; !ok {
dateByCode[item.TsCode] = make(map[string]struct{})
}
dateByCode[item.TsCode][item.TradeDate] = struct{}{}
}
maxBoardByDate := make(map[string]int, len(windowDates))
for _, tradeDate := range windowDates {
maxBoardByDate[tradeDate] = 0
}
for _, limitDates := range dateByCode {
for idx, tradeDate := range windowDates {
if _, ok := limitDates[tradeDate]; !ok {
continue
}
boardCount := 0
for j := idx; j >= 0; j-- {
if _, ok := limitDates[windowDates[j]]; !ok {
break
}
boardCount++
}
if boardCount > maxBoardByDate[tradeDate] {
maxBoardByDate[tradeDate] = boardCount
}
}
}
stats := make([]model.LimitBoardDailyMax, 0, len(windowDates))
for _, tradeDate := range windowDates {
stats = append(stats, model.LimitBoardDailyMax{
TradeDate: tradeDate,
MaxBoardCount: maxBoardByDate[tradeDate],
})
}
return stats, nil
}
// loadTradeCalendar 查询不晚于 tradeDate 的上交所开市日,并返回 tradeDate 在切片中的索引。
//
// dateIndex 为 -1 表示传入日期不是当前交易日历中的开市日。
func (s *LimitBoardService) loadTradeCalendar(tradeDate string) ([]string, int, error) {
var tradeDates []string
if err := s.DB.Table("trade_calendar").
@@ -332,289 +63,3 @@ func (s *LimitBoardService) loadTradeCalendar(tradeDate string) ([]string, int,
return tradeDates, dateIndex, nil
}
func (s *LimitBoardService) loadCurrentLimitStocks(tradeDate string) ([]model.LimitBoardStock, error) {
var stocks []model.LimitBoardStock
query := `SELECT
l.symbol,
l.ts_code,
l.name,
COALESCE(l.market, '') AS market,
DATE_FORMAT(l.trade_date, '%Y-%m-%d') AS trade_date,
COALESCE(l.calc_close, l.tushare_close, d.close, 0) AS close,
COALESCE(l.calc_limit_price, 0) AS limit_price,
COALESCE(l.tushare_pct_chg, d.pct_chg, 0) AS pct_chg,
l.tushare_amount AS ak_amount,
l.tushare_float_mv AS ak_float_market_value,
l.tushare_total_mv AS ak_total_market_value,
l.tushare_turnover_ratio AS ak_turnover_rate,
l.tushare_fd_amount AS ak_seal_amount,
l.tushare_first_time AS ak_first_limit_time,
l.tushare_last_time AS ak_last_limit_time,
l.tushare_open_times AS ak_break_limit_count,
l.tushare_up_stat AS ak_limit_up_stat,
l.tushare_limit_times AS ak_limit_up_days,
l.tushare_industry AS ak_industry,
l.calc_vol AS vol
FROM stock_limit_combined_data l
LEFT JOIN stock_daily d
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`
err := s.DB.Raw(query, tradeDate).Scan(&stocks).Error
return stocks, err
}
func (s *LimitBoardService) loadLimitOpenCompareStocks(tradeDate, previousTradeDate, previousTwoTradeDate string) ([]model.LimitOpenCompareStock, error) {
var stocks []model.LimitOpenCompareStock
query := `SELECT
l.symbol,
l.ts_code,
l.name,
COALESCE(l.market, '') AS market,
DATE_FORMAT(l.trade_date, '%Y-%m-%d') AS trade_date,
d.open IS NOT NULL AS has_open,
COALESCE(d.open, 0) AS open,
COALESCE(l.calc_close, l.tushare_close, d.close, 0) AS close,
? AS previous_trade_date,
? AS previous_two_trade_date,
pd.close IS NOT NULL AS has_previous_close,
COALESCE(pd.close, 0) AS previous_close,
(? <> '' AND NOT EXISTS (
SELECT 1
FROM stock_limit_combined_data ppl
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,
l.tushare_amount AS ak_amount,
l.tushare_float_mv AS ak_float_market_value,
l.tushare_total_mv AS ak_total_market_value,
l.tushare_turnover_ratio AS ak_turnover_rate,
l.tushare_fd_amount AS ak_seal_amount,
l.tushare_first_time AS ak_first_limit_time,
l.tushare_last_time AS ak_last_limit_time,
l.tushare_open_times AS ak_break_limit_count,
l.tushare_up_stat AS ak_limit_up_stat,
l.tushare_limit_times AS ak_limit_up_days,
l.tushare_industry AS ak_industry,
l.calc_vol AS vol
FROM stock_limit_combined_data l
LEFT JOIN stock_daily d
ON l.ts_code = d.ts_code AND d.trade_date = l.trade_date
LEFT JOIN stock_daily pd
ON l.ts_code = pd.ts_code AND pd.trade_date = ?
INNER JOIN stock_limit_combined_data pl
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`
if previousTradeDate == "" {
return stocks, nil
}
err := s.DB.Raw(query, previousTradeDate, previousTwoTradeDate, previousTwoTradeDate, previousTwoTradeDate, previousTradeDate, previousTradeDate, tradeDate).Scan(&stocks).Error
return stocks, err
}
func (s *LimitBoardService) loadLimitOpenThresholdStocks(tradeDate, previousTradeDate string) ([]model.LimitOpenThresholdStock, error) {
var stocks []model.LimitOpenThresholdStock
query := `SELECT
l.symbol,
l.ts_code,
l.name,
COALESCE(l.market, '') AS market,
DATE_FORMAT(l.trade_date, '%Y-%m-%d') AS trade_date,
d.open IS NOT NULL AS has_open,
COALESCE(d.open, 0) AS open,
COALESCE(l.calc_close, l.tushare_close, d.close, 0) AS close,
? AS previous_trade_date,
(pl.calc_close IS NOT NULL OR pl.tushare_close IS NOT NULL OR pd.close IS NOT NULL) AS has_previous_close,
COALESCE(pl.calc_close, pl.tushare_close, pd.close, 0) AS previous_close,
COALESCE(l.calc_limit_price, 0) AS limit_price,
COALESCE(l.tushare_pct_chg, d.pct_chg, 0) AS pct_chg,
l.tushare_amount AS ak_amount,
l.tushare_float_mv AS ak_float_market_value,
l.tushare_total_mv AS ak_total_market_value,
l.tushare_turnover_ratio AS ak_turnover_rate,
l.tushare_fd_amount AS ak_seal_amount,
l.tushare_first_time AS ak_first_limit_time,
l.tushare_last_time AS ak_last_limit_time,
l.tushare_open_times AS ak_break_limit_count,
l.tushare_up_stat AS ak_limit_up_stat,
l.tushare_limit_times AS ak_limit_up_days,
l.tushare_industry AS ak_industry,
l.calc_vol AS vol
FROM stock_limit_combined_data l
LEFT JOIN stock_daily d
ON l.ts_code = d.ts_code AND d.trade_date = l.trade_date
LEFT JOIN stock_daily pd
ON l.ts_code = pd.ts_code AND pd.trade_date = ?
INNER JOIN stock_limit_combined_data pl
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`
if previousTradeDate == "" {
return stocks, nil
}
err := s.DB.Raw(query, previousTradeDate, previousTradeDate, previousTradeDate, tradeDate).Scan(&stocks).Error
return stocks, err
}
func (s *LimitBoardService) loadLimitHistory(stocks []model.LimitBoardStock, tradeDate string) (map[string]map[string]struct{}, error) {
codes := make([]string, 0, len(stocks))
for _, stock := range stocks {
codes = append(codes, stock.TsCode)
}
var history []limitBoardHistoryRow
if err := s.DB.Table(stockLimitCombinedTable).
Select("ts_code, DATE_FORMAT(trade_date, '%Y-%m-%d') AS trade_date").
Where("ts_code IN ? AND trade_date <= ? AND final_status = ?", codes, tradeDate, 1).
Scan(&history).Error; err != nil {
return nil, err
}
historyMap := make(map[string]map[string]struct{}, len(codes))
for _, item := range history {
if _, ok := historyMap[item.TsCode]; !ok {
historyMap[item.TsCode] = make(map[string]struct{})
}
historyMap[item.TsCode][item.TradeDate] = struct{}{}
}
return historyMap, nil
}
func buildLimitBoardGroups(stocks []model.LimitBoardStock, tradeDates []string, dateIndex int, historyMap map[string]map[string]struct{}) (map[int][]model.LimitBoardStock, int) {
groupMap := make(map[int][]model.LimitBoardStock)
maxBoardCount := 0
for i := range stocks {
boardCount := calcBoardCount(tradeDates, dateIndex, historyMap[stocks[i].TsCode])
stocks[i].BoardCount = boardCount
stocks[i].BoardLabel = boardLabel(boardCount)
stocks[i].DisplayStatus = boardLabel(boardCount)
if boardCount > maxBoardCount {
maxBoardCount = boardCount
}
groupMap[boardCount] = append(groupMap[boardCount], stocks[i])
}
return groupMap, maxBoardCount
}
func calcBoardCount(tradeDates []string, dateIndex int, limitDates map[string]struct{}) int {
boardCount := 0
for i := dateIndex; i >= 0; i-- {
if _, ok := limitDates[tradeDates[i]]; !ok {
break
}
boardCount++
}
return boardCount
}
func sortLimitBoardGroups(groupMap map[int][]model.LimitBoardStock) []model.LimitBoardGroup {
boardCounts := make([]int, 0, len(groupMap))
for boardCount := range groupMap {
boardCounts = append(boardCounts, boardCount)
}
sort.Sort(sort.Reverse(sort.IntSlice(boardCounts)))
groups := make([]model.LimitBoardGroup, 0, len(boardCounts))
for _, boardCount := range boardCounts {
stocks := groupMap[boardCount]
sort.Slice(stocks, func(i, j int) bool {
if stocks[i].PctChg == stocks[j].PctChg {
return stocks[i].TsCode < stocks[j].TsCode
}
return stocks[i].PctChg > stocks[j].PctChg
})
groups = append(groups, model.LimitBoardGroup{
BoardCount: boardCount,
Label: boardLabel(boardCount),
Count: len(stocks),
Stocks: stocks,
})
}
return groups
}
func buildLimitOpenCompareGroups(openLowerStocks, otherStocks []model.LimitOpenCompareStock) []model.LimitOpenCompareGroup {
if openLowerStocks == nil {
openLowerStocks = []model.LimitOpenCompareStock{}
}
if otherStocks == nil {
otherStocks = []model.LimitOpenCompareStock{}
}
sortLimitOpenCompareStocks(openLowerStocks)
sortLimitOpenCompareStocks(otherStocks)
return []model.LimitOpenCompareGroup{
{
Key: "open_lower_previous_close",
Label: "开盘低于前收",
Count: len(openLowerStocks),
Stocks: openLowerStocks,
},
{
Key: "others",
Label: "其他涨停股",
Count: len(otherStocks),
Stocks: otherStocks,
},
}
}
func buildLimitOpenThresholdGroups(withinThresholdStocks, atOrAboveThresholdStocks []model.LimitOpenThresholdStock) []model.LimitOpenThresholdGroup {
if withinThresholdStocks == nil {
withinThresholdStocks = []model.LimitOpenThresholdStock{}
}
if atOrAboveThresholdStocks == nil {
atOrAboveThresholdStocks = []model.LimitOpenThresholdStock{}
}
sortLimitOpenThresholdStocks(withinThresholdStocks)
sortLimitOpenThresholdStocks(atOrAboveThresholdStocks)
return []model.LimitOpenThresholdGroup{
{
Key: "open_within_previous_close_101",
Label: "开盘价低于前收101%",
Count: len(withinThresholdStocks),
Stocks: withinThresholdStocks,
},
{
Key: "open_at_or_above_previous_close_101",
Label: "开盘价大于等于前收101%",
Count: len(atOrAboveThresholdStocks),
Stocks: atOrAboveThresholdStocks,
},
}
}
func sortLimitOpenCompareStocks(stocks []model.LimitOpenCompareStock) {
sort.Slice(stocks, func(i, j int) bool {
if stocks[i].PctChg == stocks[j].PctChg {
return stocks[i].TsCode < stocks[j].TsCode
}
return stocks[i].PctChg > stocks[j].PctChg
})
}
func sortLimitOpenThresholdStocks(stocks []model.LimitOpenThresholdStock) {
sort.Slice(stocks, func(i, j int) bool {
if stocks[i].PctChg == stocks[j].PctChg {
return stocks[i].TsCode < stocks[j].TsCode
}
return stocks[i].PctChg > stocks[j].PctChg
})
}
@@ -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 // 返回转换后的股票明细。
}
+353
View File
@@ -0,0 +1,353 @@
package service
import (
"math"
"sort"
"strings"
"level_go/internal/model"
)
// secondCalRow 是二板日历 SQL 查询扫描出的单股基础数据。
type secondCalRow 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"` // T 日二板状态。
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 日收盘价。
}
// secondCalDaySeed 是日期范围内每个交易日的前后交易日信息。
type secondCalDaySeed 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 交易日。
}
// GetSecondCal 返回按交易日聚合的二板日历统计。
func (s *LimitBoardService) GetSecondCal(req model.SecondCalRequest) (model.SecondCalResponse, error) {
resp := model.SecondCalResponse{
TradeStartDate: req.TradeStartDate,
TradeEndDate: req.TradeEndDate,
List: []model.SecondCalDay{},
}
// 先加载完整交易日骨架,确保没有二板的交易日也能出现在日历中。
days, dayIndex, err := s.loadSecondCalDays(req.TradeStartDate, req.TradeEndDate)
if err != nil {
return resp, err
}
for _, day := range days {
resp.List = append(resp.List, model.SecondCalDay{
TradeDate: day.TradeDate,
PreviousTradeDate: day.PreviousTradeDate,
PreviousTwoTradeDate: day.PreviousTwoTradeDate,
NextTradeDate: day.NextTradeDate,
Stocks: []model.SecondCalStock{},
TotalStocks: []model.SecondCalStock{},
})
}
// 再加载命中二板口径的单股明细,并累加到对应交易日。
rows, err := s.loadSecondCalRows(req.TradeStartDate, req.TradeEndDate)
if err != nil {
return resp, err
}
for _, row := range rows {
item := buildSecondCalStock(row)
idx, ok := dayIndex[item.TradeDate]
if !ok {
continue
}
accumulateSecondCalDay(&resp.List[idx], item)
}
// 最后汇总整个日期范围,并补齐各交易日的概率字段。
resp.Summary.TradeDayCount = len(resp.List)
for i := range resp.List {
finalizeSecondCalDay(&resp.List[i])
accumulateSecondCalSummary(&resp.Summary, resp.List[i])
}
finalizeSecondCalSummary(&resp.Summary)
return resp, nil
}
// loadSecondCalDays 查询日期范围内的开市交易日和相邻交易日。
func (s *LimitBoardService) loadSecondCalDays(startDate, endDate string) ([]secondCalDaySeed, map[string]int, error) {
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
}
// 构建交易日到响应数组下标的映射,后续聚合明细时可以 O(1) 定位。
dayIndex := make(map[string]int, len(days))
for i, day := range days {
dayIndex[day.TradeDate] = i
}
return days, dayIndex, nil
}
// loadSecondCalRows 查询 T-1 涨停、T-2 未涨停、T 日再次触及涨停价的二板股票。
func (s *LimitBoardService) loadSecondCalRows(startDate, endDate string) ([]secondCalRow, error) {
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,
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,
COALESCE(symbol, '') AS 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
FROM (
SELECT
c.trade_date,
c.previous_trade_date,
c.previous_two_trade_date,
c.next_trade_date,
d.symbol,
pl.ts_code,
COALESCE(NULLIF(sb.name, ''), NULLIF(pl.name, ''), '') AS name,
COALESCE(NULLIF(sb.market, ''), '') AS market,
COALESCE(NULLIF(sb.industry, ''), NULLIF(pl.industry, ''), '') AS industry,
COALESCE(d.pre_close, pl.close) AS previous_close,
ROUND(
COALESCE(d.pre_close, pl.close) *
CASE
WHEN COALESCE(sb.market, '') = '北交所' THEN 1.30
WHEN COALESCE(sb.market, '') IN ('创业板', '科创板') THEN 1.20
WHEN COALESCE(sb.name, pl.name, '') LIKE '%ST%' THEN 1.05
ELSE 1.10
END,
2
) AS limit_price,
d.high AS t_high,
d.close AS t_close,
d.pct_chg AS t_pct_chg,
CASE
WHEN ABS(d.close - d.high) < 0.0001 THEN 'limit'
WHEN d.close < d.high THEN 'break'
ELSE 'unknown'
END AS status,
CASE
WHEN ABS(d.close - d.high) < 0.0001
THEN NULLIF(NULLIF(TRIM(tlu.first_time), ''), 'None')
WHEN d.close < d.high
THEN NULLIF(NULLIF(TRIM(tlz.first_time), ''), 'None')
ELSE NULL
END AS first_limit_time,
CASE
WHEN ABS(d.close - d.high) < 0.0001 THEN tlu.open_times
WHEN d.close < d.high THEN tlz.open_times
ELSE NULL
END AS open_times,
nd.high AS next_high,
nd.close AS next_close
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 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 d.ts_code = pl.ts_code AND d.trade_date = c.trade_date
LEFT JOIN stock_daily nd
ON nd.ts_code = pl.ts_code AND nd.trade_date = c.next_trade_date
LEFT JOIN limit_list_d tlu
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 tlz.ts_code = pl.ts_code AND tlz.trade_date = c.trade_date AND tlz.` + "`limit`" + ` = 'Z'
LEFT JOIN stock_basic sb
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
AND d.high IS NOT NULL
AND d.close IS NOT NULL
) candidate
WHERE previous_close IS NOT NULL
AND limit_price IS NOT NULL
AND limit_price > 0
AND t_high >= limit_price - 0.0001
AND status IN ('limit', 'break')
ORDER BY trade_date ASC, ts_code ASC`
var rows []secondCalRow
err := s.DB.Raw(query, startDate, endDate).Scan(&rows).Error
return rows, err
}
// buildSecondCalStock 把数据库行转换成接口明细,并计算 T+1 盘中收益。
func buildSecondCalStock(row secondCalRow) model.SecondCalStock {
item := model.SecondCalStock{
TradeDate: row.TradeDate,
PreviousTradeDate: row.PreviousTradeDate,
PreviousTwoTradeDate: row.PreviousTwoTradeDate,
NextTradeDate: row.NextTradeDate,
Symbol: row.Symbol,
TsCode: row.TsCode,
Name: row.Name,
Market: row.Market,
Industry: row.Industry,
PreviousClose: row.PreviousClose,
LimitPrice: row.LimitPrice,
THigh: row.THigh,
TClose: row.TClose,
TPctChg: row.TPctChg,
Status: row.Status,
OpenTimes: row.OpenTimes,
NextHigh: row.NextHigh,
NextClose: row.NextClose,
}
if row.FirstLimitTime != nil {
item.FirstLimitTime = formatLimitTime(*row.FirstLimitTime)
}
if row.THigh != nil && *row.THigh > 0 && row.NextHigh != nil {
// T+1 盘中盈利统一按 T+1 最高价是否高于 T 日最高价判断。
profitable := *row.NextHigh > *row.THigh
item.NextIntradayProfitable = &profitable
profitPct := roundSecondCalPct((*row.NextHigh - *row.THigh) / *row.THigh * 100)
item.NextHighProfitPct = &profitPct
}
return item
}
// accumulateSecondCalDay 把单只股票累加到对应交易日统计中。
func accumulateSecondCalDay(day *model.SecondCalDay, item model.SecondCalStock) {
day.SecondBoardCount++
switch item.Status {
case "limit":
day.LimitCount++
case "break":
day.BreakCount++
}
if item.NextIntradayProfitable != nil {
day.NextTradeDataCount++
if item.Status == "limit" {
day.LimitNextDataCount++
}
if item.Status == "break" {
day.BreakNextDataCount++
}
if *item.NextIntradayProfitable {
day.NextIntradayProfitCount++
if item.Status == "limit" {
day.LimitNextProfitCount++
}
if item.Status == "break" {
day.BreakNextProfitCount++
}
}
}
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)
}
// accumulateSecondCalSummary 把单日统计累加到日期范围汇总中。
func accumulateSecondCalSummary(summary *model.SecondCalSummary, day model.SecondCalDay) {
summary.SecondBoardCount += day.SecondBoardCount
summary.LimitCount += day.LimitCount
summary.BreakCount += day.BreakCount
summary.NextTradeDataCount += day.NextTradeDataCount
summary.NextIntradayProfitCount += day.NextIntradayProfitCount
summary.LimitNextDataCount += day.LimitNextDataCount
summary.LimitNextProfitCount += day.LimitNextProfitCount
summary.BreakNextDataCount += day.BreakNextDataCount
summary.BreakNextProfitCount += day.BreakNextProfitCount
}
// finalizeSecondCalSummary 计算日期范围汇总的概率字段。
func finalizeSecondCalSummary(summary *model.SecondCalSummary) {
summary.NextIntradayProfitRate = secondCalRate(summary.NextIntradayProfitCount, summary.NextTradeDataCount)
summary.LimitNextProfitRate = secondCalRate(summary.LimitNextProfitCount, summary.LimitNextDataCount)
summary.BreakNextProfitRate = secondCalRate(summary.BreakNextProfitCount, summary.BreakNextDataCount)
}
// secondCalRate 计算百分比概率并保留两位小数。
func secondCalRate(numerator, denominator int) float64 {
if denominator == 0 {
return 0
}
return roundSecondCalPct(float64(numerator) / float64(denominator) * 100)
}
// roundSecondCalPct 对二板日历中的百分比做两位小数舍入。
func roundSecondCalPct(value float64) float64 {
return math.Round(value*100) / 100
}
+16 -8
View File
@@ -8,10 +8,12 @@ import (
"level_go/internal/model"
)
// StockService 提供股票行情查询和区间表现分析能力。
type StockService struct {
DB *gorm.DB
DB *gorm.DB // 数据库连接。
}
// GetHistoryKline 查询指定股票自固定起点以来的日 K 线。
func (s *StockService) GetHistoryKline(code string) ([]model.StockDaily, error) {
var dailies []model.StockDaily
startDate := "2020-01-02"
@@ -24,10 +26,14 @@ func (s *StockService) GetHistoryKline(code string) ([]model.StockDaily, error)
return dailies, err
}
// roundTo2 把浮点数四舍五入到两位小数。
func roundTo2(val float64) float64 {
return math.Round(val*100) / 100
}
// AnalyzePerformance 分析某个涨停日之后到结束日期之间的下跌和反弹表现。
//
// startDate 既是涨停筛选日期,也是行情区间起点;endDate 是行情区间终点。
func (s *StockService) AnalyzePerformance(startDate, endDate string) ([]model.AnalysisResponse, error) {
var results []model.AnalysisResponse
var limits []struct {
@@ -37,7 +43,7 @@ func (s *StockService) AnalyzePerformance(startDate, endDate string) ([]model.An
High float64
}
// 查询涨停股
// 查询 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 l.ts_code = d.ts_code AND l.trade_date = d.trade_date
WHERE l.trade_date = ? AND l.final_status = 1`
@@ -75,7 +81,7 @@ func (s *StockService) AnalyzePerformance(startDate, endDate string) ([]model.An
continue
}
// A. 计算区间绝对最高价
// A. 计算区间绝对最高价,用于前端展示真实区间高点。
overallMax := dailies[0].High
overallMaxDate := dailies[0].TradeDate.Format("2006-01-02")
for _, d := range dailies {
@@ -85,11 +91,11 @@ func (s *StockService) AnalyzePerformance(startDate, endDate string) ([]model.An
}
}
// B. 逻辑计算
// B. 逻辑计算以涨停日最高价为分界,区分“未跌破涨停高点”和“跌破后反弹”两类形态。
limitHigh := stock.High
postDailies := dailies[1:]
// 找出涨停后的全局最低
// 找出涨停后的全局最低,用来判断是否跌破涨停日最高价。
minLowAfterLimit := postDailies[0].Low
for _, d := range postDailies {
if d.Low < minLowAfterLimit {
@@ -102,7 +108,7 @@ func (s *StockService) AnalyzePerformance(startDate, endDate string) ([]model.An
var logicMaxDate string
if minLowAfterLimit >= limitHigh {
// 形态 1:强势。计算涨幅
// 形态 1:强势,后续最低价仍高于涨停日最高价,因此只统计向上空间。
currMax := limitHigh
maxIdx := -1
for i, d := range postDailies {
@@ -120,7 +126,7 @@ func (s *StockService) AnalyzePerformance(startDate, endDate string) ([]model.An
}
dropPct = nil
} else {
// 形态 2:破位。计算降幅和反弹
// 形态 2:破位,先找到破位最低点,再计算从此前最高点到最低点的降幅。
absMinLow := postDailies[0].Low
minIdx := 0
for i, d := range postDailies {
@@ -129,7 +135,7 @@ func (s *StockService) AnalyzePerformance(startDate, endDate string) ([]model.An
minIdx = i
}
}
// 找到涨停到该最低点之间的最高
// 找到涨停日到最低点之间的最高价,作为本次回撤的逻辑高点。
rangeDailies := dailies[:minIdx+2]
rangeMax := dailies[0].High
maxD := startDate
@@ -150,6 +156,7 @@ func (s *StockService) AnalyzePerformance(startDate, endDate string) ([]model.An
reboundPct = &rVal
}
// 收盘价序列给前端画趋势图使用。
var closePrices []float64
for _, d := range dailies {
closePrices = append(closePrices, d.Close)
@@ -171,6 +178,7 @@ func (s *StockService) AnalyzePerformance(startDate, endDate string) ([]model.An
})
}
// 优先展示跌幅更大的破位股票;强势股票由于 DropPct 为 nil 排在后面。
sort.Slice(results, func(i, j int) bool {
if results[i].DropPct == nil && results[j].DropPct != nil {
return false
+21 -10
View File
@@ -2,26 +2,37 @@ package config
import (
"fmt"
"github.com/spf13/viper"
)
// Config 描述 config.yaml 中使用到的应用配置。
//
// Database 保存 MySQL 连接参数,Server 保存 HTTP 服务参数。结构体字段名需要与配置文件
// 中的层级保持一致,Viper 会在 InitConfig 中把 YAML 内容反序列化到该结构体。
type Config struct {
// Database 是 MySQL 连接配置。
Database struct {
User string
Password string
Host string
Port int
Dbname string
Charset string
Collation string
User string // 数据库用户名。
Password string // 数据库密码。
Host string // 数据库主机地址。
Port int // 数据库端口。
Dbname string // 数据库名称。
Charset string // 连接字符集,例如 utf8mb4。
Collation string // 排序规则;为空时 GetDSN 会使用 utf8mb4_unicode_ci。
}
// Server 是 HTTP 服务配置。
Server struct {
Port int
Port int // 服务监听端口。
}
}
// AppConfig 保存进程级全局配置。调用 InitConfig 后才可安全读取。
var AppConfig *Config
// InitConfig 从当前目录或 ./config 目录读取 config.yaml,并解析到 AppConfig。
//
// 这里使用 panic 暴露配置错误,因为配置缺失或格式错误会导致服务无法正确启动。
func InitConfig() {
viper.SetConfigName("config")
viper.SetConfigType("yaml")
@@ -37,7 +48,7 @@ func InitConfig() {
}
}
// GetDSN 生成 MySQL 连接字符串
// GetDSN 根据 AppConfig 生成 GORM/MySQL 使用的 DSN 连接字符串。
func GetDSN() string {
db := AppConfig.Database
collation := db.Collation
@@ -48,7 +59,7 @@ func GetDSN() string {
db.User, db.Password, db.Host, db.Port, db.Dbname, db.Charset, collation)
}
// GetServerPort 获取服务器端口字符串
// GetServerPort 返回 Gin Run 所需的端口格式,例如 ":8080"。
func GetServerPort() string {
return fmt.Sprintf(":%d", AppConfig.Server.Port)
}