Compare commits

..

18 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
yang 0aa4c1aff9 接口优化 2026-05-07 00:24:31 +08:00
yang cf71dd2207 修改二板涨停情况查询,新增按条件筛选 2026-05-06 18:11:30 +08:00
yang f1734a68ae Merge pull request '新增当日开盘价低于前一交易日涨停价格 101% 的判断。新增下载csv 文件接口。' (#3) from main into master
Reviewed-on: #3
2026-05-05 19:33:14 +08:00
32 changed files with 3790 additions and 873 deletions
+6
View File
@@ -10,3 +10,9 @@
# 依赖目录 (如果使用 vendor 模式) # 依赖目录 (如果使用 vendor 模式)
# vendor/ # vendor/
# 本地运行配置,包含数据库账号密码,不提交到仓库
/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 package main
import ( import (
"github.com/gin-gonic/gin"
"gorm.io/driver/mysql" "gorm.io/driver/mysql"
"gorm.io/gorm" "gorm.io/gorm"
"level_go/internal/api" "level_go/internal/api"
"level_go/internal/router" "level_go/internal/router"
"level_go/internal/service" "level_go/internal/service"
"level_go/pkg/config" // 引入配置包 "level_go/pkg/config"
"log" "log"
) )
// main 是服务启动入口,负责完成配置加载、数据库连接、依赖组装和 HTTP 路由启动。
func main() { func main() {
// 1. 初始化配置 gin.SetMode(gin.ReleaseMode)
// 初始化配置文件,后续数据库 DSN 和服务端口都从全局配置中读取。
config.InitConfig() config.InitConfig()
// 2. 使用配置中的 DSN 连接数据库 // 使用配置中的 MySQL DSN 建立 GORM 连接;连接失败时直接终止进程,避免服务在不可用状态下启动。
dsn := config.GetDSN() dsn := config.GetDSN()
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{}) db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
if err != nil { if err != nil {
log.Fatal("数据库连接失败:", err) 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} commonSrv := &service.CommonService{DB: db}
stockSrv := &service.StockService{DB: db} stockSrv := &service.StockService{DB: db}
limitBoardSrv := &service.LimitBoardService{DB: db} limitBoardSrv := &service.LimitBoardService{DB: db}
@@ -30,7 +40,7 @@ func main() {
stockHandler := &api.StockHandler{Service: stockSrv} stockHandler := &api.StockHandler{Service: stockSrv}
limitBoardHandler := api.NewLimitBoardHandler(limitBoardSrv) limitBoardHandler := api.NewLimitBoardHandler(limitBoardSrv)
// 4. 启动路由 // 创建 Gin 路由并启动 HTTP 服务。
r := router.NewRouter(commonHandler, stockHandler, limitBoardHandler) r := router.NewRouter(commonHandler, stockHandler, limitBoardHandler)
port := config.GetServerPort() port := config.GetServerPort()
-20
View File
@@ -1,20 +0,0 @@
## 阿里云数据库
#database:
# user: "yangfan"
# password: "aA%8023321088"
# host: "8.159.129.156"
# port: 10836
# dbname: "level"
# charset: "utf8mb4"
# 本地数据库
database:
user: "yangfan"
password: "admin"
host: "localhost"
port: 3306
dbname: "level"
charset: "utf8mb4"
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;
+8 -2
View File
@@ -4,7 +4,7 @@
- 服务前缀:`/api/v1` - 服务前缀:`/api/v1`
- 响应格式:`application/json` - 响应格式:`application/json`
- 当前项目接口总数:7 - 当前项目接口总数:13
## 2. 接口列表 ## 2. 接口列表
@@ -16,7 +16,13 @@
| 涨停连板 | `GET` | `/api/v1/limit-board/overview` | 获取交易日涨停分组与近 30 日最高板趋势 | [limit-board-overview.md](./limit-board-overview.md) | | 涨停连板 | `GET` | `/api/v1/limit-board/overview` | 获取交易日涨停分组与近 30 日最高板趋势 | [limit-board-overview.md](./limit-board-overview.md) |
| 涨停低开分组 | `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-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/open-threshold` | 获取查询日和前一交易日均涨停的股票,并按开盘价是否低于前一交易日收盘价 101% 分组 | [limit-board-open-threshold.md](./limit-board-open-threshold.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/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. 错误响应约定 ## 3. 错误响应约定
+161 -39
View File
@@ -1,69 +1,191 @@
# 二板早盘次日盈利 CSV 下载接口文档 # 二板早盘次日盈利接口文档
## 1. 接口说明 ## 1. 接口说明
- 接口名称:二板早盘次日盈利 CSV 下载 本页包含二板早盘次日盈利的列表查询接口和 CSV 下载接口。两个接口使用同一套查询条件和同一套返回字段;列表接口分页,CSV 下载接口导出筛选后的全量数据。
- 请求方式:`GET`
- 请求路径:`/api/v1/download/limit-board/second-board-profit-csv`
- 接口用途:下载符合二板早盘开盘阈值条件的股票明细,并查看后一交易日是否盈利。
## 2. 请求参数 业务筛选基础条件:
- T-2 交易日无涨停。
- T-1 交易日涨停。
- T 交易日涨停。
## 2. 接口地址
| 接口名称 | 请求方式 | 请求路径 | 说明 |
| --- | --- | --- | --- |
| 二板早盘次日盈利列表查询 | `GET` | `/api/v1/limit-board/second-board-profit` | 前端页面分页展示数据 |
| 二板早盘次日盈利 CSV 下载 | `GET` | `/api/v1/download/limit-board/second-board-profit-csv` | 下载筛选条件范围内的全量 CSV 数据 |
## 3. 请求参数
| 参数名 | 类型 | 必填 | 默认值 | 示例 | 说明 | | 参数名 | 类型 | 必填 | 默认值 | 示例 | 说明 |
| --- | --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- | --- |
| `start_date` | `string` | 否 | `2020-01-01` | `2026-01-01` | 开始交易日期,格式固定为 `YYYY-MM-DD` | | `tradeStartDate` | `string` | 否 | `2020-01-01` | `2026-01-01` | 交易日开始日期,格式固定为 `YYYY-MM-DD` |
| `end_date` | `string` | 否 | `2026-12-31` | `2026-12-31` | 结束交易日期,格式固定为 `YYYY-MM-DD` | | `tradeEndDate` | `string` | 否 | `2026-12-31` | `2026-12-31` | 交易日结束日期,格式固定为 `YYYY-MM-DD` |
| `limitUpTimeFilterType1` | `string` | 否 | 空 | `>=` | T 日首次涨停时间第一组筛选操作符,支持 `<`、`>`、`<=`、`>=`、`=` |
| `limitUpTimeFilterValue1` | `string` | 否 | 空 | `09:30:00` | T 日首次涨停时间第一组筛选值 |
| `limitUpTimeFilterType2` | `string` | 否 | 空 | `<` | T 日首次涨停时间第二组筛选操作符,支持 `<`、`>`、`<=`、`>=`、`=` |
| `limitUpTimeFilterValue2` | `string` | 否 | 空 | `11:00:00` | T 日首次涨停时间第二组筛选值 |
| `openPctChgFilterType1` | `string` | 否 | 空 | `>=` | T 日开盘涨跌幅第一组筛选操作符,支持 `<`、`>`、`<=`、`>=`、`=` |
| `openPctChgFilterValue1` | `number` | 否 | 空 | `-2` | T 日开盘涨跌幅第一组筛选值,按 `(T日开盘价 - T-1收盘价) / T-1收盘价 * 100` 计算 |
| `openPctChgFilterType2` | `string` | 否 | 空 | `<=` | T 日开盘涨跌幅第二组筛选操作符,支持 `<`、`>`、`<=`、`>=`、`=` |
| `openPctChgFilterValue2` | `number` | 否 | 空 | `3` | T 日开盘涨跌幅第二组筛选值,按 `(T日开盘价 - T-1收盘价) / T-1收盘价 * 100` 计算 |
| `page` | `int` | 否 | `1` | `1` | 仅列表接口支持,页码从 1 开始 |
| `pageSize` | `int` | 否 | `10` | `20` | 仅列表接口支持,每页数量 |
- `start_date` 可以等于 `end_date`,用于下载单日数据。 兼容说明:CSV 下载接口仍兼容旧参数 `start_date` 和 `end_date`,建议新调用统一使用 `tradeStartDate` 和 `tradeEndDate`。
- `end_date` 不能早于 `start_date`。
请求示例: 筛选规则:
- 每个筛选项最多支持两组条件,第一组为 `Type1 + Value1`,第二组为 `Type2 + Value2`。
- 四个值都可以为空;当某组 `Type` 和 `Value` 都为空时,该组条件不生效。
- 如果某组条件生效,`Type` 和 `Value` 必须同时填写。
- 两组条件同时存在时按 AND 关系过滤,可用于表达区间,例如 `>=09:30:00` 且 `<11:00:00`。
- 时间值支持 `HH:MM`、`HH:MM:SS`、`HHMM`、`HHMMSS` 格式。
- 实际请求 URL 中 `<`、`>`、`=` 等字符建议做 URL 编码,例如 `>=` 编码为 `%3E%3D`,`<` 编码为 `%3C`。
- 后端也兼容数组式传参,例如 `limitUpTimeFilter[]=%3E%3D&limitUpTimeFilter[]=09:30:00&limitUpTimeFilter[]=%3C&limitUpTimeFilter[]=11:00:00`,数组四个值的含义依次等同于 `Type1`、`Value1`、`Type2`、`Value2`。
## 4. 请求示例
列表查询:
```http ```http
GET /api/v1/download/limit-board/second-board-profit-csv?start_date=2026-01-01&end_date=2026-12-31 GET /api/v1/limit-board/second-board-profit?tradeStartDate=2026-01-01&tradeEndDate=2026-12-31&page=1&pageSize=10
``` ```
单日下载示例: 带筛选条件的列表查询:
```http ```http
GET /api/v1/download/limit-board/second-board-profit-csv?start_date=2026-04-29&end_date=2026-04-29 GET /api/v1/limit-board/second-board-profit?tradeStartDate=2026-04-01&tradeEndDate=2026-04-30&limitUpTimeFilterType1=%3E%3D&limitUpTimeFilterValue1=09:30:00&limitUpTimeFilterType2=%3C&limitUpTimeFilterValue2=11:00:00&openPctChgFilterType1=%3E%3D&openPctChgFilterValue1=-2&openPctChgFilterType2=%3C%3D&openPctChgFilterValue2=3&page=1&pageSize=20
``` ```
## 3. 响应说明 CSV 下载:
```http
GET /api/v1/download/limit-board/second-board-profit-csv?tradeStartDate=2026-04-01&tradeEndDate=2026-04-30&limitUpTimeFilterType1=%3E%3D&limitUpTimeFilterValue1=09:30:00&limitUpTimeFilterType2=%3C&limitUpTimeFilterValue2=11:00:00&openPctChgFilterType1=%3E%3D&openPctChgFilterValue1=-2&openPctChgFilterType2=%3C%3D&openPctChgFilterValue2=3
```
## 5. 列表响应
| 字段名 | 类型 | 说明 |
| --- | --- | --- |
| `trade_start_date` | `string` | 查询交易日开始日期 |
| `trade_end_date` | `string` | 查询交易日结束日期 |
| `limit_up_time_filter_type1` | `string` | 首次涨停时间第一组筛选操作符 |
| `limit_up_time_filter_value1` | `string` | 首次涨停时间第一组筛选值 |
| `limit_up_time_filter_type2` | `string` | 首次涨停时间第二组筛选操作符 |
| `limit_up_time_filter_value2` | `string` | 首次涨停时间第二组筛选值 |
| `open_pct_chg_filter_type1` | `string` | T 日开盘涨跌幅第一组筛选操作符 |
| `open_pct_chg_filter_value1` | `string` | T 日开盘涨跌幅第一组筛选值 |
| `open_pct_chg_filter_type2` | `string` | T 日开盘涨跌幅第二组筛选操作符 |
| `open_pct_chg_filter_value2` | `string` | T 日开盘涨跌幅第二组筛选值 |
| `page` | `int` | 当前页码 |
| `page_size` | `int` | 每页数量 |
| `total` | `int` | 筛选后的总记录数 |
| `total_pages` | `int` | 总页数 |
| `list` | `array` | 当前页数据 |
`list[]` 字段:
| 字段名 | 类型 | CSV 列名 | 说明 |
| --- | --- | --- | --- |
| `trade_date` | `string` | `交易日` | T 日交易日 |
| `ts_code` | `string` | `股票代码` | TS 股票代码 |
| `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 时为 `是`,否则为 `否`;无次日数据时为 `无次日数据` |
| `next_high_profit_pct` | `number \| null` | `T+1最高盈利%` | T+1 最高价相对 T 日收盘价的收益率百分比 |
| `next_low_profit_pct` | `number \| null` | `T+1最低盈利%` | T+1 最低价相对 T 日收盘价的收益率百分比 |
| `break_limit_count` | `int \| null` | `开板次数` | T 日开板次数 |
| `turnover_rate_pct` | `number \| null` | `换手率%` | T 日换手率 |
| `first_limit_time` | `string` | `首次涨停时间` | T 日首次涨停时间,格式化为 `HH:MM:SS` |
| `seal_amount` | `number \| null` | `封单额` | T 日封单额 |
| `amount` | `number \| null` | `成交额` | T 日成交额 |
响应示例:
```json
{
"trade_start_date": "2026-04-01",
"trade_end_date": "2026-04-30",
"limit_up_time_filter_type1": ">=",
"limit_up_time_filter_value1": "09:30:00",
"limit_up_time_filter_type2": "<",
"limit_up_time_filter_value2": "11:00:00",
"open_pct_chg_filter_type1": ">=",
"open_pct_chg_filter_value1": "-2",
"open_pct_chg_filter_type2": "<=",
"open_pct_chg_filter_value2": "3",
"page": 1,
"page_size": 10,
"total": 26,
"total_pages": 3,
"list": [
{
"trade_date": "2026-04-24",
"ts_code": "000001.SZ",
"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": "是",
"next_high_profit_pct": 4.21,
"next_low_profit_pct": -2.15,
"break_limit_count": 2,
"turnover_rate_pct": 18.64,
"first_limit_time": "09:35:12",
"seal_amount": 286000000,
"amount": 1523000000
}
]
}
```
## 6. CSV 响应
- 响应类型:`text/csv; charset=utf-8` - 响应类型:`text/csv; charset=utf-8`
- 响应头:`Content-Disposition: attachment; filename="limit_second_board_profit_YYYYMMDD_YYYYMMDD_daily_detail.csv"` - 响应头:`Content-Disposition: attachment; filename="limit_second_board_profit_YYYYMMDD_YYYYMMDD_daily_detail.csv"`
- CSV 文件带 UTF-8 BOM,便于 Excel 直接打开中文列名。 - CSV 文件带 UTF-8 BOM,便于 Excel 直接打开中文列名。
- CSV 下载接口不分页,默认导出查询条件范围内的全部数据。
## 4. CSV 字段 CSV 字段顺序:
| 字段名 | 说明 | | 字段名 | 说明 |
| --- | --- | | --- | --- |
| `交易日` | 符合条件的交易日 | | `交易日` | T 日交易日 |
| `前一交易日` | 交易日的前一个交易日 |
| `前二交易日` | 交易日的前两个交易日 |
| `后一交易日` | 用于判断盈利的后一交易日 |
| `股票代码` | TS 股票代码 | | `股票代码` | TS 股票代码 |
| `股票简称` | 股票名称 | | `股票简称` | 股票简称 |
| `市场` | 市场信息 | | `市场` | 市场信息 |
| `行业` | 所属行业 | | `行业` | 所属行业 |
| `T日开盘价` | 交易日开盘价 | | `T-1日开盘涨跌幅%` | T-1 日开盘价相对 T-2 收盘价的涨跌幅百分比 |
| `T日收盘价` | 交易日收盘价 | | `T日开盘涨跌幅%` | T 日开盘价相对 T-1 收盘价的涨跌幅百分比 |
| `T日涨停价` | 理论涨停价 | | `T+1日开盘涨跌幅%` | T+1 日开盘价相对 T 日收盘价的涨跌幅百分比 |
| `T-1收盘价` | 前一交易日收盘价 | | `T+1开盘是否盈利` | T+1 开盘收益大于 0 时为 `是`,否则为 `否`;无次日数据时为 `无次日数据` |
| `开盘/T-1收盘%` | 交易日开盘价相对前一交易日收盘价的百分比 | | `T+1收盘是否盈利` | T+1 收盘收益大于 0 时为 `是`,否则为 `否`;无次日数据时为 `无次日数据` |
| `首次涨停时间` | 交易日首次涨停时间,格式化为 `HH:MM:SS` | | `T+1盘中是否可盈利` | T+1 最高收益大于 0 时为 `是`,否则为 `否`;无次日数据时为 `无次日数据` |
| `T+1开盘收益%` | 后一交易日开盘价相对 T 日收盘价的收益率 | | `T+1最高盈利%` | T+1 最高价相对 T 日收盘价的收益率百分比 |
| `T+1最高收益%` | 后一交易日最高价相对 T 日收盘价的收益率 | | `T+1最低盈利%` | T+1 最低价相对 T 日收盘价的收益率百分比 |
| `T+1收盘收益%` | 后一交易日收盘价相对 T 日收盘价的收益率 | | `开板次数` | T 日开板次数 |
| `T+1收盘是否盈利` | `T+1收盘收益% > 0` 时为 `是`,否则为 `否` | | `换手率%` | T 日换手率 |
| `T+1盘中是否可盈利` | `T+1最高收益% > 0` 时为 `是`,否则为 `否` | | `首次涨停时间` | T 日首次涨停时间,格式化为 `HH:MM:SS` |
| `封单额` | T 日封单额 |
| `成交额` | T 日成交额 |
## 5. 业务规则 ## 7. 错误响应
- T 日涨停。 ```json
- T-1 交易日也涨停。 {
- T-2 交易日必须不涨停。 "error": "参数错误"
- T 日开盘价必须低于 T-1 收盘价的 101%。 }
- T 日首次涨停时间必须早于 11:00。 ```
- 盈利口径默认按 T 日收盘价买入,T+1 收盘价卖出。
+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" "net/http"
) )
// CommonHandler 承载通用基础数据接口。
type CommonHandler struct { type CommonHandler struct {
Service *service.CommonService Service *service.CommonService // 通用数据服务,负责访问交易日历等基础表。
} }
// GetTradeCalendar 返回上交所开市日期列表和最新开市日期。
func (h *CommonHandler) GetTradeCalendar(c *gin.Context) { func (h *CommonHandler) GetTradeCalendar(c *gin.Context) {
resp, err := h.Service.GetTradeCalendar() resp, err := h.Service.GetTradeCalendar()
if err != nil { 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 package api
import ( import (
"errors"
"net/http" "net/http"
"strconv"
"strings"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"level_go/internal/model" "level_go/internal/model"
"level_go/internal/service" "level_go/internal/service"
) )
// LimitBoardHandler 承载涨停板相关 HTTP 接口。
type LimitBoardHandler struct { type LimitBoardHandler struct {
Service *service.LimitBoardService Service *service.LimitBoardService // 涨停板服务,封装统计、分组和导出逻辑。
} }
// NewLimitBoardHandler 创建涨停板接口 Handler。
func NewLimitBoardHandler(s *service.LimitBoardService) *LimitBoardHandler { func NewLimitBoardHandler(s *service.LimitBoardService) *LimitBoardHandler {
return &LimitBoardHandler{Service: s} return &LimitBoardHandler{Service: s}
} }
// GetOverview 返回指定交易日的涨停股连板概览。
func (h *LimitBoardHandler) GetOverview(c *gin.Context) { func (h *LimitBoardHandler) GetOverview(c *gin.Context) {
var req model.LimitBoardRequest var req model.LimitBoardRequest
if err := c.ShouldBindQuery(&req); err != nil || req.TradeDate == "" { 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) c.JSON(http.StatusOK, resp)
} }
// GetOpenLowerGroups 返回昨日涨停且今日继续涨停股票的开盘对比分组。
func (h *LimitBoardHandler) GetOpenLowerGroups(c *gin.Context) { func (h *LimitBoardHandler) GetOpenLowerGroups(c *gin.Context) {
var req model.LimitBoardRequest var req model.LimitBoardRequest
if err := c.ShouldBindQuery(&req); err != nil || req.TradeDate == "" { 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) 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) { func (h *LimitBoardHandler) GetOpenThresholdGroups(c *gin.Context) {
var req model.LimitBoardRequest var req model.LimitBoardRequest
if err := c.ShouldBindQuery(&req); err != nil || req.TradeDate == "" { if err := c.ShouldBindQuery(&req); err != nil || req.TradeDate == "" {
+473 -76
View File
@@ -14,10 +14,15 @@ import (
) )
const ( const (
// defaultSecondProfitStartDate 是二板收益统计的默认起始日期。
defaultSecondProfitStartDate = "2020-01-01" defaultSecondProfitStartDate = "2020-01-01"
defaultSecondProfitEndDate = "2026-12-31" // defaultSecondProfitEndDate 是二板收益统计的默认结束日期。
defaultSecondProfitEndDate = "2026-12-31"
) )
// secondProfitCSVRow 是二板收益查询的内部行结构。
//
// 前半部分字段直接由 SQL 扫描得到,后半部分字段在 loadSecondProfitRows 中根据行情计算。
type secondProfitCSVRow struct { type secondProfitCSVRow struct {
TradeDate string `gorm:"column:trade_date"` TradeDate string `gorm:"column:trade_date"`
PreviousTradeDate string `gorm:"column:previous_trade_date"` PreviousTradeDate string `gorm:"column:previous_trade_date"`
@@ -28,10 +33,12 @@ type secondProfitCSVRow struct {
Name string `gorm:"column:name"` Name string `gorm:"column:name"`
Market string `gorm:"column:market"` Market string `gorm:"column:market"`
Industry string `gorm:"column:industry"` Industry string `gorm:"column:industry"`
Open float64 `gorm:"column:open"` PreviousOpen sql.NullFloat64 `gorm:"column:previous_open"`
Close float64 `gorm:"column:close_price"` 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"` LimitPrice float64 `gorm:"column:limit_price"`
PreviousClose float64 `gorm:"column:previous_close"` PreviousClose sql.NullFloat64 `gorm:"column:previous_close"`
FirstLimitTime sql.NullString `gorm:"column:tushare_first_time"` FirstLimitTime sql.NullString `gorm:"column:tushare_first_time"`
PctChg sql.NullFloat64 `gorm:"column:pct_chg"` PctChg sql.NullFloat64 `gorm:"column:pct_chg"`
Amount sql.NullFloat64 `gorm:"column:tushare_amount"` Amount sql.NullFloat64 `gorm:"column:tushare_amount"`
@@ -44,30 +51,144 @@ type secondProfitCSVRow struct {
NextLow sql.NullFloat64 `gorm:"column:next_low"` NextLow sql.NullFloat64 `gorm:"column:next_low"`
NextClose sql.NullFloat64 `gorm:"column:next_close"` NextClose sql.NullFloat64 `gorm:"column:next_close"`
OpenPreviousCloseRatio float64 OpenPreviousCloseRatio float64
PreviousOpenPctChg sql.NullFloat64
OpenPctChg sql.NullFloat64
NextOpenReturnPct sql.NullFloat64 NextOpenReturnPct sql.NullFloat64
NextHighReturnPct sql.NullFloat64 NextHighReturnPct sql.NullFloat64
NextLowReturnPct sql.NullFloat64
NextCloseReturnPct sql.NullFloat64 NextCloseReturnPct sql.NullFloat64
NextOpenProfitable string
NextCloseProfitable string NextCloseProfitable string
NextIntradayProfitable string NextIntradayProfitable string
} }
func (h *LimitBoardHandler) DownloadSecondBoardProfitCSV(c *gin.Context) { // secondProfitQuery 保存二板收益接口和 CSV 导出共用的查询条件。
startDate := c.DefaultQuery("start_date", defaultSecondProfitStartDate) type secondProfitQuery struct {
endDate := c.DefaultQuery("end_date", defaultSecondProfitEndDate) TradeStartDate string
if err := validateSecondProfitDateRange(startDate, endDate); err != nil { TradeEndDate string
LimitUpTimeFilterType1 string
LimitUpTimeFilterValue1 string
LimitUpTimeFilterType2 string
LimitUpTimeFilterValue2 string
OpenPctChgFilterType1 string
OpenPctChgFilterValue1 string
OpenPctChgFilterType2 string
OpenPctChgFilterValue2 string
LimitUpTimeRule secondProfitCompareFilter
OpenPctChgRule secondProfitCompareFilter
Page int
PageSize int
}
// secondProfitCompareFilter 表示多个比较条件的组合过滤器。
//
// 当前逻辑使用 AND 关系:所有 Conditions 均满足时才认为匹配。
type secondProfitCompareFilter struct {
Conditions []secondProfitCompareCondition
}
// secondProfitCompareCondition 表示一个形如 value < 10 或 value >= 09:30 的比较条件。
type secondProfitCompareCondition struct {
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"`
NextHighProfitPct *float64 `json:"next_high_profit_pct"`
NextLowProfitPct *float64 `json:"next_low_profit_pct"`
BreakLimitCount *int64 `json:"break_limit_count"`
TurnoverRatePct *float64 `json:"turnover_rate_pct"`
FirstLimitTime string `json:"first_limit_time"`
SealAmount *float64 `json:"seal_amount"`
Amount *float64 `json:"amount"`
}
// ListSecondBoardProfit 返回二板收益明细的分页 JSON 数据。
//
// 与 CSV 导出使用同一套查询和筛选逻辑,区别是这里会按 page/pageSize 截取结果。
func (h *LimitBoardHandler) ListSecondBoardProfit(c *gin.Context) {
req, err := parseSecondProfitQuery(c, true)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
rows, err := h.loadSecondProfitCSVRows(startDate, endDate) rows, err := h.loadSecondProfitRows(req)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
total := len(rows)
start := (req.Page - 1) * req.PageSize
if start > total {
start = total
}
end := start + req.PageSize
if end > total {
end = total
}
items := make([]secondProfitListItem, 0, end-start)
for _, row := range rows[start:end] {
items = append(items, secondProfitBuildItem(row))
}
totalPages := 0
if total > 0 {
totalPages = (total + req.PageSize - 1) / req.PageSize
}
c.JSON(http.StatusOK, gin.H{
"trade_start_date": req.TradeStartDate,
"trade_end_date": req.TradeEndDate,
"limit_up_time_filter_type1": req.LimitUpTimeFilterType1,
"limit_up_time_filter_value1": req.LimitUpTimeFilterValue1,
"limit_up_time_filter_type2": req.LimitUpTimeFilterType2,
"limit_up_time_filter_value2": req.LimitUpTimeFilterValue2,
"open_pct_chg_filter_type1": req.OpenPctChgFilterType1,
"open_pct_chg_filter_value1": req.OpenPctChgFilterValue1,
"open_pct_chg_filter_type2": req.OpenPctChgFilterType2,
"open_pct_chg_filter_value2": req.OpenPctChgFilterValue2,
"page": req.Page,
"page_size": req.PageSize,
"total": total,
"total_pages": totalPages,
"list": items,
})
}
// DownloadSecondBoardProfitCSV 导出二板收益明细 CSV。
//
// 输出 UTF-8 BOM,便于 Excel 直接打开中文表头和内容。
func (h *LimitBoardHandler) DownloadSecondBoardProfitCSV(c *gin.Context) {
req, err := parseSecondProfitQuery(c, false)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
rows, err := h.loadSecondProfitRows(req)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
} }
filename := fmt.Sprintf("limit_second_board_profit_%s_%s_daily_detail.csv", filename := fmt.Sprintf("limit_second_board_profit_%s_%s_daily_detail.csv",
strings.ReplaceAll(startDate, "-", ""), strings.ReplaceAll(req.TradeStartDate, "-", ""),
strings.ReplaceAll(endDate, "-", ""), strings.ReplaceAll(req.TradeEndDate, "-", ""),
) )
c.Header("Content-Type", "text/csv; charset=utf-8") c.Header("Content-Type", "text/csv; charset=utf-8")
c.Header("Content-Disposition", `attachment; filename="`+filename+`"`) c.Header("Content-Disposition", `attachment; filename="`+filename+`"`)
@@ -88,42 +209,160 @@ 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))
limitUpTimeType1, limitUpTimeValue1, limitUpTimeType2, limitUpTimeValue2 := secondProfitFilterParts(c, "limitUpTimeFilter")
openPctChgType1, openPctChgValue1, openPctChgType2, openPctChgValue2 := secondProfitFilterParts(c, "openPctChgFilter")
req := secondProfitQuery{
TradeStartDate: startDate,
TradeEndDate: endDate,
LimitUpTimeFilterType1: limitUpTimeType1,
LimitUpTimeFilterValue1: limitUpTimeValue1,
LimitUpTimeFilterType2: limitUpTimeType2,
LimitUpTimeFilterValue2: limitUpTimeValue2,
OpenPctChgFilterType1: openPctChgType1,
OpenPctChgFilterValue1: openPctChgValue1,
OpenPctChgFilterType2: openPctChgType2,
OpenPctChgFilterValue2: openPctChgValue2,
Page: 1,
PageSize: 10,
}
if err := validateSecondProfitDateRange(req.TradeStartDate, req.TradeEndDate); err != nil {
return req, err
}
timeRule, err := parseSecondProfitCompareFilter(
req.LimitUpTimeFilterType1,
req.LimitUpTimeFilterValue1,
req.LimitUpTimeFilterType2,
req.LimitUpTimeFilterValue2,
parseSecondProfitTimeValue,
"limitUpTimeFilter",
)
if err != nil {
return req, err
}
req.LimitUpTimeRule = timeRule
pctRule, err := parseSecondProfitCompareFilter(
req.OpenPctChgFilterType1,
req.OpenPctChgFilterValue1,
req.OpenPctChgFilterType2,
req.OpenPctChgFilterValue2,
parseSecondProfitFloatValue,
"openPctChgFilter",
)
if err != nil {
return req, err
}
req.OpenPctChgRule = pctRule
if includePagination {
pageText := strings.TrimSpace(c.DefaultQuery("page", "1"))
page, err := strconv.Atoi(pageText)
if err != nil || page <= 0 {
return req, fmt.Errorf("page 必须为正整数")
}
req.Page = page
pageSizeText := strings.TrimSpace(c.DefaultQuery("pageSize", "10"))
pageSize, err := strconv.Atoi(pageSizeText)
if err != nil || pageSize <= 0 {
return req, fmt.Errorf("pageSize 必须为正整数")
}
req.PageSize = pageSize
}
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"))
type2 := strings.TrimSpace(c.Query(base + "Type2"))
value2 := strings.TrimSpace(c.Query(base + "Value2"))
if type1 != "" || value1 != "" || type2 != "" || value2 != "" {
return type1, value1, type2, value2
}
values := c.QueryArray(base)
if len(values) == 0 {
values = c.QueryArray(base + "[]")
}
if len(values) > 0 {
return secondProfitFilterArrayValue(values, 0),
secondProfitFilterArrayValue(values, 1),
secondProfitFilterArrayValue(values, 2),
secondProfitFilterArrayValue(values, 3)
}
return "", "", "", ""
}
// secondProfitFilterArrayValue 安全读取数组参数中的指定位置。
func secondProfitFilterArrayValue(values []string, index int) string {
if index >= len(values) {
return ""
}
return strings.TrimSpace(values[index])
}
// validateSecondProfitDateRange 校验查询日期格式和先后顺序。
func validateSecondProfitDateRange(startDate, endDate string) error { func validateSecondProfitDateRange(startDate, endDate string) error {
start, err := time.Parse("2006-01-02", startDate) start, err := time.Parse("2006-01-02", startDate)
if err != nil { if err != nil {
return fmt.Errorf("start_date 格式必须为 YYYY-MM-DD") return fmt.Errorf("tradeStartDate 格式必须为 YYYY-MM-DD")
} }
end, err := time.Parse("2006-01-02", endDate) end, err := time.Parse("2006-01-02", endDate)
if err != nil { if err != nil {
return fmt.Errorf("end_date 格式必须为 YYYY-MM-DD") return fmt.Errorf("tradeEndDate 格式必须为 YYYY-MM-DD")
} }
if end.Before(start) { if end.Before(start) {
return fmt.Errorf("end_date 不能早于 start_date") return fmt.Errorf("tradeEndDate 不能早于 tradeStartDate")
} }
return nil return nil
} }
func (h *LimitBoardHandler) loadSecondProfitCSVRows(startDate, endDate string) ([]secondProfitCSVRow, error) { // loadSecondProfitRows 查询并计算二板收益明细。
//
// SQL 先找出“前一日涨停、前两日未涨停、当日继续涨停”的股票,再补齐 T+1 行情;
// Go 代码负责应用动态过滤条件并计算开盘、最高、最低、收盘相对 T 日收盘的收益率。
func (h *LimitBoardHandler) loadSecondProfitRows(req secondProfitQuery) ([]secondProfitCSVRow, error) {
query := ` query := `
WITH open_calendar AS ( WITH calendar AS (
SELECT SELECT
DATE_FORMAT(cal_date, '%Y-%m-%d') AS trade_date, cal_date AS trade_date,
LAG(DATE_FORMAT(cal_date, '%Y-%m-%d'), 1) OVER (ORDER BY cal_date) AS previous_trade_date, LAG(cal_date) 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, LAG(cal_date, 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 LEAD(cal_date) OVER (ORDER BY cal_date) AS next_trade_date
FROM trade_calendar FROM trade_calendar
WHERE exchange = 'SSE' AND is_open = 1 WHERE exchange = 'SSE' AND is_open = 1
),
calendar_window AS (
SELECT *
FROM calendar
WHERE trade_date BETWEEN ? AND ?
) )
SELECT SELECT
c.trade_date, DATE_FORMAT(c.trade_date, '%Y-%m-%d') AS trade_date,
c.previous_trade_date, DATE_FORMAT(c.previous_trade_date, '%Y-%m-%d') AS previous_trade_date,
c.previous_two_trade_date, DATE_FORMAT(c.previous_two_trade_date, '%Y-%m-%d') AS previous_two_trade_date,
COALESCE(c.next_trade_date, '') AS next_trade_date, COALESCE(DATE_FORMAT(c.next_trade_date, '%Y-%m-%d'), '') AS next_trade_date,
l.ts_code, l.ts_code,
COALESCE(l.symbol, '') AS symbol, COALESCE(l.symbol, '') AS symbol,
COALESCE(l.name, '') AS name, COALESCE(l.name, '') AS name,
COALESCE(l.market, '') AS market, COALESCE(l.market, '') AS market,
COALESCE(l.tushare_industry, '') AS industry, COALESCE(l.tushare_industry, '') AS industry,
pd.open AS previous_open,
COALESCE(pd.pre_close, ppd.close) AS previous_two_close,
d.open, d.open,
COALESCE(l.calc_close, l.tushare_close, d.close) AS close_price, COALESCE(l.calc_close, l.tushare_close, d.close) AS close_price,
COALESCE(l.calc_limit_price, 0) AS limit_price, COALESCE(l.calc_limit_price, 0) AS limit_price,
@@ -139,7 +378,7 @@ SELECT
nd.high AS next_high, nd.high AS next_high,
nd.low AS next_low, nd.low AS next_low,
nd.close AS next_close nd.close AS next_close
FROM open_calendar c FROM calendar_window c
INNER JOIN stock_limit_combined_data l INNER JOIN stock_limit_combined_data l
ON l.trade_date = c.trade_date AND l.final_status = 1 ON l.trade_date = c.trade_date AND l.final_status = 1
INNER JOIN stock_limit_combined_data pl INNER JOIN stock_limit_combined_data pl
@@ -147,36 +386,59 @@ INNER JOIN stock_limit_combined_data pl
LEFT JOIN stock_limit_combined_data ppl 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 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 LEFT JOIN stock_daily d
ON d.ts_code = l.ts_code AND d.trade_date = l.trade_date ON l.ts_code = d.ts_code AND d.trade_date = l.trade_date
LEFT JOIN stock_daily pd LEFT JOIN stock_daily pd
ON pd.ts_code = l.ts_code AND pd.trade_date = c.previous_trade_date ON l.ts_code = pd.ts_code AND pd.trade_date = c.previous_trade_date
LEFT JOIN stock_daily ppd
ON l.ts_code = ppd.ts_code AND ppd.trade_date = c.previous_two_trade_date
LEFT JOIN stock_daily nd LEFT JOIN stock_daily nd
ON nd.ts_code = l.ts_code AND nd.trade_date = c.next_trade_date ON l.ts_code = nd.ts_code AND nd.trade_date = c.next_trade_date
WHERE c.trade_date BETWEEN ? AND ? WHERE c.previous_trade_date IS NOT NULL
AND c.previous_trade_date IS NOT NULL
AND c.previous_two_trade_date IS NOT NULL AND c.previous_two_trade_date IS NOT NULL
AND ppl.ts_code IS NULL AND ppl.ts_code IS NULL
AND d.open IS NOT NULL
AND COALESCE(pl.calc_close, pl.tushare_close, pd.close) > 0
AND d.open < COALESCE(pl.calc_close, pl.tushare_close, pd.close) * 1.01
ORDER BY c.trade_date ASC, l.ts_code ASC` ORDER BY c.trade_date ASC, l.ts_code ASC`
var rawRows []secondProfitCSVRow var rawRows []secondProfitCSVRow
if err := h.Service.DB.Raw(query, startDate, endDate).Scan(&rawRows).Error; err != nil { if err := h.Service.DB.Raw(query, req.TradeStartDate, req.TradeEndDate).Scan(&rawRows).Error; err != nil {
return nil, err return nil, err
} }
rows := make([]secondProfitCSVRow, 0, len(rawRows)) rows := make([]secondProfitCSVRow, 0, len(rawRows))
for _, row := range rawRows { for _, row := range rawRows {
if !isSecondProfitBeforeEleven(row.FirstLimitTime.String) { // 首次涨停时间过滤在内存中执行,便于兼容 0930、93000、09:30:00 等多种时间格式。
if req.LimitUpTimeRule.Enabled() {
limitTimeValue, ok := secondProfitTimeSeconds(row.FirstLimitTime.String)
if !ok || !req.LimitUpTimeRule.Match(float64(limitTimeValue)) {
continue
}
}
// 开盘涨跌幅依赖前收价格,只有行情完整且前收大于 0 时才计算。
if row.Open.Valid && row.PreviousClose.Valid && row.PreviousClose.Float64 > 0 {
row.OpenPreviousCloseRatio = row.Open.Float64 / row.PreviousClose.Float64
row.OpenPctChg = sql.NullFloat64{
Float64: secondProfitRound2((row.Open.Float64 - row.PreviousClose.Float64) / row.PreviousClose.Float64 * 100),
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 continue
} }
row.OpenPreviousCloseRatio = row.Open / row.PreviousClose
if row.Close > 0 { // T+1 收益统一以 T 日收盘价为基准,缺失的次日行情会保留为 NullFloat64。
row.NextOpenReturnPct = secondProfitPctReturn(row.NextOpen, row.Close) if row.Close.Valid && row.Close.Float64 > 0 {
row.NextHighReturnPct = secondProfitPctReturn(row.NextHigh, row.Close) row.NextOpenReturnPct = secondProfitPctReturn(row.NextOpen, row.Close.Float64)
row.NextCloseReturnPct = secondProfitPctReturn(row.NextClose, row.Close) row.NextHighReturnPct = secondProfitPctReturn(row.NextHigh, row.Close.Float64)
row.NextLowReturnPct = secondProfitPctReturn(row.NextLow, row.Close.Float64)
row.NextCloseReturnPct = secondProfitPctReturn(row.NextClose, row.Close.Float64)
} }
row.NextOpenProfitable = secondProfitLabel(row.NextOpenReturnPct)
row.NextCloseProfitable = secondProfitLabel(row.NextCloseReturnPct) row.NextCloseProfitable = secondProfitLabel(row.NextCloseReturnPct)
row.NextIntradayProfitable = secondProfitLabel(row.NextHighReturnPct) row.NextIntradayProfitable = secondProfitLabel(row.NextHighReturnPct)
rows = append(rows, row) rows = append(rows, row)
@@ -184,64 +446,174 @@ ORDER BY c.trade_date ASC, l.ts_code ASC`
return rows, nil return rows, nil
} }
// secondProfitCSVHeaders 返回二板收益 CSV 表头。
func secondProfitCSVHeaders() []string { func secondProfitCSVHeaders() []string {
return []string{ return []string{
"交易日", "前一交易日", "前二交易日", "后一交易日", "股票代码", "股票简称", "市场", "行业", "交易日", "股票代码", "股票简称", "市场", "行业",
"T日开盘价", "T日收盘价", "T日涨停价", "T-1收盘价", "开盘/T-1收盘%", "首次涨停时间", "T-1日开盘涨跌幅%",
"T日涨跌幅%", "成交额", "换手率%", "封单额", "开板次数", "涨停统计", "T日开盘涨跌幅%", "T+1日开盘涨跌幅%",
"T+1开盘价", "T+1最高价", "T+1最低价", "T+1收盘价", "T+1开盘是否盈利", "T+1收盘是否盈利", "T+1盘中是否可盈利",
"T+1开盘收益%", "T+1最高收益%", "T+1收盘收益%", "T+1收盘是否盈利", "T+1盘中是否可盈利", "T+1最高盈利%", "T+1最低盈利%", "开板次数", "换手率%",
"首次涨停时间", "封单额", "成交额",
} }
} }
// secondProfitCSVRecord 把内部行结构转换为 CSV 字段。
func secondProfitCSVRecord(row secondProfitCSVRow) []string { func secondProfitCSVRecord(row secondProfitCSVRow) []string {
return []string{ return []string{
row.TradeDate, row.TradeDate,
row.PreviousTradeDate,
row.PreviousTwoTradeDate,
row.NextTradeDate,
row.TsCode, row.TsCode,
row.Name, row.Name,
row.Market, row.Market,
row.Industry, row.Industry,
secondProfitFormatFloat(row.Open), secondProfitFormatNullFloat(row.PreviousOpenPctChg),
secondProfitFormatFloat(row.Close), secondProfitFormatNullFloat(row.OpenPctChg),
secondProfitFormatFloat(row.LimitPrice),
secondProfitFormatFloat(row.PreviousClose),
secondProfitFormatFloat(secondProfitRound2(row.OpenPreviousCloseRatio * 100)),
secondProfitFormatLimitTime(row.FirstLimitTime.String),
secondProfitFormatNullFloat(row.PctChg),
secondProfitFormatNullFloat(row.Amount),
secondProfitFormatNullFloat(row.TurnoverRate),
secondProfitFormatNullFloat(row.SealAmount),
secondProfitFormatNullInt(row.BreakLimitCount),
row.LimitUpStat,
secondProfitFormatNullFloat(row.NextOpen),
secondProfitFormatNullFloat(row.NextHigh),
secondProfitFormatNullFloat(row.NextLow),
secondProfitFormatNullFloat(row.NextClose),
secondProfitFormatNullFloat(row.NextOpenReturnPct), secondProfitFormatNullFloat(row.NextOpenReturnPct),
secondProfitFormatNullFloat(row.NextHighReturnPct), row.NextOpenProfitable,
secondProfitFormatNullFloat(row.NextCloseReturnPct),
row.NextCloseProfitable, row.NextCloseProfitable,
row.NextIntradayProfitable, row.NextIntradayProfitable,
secondProfitFormatNullFloat(row.NextHighReturnPct),
secondProfitFormatNullFloat(row.NextLowReturnPct),
secondProfitFormatNullInt(row.BreakLimitCount),
secondProfitFormatNullFloat(row.TurnoverRate),
secondProfitFormatLimitTime(row.FirstLimitTime.String),
secondProfitFormatNullFloat(row.SealAmount),
secondProfitFormatNullFloat(row.Amount),
} }
} }
func isSecondProfitBeforeEleven(limitTime string) bool { // secondProfitBuildItem 把内部行结构转换为 JSON 列表项。
func secondProfitBuildItem(row secondProfitCSVRow) secondProfitListItem {
return secondProfitListItem{
TradeDate: row.TradeDate,
TsCode: row.TsCode,
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,
NextHighProfitPct: secondProfitNullFloatPtr(row.NextHighReturnPct),
NextLowProfitPct: secondProfitNullFloatPtr(row.NextLowReturnPct),
BreakLimitCount: secondProfitNullIntPtr(row.BreakLimitCount),
TurnoverRatePct: secondProfitNullFloatPtr(row.TurnoverRate),
FirstLimitTime: secondProfitFormatLimitTime(row.FirstLimitTime.String),
SealAmount: secondProfitNullFloatPtr(row.SealAmount),
Amount: secondProfitNullFloatPtr(row.Amount),
}
}
// 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 {
return filter, err
}
if err := appendSecondProfitCompareCondition(&filter, op2, value2, parseValue, fieldName, "第二组"); err != nil {
return filter, err
}
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)
if op == "" && rawValue == "" {
return nil
}
if op == "" || rawValue == "" {
return fmt.Errorf("%s %s操作符和值必须同时填写", fieldName, groupName)
}
if !isSecondProfitCompareOp(op) {
return fmt.Errorf("%s %s操作符必须为 <、>、<=、>=、=", fieldName, groupName)
}
value, err := parseValue(rawValue)
if err != nil {
return fmt.Errorf("%s %s值格式错误", fieldName, groupName)
}
filter.Conditions = append(filter.Conditions, secondProfitCompareCondition{Op: op, Value: value})
return nil
}
// isSecondProfitCompareOp 判断操作符是否在白名单内。
func isSecondProfitCompareOp(op string) bool {
switch op {
case "<", ">", "<=", ">=", "=":
return true
default:
return false
}
}
// Enabled 表示过滤器中是否存在有效条件。
func (f secondProfitCompareFilter) Enabled() bool {
return len(f.Conditions) > 0
}
// Match 判断输入值是否满足过滤器中的所有条件。
func (f secondProfitCompareFilter) Match(value float64) bool {
if !f.Enabled() {
return true
}
for _, condition := range f.Conditions {
if !condition.Match(value) {
return false
}
}
return true
}
// Match 判断输入值是否满足单个比较条件。
func (c secondProfitCompareCondition) Match(value float64) bool {
switch c.Op {
case "<":
return value < c.Value
case "<=":
return value <= c.Value
case ">":
return value > c.Value
case ">=":
return value >= c.Value
case "=":
return value == c.Value
default:
return false
}
}
// parseSecondProfitTimeValue 把时间字符串转换为当天秒数。
func parseSecondProfitTimeValue(value string) (float64, error) {
seconds, ok := secondProfitTimeSeconds(value)
if !ok {
return 0, fmt.Errorf("invalid time")
}
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) timeText := strings.TrimSpace(limitTime)
if timeText == "" { if timeText == "" {
return false return 0, false
} }
hour, minute, second := 0, 0, 0 hour, minute, second := 0, 0, 0
if strings.Contains(timeText, ":") { if strings.Contains(timeText, ":") {
if n, _ := fmt.Sscanf(timeText, "%d:%d:%d", &hour, &minute, &second); n < 2 { if n, _ := fmt.Sscanf(timeText, "%d:%d:%d", &hour, &minute, &second); n < 2 {
return false return 0, false
} }
} else { } else {
if len(timeText) < 4 { if len(timeText) < 4 {
return false return 0, false
} }
switch len(timeText) { switch len(timeText) {
case 4: case 4:
@@ -250,28 +622,29 @@ func isSecondProfitBeforeEleven(limitTime string) bool {
timeText = "0" + timeText timeText = "0" + timeText
case 6: case 6:
default: default:
return false return 0, false
} }
var err error var err error
hour, err = strconv.Atoi(timeText[0:2]) hour, err = strconv.Atoi(timeText[0:2])
if err != nil { if err != nil {
return false return 0, false
} }
minute, err = strconv.Atoi(timeText[2:4]) minute, err = strconv.Atoi(timeText[2:4])
if err != nil { if err != nil {
return false return 0, false
} }
second, err = strconv.Atoi(timeText[4:6]) second, err = strconv.Atoi(timeText[4:6])
if err != nil { if err != nil {
return false return 0, false
} }
} }
if hour < 0 || hour > 23 || minute < 0 || minute > 59 || second < 0 || second > 59 { if hour < 0 || hour > 23 || minute < 0 || minute > 59 || second < 0 || second > 59 {
return false return 0, false
} }
return hour < 11 return hour*3600 + minute*60 + second, true
} }
// secondProfitFormatLimitTime 把紧凑时间格式格式化为 HH:MM:SS。
func secondProfitFormatLimitTime(value string) string { func secondProfitFormatLimitTime(value string) string {
timeText := strings.TrimSpace(value) timeText := strings.TrimSpace(value)
if timeText == "" || strings.Contains(timeText, ":") { if timeText == "" || strings.Contains(timeText, ":") {
@@ -289,6 +662,7 @@ func secondProfitFormatLimitTime(value string) string {
return timeText[0:2] + ":" + timeText[2:4] + ":" + timeText[4:6] return timeText[0:2] + ":" + timeText[2:4] + ":" + timeText[4:6]
} }
// secondProfitPctReturn 计算相对基准价的百分比收益。
func secondProfitPctReturn(value sql.NullFloat64, base float64) sql.NullFloat64 { func secondProfitPctReturn(value sql.NullFloat64, base float64) sql.NullFloat64 {
if !value.Valid || base == 0 { if !value.Valid || base == 0 {
return sql.NullFloat64{} return sql.NullFloat64{}
@@ -296,6 +670,7 @@ func secondProfitPctReturn(value sql.NullFloat64, base float64) sql.NullFloat64
return sql.NullFloat64{Float64: secondProfitRound2((value.Float64 - base) / base * 100), Valid: true} return sql.NullFloat64{Float64: secondProfitRound2((value.Float64 - base) / base * 100), Valid: true}
} }
// secondProfitLabel 根据收益是否大于 0 输出中文盈利标签。
func secondProfitLabel(value sql.NullFloat64) string { func secondProfitLabel(value sql.NullFloat64) string {
if !value.Valid { if !value.Valid {
return "无次日数据" return "无次日数据"
@@ -306,14 +681,17 @@ func secondProfitLabel(value sql.NullFloat64) string {
return "否" return "否"
} }
// secondProfitRound2 保留两位小数。
func secondProfitRound2(value float64) float64 { func secondProfitRound2(value float64) float64 {
return math.Round(value*100) / 100 return math.Round(value*100) / 100
} }
// secondProfitFormatFloat 使用最短十进制格式输出浮点数。
func secondProfitFormatFloat(value float64) string { func secondProfitFormatFloat(value float64) string {
return strconv.FormatFloat(value, 'f', -1, 64) return strconv.FormatFloat(value, 'f', -1, 64)
} }
// secondProfitFormatNullFloat 格式化可空浮点数,空值输出为空字符串。
func secondProfitFormatNullFloat(value sql.NullFloat64) string { func secondProfitFormatNullFloat(value sql.NullFloat64) string {
if !value.Valid { if !value.Valid {
return "" return ""
@@ -321,9 +699,28 @@ func secondProfitFormatNullFloat(value sql.NullFloat64) string {
return secondProfitFormatFloat(value.Float64) return secondProfitFormatFloat(value.Float64)
} }
// secondProfitFormatNullInt 格式化可空整数,空值输出为空字符串。
func secondProfitFormatNullInt(value sql.NullInt64) string { func secondProfitFormatNullInt(value sql.NullInt64) string {
if !value.Valid { if !value.Valid {
return "" return ""
} }
return strconv.FormatInt(value.Int64, 10) return strconv.FormatInt(value.Int64, 10)
} }
// secondProfitNullFloatPtr 把 sql.NullFloat64 转换为 JSON 友好的 *float64。
func secondProfitNullFloatPtr(value sql.NullFloat64) *float64 {
if !value.Valid {
return nil
}
v := value.Float64
return &v
}
// secondProfitNullIntPtr 把 sql.NullInt64 转换为 JSON 友好的 *int64。
func secondProfitNullIntPtr(value sql.NullInt64) *int64 {
if !value.Valid {
return nil
}
v := value.Int64
return &v
}
@@ -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" "net/http"
) )
// StockHandler 承载股票行情和走势分析接口。
type StockHandler struct { type StockHandler struct {
Service *service.StockService Service *service.StockService // 股票服务,封装行情查询和区间分析逻辑。
} }
// NewStockHandler 创建股票接口 Handler。
func NewStockHandler(s *service.StockService) *StockHandler { func NewStockHandler(s *service.StockService) *StockHandler {
return &StockHandler{Service: s} return &StockHandler{Service: s}
} }
// GetKlineData 获取指定股票从2024-09-24至今的K线 // GetKlineData 获取指定股票的历史 K 线数据。
//
// 股票代码来自路径参数 :code,返回按交易日升序排列的 stock_daily 记录。
func (h *StockHandler) GetKlineData(c *gin.Context) { func (h *StockHandler) GetKlineData(c *gin.Context) {
code := c.Param("code") code := c.Param("code")
if code == "" { if code == "" {
@@ -32,6 +36,7 @@ func (h *StockHandler) GetKlineData(c *gin.Context) {
c.JSON(http.StatusOK, data) c.JSON(http.StatusOK, data)
} }
// Analyze 根据前端传入的起止日期分析涨停后走势。
func (h *StockHandler) Analyze(c *gin.Context) { func (h *StockHandler) Analyze(c *gin.Context) {
var req model.AnalyzeRequest var req model.AnalyzeRequest
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
+9 -6
View File
@@ -2,16 +2,19 @@ package model
import "time" import "time"
// TradeCalendar 映射交易日历表,用于判断某天是否为指定交易所的开市日。
type TradeCalendar struct { type TradeCalendar struct {
CalDate time.Time `gorm:"column:cal_date;primaryKey"` CalDate time.Time `gorm:"column:cal_date;primaryKey"` // 日历日期。
Exchange string `gorm:"column:exchange;primaryKey"` Exchange string `gorm:"column:exchange;primaryKey"` // 交易所代码,例如 SSE。
IsOpen int `gorm:"column:is_open"` IsOpen int `gorm:"column:is_open"` // 是否开市,1 表示开市。
PretradeDate time.Time `gorm:"column:pretrade_date"` PretradeDate time.Time `gorm:"column:pretrade_date"` // 上一个交易日。
} }
// TableName 指定 GORM 查询时使用 trade_calendar 表。
func (TradeCalendar) TableName() string { return "trade_calendar" } func (TradeCalendar) TableName() string { return "trade_calendar" }
// CalendarResponse 是交易日历接口响应。
type CalendarResponse struct { type CalendarResponse struct {
OpenDates []string `json:"open_dates"` OpenDates []string `json:"open_dates"` // 所有开市日期,格式 YYYY-MM-DD。
MaxDate string `json:"max_date"` MaxDate string `json:"max_date"` // 当前返回结果中的最大开市日期。
} }
+320 -119
View File
@@ -1,162 +1,363 @@
package model package model
// LimitBoardRequest 是按交易日查询涨停板数据的通用请求。
type LimitBoardRequest struct { 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 { type LimitBoardStock struct {
Symbol string `json:"symbol"` Symbol string `json:"symbol"` // 市场展示用股票代码。
TsCode string `json:"ts_code"` TsCode string `json:"ts_code"` // TuShare 股票代码。
Name string `json:"name"` Name string `json:"name"` // 股票简称。
Market string `json:"market"` Market string `json:"market"` // 所属市场。
TradeDate string `json:"trade_date"` TradeDate string `json:"trade_date"` // 交易日。
Close float64 `json:"close"` Close float64 `json:"close"` // 收盘价。
LimitPrice float64 `json:"limit_price"` LimitPrice float64 `json:"limit_price"` // 涨停价。
PctChg float64 `json:"pct_chg"` PctChg float64 `json:"pct_chg"` // 当日涨跌幅。
AkAmount *float64 `json:"ak_amount"` AkAmount *float64 `json:"ak_amount"` // 成交额,数据库为空时返回 null。
AkFloatMarketValue *float64 `json:"ak_float_market_value"` AkFloatMarketValue *float64 `json:"ak_float_market_value"` // 流通市值。
AkTotalMarketValue *float64 `json:"ak_total_market_value"` AkTotalMarketValue *float64 `json:"ak_total_market_value"` // 总市值。
AkTurnoverRate *float64 `json:"ak_turnover_rate"` AkTurnoverRate *float64 `json:"ak_turnover_rate"` // 换手率。
AkSealAmount *float64 `json:"ak_seal_amount"` AkSealAmount *float64 `json:"ak_seal_amount"` // 封单额。
AkFirstLimitTime *string `json:"ak_first_limit_time"` AkFirstLimitTime *string `json:"ak_first_limit_time"` // 首次涨停时间。
AkLastLimitTime *string `json:"ak_last_limit_time"` AkLastLimitTime *string `json:"ak_last_limit_time"` // 最后涨停时间。
AkBreakLimitCount *int `json:"ak_break_limit_count"` AkBreakLimitCount *int `json:"ak_break_limit_count"` // 开板次数。
AkLimitUpStat *string `json:"ak_limit_up_stat"` AkLimitUpStat *string `json:"ak_limit_up_stat"` // 连板状态原始描述。
AkLimitUpDays *int `json:"ak_limit_up_days"` AkLimitUpDays *int `json:"ak_limit_up_days"` // 连续涨停天数。
AkIndustry *string `json:"ak_industry"` AkIndustry *string `json:"ak_industry"` // 行业。
Vol *int64 `json:"vol"` Vol *int64 `json:"vol"` // 成交量。
BoardCount int `json:"board_count"` BoardCount int `json:"board_count"` // 计算出的连板数。
BoardLabel string `json:"board_label"` BoardLabel string `json:"board_label"` // 连板标签,例如 二板。
DisplayStatus string `json:"display_status"` DisplayStatus string `json:"display_status"` // 前端展示状态,当前与 BoardLabel 保持一致。
} }
// LimitOpenCompareStock 表示“今日涨停且昨日涨停”的开盘价对比数据。
type LimitOpenCompareStock struct { type LimitOpenCompareStock struct {
Symbol string `json:"symbol"` Symbol string `json:"symbol"` // 市场展示用股票代码。
TsCode string `json:"ts_code"` TsCode string `json:"ts_code"` // TuShare 股票代码。
Name string `json:"name"` Name string `json:"name"` // 股票简称。
Market string `json:"market"` Market string `json:"market"` // 所属市场。
TradeDate string `json:"trade_date"` TradeDate string `json:"trade_date"` // 查询交易日。
HasOpen bool `json:"-"` HasOpen bool `json:"-"` // 数据库是否存在今日开盘价,内部计算使用。
Open float64 `json:"open"` Open float64 `json:"open"` // 今日开盘价。
Close float64 `json:"close"` Close float64 `json:"close"` // 今日收盘价。
PreviousTradeDate string `json:"previous_trade_date"` PreviousTradeDate string `json:"previous_trade_date"` // 上一个交易日。
PreviousTwoTradeDate string `json:"previous_two_trade_date"` PreviousTwoTradeDate string `json:"previous_two_trade_date"` // 上上个交易日。
HasPreviousClose bool `json:"-"` HasPreviousClose bool `json:"-"` // 数据库是否存在前收价,内部计算使用。
PreviousClose float64 `json:"previous_close"` PreviousClose float64 `json:"previous_close"` // 上一个交易日收盘价。
IsFirstToSecondBoard bool `json:"is_first_to_second_board"` IsFirstToSecondBoard bool `json:"is_first_to_second_board"` // 是否属于首板进二板。
IsFirstSecondBeforeEleven bool `json:"is_first_second_before_eleven"` IsFirstSecondBeforeEleven bool `json:"is_first_second_before_eleven"` // 首板进二板且 11 点前封板。
HighlightLabel string `json:"highlight_label"` HighlightLabel string `json:"highlight_label"` // 前端高亮标签。
LimitPrice float64 `json:"limit_price"` LimitPrice float64 `json:"limit_price"` // 今日涨停价。
PctChg float64 `json:"pct_chg"` PctChg float64 `json:"pct_chg"` // 今日涨跌幅。
AkAmount *float64 `json:"ak_amount"` AkAmount *float64 `json:"ak_amount"` // 成交额。
AkFloatMarketValue *float64 `json:"ak_float_market_value"` AkFloatMarketValue *float64 `json:"ak_float_market_value"` // 流通市值。
AkTotalMarketValue *float64 `json:"ak_total_market_value"` AkTotalMarketValue *float64 `json:"ak_total_market_value"` // 总市值。
AkTurnoverRate *float64 `json:"ak_turnover_rate"` AkTurnoverRate *float64 `json:"ak_turnover_rate"` // 换手率。
AkSealAmount *float64 `json:"ak_seal_amount"` AkSealAmount *float64 `json:"ak_seal_amount"` // 封单额。
AkFirstLimitTime *string `json:"ak_first_limit_time"` AkFirstLimitTime *string `json:"ak_first_limit_time"` // 首次涨停时间。
AkLastLimitTime *string `json:"ak_last_limit_time"` AkLastLimitTime *string `json:"ak_last_limit_time"` // 最后涨停时间。
AkBreakLimitCount *int `json:"ak_break_limit_count"` AkBreakLimitCount *int `json:"ak_break_limit_count"` // 开板次数。
AkLimitUpStat *string `json:"ak_limit_up_stat"` AkLimitUpStat *string `json:"ak_limit_up_stat"` // 连板状态原始描述。
AkLimitUpDays *int `json:"ak_limit_up_days"` AkLimitUpDays *int `json:"ak_limit_up_days"` // 连续涨停天数。
AkIndustry *string `json:"ak_industry"` AkIndustry *string `json:"ak_industry"` // 行业。
Vol *int64 `json:"vol"` Vol *int64 `json:"vol"` // 成交量。
} }
// LimitOpenThresholdStock 表示昨日涨停股在今日开盘阈值分组中的单股数据。
type LimitOpenThresholdStock struct { type LimitOpenThresholdStock struct {
Symbol string `json:"symbol"` Symbol string `json:"symbol"` // 市场展示用股票代码。
TsCode string `json:"ts_code"` TsCode string `json:"ts_code"` // TuShare 股票代码。
Name string `json:"name"` Name string `json:"name"` // 股票简称。
Market string `json:"market"` Market string `json:"market"` // 所属市场。
TradeDate string `json:"trade_date"` TradeDate string `json:"trade_date"` // 查询交易日。
HasOpen bool `json:"-"` HasOpen bool `json:"-"` // 数据库是否存在今日开盘价,内部计算使用。
Open float64 `json:"open"` Open float64 `json:"open"` // 今日开盘价。
Close float64 `json:"close"` Close float64 `json:"close"` // 今日收盘价。
PreviousTradeDate string `json:"previous_trade_date"` PreviousTradeDate string `json:"previous_trade_date"` // 上一个交易日。
HasPreviousClose bool `json:"-"` HasPreviousClose bool `json:"-"` // 数据库是否存在前收价,内部计算使用。
PreviousClose float64 `json:"previous_close"` PreviousClose float64 `json:"previous_close"` // 上一个交易日收盘价。
OpenThresholdPrice float64 `json:"open_threshold_price"` OpenThresholdPrice float64 `json:"open_threshold_price"` // 前收价乘以阈值比例后的开盘阈值。
OpenPreviousCloseRatio float64 `json:"open_previous_close_ratio"` OpenPreviousCloseRatio float64 `json:"open_previous_close_ratio"` // 今日开盘价与前收价的比值。
LimitPrice float64 `json:"limit_price"` LimitPrice float64 `json:"limit_price"` // 今日涨停价。
PctChg float64 `json:"pct_chg"` PctChg float64 `json:"pct_chg"` // 今日涨跌幅。
AkAmount *float64 `json:"ak_amount"` AkAmount *float64 `json:"ak_amount"` // 成交额。
AkFloatMarketValue *float64 `json:"ak_float_market_value"` AkFloatMarketValue *float64 `json:"ak_float_market_value"` // 流通市值。
AkTotalMarketValue *float64 `json:"ak_total_market_value"` AkTotalMarketValue *float64 `json:"ak_total_market_value"` // 总市值。
AkTurnoverRate *float64 `json:"ak_turnover_rate"` AkTurnoverRate *float64 `json:"ak_turnover_rate"` // 换手率。
AkSealAmount *float64 `json:"ak_seal_amount"` AkSealAmount *float64 `json:"ak_seal_amount"` // 封单额。
AkFirstLimitTime *string `json:"ak_first_limit_time"` AkFirstLimitTime *string `json:"ak_first_limit_time"` // 首次涨停时间。
AkLastLimitTime *string `json:"ak_last_limit_time"` AkLastLimitTime *string `json:"ak_last_limit_time"` // 最后涨停时间。
AkBreakLimitCount *int `json:"ak_break_limit_count"` AkBreakLimitCount *int `json:"ak_break_limit_count"` // 开板次数。
AkLimitUpStat *string `json:"ak_limit_up_stat"` AkLimitUpStat *string `json:"ak_limit_up_stat"` // 连板状态原始描述。
AkLimitUpDays *int `json:"ak_limit_up_days"` AkLimitUpDays *int `json:"ak_limit_up_days"` // 连续涨停天数。
AkIndustry *string `json:"ak_industry"` AkIndustry *string `json:"ak_industry"` // 行业。
Vol *int64 `json:"vol"` 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 { type LimitOpenCompareGroup struct {
Key string `json:"key"` Key string `json:"key"` // 分组唯一标识。
Label string `json:"label"` Label string `json:"label"` // 分组展示名称。
Count int `json:"count"` Count int `json:"count"` // 分组内股票数量。
Stocks []LimitOpenCompareStock `json:"stocks"` Stocks []LimitOpenCompareStock `json:"stocks"` // 分组内股票列表。
} }
// LimitOpenThresholdGroup 是开盘价阈值接口的分组结果。
type LimitOpenThresholdGroup struct { type LimitOpenThresholdGroup struct {
Key string `json:"key"` Key string `json:"key"` // 分组唯一标识。
Label string `json:"label"` Label string `json:"label"` // 分组展示名称。
Count int `json:"count"` Count int `json:"count"` // 分组内股票数量。
Stocks []LimitOpenThresholdStock `json:"stocks"` Stocks []LimitOpenThresholdStock `json:"stocks"` // 分组内股票列表。
} }
// LimitOpenCompareSummary 汇总昨日涨停股今日开盘表现。
type LimitOpenCompareSummary struct { type LimitOpenCompareSummary struct {
LimitCount int `json:"limit_count"` LimitCount int `json:"limit_count"` // 昨日涨停且今日继续涨停的股票总数。
OpenLowerPreviousCloseCount int `json:"open_lower_previous_close_count"` OpenLowerPreviousCloseCount int `json:"open_lower_previous_close_count"` // 今日开盘低于昨日收盘的股票数量。
OthersCount int `json:"others_count"` OthersCount int `json:"others_count"` // 未进入低开分组的其他股票数量。
FirstToSecondBoardCount int `json:"first_to_second_board_count"` FirstToSecondBoardCount int `json:"first_to_second_board_count"` // 首板进二板股票数量。
FirstSecondBeforeElevenCount int `json:"first_second_before_eleven_count"` FirstSecondBeforeElevenCount int `json:"first_second_before_eleven_count"` // 首板进二板且 11 点前封板数量。
} }
// LimitOpenThresholdSummary 汇总开盘阈值分组统计。
type LimitOpenThresholdSummary struct { type LimitOpenThresholdSummary struct {
LimitCount int `json:"limit_count"` LimitCount int `json:"limit_count"` // 昨日涨停且今日继续涨停的股票总数。
OpenWithinThresholdCount int `json:"open_within_threshold_count"` OpenWithinThresholdCount int `json:"open_within_threshold_count"` // 今日开盘低于阈值的股票数量。
OpenAtOrAboveThresholdCount int `json:"open_at_or_above_threshold_count"` OpenAtOrAboveThresholdCount int `json:"open_at_or_above_threshold_count"` // 今日开盘达到或超过阈值的股票数量。
ThresholdRatio float64 `json:"threshold_ratio"` 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 { type LimitOpenCompareResponse struct {
TradeDate string `json:"trade_date"` TradeDate string `json:"trade_date"` // 查询交易日。
PreviousTradeDate string `json:"previous_trade_date"` PreviousTradeDate string `json:"previous_trade_date"` // 上一个交易日。
PreviousTwoTradeDate string `json:"previous_two_trade_date"` PreviousTwoTradeDate string `json:"previous_two_trade_date"` // 上上个交易日。
Summary LimitOpenCompareSummary `json:"summary"` Summary LimitOpenCompareSummary `json:"summary"` // 汇总统计。
Groups []LimitOpenCompareGroup `json:"groups"` 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 { type LimitOpenThresholdResponse struct {
TradeDate string `json:"trade_date"` TradeDate string `json:"trade_date"` // 查询交易日。
PreviousTradeDate string `json:"previous_trade_date"` PreviousTradeDate string `json:"previous_trade_date"` // 上一个交易日。
Summary LimitOpenThresholdSummary `json:"summary"` Summary LimitOpenThresholdSummary `json:"summary"` // 汇总统计。
Groups []LimitOpenThresholdGroup `json:"groups"` Groups []LimitOpenThresholdGroup `json:"groups"` // 固定分组结果。
} }
// LimitBoardGroup 是涨停概览按连板数聚合后的分组。
type LimitBoardGroup struct { type LimitBoardGroup struct {
BoardCount int `json:"board_count"` BoardCount int `json:"board_count"` // 连板数。
Label string `json:"label"` Label string `json:"label"` // 连板展示标签。
Count int `json:"count"` Count int `json:"count"` // 分组股票数量。
Stocks []LimitBoardStock `json:"stocks"` Stocks []LimitBoardStock `json:"stocks"` // 分组股票列表。
} }
// LimitBoardSummary 汇总涨停概览的核心统计。
type LimitBoardSummary struct { type LimitBoardSummary struct {
MaxBoardCount int `json:"max_board_count"` MaxBoardCount int `json:"max_board_count"` // 当日最高连板数。
LimitCount int `json:"limit_count"` LimitCount int `json:"limit_count"` // 当日涨停股票数量。
GroupCount int `json:"group_count"` GroupCount int `json:"group_count"` // 连板分组数量。
} }
// LimitBoardDailyMax 表示某个交易日的最高连板数。
type LimitBoardDailyMax struct { type LimitBoardDailyMax struct {
TradeDate string `json:"trade_date"` TradeDate string `json:"trade_date"` // 交易日。
MaxBoardCount int `json:"max_board_count"` MaxBoardCount int `json:"max_board_count"` // 当日最高连板数。
} }
// LimitBoardResponse 是涨停概览接口响应。
type LimitBoardResponse struct { type LimitBoardResponse struct {
TradeDate string `json:"trade_date"` TradeDate string `json:"trade_date"` // 查询交易日。
Summary LimitBoardSummary `json:"summary"` Summary LimitBoardSummary `json:"summary"` // 当日概览统计。
RecentMaxBoardStats []LimitBoardDailyMax `json:"recent_max_board_stats"` RecentMaxBoardStats []LimitBoardDailyMax `json:"recent_max_board_stats"` // 最近窗口内每日最高连板数。
Groups []LimitBoardGroup `json:"groups"` Groups []LimitBoardGroup `json:"groups"` // 按连板数倒序排列的股票分组。
} }
+17 -17
View File
@@ -2,30 +2,30 @@ package model
import "time" import "time"
// StockDaily 对应数据库中的 stock_daily 表 // StockDaily 对应数据库中的 stock_daily 表,保存单只股票每日行情。
type StockDaily struct { type StockDaily struct {
TsCode string `gorm:"column:ts_code" json:"ts_code"` TsCode string `gorm:"column:ts_code" json:"ts_code"` // 股票代码,通常为 TuShare ts_code。
TradeDate time.Time `gorm:"column:trade_date" json:"trade_date"` TradeDate time.Time `gorm:"column:trade_date" json:"trade_date"` // 交易日期。
Open float64 `gorm:"column:open" json:"open"` Open float64 `gorm:"column:open" json:"open"` // 开盘价。
High float64 `gorm:"column:high" json:"high"` High float64 `gorm:"column:high" json:"high"` // 最高价。
Low float64 `gorm:"column:low" json:"low"` Low float64 `gorm:"column:low" json:"low"` // 最低价。
Close float64 `gorm:"column:close" json:"close"` Close float64 `gorm:"column:close" json:"close"` // 收盘价。
PctChg float64 `gorm:"column:pct_chg" json:"pct_chg"` PctChg float64 `gorm:"column:pct_chg" json:"pct_chg"` // 当日涨跌幅。
Vol float64 `gorm:"column:vol" json:"vol"` Vol float64 `gorm:"column:vol" json:"vol"` // 成交量。
} }
// AnalyzeRequest 用于接收前端 POST 请求的参数 // AnalyzeRequest 用于接收前端 POST 请求的分析区间参数。
type AnalyzeRequest struct { type AnalyzeRequest struct {
StartDate string `json:"start_date"` StartDate string `json:"start_date"` // 起始交易日。
EndDate string `json:"end_date"` EndDate string `json:"end_date"` // 结束交易日。
} }
// AnalysisResponse 用于向前端返回分析结果 // AnalysisResponse 用于向前端返回涨停后走势分析结果。
type AnalysisResponse struct { type AnalysisResponse struct {
Code string `json:"code"` Code string `json:"code"` // 股票代码。
Name string `json:"name"` Name string `json:"name"` // 股票简称。
DayOpen float64 `json:"day_open"` DayOpen float64 `json:"day_open"` // 涨停日开盘价。
DayHigh float64 `json:"day_high"` DayHigh float64 `json:"day_high"` // 涨停日最高价。
MaxHigh float64 `json:"max_high"` // 逻辑最高点(用于计算降幅) MaxHigh float64 `json:"max_high"` // 逻辑最高点(用于计算降幅)
MaxDate string `json:"max_date"` // 逻辑最高点日期 MaxDate string `json:"max_date"` // 逻辑最高点日期
OverallMaxHigh float64 `json:"overall_max_high"` // 区间绝对最高点 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),
})
}
}
+13 -2
View File
@@ -5,10 +5,14 @@ import (
"level_go/internal/api" "level_go/internal/api"
) )
// NewRouter 创建并配置 API 路由。
//
// 函数集中注册跨域中间件和 v1 版本接口,调用方只需要注入各业务 Handler。
func NewRouter(commonApi *api.CommonHandler, stockApi *api.StockHandler, limitBoardApi *api.LimitBoardHandler) *gin.Engine { 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) { r.Use(func(c *gin.Context) {
c.Writer.Header().Set("Access-Control-Allow-Origin", "*") c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, GET, OPTIONS") 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() c.Next()
}) })
r.Use(cacheableGETResponseMiddleware(newResponseCacheStore()))
v1 := r.Group("/api/v1") v1 := r.Group("/api/v1")
{ {
@@ -33,11 +38,17 @@ func NewRouter(commonApi *api.CommonHandler, stockApi *api.StockHandler, limitBo
limitBoard.GET("/open-lower-groups", limitBoardApi.GetOpenLowerGroups) limitBoard.GET("/open-lower-groups", limitBoardApi.GetOpenLowerGroups)
limitBoard.GET("/open-threshold", limitBoardApi.GetOpenThresholdGroups) limitBoard.GET("/open-threshold", limitBoardApi.GetOpenThresholdGroups)
limitBoard.GET("/open-threshold-groups", 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 := v1.Group("/download")
{ {
download.GET("/limit-board/second-board-profit-csv", limitBoardApi.DownloadSecondBoardProfitCSV) download.GET("/limit-board/second-board-profit-csv", limitBoardApi.DownloadSecondBoardProfitCSV)
download.GET("/limit-board/break-next-performance-csv", limitBoardApi.DownloadBreakNextPerformanceCSV)
} }
} }
return r return r
+4 -1
View File
@@ -5,10 +5,12 @@ import (
"level_go/internal/model" "level_go/internal/model"
) )
// CommonService 提供通用基础数据查询能力。
type CommonService struct { type CommonService struct {
DB *gorm.DB DB *gorm.DB // 数据库连接。
} }
// GetTradeCalendar 查询上交所全部开市日期,并返回最新开市日期。
func (s *CommonService) GetTradeCalendar() (model.CalendarResponse, error) { func (s *CommonService) GetTradeCalendar() (model.CalendarResponse, error) {
var results []struct { var results []struct {
DateStr string `gorm:"column:ds"` DateStr string `gorm:"column:ds"`
@@ -24,6 +26,7 @@ func (s *CommonService) GetTradeCalendar() (model.CalendarResponse, error) {
return resp, err return resp, err
} }
// 按日期升序扫描,因此循环中最后一次赋值即为最大开市日期。
for _, r := range results { for _, r := range results {
resp.OpenDates = append(resp.OpenDates, r.DateStr) resp.OpenDates = append(resp.OpenDates, r.DateStr)
resp.MaxDate = 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 ( import (
"fmt" "fmt"
"sort"
"strconv"
"strings"
"gorm.io/gorm" "gorm.io/gorm"
"level_go/internal/model"
) )
// LimitBoardService 提供涨停板概览、分组、收益统计等业务能力。
type LimitBoardService struct { type LimitBoardService struct {
DB *gorm.DB DB *gorm.DB // 数据库连接。
} }
// stockLimitCombinedTable 是涨停合并数据表名,集中定义便于多个查询复用。
const stockLimitCombinedTable = "stock_limit_combined_data" const stockLimitCombinedTable = "stock_limit_combined_data"
type limitBoardHistoryRow struct { // boardLabel 把连续涨停天数转换为中文连板标签。
TsCode string `gorm:"column:ts_code"`
TradeDate string `gorm:"column:trade_date"`
}
func boardLabel(boardCount int) string { func boardLabel(boardCount int) string {
switch boardCount { switch boardCount {
case 1: case 1:
@@ -46,272 +40,9 @@ func boardLabel(boardCount int) string {
} }
} }
func (s *LimitBoardService) GetLimitBoardOverview(tradeDate string) (model.LimitBoardResponse, error) { // loadTradeCalendar 查询不晚于 tradeDate 的上交所开市日,并返回 tradeDate 在切片中的索引。
resp := model.LimitBoardResponse{TradeDate: tradeDate} //
// dateIndex 为 -1 表示传入日期不是当前交易日历中的开市日。
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
}
func (s *LimitBoardService) loadTradeCalendar(tradeDate string) ([]string, int, error) { func (s *LimitBoardService) loadTradeCalendar(tradeDate string) ([]string, int, error) {
var tradeDates []string var tradeDates []string
if err := s.DB.Table("trade_calendar"). if err := s.DB.Table("trade_calendar").
@@ -332,289 +63,3 @@ func (s *LimitBoardService) loadTradeCalendar(tradeDate string) ([]string, int,
return tradeDates, dateIndex, nil 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 d.ts_code = l.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 d.ts_code = l.ts_code AND d.trade_date = l.trade_date
LEFT JOIN stock_daily pd
ON pd.ts_code = l.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 d.ts_code = l.ts_code AND d.trade_date = l.trade_date
LEFT JOIN stock_daily pd
ON pd.ts_code = l.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" "level_go/internal/model"
) )
// StockService 提供股票行情查询和区间表现分析能力。
type StockService struct { type StockService struct {
DB *gorm.DB DB *gorm.DB // 数据库连接。
} }
// GetHistoryKline 查询指定股票自固定起点以来的日 K 线。
func (s *StockService) GetHistoryKline(code string) ([]model.StockDaily, error) { func (s *StockService) GetHistoryKline(code string) ([]model.StockDaily, error) {
var dailies []model.StockDaily var dailies []model.StockDaily
startDate := "2020-01-02" startDate := "2020-01-02"
@@ -24,10 +26,14 @@ func (s *StockService) GetHistoryKline(code string) ([]model.StockDaily, error)
return dailies, err return dailies, err
} }
// roundTo2 把浮点数四舍五入到两位小数。
func roundTo2(val float64) float64 { func roundTo2(val float64) float64 {
return math.Round(val*100) / 100 return math.Round(val*100) / 100
} }
// AnalyzePerformance 分析某个涨停日之后到结束日期之间的下跌和反弹表现。
//
// startDate 既是涨停筛选日期,也是行情区间起点;endDate 是行情区间终点。
func (s *StockService) AnalyzePerformance(startDate, endDate string) ([]model.AnalysisResponse, error) { func (s *StockService) AnalyzePerformance(startDate, endDate string) ([]model.AnalysisResponse, error) {
var results []model.AnalysisResponse var results []model.AnalysisResponse
var limits []struct { var limits []struct {
@@ -37,7 +43,7 @@ func (s *StockService) AnalyzePerformance(startDate, endDate string) ([]model.An
High float64 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 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 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` WHERE l.trade_date = ? AND l.final_status = 1`
@@ -75,7 +81,7 @@ func (s *StockService) AnalyzePerformance(startDate, endDate string) ([]model.An
continue continue
} }
// A. 计算区间绝对最高价 // A. 计算区间绝对最高价,用于前端展示真实区间高点。
overallMax := dailies[0].High overallMax := dailies[0].High
overallMaxDate := dailies[0].TradeDate.Format("2006-01-02") overallMaxDate := dailies[0].TradeDate.Format("2006-01-02")
for _, d := range dailies { for _, d := range dailies {
@@ -85,11 +91,11 @@ func (s *StockService) AnalyzePerformance(startDate, endDate string) ([]model.An
} }
} }
// B. 逻辑计算 // B. 逻辑计算以涨停日最高价为分界,区分“未跌破涨停高点”和“跌破后反弹”两类形态。
limitHigh := stock.High limitHigh := stock.High
postDailies := dailies[1:] postDailies := dailies[1:]
// 找出涨停后的全局最低 // 找出涨停后的全局最低,用来判断是否跌破涨停日最高价。
minLowAfterLimit := postDailies[0].Low minLowAfterLimit := postDailies[0].Low
for _, d := range postDailies { for _, d := range postDailies {
if d.Low < minLowAfterLimit { if d.Low < minLowAfterLimit {
@@ -102,7 +108,7 @@ func (s *StockService) AnalyzePerformance(startDate, endDate string) ([]model.An
var logicMaxDate string var logicMaxDate string
if minLowAfterLimit >= limitHigh { if minLowAfterLimit >= limitHigh {
// 形态 1:强势。计算涨幅 // 形态 1:强势,后续最低价仍高于涨停日最高价,因此只统计向上空间。
currMax := limitHigh currMax := limitHigh
maxIdx := -1 maxIdx := -1
for i, d := range postDailies { for i, d := range postDailies {
@@ -120,7 +126,7 @@ func (s *StockService) AnalyzePerformance(startDate, endDate string) ([]model.An
} }
dropPct = nil dropPct = nil
} else { } else {
// 形态 2:破位。计算降幅和反弹 // 形态 2:破位,先找到破位最低点,再计算从此前最高点到最低点的降幅。
absMinLow := postDailies[0].Low absMinLow := postDailies[0].Low
minIdx := 0 minIdx := 0
for i, d := range postDailies { for i, d := range postDailies {
@@ -129,7 +135,7 @@ func (s *StockService) AnalyzePerformance(startDate, endDate string) ([]model.An
minIdx = i minIdx = i
} }
} }
// 找到涨停到该最低点之间的最高 // 找到涨停日到最低点之间的最高价,作为本次回撤的逻辑高点。
rangeDailies := dailies[:minIdx+2] rangeDailies := dailies[:minIdx+2]
rangeMax := dailies[0].High rangeMax := dailies[0].High
maxD := startDate maxD := startDate
@@ -150,6 +156,7 @@ func (s *StockService) AnalyzePerformance(startDate, endDate string) ([]model.An
reboundPct = &rVal reboundPct = &rVal
} }
// 收盘价序列给前端画趋势图使用。
var closePrices []float64 var closePrices []float64
for _, d := range dailies { for _, d := range dailies {
closePrices = append(closePrices, d.Close) 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 { sort.Slice(results, func(i, j int) bool {
if results[i].DropPct == nil && results[j].DropPct != nil { if results[i].DropPct == nil && results[j].DropPct != nil {
return false return false
+27 -11
View File
@@ -2,25 +2,37 @@ package config
import ( import (
"fmt" "fmt"
"github.com/spf13/viper" "github.com/spf13/viper"
) )
// Config 描述 config.yaml 中使用到的应用配置。
//
// Database 保存 MySQL 连接参数,Server 保存 HTTP 服务参数。结构体字段名需要与配置文件
// 中的层级保持一致,Viper 会在 InitConfig 中把 YAML 内容反序列化到该结构体。
type Config struct { type Config struct {
// Database 是 MySQL 连接配置。
Database struct { Database struct {
User string User string // 数据库用户名。
Password string Password string // 数据库密码。
Host string Host string // 数据库主机地址。
Port int Port int // 数据库端口。
Dbname string Dbname string // 数据库名称。
Charset string Charset string // 连接字符集,例如 utf8mb4。
Collation string // 排序规则;为空时 GetDSN 会使用 utf8mb4_unicode_ci。
} }
// Server 是 HTTP 服务配置。
Server struct { Server struct {
Port int Port int // 服务监听端口。
} }
} }
// AppConfig 保存进程级全局配置。调用 InitConfig 后才可安全读取。
var AppConfig *Config var AppConfig *Config
// InitConfig 从当前目录或 ./config 目录读取 config.yaml,并解析到 AppConfig。
//
// 这里使用 panic 暴露配置错误,因为配置缺失或格式错误会导致服务无法正确启动。
func InitConfig() { func InitConfig() {
viper.SetConfigName("config") viper.SetConfigName("config")
viper.SetConfigType("yaml") viper.SetConfigType("yaml")
@@ -36,14 +48,18 @@ func InitConfig() {
} }
} }
// GetDSN 生成 MySQL 连接字符串 // GetDSN 根据 AppConfig 生成 GORM/MySQL 使用的 DSN 连接字符串。
func GetDSN() string { func GetDSN() string {
db := AppConfig.Database db := AppConfig.Database
return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=%s&parseTime=True&loc=Local", collation := db.Collation
db.User, db.Password, db.Host, db.Port, db.Dbname, db.Charset) if collation == "" {
collation = "utf8mb4_unicode_ci"
}
return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=%s&collation=%s&parseTime=True&loc=Local",
db.User, db.Password, db.Host, db.Port, db.Dbname, db.Charset, collation)
} }
// GetServerPort 获取服务器端口字符串 // GetServerPort 返回 Gin Run 所需的端口格式,例如 ":8080"。
func GetServerPort() string { func GetServerPort() string {
return fmt.Sprintf(":%d", AppConfig.Server.Port) return fmt.Sprintf(":%d", AppConfig.Server.Port)
} }