Compare commits
27 Commits
95bf0c3342
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
| a3292c3dd9 | |||
| 507b79f257 | |||
| 49cd4f85df | |||
| 2f36adcb43 | |||
| 94e8a4f770 | |||
| 50b10fa880 | |||
| 61cc29d7b3 | |||
| 81dc55baaf | |||
| faf535d889 | |||
| d4bb849f79 | |||
| 8504838ccb | |||
| d5c053e59d | |||
| 9ea4b61ee3 | |||
| 844981a312 | |||
| 373f7553cd | |||
| 0aa4c1aff9 | |||
| cf71dd2207 | |||
| f1734a68ae | |||
| cd25b65314 | |||
| 996d4f0af7 | |||
| f78dfaec74 | |||
| 8cacfb1c49 | |||
| 4462a5978e | |||
| 50d1a53944 | |||
| ee1707c17f | |||
| af318b2604 | |||
| 7a7e28684c |
+7
-1
@@ -9,4 +9,10 @@
|
|||||||
*.iml
|
*.iml
|
||||||
|
|
||||||
# 依赖目录 (如果使用 vendor 模式)
|
# 依赖目录 (如果使用 vendor 模式)
|
||||||
# vendor/
|
# vendor/
|
||||||
|
|
||||||
|
# 本地运行配置,包含数据库账号密码,不提交到仓库
|
||||||
|
/config.yaml
|
||||||
|
/config.yml
|
||||||
|
/config/config.yaml
|
||||||
|
/config/config.yml
|
||||||
|
|||||||
@@ -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` 导出。
|
||||||
+29
-48
@@ -1,68 +1,49 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"github.com/gin-gonic/gin"
|
"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/router"
|
||||||
"level_go/internal/service"
|
"level_go/internal/service"
|
||||||
"level_go/pkg/config"
|
"level_go/pkg/config"
|
||||||
|
"log"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// main 是服务启动入口,负责完成配置加载、数据库连接、依赖组装和 HTTP 路由启动。
|
||||||
func main() {
|
func main() {
|
||||||
// 1. 加载配置
|
gin.SetMode(gin.ReleaseMode)
|
||||||
|
|
||||||
|
// 初始化配置文件,后续数据库 DSN 和服务端口都从全局配置中读取。
|
||||||
config.InitConfig()
|
config.InitConfig()
|
||||||
dbConf := config.AppConfig.Database
|
|
||||||
|
|
||||||
// 2. 连接数据库
|
|
||||||
dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=%s&parseTime=True&loc=Local",
|
|
||||||
dbConf.User, dbConf.Password, dbConf.Host, dbConf.Port, dbConf.Dbname, dbConf.Charset)
|
|
||||||
|
|
||||||
|
// 使用配置中的 MySQL DSN 建立 GORM 连接;连接失败时直接终止进程,避免服务在不可用状态下启动。
|
||||||
|
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 {
|
||||||
panic("数据库连接失败: " + err.Error())
|
log.Fatal("数据库连接失败:", err)
|
||||||
}
|
}
|
||||||
|
sqlDB, err := db.DB()
|
||||||
// 3. 注入 Service
|
if err != nil {
|
||||||
stockSvc := &service.StockService{DB: db}
|
log.Fatal("获取数据库连接池失败:", err)
|
||||||
|
|
||||||
// 4. 路由设置
|
|
||||||
r := gin.Default()
|
|
||||||
|
|
||||||
// 跨域中间件
|
|
||||||
r.Use(func(c *gin.Context) {
|
|
||||||
c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
|
|
||||||
c.Writer.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
|
||||||
c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
|
||||||
if c.Request.Method == "OPTIONS" {
|
|
||||||
c.AbortWithStatus(204)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.Next()
|
|
||||||
})
|
|
||||||
|
|
||||||
api := r.Group("/api/v1")
|
|
||||||
{
|
|
||||||
api.GET("/stock/analyze", func(c *gin.Context) {
|
|
||||||
start := c.Query("start_date")
|
|
||||||
end := c.Query("end_date")
|
|
||||||
|
|
||||||
if start == "" || end == "" {
|
|
||||||
c.JSON(400, gin.H{"error": "参数缺失"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
data, err := stockSvc.AnalyzePerformance(start, end)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(500, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.JSON(200, data)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
sqlDB.SetMaxIdleConns(20)
|
||||||
|
sqlDB.SetMaxOpenConns(100)
|
||||||
|
|
||||||
// 5. 启动
|
// 依赖注入:Service 持有数据库连接,Handler 持有对应 Service。
|
||||||
port := fmt.Sprintf(":%d", config.AppConfig.Server.Port)
|
commonSrv := &service.CommonService{DB: db}
|
||||||
fmt.Printf("服务已启动,监听端口 %s\n", port)
|
stockSrv := &service.StockService{DB: db}
|
||||||
|
limitBoardSrv := &service.LimitBoardService{DB: db}
|
||||||
|
|
||||||
|
commonHandler := &api.CommonHandler{Service: commonSrv}
|
||||||
|
stockHandler := &api.StockHandler{Service: stockSrv}
|
||||||
|
limitBoardHandler := api.NewLimitBoardHandler(limitBoardSrv)
|
||||||
|
|
||||||
|
// 创建 Gin 路由并启动 HTTP 服务。
|
||||||
|
r := router.NewRouter(commonHandler, stockHandler, limitBoardHandler)
|
||||||
|
|
||||||
|
port := config.GetServerPort()
|
||||||
|
log.Printf("服务启动于端口 %s", port)
|
||||||
r.Run(port)
|
r.Run(port)
|
||||||
}
|
}
|
||||||
|
|||||||
-10
@@ -1,10 +0,0 @@
|
|||||||
database:
|
|
||||||
user: "yangfan"
|
|
||||||
password: "aA%8023321088"
|
|
||||||
host: "8.159.129.156"
|
|
||||||
port: 10836
|
|
||||||
dbname: "level"
|
|
||||||
charset: "utf8mb4"
|
|
||||||
|
|
||||||
server:
|
|
||||||
port: 18080
|
|
||||||
@@ -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`);
|
||||||
@@ -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;
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# 接口文档总览
|
||||||
|
|
||||||
|
## 1. 基础说明
|
||||||
|
|
||||||
|
- 服务前缀:`/api/v1`
|
||||||
|
- 响应格式:`application/json`
|
||||||
|
- 当前项目接口总数:13
|
||||||
|
|
||||||
|
## 2. 接口列表
|
||||||
|
|
||||||
|
| 模块 | 方法 | 路径 | 说明 | 文档 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| 公共 | `GET` | `/api/v1/common/trade-calendar` | 获取交易日历 | [common-trade-calendar.md](./common-trade-calendar.md) |
|
||||||
|
| 股票分析 | `POST` | `/api/v1/stock/analyze` | 分析指定涨停日股票后续表现 | [stock-analyze.md](./stock-analyze.md) |
|
||||||
|
| 股票行情 | `GET` | `/api/v1/stock/kline/:code` | 获取指定股票历史 K 线 | [stock-kline.md](./stock-kline.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-threshold` | 获取查询日和前一交易日均涨停的股票,并按开盘价是否低于前一交易日收盘价 101% 分组 | [limit-board-open-threshold.md](./limit-board-open-threshold.md) |
|
||||||
|
| 二板早盘次日盈利 | `GET` | `/api/v1/limit-board/second-board-profit` | 分页查询 T-2 无涨停、T-1 涨停、T 涨停的次日盈利明细 | [download-second-board-profit-csv.md](./download-second-board-profit-csv.md) |
|
||||||
|
| 二板获利综合 | `GET` | `/api/v1/limit-board/second-board-profit-summary` | 汇总每只个股 2020 年至今 09:31:00-09:59:59 二板后的次日最大获利,并按累计获利倒序排序 | [second-board-profit-summary.md](./second-board-profit-summary.md) |
|
||||||
|
| 个股二板获利详情 | `GET` | `/api/v1/limit-board/second-board-profit-detail` | 查询单只个股每次 09:31:00-09:59:59 二板后的次日最大获利 | [second-board-profit-summary.md](./second-board-profit-summary.md) |
|
||||||
|
| 二板日历 | `GET` | `/api/v1/limit-board/second-cal` | 按交易日统计二板数量、封板/炸板数量和 T+1 盘中盈利概率 | [second-cal.md](./second-cal.md) |
|
||||||
|
| 炸板次日表现 | `GET` | `/api/v1/limit-board/break-next-performance` | 分页查询 T-2 未涨停、T-1 涨停、T 日炸板股票及 T+1 表现 | [limit-board-break-next-performance.md](./limit-board-break-next-performance.md) |
|
||||||
|
| 下载 | `GET` | `/api/v1/download/limit-board/second-board-profit-csv` | 下载筛选条件范围内的二板早盘次日盈利 CSV 明细 | [download-second-board-profit-csv.md](./download-second-board-profit-csv.md) |
|
||||||
|
| 下载 | `GET` | `/api/v1/download/limit-board/break-next-performance-csv` | 下载筛选条件范围内的炸板次日表现 CSV 明细 | [limit-board-break-next-performance.md](./limit-board-break-next-performance.md) |
|
||||||
|
|
||||||
|
## 3. 错误响应约定
|
||||||
|
|
||||||
|
当前项目未统一封装错误码,接口错误响应以 `error` 字段返回文本信息。
|
||||||
|
|
||||||
|
示例:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": "参数错误"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. 跨域说明
|
||||||
|
|
||||||
|
服务已开启基础跨域头:
|
||||||
|
|
||||||
|
- `Access-Control-Allow-Origin: *`
|
||||||
|
- `Access-Control-Allow-Methods: POST, GET, OPTIONS`
|
||||||
|
- `Access-Control-Allow-Headers: Content-Type`
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
# 交易日历接口文档
|
||||||
|
|
||||||
|
## 1. 接口说明
|
||||||
|
|
||||||
|
- 接口名称:交易日历
|
||||||
|
- 请求方式:`GET`
|
||||||
|
- 请求路径:`/api/v1/common/trade-calendar`
|
||||||
|
- 接口用途:获取上交所开市交易日列表,并返回当前数据中的最大交易日。
|
||||||
|
|
||||||
|
## 2. 请求参数
|
||||||
|
|
||||||
|
无
|
||||||
|
|
||||||
|
请求示例:
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/v1/common/trade-calendar
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. 响应说明
|
||||||
|
|
||||||
|
| 字段名 | 类型 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `open_dates` | `array[string]` | 全部开市日期列表,格式 `YYYY-MM-DD` |
|
||||||
|
| `max_date` | `string` | 开市日期列表中的最大日期 |
|
||||||
|
|
||||||
|
## 4. 响应示例
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"open_dates": [
|
||||||
|
"2026-04-20",
|
||||||
|
"2026-04-21",
|
||||||
|
"2026-04-22",
|
||||||
|
"2026-04-23",
|
||||||
|
"2026-04-24"
|
||||||
|
],
|
||||||
|
"max_date": "2026-04-24"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. 业务规则
|
||||||
|
|
||||||
|
- 只返回 `trade_calendar` 表中 `exchange = 'SSE'` 且 `is_open = 1` 的交易日。
|
||||||
|
- 日期按升序返回。
|
||||||
|
- `max_date` 等于列表中的最后一个日期。
|
||||||
|
|
||||||
|
## 6. 错误响应
|
||||||
|
|
||||||
|
服务端异常时:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": "具体错误信息"
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
# 二板早盘次日盈利接口文档
|
||||||
|
|
||||||
|
## 1. 接口说明
|
||||||
|
|
||||||
|
本页包含二板早盘次日盈利的列表查询接口和 CSV 下载接口。两个接口使用同一套查询条件和同一套返回字段;列表接口分页,CSV 下载接口导出筛选后的全量数据。
|
||||||
|
|
||||||
|
业务筛选基础条件:
|
||||||
|
|
||||||
|
- 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. 请求参数
|
||||||
|
|
||||||
|
| 参数名 | 类型 | 必填 | 默认值 | 示例 | 说明 |
|
||||||
|
| --- | --- | --- | --- | --- | --- |
|
||||||
|
| `tradeStartDate` | `string` | 否 | `2020-01-01` | `2026-01-01` | 交易日开始日期,格式固定为 `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` | 仅列表接口支持,每页数量 |
|
||||||
|
|
||||||
|
兼容说明:CSV 下载接口仍兼容旧参数 `start_date` 和 `end_date`,建议新调用统一使用 `tradeStartDate` 和 `tradeEndDate`。
|
||||||
|
|
||||||
|
筛选规则:
|
||||||
|
|
||||||
|
- 每个筛选项最多支持两组条件,第一组为 `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
|
||||||
|
GET /api/v1/limit-board/second-board-profit?tradeStartDate=2026-01-01&tradeEndDate=2026-12-31&page=1&pageSize=10
|
||||||
|
```
|
||||||
|
|
||||||
|
带筛选条件的列表查询:
|
||||||
|
|
||||||
|
```http
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
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`
|
||||||
|
- 响应头:`Content-Disposition: attachment; filename="limit_second_board_profit_YYYYMMDD_YYYYMMDD_daily_detail.csv"`
|
||||||
|
- CSV 文件带 UTF-8 BOM,便于 Excel 直接打开中文列名。
|
||||||
|
- CSV 下载接口不分页,默认导出查询条件范围内的全部数据。
|
||||||
|
|
||||||
|
CSV 字段顺序:
|
||||||
|
|
||||||
|
| 字段名 | 说明 |
|
||||||
|
| --- | --- |
|
||||||
|
| `交易日` | T 日交易日 |
|
||||||
|
| `股票代码` | TS 股票代码 |
|
||||||
|
| `股票简称` | 股票简称 |
|
||||||
|
| `市场` | 市场信息 |
|
||||||
|
| `行业` | 所属行业 |
|
||||||
|
| `T-1日开盘涨跌幅%` | T-1 日开盘价相对 T-2 收盘价的涨跌幅百分比 |
|
||||||
|
| `T日开盘涨跌幅%` | T 日开盘价相对 T-1 收盘价的涨跌幅百分比 |
|
||||||
|
| `T+1日开盘涨跌幅%` | T+1 日开盘价相对 T 日收盘价的涨跌幅百分比 |
|
||||||
|
| `T+1开盘是否盈利` | T+1 开盘收益大于 0 时为 `是`,否则为 `否`;无次日数据时为 `无次日数据` |
|
||||||
|
| `T+1收盘是否盈利` | T+1 收盘收益大于 0 时为 `是`,否则为 `否`;无次日数据时为 `无次日数据` |
|
||||||
|
| `T+1盘中是否可盈利` | T+1 最高收益大于 0 时为 `是`,否则为 `否`;无次日数据时为 `无次日数据` |
|
||||||
|
| `T+1最高盈利%` | T+1 最高价相对 T 日收盘价的收益率百分比 |
|
||||||
|
| `T+1最低盈利%` | T+1 最低价相对 T 日收盘价的收益率百分比 |
|
||||||
|
| `开板次数` | T 日开板次数 |
|
||||||
|
| `换手率%` | T 日换手率 |
|
||||||
|
| `首次涨停时间` | T 日首次涨停时间,格式化为 `HH:MM:SS` |
|
||||||
|
| `封单额` | T 日封单额 |
|
||||||
|
| `成交额` | T 日成交额 |
|
||||||
|
|
||||||
|
## 7. 错误响应
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": "参数错误"
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -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` |
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
# 涨停低开分组接口文档
|
||||||
|
|
||||||
|
## 1. 接口说明
|
||||||
|
|
||||||
|
- 接口名称:涨停低开分组
|
||||||
|
- 请求方式:`GET`
|
||||||
|
- 请求路径:`/api/v1/limit-board/open-lower-groups`
|
||||||
|
- 接口用途:查询指定交易日涨停且前一交易日也涨停的股票,并按指定交易日开盘价是否低于前一交易日收盘价分为两组。
|
||||||
|
|
||||||
|
## 2. 请求参数
|
||||||
|
|
||||||
|
| 参数名 | 类型 | 必填 | 示例 | 说明 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| `trade_date` | `string` | 是 | `2026-04-24` | 交易日,格式固定为 `YYYY-MM-DD` |
|
||||||
|
|
||||||
|
请求示例:
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/v1/limit-board/open-lower-groups?trade_date=2026-04-24
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. 响应说明
|
||||||
|
|
||||||
|
### 3.1 顶层字段
|
||||||
|
|
||||||
|
| 字段名 | 类型 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `trade_date` | `string` | 查询的交易日 |
|
||||||
|
| `previous_trade_date` | `string` | 前一交易日;如果查询日没有前一交易日则为空字符串 |
|
||||||
|
| `previous_two_trade_date` | `string` | 前二交易日;如果查询日没有前二交易日则为空字符串 |
|
||||||
|
| `summary` | `object` | 当日涨停摘要信息 |
|
||||||
|
| `groups` | `array` | 分组后的股票列表,固定返回两组 |
|
||||||
|
|
||||||
|
### 3.2 summary 字段
|
||||||
|
|
||||||
|
| 字段名 | 类型 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `limit_count` | `int` | 指定交易日涨停且前一交易日也涨停的股票总数 |
|
||||||
|
| `open_lower_previous_close_count` | `int` | 指定交易日开盘价低于前一交易日收盘价的股票数量 |
|
||||||
|
| `others_count` | `int` | 样本池内其他股票数量 |
|
||||||
|
| `first_to_second_board_count` | `int` | 前一交易日是首板、指定交易日是二板的股票数量 |
|
||||||
|
| `first_second_before_eleven_count` | `int` | 开盘低于前收、首板进二板、且指定交易日首次涨停时间早于 11:00 的股票数量 |
|
||||||
|
|
||||||
|
### 3.3 groups 字段
|
||||||
|
|
||||||
|
| 字段名 | 类型 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `key` | `string` | 分组标识,取值为 `open_lower_previous_close` 或 `others` |
|
||||||
|
| `label` | `string` | 分组展示文案 |
|
||||||
|
| `count` | `int` | 当前分组内股票数量 |
|
||||||
|
| `stocks` | `array` | 当前分组内的股票列表 |
|
||||||
|
|
||||||
|
### 3.4 groups[].stocks 字段
|
||||||
|
|
||||||
|
| 字段名 | 类型 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `symbol` | `string` | 股票代码 |
|
||||||
|
| `ts_code` | `string` | TS 股票代码 |
|
||||||
|
| `name` | `string` | 股票名称 |
|
||||||
|
| `market` | `string` | 所属板块或市场信息 |
|
||||||
|
| `trade_date` | `string` | 交易日 |
|
||||||
|
| `open` | `number` | 当日开盘价 |
|
||||||
|
| `close` | `number` | 当日收盘价 |
|
||||||
|
| `previous_trade_date` | `string` | 前一交易日 |
|
||||||
|
| `previous_two_trade_date` | `string` | 前二交易日 |
|
||||||
|
| `previous_close` | `number` | 前一交易日收盘价 |
|
||||||
|
| `is_first_to_second_board` | `boolean` | 是否为前一交易日首板、指定交易日二板 |
|
||||||
|
| `is_first_second_before_eleven` | `boolean` | 是否满足开盘低于前收、首板进二板、首次涨停时间早于 11:00 |
|
||||||
|
| `highlight_label` | `string` | 重点标记文案;优先返回 `首二一一`,其次返回 `首板进二板`,否则为空字符串 |
|
||||||
|
| `limit_price` | `number` | 理论涨停价 |
|
||||||
|
| `pct_chg` | `number` | 当日涨跌幅 |
|
||||||
|
| `ak_amount` | `number \| null` | AKShare 成交额 |
|
||||||
|
| `ak_float_market_value` | `number \| null` | AKShare 流通市值 |
|
||||||
|
| `ak_total_market_value` | `number \| null` | AKShare 总市值 |
|
||||||
|
| `ak_turnover_rate` | `number \| null` | AKShare 换手率 |
|
||||||
|
| `ak_seal_amount` | `number \| null` | AKShare 封板资金 |
|
||||||
|
| `ak_first_limit_time` | `string \| null` | Tushare 首次封板时间,可能为 `09:35:12` 或 `93200` 这类紧凑格式 |
|
||||||
|
| `ak_last_limit_time` | `string \| null` | AKShare 最后封板时间 |
|
||||||
|
| `ak_break_limit_count` | `int \| null` | AKShare 炸板次数 |
|
||||||
|
| `ak_limit_up_stat` | `string \| null` | AKShare 涨停统计 |
|
||||||
|
| `ak_limit_up_days` | `int \| null` | AKShare 连板数 |
|
||||||
|
| `ak_industry` | `string \| null` | AKShare 所属行业 |
|
||||||
|
| `vol` | `int \| null` | 成交量 |
|
||||||
|
|
||||||
|
## 4. 响应示例
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"trade_date": "2026-04-24",
|
||||||
|
"previous_trade_date": "2026-04-23",
|
||||||
|
"previous_two_trade_date": "2026-04-22",
|
||||||
|
"summary": {
|
||||||
|
"limit_count": 23,
|
||||||
|
"open_lower_previous_close_count": 5,
|
||||||
|
"others_count": 18,
|
||||||
|
"first_to_second_board_count": 3,
|
||||||
|
"first_second_before_eleven_count": 2
|
||||||
|
},
|
||||||
|
"groups": [
|
||||||
|
{
|
||||||
|
"key": "open_lower_previous_close",
|
||||||
|
"label": "开盘低于前收",
|
||||||
|
"count": 5,
|
||||||
|
"stocks": [
|
||||||
|
{
|
||||||
|
"symbol": "000001",
|
||||||
|
"ts_code": "000001.SZ",
|
||||||
|
"name": "示例股份",
|
||||||
|
"market": "主板",
|
||||||
|
"trade_date": "2026-04-24",
|
||||||
|
"open": 11.1,
|
||||||
|
"close": 12.35,
|
||||||
|
"previous_trade_date": "2026-04-23",
|
||||||
|
"previous_two_trade_date": "2026-04-22",
|
||||||
|
"previous_close": 11.2,
|
||||||
|
"is_first_to_second_board": true,
|
||||||
|
"is_first_second_before_eleven": true,
|
||||||
|
"highlight_label": "首二一一",
|
||||||
|
"limit_price": 12.35,
|
||||||
|
"pct_chg": 10.01,
|
||||||
|
"ak_amount": 1523000000,
|
||||||
|
"ak_float_market_value": 8245000000,
|
||||||
|
"ak_total_market_value": 12678000000,
|
||||||
|
"ak_turnover_rate": 18.64,
|
||||||
|
"ak_seal_amount": 286000000,
|
||||||
|
"ak_first_limit_time": "09:35:12",
|
||||||
|
"ak_last_limit_time": "14:42:08",
|
||||||
|
"ak_break_limit_count": 2,
|
||||||
|
"ak_limit_up_stat": "1天1板",
|
||||||
|
"ak_limit_up_days": 1,
|
||||||
|
"ak_industry": "软件开发",
|
||||||
|
"vol": 123456789
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "others",
|
||||||
|
"label": "其他涨停股",
|
||||||
|
"count": 18,
|
||||||
|
"stocks": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. 业务规则
|
||||||
|
|
||||||
|
- 只统计指定交易日 `stock_limit_combined_data.final_status = 1` 且前一交易日 `stock_limit_combined_data.final_status = 1` 的股票。
|
||||||
|
- 前一交易日、前二交易日通过 `trade_calendar` 中 `exchange = 'SSE'` 且 `is_open = 1` 的交易日确定。
|
||||||
|
- 只有当日 `stock_daily.open` 和前一交易日 `stock_daily.close` 都存在,且 `open < previous_close` 时,股票才会进入 `open_lower_previous_close` 分组。
|
||||||
|
- 行情缺失、没有前一交易日、或不满足 `open < previous_close` 的股票都会进入 `others` 分组。
|
||||||
|
- 当指定交易日涨停、前一交易日涨停、前二交易日不涨停时,返回 `is_first_to_second_board = true`,并设置 `highlight_label = "首板进二板"`。
|
||||||
|
- 在 `open_lower_previous_close` 分组内,如果同时满足 `is_first_to_second_board = true` 且 `ak_first_limit_time` 早于 `11:00`,返回 `is_first_second_before_eleven = true`,并将 `highlight_label` 设为 `首二一一`。`93200` 会按 `09:32:00` 处理。
|
||||||
|
- 每个分组内的 `stocks` 按 `pct_chg` 从大到小排序;如果 `pct_chg` 相同,则按 `ts_code` 升序排序。
|
||||||
|
|
||||||
|
## 6. 错误响应
|
||||||
|
|
||||||
|
当缺少 `trade_date` 参数时:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": "trade_date 参数不能为空"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
当服务端内部异常时:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": "具体错误信息"
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
# 涨停开盘阈值分组接口文档
|
||||||
|
|
||||||
|
## 1. 接口说明
|
||||||
|
|
||||||
|
- 接口名称:涨停开盘阈值分组
|
||||||
|
- 请求方式:`GET`
|
||||||
|
- 请求路径:`/api/v1/limit-board/open-threshold`
|
||||||
|
- 接口用途:查询指定交易日涨停且前一交易日也涨停的股票,并按指定交易日开盘价是否低于前一交易日收盘价的 101% 分为两组。
|
||||||
|
|
||||||
|
## 2. 请求参数
|
||||||
|
|
||||||
|
| 参数名 | 类型 | 必填 | 示例 | 说明 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| `trade_date` | `string` | 是 | `2026-04-24` | 交易日,格式固定为 `YYYY-MM-DD` |
|
||||||
|
|
||||||
|
请求示例:
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/v1/limit-board/open-threshold?trade_date=2026-04-24
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. 响应说明
|
||||||
|
|
||||||
|
### 3.1 顶层字段
|
||||||
|
|
||||||
|
| 字段名 | 类型 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `trade_date` | `string` | 查询的交易日 |
|
||||||
|
| `previous_trade_date` | `string` | 前一交易日;如果查询日没有前一交易日则为空字符串 |
|
||||||
|
| `summary` | `object` | 摘要信息 |
|
||||||
|
| `groups` | `array` | 分组后的股票列表,固定返回两组 |
|
||||||
|
|
||||||
|
### 3.2 summary 字段
|
||||||
|
|
||||||
|
| 字段名 | 类型 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `limit_count` | `int` | 指定交易日涨停且前一交易日也涨停的股票总数 |
|
||||||
|
| `open_within_threshold_count` | `int` | 指定交易日开盘价低于前一交易日收盘价 101% 的股票数量 |
|
||||||
|
| `open_at_or_above_threshold_count` | `int` | 指定交易日开盘价大于等于前一交易日收盘价 101% 的股票数量 |
|
||||||
|
| `threshold_ratio` | `number` | 分组阈值,固定为 `1.01` |
|
||||||
|
|
||||||
|
### 3.3 groups 字段
|
||||||
|
|
||||||
|
| 字段名 | 类型 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `key` | `string` | 分组标识,取值为 `open_within_previous_close_101` 或 `open_at_or_above_previous_close_101` |
|
||||||
|
| `label` | `string` | 分组展示文案 |
|
||||||
|
| `count` | `int` | 当前分组内股票数量 |
|
||||||
|
| `stocks` | `array` | 当前分组内的股票列表 |
|
||||||
|
|
||||||
|
### 3.4 groups[].stocks 字段
|
||||||
|
|
||||||
|
| 字段名 | 类型 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `symbol` | `string` | 股票代码 |
|
||||||
|
| `ts_code` | `string` | TS 股票代码 |
|
||||||
|
| `name` | `string` | 股票名称 |
|
||||||
|
| `market` | `string` | 所属板块或市场信息 |
|
||||||
|
| `trade_date` | `string` | 交易日 |
|
||||||
|
| `open` | `number` | 当日开盘价 |
|
||||||
|
| `close` | `number` | 当日收盘价 |
|
||||||
|
| `previous_trade_date` | `string` | 前一交易日 |
|
||||||
|
| `previous_close` | `number` | 前一交易日收盘价,优先取前一交易日 `stock_limit_combined_data.calc_close/tushare_close` |
|
||||||
|
| `open_threshold_price` | `number` | 分组阈值价格,即 `previous_close * 1.01` |
|
||||||
|
| `open_previous_close_ratio` | `number` | 当日开盘价 / 前一交易日收盘价 |
|
||||||
|
| `limit_price` | `number` | 理论涨停价 |
|
||||||
|
| `pct_chg` | `number` | 当日涨跌幅 |
|
||||||
|
| `ak_amount` | `number \| null` | Tushare 成交额 |
|
||||||
|
| `ak_float_market_value` | `number \| null` | Tushare 流通市值 |
|
||||||
|
| `ak_total_market_value` | `number \| null` | Tushare 总市值 |
|
||||||
|
| `ak_turnover_rate` | `number \| null` | Tushare 换手率 |
|
||||||
|
| `ak_seal_amount` | `number \| null` | Tushare 封板资金 |
|
||||||
|
| `ak_first_limit_time` | `string \| null` | Tushare 首次封板时间,可能为 `09:35:12` 或 `93200` 这类紧凑格式 |
|
||||||
|
| `ak_last_limit_time` | `string \| null` | Tushare 最后封板时间 |
|
||||||
|
| `ak_break_limit_count` | `int \| null` | Tushare 炸板次数 |
|
||||||
|
| `ak_limit_up_stat` | `string \| null` | Tushare 涨停统计 |
|
||||||
|
| `ak_limit_up_days` | `int \| null` | Tushare 连板数 |
|
||||||
|
| `ak_industry` | `string \| null` | Tushare 所属行业 |
|
||||||
|
| `vol` | `int \| null` | 成交量 |
|
||||||
|
|
||||||
|
## 4. 响应示例
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"trade_date": "2026-04-24",
|
||||||
|
"previous_trade_date": "2026-04-23",
|
||||||
|
"summary": {
|
||||||
|
"limit_count": 23,
|
||||||
|
"open_within_threshold_count": 8,
|
||||||
|
"open_at_or_above_threshold_count": 15,
|
||||||
|
"threshold_ratio": 1.01
|
||||||
|
},
|
||||||
|
"groups": [
|
||||||
|
{
|
||||||
|
"key": "open_within_previous_close_101",
|
||||||
|
"label": "开盘价低于前收101%",
|
||||||
|
"count": 8,
|
||||||
|
"stocks": [
|
||||||
|
{
|
||||||
|
"symbol": "000001",
|
||||||
|
"ts_code": "000001.SZ",
|
||||||
|
"name": "示例股份",
|
||||||
|
"market": "主板",
|
||||||
|
"trade_date": "2026-04-24",
|
||||||
|
"open": 10.09,
|
||||||
|
"close": 11.0,
|
||||||
|
"previous_trade_date": "2026-04-23",
|
||||||
|
"previous_close": 10.0,
|
||||||
|
"open_threshold_price": 10.1,
|
||||||
|
"open_previous_close_ratio": 1.009,
|
||||||
|
"limit_price": 11.0,
|
||||||
|
"pct_chg": 10.0,
|
||||||
|
"ak_amount": 1523000000,
|
||||||
|
"ak_float_market_value": 8245000000,
|
||||||
|
"ak_total_market_value": 12678000000,
|
||||||
|
"ak_turnover_rate": 18.64,
|
||||||
|
"ak_seal_amount": 286000000,
|
||||||
|
"ak_first_limit_time": "93200",
|
||||||
|
"ak_last_limit_time": "144208",
|
||||||
|
"ak_break_limit_count": 2,
|
||||||
|
"ak_limit_up_stat": "2天2板",
|
||||||
|
"ak_limit_up_days": 2,
|
||||||
|
"ak_industry": "软件开发",
|
||||||
|
"vol": 123456789
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "open_at_or_above_previous_close_101",
|
||||||
|
"label": "开盘价大于等于前收101%",
|
||||||
|
"count": 15,
|
||||||
|
"stocks": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. 业务规则
|
||||||
|
|
||||||
|
- 只统计指定交易日 `stock_limit_combined_data.final_status = 1` 且前一交易日 `stock_limit_combined_data.final_status = 1` 的股票。
|
||||||
|
- 前一交易日通过 `trade_calendar` 中 `exchange = 'SSE'` 且 `is_open = 1` 的交易日确定。
|
||||||
|
- 分组阈值为 `previous_close * 1.01`。
|
||||||
|
- 当 `open < previous_close * 1.01` 时,股票进入 `open_within_previous_close_101` 分组。
|
||||||
|
- 当 `open >= previous_close * 1.01` 时,股票进入 `open_at_or_above_previous_close_101` 分组。
|
||||||
|
- 如果当日开盘价或前一交易日收盘价缺失,股票进入 `open_at_or_above_previous_close_101` 分组。
|
||||||
|
- 每个分组内的 `stocks` 按 `pct_chg` 从大到小排序;如果 `pct_chg` 相同,则按 `ts_code` 升序排序。
|
||||||
|
|
||||||
|
## 6. 错误响应
|
||||||
|
|
||||||
|
当缺少 `trade_date` 参数时:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": "trade_date 参数不能为空"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
当服务端内部异常时:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": "具体错误信息"
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
# 涨停连板概览接口文档
|
||||||
|
|
||||||
|
## 1. 接口说明
|
||||||
|
|
||||||
|
- 接口名称:涨停连板概览
|
||||||
|
- 请求方式:`GET`
|
||||||
|
- 请求路径:`/api/v1/limit-board/overview`
|
||||||
|
- 接口用途:查询指定交易日的涨停股票,按连板高度分组返回,并附带近 30 个交易日的每日最高连板数。
|
||||||
|
|
||||||
|
## 2. 请求参数
|
||||||
|
|
||||||
|
| 参数名 | 类型 | 必填 | 示例 | 说明 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| `trade_date` | `string` | 是 | `2026-04-24` | 交易日,格式固定为 `YYYY-MM-DD` |
|
||||||
|
|
||||||
|
请求示例:
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/v1/limit-board/overview?trade_date=2026-04-24
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. 响应说明
|
||||||
|
|
||||||
|
### 3.1 顶层字段
|
||||||
|
|
||||||
|
| 字段名 | 类型 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `trade_date` | `string` | 查询的交易日 |
|
||||||
|
| `summary` | `object` | 当日涨停摘要信息 |
|
||||||
|
| `recent_max_board_stats` | `array` | 近 30 个交易日每天的最高连板数 |
|
||||||
|
| `groups` | `array` | 按连板高度分组后的股票列表,已按板数从高到低排序 |
|
||||||
|
|
||||||
|
### 3.2 summary 字段
|
||||||
|
|
||||||
|
| 字段名 | 类型 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `max_board_count` | `int` | 当天最高连板数 |
|
||||||
|
| `limit_count` | `int` | 当天涨停股票总数 |
|
||||||
|
| `group_count` | `int` | 当天连板分组数量 |
|
||||||
|
|
||||||
|
### 3.3 recent_max_board_stats 字段
|
||||||
|
|
||||||
|
| 字段名 | 类型 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `trade_date` | `string` | 交易日 |
|
||||||
|
| `max_board_count` | `int` | 该交易日市场最高连板数 |
|
||||||
|
|
||||||
|
### 3.4 groups 字段
|
||||||
|
|
||||||
|
| 字段名 | 类型 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `board_count` | `int` | 当前分组对应的连板数 |
|
||||||
|
| `label` | `string` | 当前分组显示文案,如 `首板`、`二板`、`三板` |
|
||||||
|
| `count` | `int` | 当前分组内股票数量 |
|
||||||
|
| `stocks` | `array` | 当前分组内的股票列表 |
|
||||||
|
|
||||||
|
### 3.5 groups[].stocks 字段
|
||||||
|
|
||||||
|
| 字段名 | 类型 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `symbol` | `string` | 股票代码 |
|
||||||
|
| `ts_code` | `string` | TS 股票代码 |
|
||||||
|
| `name` | `string` | 股票名称 |
|
||||||
|
| `market` | `string` | 所属板块或市场信息 |
|
||||||
|
| `trade_date` | `string` | 交易日 |
|
||||||
|
| `close` | `number` | 收盘价 |
|
||||||
|
| `limit_price` | `number` | 理论涨停价 |
|
||||||
|
| `pct_chg` | `number` | 当日涨跌幅 |
|
||||||
|
| `ak_amount` | `number \| null` | AKShare 成交额 |
|
||||||
|
| `ak_float_market_value` | `number \| null` | AKShare 流通市值 |
|
||||||
|
| `ak_total_market_value` | `number \| null` | AKShare 总市值 |
|
||||||
|
| `ak_turnover_rate` | `number \| null` | AKShare 换手率 |
|
||||||
|
| `ak_seal_amount` | `number \| null` | AKShare 封板资金 |
|
||||||
|
| `ak_first_limit_time` | `string \| null` | Tushare 首次封板时间,可能为 `09:35:12` 或 `93200` 这类紧凑格式 |
|
||||||
|
| `ak_last_limit_time` | `string \| null` | AKShare 最后封板时间 |
|
||||||
|
| `ak_break_limit_count` | `int \| null` | AKShare 炸板次数 |
|
||||||
|
| `ak_limit_up_stat` | `string \| null` | AKShare 涨停统计 |
|
||||||
|
| `ak_limit_up_days` | `int \| null` | AKShare 连板数 |
|
||||||
|
| `ak_industry` | `string \| null` | AKShare 所属行业 |
|
||||||
|
| `vol` | `int \| null` | 成交量 |
|
||||||
|
| `board_count` | `int` | 该股票对应的连续涨停板数 |
|
||||||
|
| `board_label` | `string` | 连板展示文案,如 `首板`、`二板` |
|
||||||
|
| `display_status` | `string` | 页面可直接使用的状态文案,当前与 `board_label` 一致 |
|
||||||
|
|
||||||
|
## 4. 响应示例
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"trade_date": "2026-04-24",
|
||||||
|
"summary": {
|
||||||
|
"max_board_count": 4,
|
||||||
|
"limit_count": 23,
|
||||||
|
"group_count": 4
|
||||||
|
},
|
||||||
|
"recent_max_board_stats": [
|
||||||
|
{
|
||||||
|
"trade_date": "2026-03-16",
|
||||||
|
"max_board_count": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"trade_date": "2026-03-17",
|
||||||
|
"max_board_count": 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"trade_date": "2026-04-24",
|
||||||
|
"max_board_count": 4
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"groups": [
|
||||||
|
{
|
||||||
|
"board_count": 4,
|
||||||
|
"label": "四板",
|
||||||
|
"count": 1,
|
||||||
|
"stocks": [
|
||||||
|
{
|
||||||
|
"symbol": "000001",
|
||||||
|
"ts_code": "000001.SZ",
|
||||||
|
"name": "示例股份",
|
||||||
|
"market": "主板",
|
||||||
|
"trade_date": "2026-04-24",
|
||||||
|
"close": 12.35,
|
||||||
|
"limit_price": 12.35,
|
||||||
|
"pct_chg": 10.01,
|
||||||
|
"ak_amount": 1523000000,
|
||||||
|
"ak_float_market_value": 8245000000,
|
||||||
|
"ak_total_market_value": 12678000000,
|
||||||
|
"ak_turnover_rate": 18.64,
|
||||||
|
"ak_seal_amount": 286000000,
|
||||||
|
"ak_first_limit_time": "09:35:12",
|
||||||
|
"ak_last_limit_time": "14:42:08",
|
||||||
|
"ak_break_limit_count": 2,
|
||||||
|
"ak_limit_up_stat": "4天4板",
|
||||||
|
"ak_limit_up_days": 4,
|
||||||
|
"ak_industry": "软件开发",
|
||||||
|
"vol": 123456789,
|
||||||
|
"board_count": 4,
|
||||||
|
"board_label": "四板",
|
||||||
|
"display_status": "四板"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. 排序规则
|
||||||
|
|
||||||
|
- `groups` 按 `board_count` 从大到小排序。
|
||||||
|
- 每个分组内的 `stocks` 按 `pct_chg` 从大到小排序。
|
||||||
|
- 如果 `pct_chg` 相同,则按 `ts_code` 升序排序。
|
||||||
|
- `recent_max_board_stats` 按交易日升序排序。
|
||||||
|
|
||||||
|
## 6. 业务规则
|
||||||
|
|
||||||
|
- 只统计 `stock_limit_combined_data.final_status = 1` 的记录,即涨停股票。
|
||||||
|
- 连板数的计算会结合 `trade_calendar` 开市日历处理,不会被周末或节假日打断。
|
||||||
|
- 首板、二板、三板等分组由后端直接计算,前端不需要二次处理。
|
||||||
|
- `summary.max_board_count` 即当天涨停强度最高的板数。
|
||||||
|
- `recent_max_board_stats` 返回截至查询日近 30 个交易日的每日最高连板数。
|
||||||
|
|
||||||
|
## 7. 空数据说明
|
||||||
|
|
||||||
|
当指定交易日没有涨停数据时,返回:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"trade_date": "2026-04-24",
|
||||||
|
"summary": {
|
||||||
|
"max_board_count": 0,
|
||||||
|
"limit_count": 0,
|
||||||
|
"group_count": 0
|
||||||
|
},
|
||||||
|
"recent_max_board_stats": [
|
||||||
|
{
|
||||||
|
"trade_date": "2026-03-16",
|
||||||
|
"max_board_count": 2
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"groups": []
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
说明:
|
||||||
|
|
||||||
|
- 如果 `trade_date` 是有效交易日,即使当天没有涨停数据,仍会返回近 30 个交易日的 `recent_max_board_stats`。
|
||||||
|
- 如果 `trade_date` 本身不是有效交易日,则返回空的 `recent_max_board_stats` 和空的 `groups`。
|
||||||
|
|
||||||
|
## 8. 前端对接建议
|
||||||
|
|
||||||
|
- 页面顶部可直接使用 `summary` 展示摘要卡片:
|
||||||
|
- 当天最高连板数
|
||||||
|
- 当天涨停总数
|
||||||
|
- 当天分组数
|
||||||
|
- 趋势图可直接使用 `recent_max_board_stats`:
|
||||||
|
- X 轴使用 `trade_date`
|
||||||
|
- Y 轴使用 `max_board_count`
|
||||||
|
- 页面主体直接遍历 `groups` 渲染分组区块:
|
||||||
|
- 分组标题使用 `label`
|
||||||
|
- 分组数量使用 `count`
|
||||||
|
- 股票列表使用 `stocks`
|
||||||
|
|
||||||
|
## 9. 错误响应
|
||||||
|
|
||||||
|
当缺少 `trade_date` 参数时:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": "trade_date 参数不能为空"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
当服务端内部异常时:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": "具体错误信息"
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -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": "参数错误"
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
# 涨停表现分析接口文档
|
||||||
|
|
||||||
|
## 1. 接口说明
|
||||||
|
|
||||||
|
- 接口名称:涨停表现分析
|
||||||
|
- 请求方式:`POST`
|
||||||
|
- 请求路径:`/api/v1/stock/analyze`
|
||||||
|
- 接口用途:基于指定涨停日,分析当日涨停股票在后续区间内的表现。
|
||||||
|
|
||||||
|
## 2. 请求参数
|
||||||
|
|
||||||
|
请求体类型:`application/json`
|
||||||
|
|
||||||
|
| 参数名 | 类型 | 必填 | 示例 | 说明 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| `start_date` | `string` | 是 | `2026-04-24` | 涨停发生日,格式 `YYYY-MM-DD` |
|
||||||
|
| `end_date` | `string` | 是 | `2026-05-30` | 统计截止日,格式 `YYYY-MM-DD` |
|
||||||
|
|
||||||
|
请求示例:
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/v1/stock/analyze
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"start_date": "2026-04-24",
|
||||||
|
"end_date": "2026-05-30"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. 响应说明
|
||||||
|
|
||||||
|
返回值为数组,每个元素代表一只在 `start_date` 涨停的股票。
|
||||||
|
|
||||||
|
| 字段名 | 类型 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `code` | `string` | TS 股票代码 |
|
||||||
|
| `name` | `string` | 股票名称 |
|
||||||
|
| `day_open` | `number` | 涨停当日开盘价 |
|
||||||
|
| `day_high` | `number` | 涨停当日最高价 |
|
||||||
|
| `max_high` | `number` | 逻辑最高点价格 |
|
||||||
|
| `max_date` | `string` | 逻辑最高点对应日期 |
|
||||||
|
| `overall_max_high` | `number` | 统计区间内绝对最高价 |
|
||||||
|
| `overall_max_date` | `string` | 统计区间内绝对最高价对应日期 |
|
||||||
|
| `post_min_low` | `number` | 涨停日之后的最低价 |
|
||||||
|
| `drop_pct` | `number \| null` | 从逻辑高点到最低点的降幅,未跌破时为空 |
|
||||||
|
| `rebound_pct` | `number \| null` | 强势形态下的上涨幅度,或破位后的反弹幅度 |
|
||||||
|
| `close_prices` | `array[number]` | 区间内收盘价序列,用于前端绘图 |
|
||||||
|
|
||||||
|
## 4. 响应示例
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"code": "000001.SZ",
|
||||||
|
"name": "示例股份",
|
||||||
|
"day_open": 10.12,
|
||||||
|
"day_high": 11.13,
|
||||||
|
"max_high": 12.65,
|
||||||
|
"max_date": "2026-04-29",
|
||||||
|
"overall_max_high": 12.88,
|
||||||
|
"overall_max_date": "2026-05-06",
|
||||||
|
"post_min_low": 9.94,
|
||||||
|
"drop_pct": 21.42,
|
||||||
|
"rebound_pct": 8.36,
|
||||||
|
"close_prices": [11.13, 10.88, 10.25, 11.02, 10.77]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. 排序规则
|
||||||
|
|
||||||
|
- 返回数组优先按 `drop_pct` 从大到小排序。
|
||||||
|
- `drop_pct` 不为空的股票排在前面。
|
||||||
|
- `drop_pct` 为空的股票排在后面。
|
||||||
|
|
||||||
|
## 6. 业务规则
|
||||||
|
|
||||||
|
- 仅统计 `stock_limit_combined_data.final_status = 1` 的涨停股票。
|
||||||
|
- `start_date` 用于确定样本池,即当日涨停股票。
|
||||||
|
- `end_date` 用于限定后续表现统计区间。
|
||||||
|
- 如果某只股票在区间内只有涨停当天数据,则不会进入最终结果。
|
||||||
|
|
||||||
|
## 7. 空数据说明
|
||||||
|
|
||||||
|
当指定交易日没有涨停股票,返回空数组:
|
||||||
|
|
||||||
|
```json
|
||||||
|
[]
|
||||||
|
```
|
||||||
|
|
||||||
|
## 8. 错误响应
|
||||||
|
|
||||||
|
当请求体绑定失败时:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": "参数错误"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
当服务端内部异常时:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": "具体错误信息"
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
# 股票历史 K 线接口文档
|
||||||
|
|
||||||
|
## 1. 接口说明
|
||||||
|
|
||||||
|
- 接口名称:股票历史 K 线
|
||||||
|
- 请求方式:`GET`
|
||||||
|
- 请求路径:`/api/v1/stock/kline/:code`
|
||||||
|
- 接口用途:获取指定股票自 `2024-09-24` 起的历史日线数据。
|
||||||
|
|
||||||
|
## 2. 请求参数
|
||||||
|
|
||||||
|
路径参数:
|
||||||
|
|
||||||
|
| 参数名 | 类型 | 必填 | 示例 | 说明 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| `code` | `string` | 是 | `000001.SZ` | TS 股票代码 |
|
||||||
|
|
||||||
|
请求示例:
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/v1/stock/kline/000001.SZ
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. 响应说明
|
||||||
|
|
||||||
|
返回值为数组,每个元素代表一个交易日的 K 线数据。
|
||||||
|
|
||||||
|
| 字段名 | 类型 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `ts_code` | `string` | TS 股票代码 |
|
||||||
|
| `trade_date` | `string` | 交易日时间 |
|
||||||
|
| `open` | `number` | 开盘价 |
|
||||||
|
| `high` | `number` | 最高价 |
|
||||||
|
| `low` | `number` | 最低价 |
|
||||||
|
| `close` | `number` | 收盘价 |
|
||||||
|
| `pct_chg` | `number` | 涨跌幅 |
|
||||||
|
| `vol` | `number` | 成交量 |
|
||||||
|
|
||||||
|
## 4. 响应示例
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"ts_code": "000001.SZ",
|
||||||
|
"trade_date": "2026-04-21T00:00:00+08:00",
|
||||||
|
"open": 10.01,
|
||||||
|
"high": 10.55,
|
||||||
|
"low": 9.98,
|
||||||
|
"close": 10.43,
|
||||||
|
"pct_chg": 3.24,
|
||||||
|
"vol": 1234567
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. 业务规则
|
||||||
|
|
||||||
|
- 查询起始日期固定为 `2024-09-24`。
|
||||||
|
- 返回结果按 `trade_date` 升序排序。
|
||||||
|
|
||||||
|
## 6. 空数据说明
|
||||||
|
|
||||||
|
若指定股票无数据,返回空数组:
|
||||||
|
|
||||||
|
```json
|
||||||
|
[]
|
||||||
|
```
|
||||||
|
|
||||||
|
## 7. 错误响应
|
||||||
|
|
||||||
|
当路径参数为空时:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": "股票代码不能为空"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
当服务端内部异常时:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": "具体错误信息"
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"level_go/internal/service"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CommonHandler 承载通用基础数据接口。
|
||||||
|
type CommonHandler struct {
|
||||||
|
Service *service.CommonService // 通用数据服务,负责访问交易日历等基础表。
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTradeCalendar 返回上交所开市日期列表和最新开市日期。
|
||||||
|
func (h *CommonHandler) GetTradeCalendar(c *gin.Context) {
|
||||||
|
resp, err := h.Service.GetTradeCalendar()
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, resp)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"level_go/internal/model"
|
||||||
|
"level_go/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LimitBoardHandler 承载涨停板相关 HTTP 接口。
|
||||||
|
type LimitBoardHandler struct {
|
||||||
|
Service *service.LimitBoardService // 涨停板服务,封装统计、分组和导出逻辑。
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewLimitBoardHandler 创建涨停板接口 Handler。
|
||||||
|
func NewLimitBoardHandler(s *service.LimitBoardService) *LimitBoardHandler {
|
||||||
|
return &LimitBoardHandler{Service: s}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetOverview 返回指定交易日的涨停股连板概览。
|
||||||
|
func (h *LimitBoardHandler) GetOverview(c *gin.Context) {
|
||||||
|
var req model.LimitBoardRequest
|
||||||
|
if err := c.ShouldBindQuery(&req); err != nil || req.TradeDate == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "trade_date 参数不能为空"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := h.Service.GetLimitBoardOverview(req.TradeDate)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetOpenLowerGroups 返回昨日涨停且今日继续涨停股票的开盘对比分组。
|
||||||
|
func (h *LimitBoardHandler) GetOpenLowerGroups(c *gin.Context) {
|
||||||
|
var req model.LimitBoardRequest
|
||||||
|
if err := c.ShouldBindQuery(&req); err != nil || req.TradeDate == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "trade_date 参数不能为空"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := h.Service.GetLimitOpenCompareGroups(req.TradeDate)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetBreakNextPerformance 返回首板后次日炸板股票的 T+1 表现分页结果。
|
||||||
|
func (h *LimitBoardHandler) GetBreakNextPerformance(c *gin.Context) {
|
||||||
|
req, err := parseLimitBreakNextPerformanceRequest(c, true)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := h.Service.GetLimitBreakNextPerformance(req)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSecondCal 返回日期范围内的二板日历统计。
|
||||||
|
func (h *LimitBoardHandler) GetSecondCal(c *gin.Context) {
|
||||||
|
req, err := parseSecondCalRequest(c)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := h.Service.GetSecondCal(req)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseSecondCalRequest 解析二板日历接口的日期范围参数。
|
||||||
|
func parseSecondCalRequest(c *gin.Context) (model.SecondCalRequest, error) {
|
||||||
|
req := model.SecondCalRequest{
|
||||||
|
TradeStartDate: strings.TrimSpace(c.DefaultQuery("tradeStartDate", c.Query("start_date"))),
|
||||||
|
TradeEndDate: strings.TrimSpace(c.DefaultQuery("tradeEndDate", c.Query("end_date"))),
|
||||||
|
}
|
||||||
|
if req.TradeStartDate == "" {
|
||||||
|
return req, errors.New("tradeStartDate 参数不能为空")
|
||||||
|
}
|
||||||
|
if req.TradeEndDate == "" {
|
||||||
|
return req, errors.New("tradeEndDate 参数不能为空")
|
||||||
|
}
|
||||||
|
if err := validateSecondProfitDateRange(req.TradeStartDate, req.TradeEndDate); err != nil {
|
||||||
|
return req, err
|
||||||
|
}
|
||||||
|
return req, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseLimitBreakNextPerformanceRequest 解析炸板次日表现接口的查询参数。
|
||||||
|
//
|
||||||
|
// includePagination 为 false 时跳过分页参数,供 CSV 导出复用同一套日期和过滤条件解析逻辑。
|
||||||
|
func parseLimitBreakNextPerformanceRequest(c *gin.Context, includePagination bool) (model.LimitBreakNextPerformanceRequest, error) {
|
||||||
|
req := model.LimitBreakNextPerformanceRequest{
|
||||||
|
TradeStartDate: firstNonEmptyQuery(c, "tradeStartDate", "trade_start_date", "start_date"),
|
||||||
|
TradeEndDate: firstNonEmptyQuery(c, "tradeEndDate", "trade_end_date", "end_date"),
|
||||||
|
Page: 1,
|
||||||
|
PageSize: 20,
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.TradeStartDate == "" {
|
||||||
|
return req, errors.New("tradeStartDate 参数不能为空")
|
||||||
|
}
|
||||||
|
if req.TradeEndDate == "" {
|
||||||
|
return req, errors.New("tradeEndDate 参数不能为空")
|
||||||
|
}
|
||||||
|
if err := validateSecondProfitDateRange(req.TradeStartDate, req.TradeEndDate); err != nil {
|
||||||
|
return req, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if includePagination {
|
||||||
|
pageText := strings.TrimSpace(c.DefaultQuery("page", "1"))
|
||||||
|
page, err := strconv.Atoi(pageText)
|
||||||
|
if err != nil || page <= 0 {
|
||||||
|
return req, errors.New("page 必须为正整数")
|
||||||
|
}
|
||||||
|
req.Page = page
|
||||||
|
|
||||||
|
pageSizeText := strings.TrimSpace(c.DefaultQuery("pageSize", "20"))
|
||||||
|
pageSize, err := strconv.Atoi(pageSizeText)
|
||||||
|
if err != nil || pageSize <= 0 {
|
||||||
|
return req, errors.New("pageSize 必须为正整数")
|
||||||
|
}
|
||||||
|
req.PageSize = pageSize
|
||||||
|
}
|
||||||
|
|
||||||
|
tOpenGainLt101, err := parseOptionalBoolQuery(c, "tOpenGainLt101", "tOpenLessThan101", "t_open_gain_lt_101")
|
||||||
|
if err != nil {
|
||||||
|
return req, errors.New("tOpenGainLt101 必须为布尔值")
|
||||||
|
}
|
||||||
|
req.TOpenGainLt101 = tOpenGainLt101
|
||||||
|
|
||||||
|
breakTimeBefore1100, err := parseOptionalBoolQuery(c, "breakTimeBefore1100", "tBreakTimeBefore1100", "break_time_before_1100")
|
||||||
|
if err != nil {
|
||||||
|
return req, errors.New("breakTimeBefore1100 必须为布尔值")
|
||||||
|
}
|
||||||
|
req.BreakTimeBefore1100 = breakTimeBefore1100
|
||||||
|
|
||||||
|
return req, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// firstNonEmptyQuery 按优先级读取第一个非空查询参数。
|
||||||
|
func firstNonEmptyQuery(c *gin.Context, keys ...string) string {
|
||||||
|
for _, key := range keys {
|
||||||
|
if value := strings.TrimSpace(c.Query(key)); value != "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseOptionalBoolQuery 从多个兼容参数名中读取可选布尔值。
|
||||||
|
//
|
||||||
|
// 返回 nil 表示用户没有传入该过滤项;返回 *bool 表示需要按 true/false 过滤。
|
||||||
|
func parseOptionalBoolQuery(c *gin.Context, keys ...string) (*bool, error) {
|
||||||
|
for _, key := range keys {
|
||||||
|
valueText, exists := c.GetQuery(key)
|
||||||
|
if !exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
valueText = strings.TrimSpace(valueText)
|
||||||
|
if valueText == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
value, err := strconv.ParseBool(valueText)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &value, nil
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetOpenThresholdGroups 返回昨日涨停股今日开盘是否低于前收 101% 的分组。
|
||||||
|
func (h *LimitBoardHandler) GetOpenThresholdGroups(c *gin.Context) {
|
||||||
|
var req model.LimitBoardRequest
|
||||||
|
if err := c.ShouldBindQuery(&req); err != nil || req.TradeDate == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "trade_date 参数不能为空"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := h.Service.GetLimitOpenThresholdGroups(req.TradeDate)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, resp)
|
||||||
|
}
|
||||||
@@ -0,0 +1,726 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"encoding/csv"
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// defaultSecondProfitStartDate 是二板收益统计的默认起始日期。
|
||||||
|
defaultSecondProfitStartDate = "2020-01-01"
|
||||||
|
// defaultSecondProfitEndDate 是二板收益统计的默认结束日期。
|
||||||
|
defaultSecondProfitEndDate = "2026-12-31"
|
||||||
|
)
|
||||||
|
|
||||||
|
// secondProfitCSVRow 是二板收益查询的内部行结构。
|
||||||
|
//
|
||||||
|
// 前半部分字段直接由 SQL 扫描得到,后半部分字段在 loadSecondProfitRows 中根据行情计算。
|
||||||
|
type secondProfitCSVRow 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"`
|
||||||
|
TsCode string `gorm:"column:ts_code"`
|
||||||
|
Symbol string `gorm:"column:symbol"`
|
||||||
|
Name string `gorm:"column:name"`
|
||||||
|
Market string `gorm:"column:market"`
|
||||||
|
Industry string `gorm:"column:industry"`
|
||||||
|
PreviousOpen sql.NullFloat64 `gorm:"column:previous_open"`
|
||||||
|
PreviousTwoClose sql.NullFloat64 `gorm:"column:previous_two_close"`
|
||||||
|
Open sql.NullFloat64 `gorm:"column:open"`
|
||||||
|
Close sql.NullFloat64 `gorm:"column:close_price"`
|
||||||
|
LimitPrice float64 `gorm:"column:limit_price"`
|
||||||
|
PreviousClose sql.NullFloat64 `gorm:"column:previous_close"`
|
||||||
|
FirstLimitTime sql.NullString `gorm:"column:tushare_first_time"`
|
||||||
|
PctChg sql.NullFloat64 `gorm:"column:pct_chg"`
|
||||||
|
Amount sql.NullFloat64 `gorm:"column:tushare_amount"`
|
||||||
|
TurnoverRate sql.NullFloat64 `gorm:"column:tushare_turnover_ratio"`
|
||||||
|
SealAmount sql.NullFloat64 `gorm:"column:tushare_fd_amount"`
|
||||||
|
BreakLimitCount sql.NullInt64 `gorm:"column:tushare_open_times"`
|
||||||
|
LimitUpStat string `gorm:"column:limit_up_stat"`
|
||||||
|
NextOpen sql.NullFloat64 `gorm:"column:next_open"`
|
||||||
|
NextHigh sql.NullFloat64 `gorm:"column:next_high"`
|
||||||
|
NextLow sql.NullFloat64 `gorm:"column:next_low"`
|
||||||
|
NextClose sql.NullFloat64 `gorm:"column:next_close"`
|
||||||
|
OpenPreviousCloseRatio float64
|
||||||
|
PreviousOpenPctChg sql.NullFloat64
|
||||||
|
OpenPctChg sql.NullFloat64
|
||||||
|
NextOpenReturnPct sql.NullFloat64
|
||||||
|
NextHighReturnPct sql.NullFloat64
|
||||||
|
NextLowReturnPct sql.NullFloat64
|
||||||
|
NextCloseReturnPct sql.NullFloat64
|
||||||
|
NextOpenProfitable string
|
||||||
|
NextCloseProfitable string
|
||||||
|
NextIntradayProfitable string
|
||||||
|
}
|
||||||
|
|
||||||
|
// secondProfitQuery 保存二板收益接口和 CSV 导出共用的查询条件。
|
||||||
|
type secondProfitQuery struct {
|
||||||
|
TradeStartDate string
|
||||||
|
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()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
filename := fmt.Sprintf("limit_second_board_profit_%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(secondProfitCSVHeaders())
|
||||||
|
for _, row := range rows {
|
||||||
|
if err := writer.Write(secondProfitCSVRecord(row)); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
start, err := time.Parse("2006-01-02", startDate)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("tradeStartDate 格式必须为 YYYY-MM-DD")
|
||||||
|
}
|
||||||
|
end, err := time.Parse("2006-01-02", endDate)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("tradeEndDate 格式必须为 YYYY-MM-DD")
|
||||||
|
}
|
||||||
|
if end.Before(start) {
|
||||||
|
return fmt.Errorf("tradeEndDate 不能早于 tradeStartDate")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadSecondProfitRows 查询并计算二板收益明细。
|
||||||
|
//
|
||||||
|
// SQL 先找出“前一日涨停、前两日未涨停、当日继续涨停”的股票,再补齐 T+1 行情;
|
||||||
|
// Go 代码负责应用动态过滤条件并计算开盘、最高、最低、收盘相对 T 日收盘的收益率。
|
||||||
|
func (h *LimitBoardHandler) loadSecondProfitRows(req secondProfitQuery) ([]secondProfitCSVRow, 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(c.trade_date, '%Y-%m-%d') AS trade_date,
|
||||||
|
DATE_FORMAT(c.previous_trade_date, '%Y-%m-%d') AS previous_trade_date,
|
||||||
|
DATE_FORMAT(c.previous_two_trade_date, '%Y-%m-%d') AS previous_two_trade_date,
|
||||||
|
COALESCE(DATE_FORMAT(c.next_trade_date, '%Y-%m-%d'), '') AS next_trade_date,
|
||||||
|
l.ts_code,
|
||||||
|
COALESCE(l.symbol, '') AS symbol,
|
||||||
|
COALESCE(l.name, '') AS name,
|
||||||
|
COALESCE(l.market, '') AS market,
|
||||||
|
COALESCE(l.tushare_industry, '') AS industry,
|
||||||
|
pd.open AS previous_open,
|
||||||
|
COALESCE(pd.pre_close, ppd.close) AS previous_two_close,
|
||||||
|
d.open,
|
||||||
|
COALESCE(l.calc_close, l.tushare_close, d.close) AS close_price,
|
||||||
|
COALESCE(l.calc_limit_price, 0) AS limit_price,
|
||||||
|
COALESCE(pl.calc_close, pl.tushare_close, pd.close) AS previous_close,
|
||||||
|
l.tushare_first_time,
|
||||||
|
COALESCE(l.tushare_pct_chg, d.pct_chg) AS pct_chg,
|
||||||
|
l.tushare_amount,
|
||||||
|
l.tushare_turnover_ratio,
|
||||||
|
l.tushare_fd_amount,
|
||||||
|
l.tushare_open_times,
|
||||||
|
COALESCE(l.tushare_up_stat, '') AS limit_up_stat,
|
||||||
|
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 = 1
|
||||||
|
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 l.ts_code = d.ts_code AND d.trade_date = l.trade_date
|
||||||
|
LEFT JOIN stock_daily pd
|
||||||
|
ON l.ts_code = pd.ts_code AND pd.trade_date = c.previous_trade_date
|
||||||
|
LEFT JOIN stock_daily ppd
|
||||||
|
ON l.ts_code = ppd.ts_code AND ppd.trade_date = c.previous_two_trade_date
|
||||||
|
LEFT JOIN stock_daily nd
|
||||||
|
ON l.ts_code = nd.ts_code AND nd.trade_date = c.next_trade_date
|
||||||
|
WHERE c.previous_trade_date IS NOT NULL
|
||||||
|
AND c.previous_two_trade_date IS NOT NULL
|
||||||
|
AND ppl.ts_code IS NULL
|
||||||
|
ORDER BY c.trade_date ASC, l.ts_code ASC`
|
||||||
|
|
||||||
|
var rawRows []secondProfitCSVRow
|
||||||
|
if err := h.Service.DB.Raw(query, req.TradeStartDate, req.TradeEndDate).Scan(&rawRows).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
rows := make([]secondProfitCSVRow, 0, len(rawRows))
|
||||||
|
for _, row := range rawRows {
|
||||||
|
// 首次涨停时间过滤在内存中执行,便于兼容 0930、93000、09:30:00 等多种时间格式。
|
||||||
|
if req.LimitUpTimeRule.Enabled() {
|
||||||
|
limitTimeValue, ok := secondProfitTimeSeconds(row.FirstLimitTime.String)
|
||||||
|
if !ok || !req.LimitUpTimeRule.Match(float64(limitTimeValue)) {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// T+1 收益统一以 T 日收盘价为基准,缺失的次日行情会保留为 NullFloat64。
|
||||||
|
if row.Close.Valid && row.Close.Float64 > 0 {
|
||||||
|
row.NextOpenReturnPct = secondProfitPctReturn(row.NextOpen, row.Close.Float64)
|
||||||
|
row.NextHighReturnPct = secondProfitPctReturn(row.NextHigh, row.Close.Float64)
|
||||||
|
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.NextIntradayProfitable = secondProfitLabel(row.NextHighReturnPct)
|
||||||
|
rows = append(rows, row)
|
||||||
|
}
|
||||||
|
return rows, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// secondProfitCSVHeaders 返回二板收益 CSV 表头。
|
||||||
|
func secondProfitCSVHeaders() []string {
|
||||||
|
return []string{
|
||||||
|
"交易日", "股票代码", "股票简称", "市场", "行业",
|
||||||
|
"T-1日开盘涨跌幅%",
|
||||||
|
"T日开盘涨跌幅%", "T+1日开盘涨跌幅%",
|
||||||
|
"T+1开盘是否盈利", "T+1收盘是否盈利", "T+1盘中是否可盈利",
|
||||||
|
"T+1最高盈利%", "T+1最低盈利%", "开板次数", "换手率%",
|
||||||
|
"首次涨停时间", "封单额", "成交额",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// secondProfitCSVRecord 把内部行结构转换为 CSV 字段。
|
||||||
|
func secondProfitCSVRecord(row secondProfitCSVRow) []string {
|
||||||
|
return []string{
|
||||||
|
row.TradeDate,
|
||||||
|
row.TsCode,
|
||||||
|
row.Name,
|
||||||
|
row.Market,
|
||||||
|
row.Industry,
|
||||||
|
secondProfitFormatNullFloat(row.PreviousOpenPctChg),
|
||||||
|
secondProfitFormatNullFloat(row.OpenPctChg),
|
||||||
|
secondProfitFormatNullFloat(row.NextOpenReturnPct),
|
||||||
|
row.NextOpenProfitable,
|
||||||
|
row.NextCloseProfitable,
|
||||||
|
row.NextIntradayProfitable,
|
||||||
|
secondProfitFormatNullFloat(row.NextHighReturnPct),
|
||||||
|
secondProfitFormatNullFloat(row.NextLowReturnPct),
|
||||||
|
secondProfitFormatNullInt(row.BreakLimitCount),
|
||||||
|
secondProfitFormatNullFloat(row.TurnoverRate),
|
||||||
|
secondProfitFormatLimitTime(row.FirstLimitTime.String),
|
||||||
|
secondProfitFormatNullFloat(row.SealAmount),
|
||||||
|
secondProfitFormatNullFloat(row.Amount),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
if timeText == "" {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
hour, minute, second := 0, 0, 0
|
||||||
|
if strings.Contains(timeText, ":") {
|
||||||
|
if n, _ := fmt.Sscanf(timeText, "%d:%d:%d", &hour, &minute, &second); n < 2 {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if len(timeText) < 4 {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
switch len(timeText) {
|
||||||
|
case 4:
|
||||||
|
timeText += "00"
|
||||||
|
case 5:
|
||||||
|
timeText = "0" + timeText
|
||||||
|
case 6:
|
||||||
|
default:
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
var err error
|
||||||
|
hour, err = strconv.Atoi(timeText[0:2])
|
||||||
|
if err != nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
minute, err = strconv.Atoi(timeText[2:4])
|
||||||
|
if err != nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
second, err = strconv.Atoi(timeText[4:6])
|
||||||
|
if err != nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if hour < 0 || hour > 23 || minute < 0 || minute > 59 || second < 0 || second > 59 {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return hour*3600 + minute*60 + second, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// secondProfitFormatLimitTime 把紧凑时间格式格式化为 HH:MM:SS。
|
||||||
|
func secondProfitFormatLimitTime(value string) string {
|
||||||
|
timeText := strings.TrimSpace(value)
|
||||||
|
if timeText == "" || strings.Contains(timeText, ":") {
|
||||||
|
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]
|
||||||
|
}
|
||||||
|
|
||||||
|
// secondProfitPctReturn 计算相对基准价的百分比收益。
|
||||||
|
func secondProfitPctReturn(value sql.NullFloat64, base float64) sql.NullFloat64 {
|
||||||
|
if !value.Valid || base == 0 {
|
||||||
|
return sql.NullFloat64{}
|
||||||
|
}
|
||||||
|
return sql.NullFloat64{Float64: secondProfitRound2((value.Float64 - base) / base * 100), Valid: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
// secondProfitLabel 根据收益是否大于 0 输出中文盈利标签。
|
||||||
|
func secondProfitLabel(value sql.NullFloat64) string {
|
||||||
|
if !value.Valid {
|
||||||
|
return "无次日数据"
|
||||||
|
}
|
||||||
|
if value.Float64 > 0 {
|
||||||
|
return "是"
|
||||||
|
}
|
||||||
|
return "否"
|
||||||
|
}
|
||||||
|
|
||||||
|
// secondProfitRound2 保留两位小数。
|
||||||
|
func secondProfitRound2(value float64) float64 {
|
||||||
|
return math.Round(value*100) / 100
|
||||||
|
}
|
||||||
|
|
||||||
|
// secondProfitFormatFloat 使用最短十进制格式输出浮点数。
|
||||||
|
func secondProfitFormatFloat(value float64) string {
|
||||||
|
return strconv.FormatFloat(value, 'f', -1, 64)
|
||||||
|
}
|
||||||
|
|
||||||
|
// secondProfitFormatNullFloat 格式化可空浮点数,空值输出为空字符串。
|
||||||
|
func secondProfitFormatNullFloat(value sql.NullFloat64) string {
|
||||||
|
if !value.Valid {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return secondProfitFormatFloat(value.Float64)
|
||||||
|
}
|
||||||
|
|
||||||
|
// secondProfitFormatNullInt 格式化可空整数,空值输出为空字符串。
|
||||||
|
func secondProfitFormatNullInt(value sql.NullInt64) string {
|
||||||
|
if !value.Valid {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"level_go/internal/model"
|
||||||
|
"level_go/internal/service"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
// StockHandler 承载股票行情和走势分析接口。
|
||||||
|
type StockHandler struct {
|
||||||
|
Service *service.StockService // 股票服务,封装行情查询和区间分析逻辑。
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewStockHandler 创建股票接口 Handler。
|
||||||
|
func NewStockHandler(s *service.StockService) *StockHandler {
|
||||||
|
return &StockHandler{Service: s}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetKlineData 获取指定股票的历史 K 线数据。
|
||||||
|
//
|
||||||
|
// 股票代码来自路径参数 :code,返回按交易日升序排列的 stock_daily 记录。
|
||||||
|
func (h *StockHandler) GetKlineData(c *gin.Context) {
|
||||||
|
code := c.Param("code")
|
||||||
|
if code == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "股票代码不能为空"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := h.Service.GetHistoryKline(code)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Analyze 根据前端传入的起止日期分析涨停后走势。
|
||||||
|
func (h *StockHandler) Analyze(c *gin.Context) {
|
||||||
|
var req model.AnalyzeRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
results, err := h.Service.AnalyzePerformance(req.StartDate, req.EndDate)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, results)
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// TradeCalendar 映射交易日历表,用于判断某天是否为指定交易所的开市日。
|
||||||
|
type TradeCalendar struct {
|
||||||
|
CalDate time.Time `gorm:"column:cal_date;primaryKey"` // 日历日期。
|
||||||
|
Exchange string `gorm:"column:exchange;primaryKey"` // 交易所代码,例如 SSE。
|
||||||
|
IsOpen int `gorm:"column:is_open"` // 是否开市,1 表示开市。
|
||||||
|
PretradeDate time.Time `gorm:"column:pretrade_date"` // 上一个交易日。
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableName 指定 GORM 查询时使用 trade_calendar 表。
|
||||||
|
func (TradeCalendar) TableName() string { return "trade_calendar" }
|
||||||
|
|
||||||
|
// CalendarResponse 是交易日历接口响应。
|
||||||
|
type CalendarResponse struct {
|
||||||
|
OpenDates []string `json:"open_dates"` // 所有开市日期,格式 YYYY-MM-DD。
|
||||||
|
MaxDate string `json:"max_date"` // 当前返回结果中的最大开市日期。
|
||||||
|
}
|
||||||
@@ -0,0 +1,363 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
// LimitBoardRequest 是按交易日查询涨停板数据的通用请求。
|
||||||
|
type LimitBoardRequest struct {
|
||||||
|
TradeDate string `form:"trade_date" json:"trade_date"` // 目标交易日,格式 YYYY-MM-DD。
|
||||||
|
}
|
||||||
|
|
||||||
|
// LimitBreakNextPerformanceRequest 是查询炸板股次日表现的请求。
|
||||||
|
type LimitBreakNextPerformanceRequest struct {
|
||||||
|
TradeStartDate string `form:"tradeStartDate" json:"trade_start_date"` // 查询起始交易日。
|
||||||
|
TradeEndDate string `form:"tradeEndDate" json:"trade_end_date"` // 查询结束交易日。
|
||||||
|
Page int `form:"page" json:"page"` // 当前页码,从 1 开始。
|
||||||
|
PageSize int `form:"pageSize" json:"page_size"` // 每页数量。
|
||||||
|
TOpenGainLt101 *bool `json:"t_open_gain_lt_101,omitempty"` // 可选过滤:T 日开盘是否低于前收 101%。
|
||||||
|
BreakTimeBefore1100 *bool `json:"break_time_before_1100,omitempty"` // 可选过滤:炸板时间是否早于 11:00。
|
||||||
|
}
|
||||||
|
|
||||||
|
// SecondCalRequest 是二板日历接口的日期范围请求。
|
||||||
|
type SecondCalRequest struct {
|
||||||
|
TradeStartDate string `form:"tradeStartDate" json:"trade_start_date"` // 查询起始 T 日。
|
||||||
|
TradeEndDate string `form:"tradeEndDate" json:"trade_end_date"` // 查询结束 T 日。
|
||||||
|
}
|
||||||
|
|
||||||
|
// SecondBoardProfitRequest 是二板获利综合和详情接口的日期范围请求。
|
||||||
|
type SecondBoardProfitRequest struct {
|
||||||
|
TradeStartDate string `form:"tradeStartDate" json:"trade_start_date"` // 查询起始 T 日,默认 2020-01-01。
|
||||||
|
TradeEndDate string `form:"tradeEndDate" json:"trade_end_date"` // 查询结束 T 日,默认当天。
|
||||||
|
TsCode string `form:"tsCode" json:"ts_code"` // 可选 TuShare 股票代码,详情接口必填。
|
||||||
|
Symbol string `form:"symbol" json:"symbol"` // 可选市场股票代码,详情接口可替代 tsCode。
|
||||||
|
}
|
||||||
|
|
||||||
|
// LimitBoardStock 表示单只涨停股在涨停概览中的展示数据。
|
||||||
|
type LimitBoardStock struct {
|
||||||
|
Symbol string `json:"symbol"` // 市场展示用股票代码。
|
||||||
|
TsCode string `json:"ts_code"` // TuShare 股票代码。
|
||||||
|
Name string `json:"name"` // 股票简称。
|
||||||
|
Market string `json:"market"` // 所属市场。
|
||||||
|
TradeDate string `json:"trade_date"` // 交易日。
|
||||||
|
Close float64 `json:"close"` // 收盘价。
|
||||||
|
LimitPrice float64 `json:"limit_price"` // 涨停价。
|
||||||
|
PctChg float64 `json:"pct_chg"` // 当日涨跌幅。
|
||||||
|
AkAmount *float64 `json:"ak_amount"` // 成交额,数据库为空时返回 null。
|
||||||
|
AkFloatMarketValue *float64 `json:"ak_float_market_value"` // 流通市值。
|
||||||
|
AkTotalMarketValue *float64 `json:"ak_total_market_value"` // 总市值。
|
||||||
|
AkTurnoverRate *float64 `json:"ak_turnover_rate"` // 换手率。
|
||||||
|
AkSealAmount *float64 `json:"ak_seal_amount"` // 封单额。
|
||||||
|
AkFirstLimitTime *string `json:"ak_first_limit_time"` // 首次涨停时间。
|
||||||
|
AkLastLimitTime *string `json:"ak_last_limit_time"` // 最后涨停时间。
|
||||||
|
AkBreakLimitCount *int `json:"ak_break_limit_count"` // 开板次数。
|
||||||
|
AkLimitUpStat *string `json:"ak_limit_up_stat"` // 连板状态原始描述。
|
||||||
|
AkLimitUpDays *int `json:"ak_limit_up_days"` // 连续涨停天数。
|
||||||
|
AkIndustry *string `json:"ak_industry"` // 行业。
|
||||||
|
Vol *int64 `json:"vol"` // 成交量。
|
||||||
|
BoardCount int `json:"board_count"` // 计算出的连板数。
|
||||||
|
BoardLabel string `json:"board_label"` // 连板标签,例如 二板。
|
||||||
|
DisplayStatus string `json:"display_status"` // 前端展示状态,当前与 BoardLabel 保持一致。
|
||||||
|
}
|
||||||
|
|
||||||
|
// LimitOpenCompareStock 表示“今日涨停且昨日涨停”的开盘价对比数据。
|
||||||
|
type LimitOpenCompareStock struct {
|
||||||
|
Symbol string `json:"symbol"` // 市场展示用股票代码。
|
||||||
|
TsCode string `json:"ts_code"` // TuShare 股票代码。
|
||||||
|
Name string `json:"name"` // 股票简称。
|
||||||
|
Market string `json:"market"` // 所属市场。
|
||||||
|
TradeDate string `json:"trade_date"` // 查询交易日。
|
||||||
|
HasOpen bool `json:"-"` // 数据库是否存在今日开盘价,内部计算使用。
|
||||||
|
Open float64 `json:"open"` // 今日开盘价。
|
||||||
|
Close float64 `json:"close"` // 今日收盘价。
|
||||||
|
PreviousTradeDate string `json:"previous_trade_date"` // 上一个交易日。
|
||||||
|
PreviousTwoTradeDate string `json:"previous_two_trade_date"` // 上上个交易日。
|
||||||
|
HasPreviousClose bool `json:"-"` // 数据库是否存在前收价,内部计算使用。
|
||||||
|
PreviousClose float64 `json:"previous_close"` // 上一个交易日收盘价。
|
||||||
|
IsFirstToSecondBoard bool `json:"is_first_to_second_board"` // 是否属于首板进二板。
|
||||||
|
IsFirstSecondBeforeEleven bool `json:"is_first_second_before_eleven"` // 首板进二板且 11 点前封板。
|
||||||
|
HighlightLabel string `json:"highlight_label"` // 前端高亮标签。
|
||||||
|
LimitPrice float64 `json:"limit_price"` // 今日涨停价。
|
||||||
|
PctChg float64 `json:"pct_chg"` // 今日涨跌幅。
|
||||||
|
AkAmount *float64 `json:"ak_amount"` // 成交额。
|
||||||
|
AkFloatMarketValue *float64 `json:"ak_float_market_value"` // 流通市值。
|
||||||
|
AkTotalMarketValue *float64 `json:"ak_total_market_value"` // 总市值。
|
||||||
|
AkTurnoverRate *float64 `json:"ak_turnover_rate"` // 换手率。
|
||||||
|
AkSealAmount *float64 `json:"ak_seal_amount"` // 封单额。
|
||||||
|
AkFirstLimitTime *string `json:"ak_first_limit_time"` // 首次涨停时间。
|
||||||
|
AkLastLimitTime *string `json:"ak_last_limit_time"` // 最后涨停时间。
|
||||||
|
AkBreakLimitCount *int `json:"ak_break_limit_count"` // 开板次数。
|
||||||
|
AkLimitUpStat *string `json:"ak_limit_up_stat"` // 连板状态原始描述。
|
||||||
|
AkLimitUpDays *int `json:"ak_limit_up_days"` // 连续涨停天数。
|
||||||
|
AkIndustry *string `json:"ak_industry"` // 行业。
|
||||||
|
Vol *int64 `json:"vol"` // 成交量。
|
||||||
|
}
|
||||||
|
|
||||||
|
// LimitOpenThresholdStock 表示昨日涨停股在今日开盘阈值分组中的单股数据。
|
||||||
|
type LimitOpenThresholdStock struct {
|
||||||
|
Symbol string `json:"symbol"` // 市场展示用股票代码。
|
||||||
|
TsCode string `json:"ts_code"` // TuShare 股票代码。
|
||||||
|
Name string `json:"name"` // 股票简称。
|
||||||
|
Market string `json:"market"` // 所属市场。
|
||||||
|
TradeDate string `json:"trade_date"` // 查询交易日。
|
||||||
|
HasOpen bool `json:"-"` // 数据库是否存在今日开盘价,内部计算使用。
|
||||||
|
Open float64 `json:"open"` // 今日开盘价。
|
||||||
|
Close float64 `json:"close"` // 今日收盘价。
|
||||||
|
PreviousTradeDate string `json:"previous_trade_date"` // 上一个交易日。
|
||||||
|
HasPreviousClose bool `json:"-"` // 数据库是否存在前收价,内部计算使用。
|
||||||
|
PreviousClose float64 `json:"previous_close"` // 上一个交易日收盘价。
|
||||||
|
OpenThresholdPrice float64 `json:"open_threshold_price"` // 前收价乘以阈值比例后的开盘阈值。
|
||||||
|
OpenPreviousCloseRatio float64 `json:"open_previous_close_ratio"` // 今日开盘价与前收价的比值。
|
||||||
|
LimitPrice float64 `json:"limit_price"` // 今日涨停价。
|
||||||
|
PctChg float64 `json:"pct_chg"` // 今日涨跌幅。
|
||||||
|
AkAmount *float64 `json:"ak_amount"` // 成交额。
|
||||||
|
AkFloatMarketValue *float64 `json:"ak_float_market_value"` // 流通市值。
|
||||||
|
AkTotalMarketValue *float64 `json:"ak_total_market_value"` // 总市值。
|
||||||
|
AkTurnoverRate *float64 `json:"ak_turnover_rate"` // 换手率。
|
||||||
|
AkSealAmount *float64 `json:"ak_seal_amount"` // 封单额。
|
||||||
|
AkFirstLimitTime *string `json:"ak_first_limit_time"` // 首次涨停时间。
|
||||||
|
AkLastLimitTime *string `json:"ak_last_limit_time"` // 最后涨停时间。
|
||||||
|
AkBreakLimitCount *int `json:"ak_break_limit_count"` // 开板次数。
|
||||||
|
AkLimitUpStat *string `json:"ak_limit_up_stat"` // 连板状态原始描述。
|
||||||
|
AkLimitUpDays *int `json:"ak_limit_up_days"` // 连续涨停天数。
|
||||||
|
AkIndustry *string `json:"ak_industry"` // 行业。
|
||||||
|
Vol *int64 `json:"vol"` // 成交量。
|
||||||
|
}
|
||||||
|
|
||||||
|
// LimitBreakNextPerformanceStock 表示首板后次日炸板股票的 T+1 表现。
|
||||||
|
type LimitBreakNextPerformanceStock struct {
|
||||||
|
TradeDate string `json:"trade_date"` // T 日交易日,即炸板发生日。
|
||||||
|
Symbol string `json:"symbol"` // 市场展示用股票代码。
|
||||||
|
TsCode string `json:"ts_code"` // TuShare 股票代码。
|
||||||
|
Name string `json:"name"` // 股票简称。
|
||||||
|
Market string `json:"market"` // 所属市场。
|
||||||
|
Industry string `json:"industry"` // 行业。
|
||||||
|
TOpen *float64 `json:"t_open"` // T 日开盘价。
|
||||||
|
TOpenPctChg *float64 `json:"t_open_pct_chg"` // T 日开盘相对前收的涨跌幅。
|
||||||
|
TOpenGainLt101 bool `json:"t_open_gain_lt_101"` // T 日开盘价是否低于前收 101%。
|
||||||
|
THigh *float64 `json:"t_high"` // T 日最高价。
|
||||||
|
TClose *float64 `json:"t_close"` // T 日收盘价。
|
||||||
|
TPctChg *float64 `json:"t_pct_chg"` // T 日涨跌幅。
|
||||||
|
FirstTime string `json:"first_time"` // TuShare 首次炸板/涨停时间,已格式化为 HH:MM:SS。
|
||||||
|
BreakTimeBefore1100 bool `json:"break_time_before_1100"` // 炸板相关时间是否早于 11:00。
|
||||||
|
HasTushareBreakData bool `json:"has_tushare_break_data"` // TuShare 是否明确标记为炸板。
|
||||||
|
NextOpen *float64 `json:"next_open"` // T+1 开盘价。
|
||||||
|
NextHigh *float64 `json:"next_high"` // T+1 最高价。
|
||||||
|
NextLow *float64 `json:"next_low"` // T+1 最低价。
|
||||||
|
NextClose *float64 `json:"next_close"` // T+1 收盘价。
|
||||||
|
NextOpenPctChg *float64 `json:"next_open_pct_chg"` // T+1 开盘相对 T 日收盘的收益率。
|
||||||
|
NextIntradayProfitable *bool `json:"next_intraday_profitable"` // T+1 最高价是否覆盖 T 日最高价。
|
||||||
|
NextHighProfitPct *float64 `json:"next_high_profit_pct"` // T+1 最高价相对 T 日收盘的最大收益率。
|
||||||
|
NextLowLossPct *float64 `json:"next_low_loss_pct"` // T+1 最低价相对 T 日收盘的最大回撤率。
|
||||||
|
NextClosePctChg *float64 `json:"next_close_pct_chg"` // T+1 收盘相对 T 日收盘的收益率。
|
||||||
|
}
|
||||||
|
|
||||||
|
// SecondCalStock 表示二板日历中单只二板股票的 T 日和 T+1 表现。
|
||||||
|
type SecondCalStock struct {
|
||||||
|
TradeDate string `json:"trade_date"` // T 日交易日。
|
||||||
|
PreviousTradeDate string `json:"previous_trade_date"` // T-1 交易日。
|
||||||
|
PreviousTwoTradeDate string `json:"previous_two_trade_date"` // T-2 交易日。
|
||||||
|
NextTradeDate string `json:"next_trade_date"` // T+1 交易日。
|
||||||
|
Symbol string `json:"symbol"` // 股票代码。
|
||||||
|
TsCode string `json:"ts_code"` // TuShare 股票代码。
|
||||||
|
Name string `json:"name"` // 股票简称。
|
||||||
|
Market string `json:"market"` // 所属板块,来自 stock_basic.market。
|
||||||
|
Industry string `json:"industry"` // 所属行业,来自 stock_basic.industry。
|
||||||
|
PreviousClose *float64 `json:"previous_close"` // T-1 收盘价。
|
||||||
|
LimitPrice *float64 `json:"limit_price"` // 按 T-1 收盘价和板块涨跌幅规则计算的 T 日涨停价。
|
||||||
|
THigh *float64 `json:"t_high"` // T 日最高价。
|
||||||
|
TClose *float64 `json:"t_close"` // T 日收盘价。
|
||||||
|
TPctChg *float64 `json:"t_pct_chg"` // T 日收盘涨跌幅。
|
||||||
|
Status string `json:"status"` // T 日二板状态:limit 表示封板,break 表示炸板。
|
||||||
|
FirstLimitTime string `json:"first_limit_time"` // T 日二板首次涨停时间,格式化为 HH:MM:SS。
|
||||||
|
OpenTimes *int `json:"open_times"` // T 日二板开板次数。
|
||||||
|
NextHigh *float64 `json:"next_high"` // T+1 日最高价。
|
||||||
|
NextClose *float64 `json:"next_close"` // T+1 日收盘价。
|
||||||
|
NextIntradayProfitable *bool `json:"next_intraday_profitable"` // T+1 盘中最高价是否高于 T 日最高价。
|
||||||
|
NextHighProfitPct *float64 `json:"next_high_profit_pct"` // T+1 最高价相对 T 日最高价的收益率。
|
||||||
|
}
|
||||||
|
|
||||||
|
// SecondBoardProfitSummaryItem 表示单只股票二板后的次日最大获利汇总。
|
||||||
|
type SecondBoardProfitSummaryItem struct {
|
||||||
|
Symbol string `json:"symbol"` // 股票代码。
|
||||||
|
TsCode string `json:"ts_code"` // TuShare 股票代码。
|
||||||
|
Name string `json:"name"` // 股票简称。
|
||||||
|
Market string `json:"market"` // 所属板块。
|
||||||
|
Industry string `json:"industry"` // 所属行业。
|
||||||
|
SecondBoardCount int `json:"second_board_count"` // 09:31:00-09:59:59 期间命中的二板次数。
|
||||||
|
LimitCount int `json:"limit_count"` // 二板封板次数。
|
||||||
|
BreakCount int `json:"break_count"` // 二板炸板次数。
|
||||||
|
ProfitDataCount int `json:"profit_data_count"` // 具备 T+1 最大获利百分比的次数。
|
||||||
|
TotalNextHighProfitPct float64 `json:"total_next_high_profit_pct"` // T+1 最大获利百分比累计值。
|
||||||
|
AverageNextHighProfitPct float64 `json:"average_next_high_profit_pct"` // T+1 最大获利百分比平均值。
|
||||||
|
FirstTradeDate string `json:"first_trade_date"` // 首次命中交易日。
|
||||||
|
LastTradeDate string `json:"last_trade_date"` // 最近命中交易日。
|
||||||
|
}
|
||||||
|
|
||||||
|
// SecondBoardProfitSummaryResponse 是二板获利综合接口响应。
|
||||||
|
type SecondBoardProfitSummaryResponse struct {
|
||||||
|
TradeStartDate string `json:"trade_start_date"` // 查询起始 T 日。
|
||||||
|
TradeEndDate string `json:"trade_end_date"` // 查询结束 T 日。
|
||||||
|
StartTime string `json:"start_time"` // 二板首次涨停时间筛选起点。
|
||||||
|
EndTime string `json:"end_time"` // 二板首次涨停时间筛选终点。
|
||||||
|
Total int `json:"total"` // 返回的股票数量。
|
||||||
|
List []SecondBoardProfitSummaryItem `json:"list"` // 按累计获利从高到低排序。
|
||||||
|
}
|
||||||
|
|
||||||
|
// SecondBoardProfitDetailResponse 是单只股票二板获利详情接口响应。
|
||||||
|
type SecondBoardProfitDetailResponse struct {
|
||||||
|
TradeStartDate string `json:"trade_start_date"` // 查询起始 T 日。
|
||||||
|
TradeEndDate string `json:"trade_end_date"` // 查询结束 T 日。
|
||||||
|
StartTime string `json:"start_time"` // 二板首次涨停时间筛选起点。
|
||||||
|
EndTime string `json:"end_time"` // 二板首次涨停时间筛选终点。
|
||||||
|
Symbol string `json:"symbol"` // 股票代码。
|
||||||
|
TsCode string `json:"ts_code"` // TuShare 股票代码。
|
||||||
|
Name string `json:"name"` // 股票简称。
|
||||||
|
Market string `json:"market"` // 所属板块。
|
||||||
|
Industry string `json:"industry"` // 所属行业。
|
||||||
|
SecondBoardCount int `json:"second_board_count"` // 命中二板次数。
|
||||||
|
ProfitDataCount int `json:"profit_data_count"` // 具备 T+1 最大获利百分比的次数。
|
||||||
|
TotalNextHighProfitPct float64 `json:"total_next_high_profit_pct"` // T+1 最大获利百分比累计值。
|
||||||
|
AverageNextHighProfitPct float64 `json:"average_next_high_profit_pct"` // T+1 最大获利百分比平均值。
|
||||||
|
List []SecondCalStock `json:"list"` // 每次二板获利详情。
|
||||||
|
}
|
||||||
|
|
||||||
|
// LimitOpenCompareGroup 是开盘价低于前收接口的分组结果。
|
||||||
|
type LimitOpenCompareGroup struct {
|
||||||
|
Key string `json:"key"` // 分组唯一标识。
|
||||||
|
Label string `json:"label"` // 分组展示名称。
|
||||||
|
Count int `json:"count"` // 分组内股票数量。
|
||||||
|
Stocks []LimitOpenCompareStock `json:"stocks"` // 分组内股票列表。
|
||||||
|
}
|
||||||
|
|
||||||
|
// LimitOpenThresholdGroup 是开盘价阈值接口的分组结果。
|
||||||
|
type LimitOpenThresholdGroup struct {
|
||||||
|
Key string `json:"key"` // 分组唯一标识。
|
||||||
|
Label string `json:"label"` // 分组展示名称。
|
||||||
|
Count int `json:"count"` // 分组内股票数量。
|
||||||
|
Stocks []LimitOpenThresholdStock `json:"stocks"` // 分组内股票列表。
|
||||||
|
}
|
||||||
|
|
||||||
|
// LimitOpenCompareSummary 汇总昨日涨停股今日开盘表现。
|
||||||
|
type LimitOpenCompareSummary struct {
|
||||||
|
LimitCount int `json:"limit_count"` // 昨日涨停且今日继续涨停的股票总数。
|
||||||
|
OpenLowerPreviousCloseCount int `json:"open_lower_previous_close_count"` // 今日开盘低于昨日收盘的股票数量。
|
||||||
|
OthersCount int `json:"others_count"` // 未进入低开分组的其他股票数量。
|
||||||
|
FirstToSecondBoardCount int `json:"first_to_second_board_count"` // 首板进二板股票数量。
|
||||||
|
FirstSecondBeforeElevenCount int `json:"first_second_before_eleven_count"` // 首板进二板且 11 点前封板数量。
|
||||||
|
}
|
||||||
|
|
||||||
|
// LimitOpenThresholdSummary 汇总开盘阈值分组统计。
|
||||||
|
type LimitOpenThresholdSummary struct {
|
||||||
|
LimitCount int `json:"limit_count"` // 昨日涨停且今日继续涨停的股票总数。
|
||||||
|
OpenWithinThresholdCount int `json:"open_within_threshold_count"` // 今日开盘低于阈值的股票数量。
|
||||||
|
OpenAtOrAboveThresholdCount int `json:"open_at_or_above_threshold_count"` // 今日开盘达到或超过阈值的股票数量。
|
||||||
|
ThresholdRatio float64 `json:"threshold_ratio"` // 阈值比例,例如 1.01 表示前收 101%。
|
||||||
|
}
|
||||||
|
|
||||||
|
// LimitBreakNextPerformanceSummary 汇总炸板股次日表现统计。
|
||||||
|
type LimitBreakNextPerformanceSummary struct {
|
||||||
|
BreakCount int `json:"break_count"` // 命中过滤条件的炸板股票数量。
|
||||||
|
NextTradeDataCount int `json:"next_trade_data_count"` // 具备 T+1 行情数据的股票数量。
|
||||||
|
}
|
||||||
|
|
||||||
|
// SecondCalSummary 汇总日期范围内所有二板日历指标。
|
||||||
|
type SecondCalSummary struct {
|
||||||
|
TradeDayCount int `json:"trade_day_count"` // 返回的交易日数量。
|
||||||
|
SecondBoardCount int `json:"second_board_count"` // 触及二板涨停价的股票总数。
|
||||||
|
LimitCount int `json:"limit_count"` // 二板中封住涨停的股票数量。
|
||||||
|
BreakCount int `json:"break_count"` // 二板中炸板的股票数量。
|
||||||
|
NextTradeDataCount int `json:"next_trade_data_count"` // 具备 T+1 最高价和 T 日最高价的股票数量。
|
||||||
|
NextIntradayProfitCount int `json:"next_intraday_profit_count"` // T+1 最高价高于 T 日最高价的股票数量。
|
||||||
|
NextIntradayProfitRate float64 `json:"next_intraday_profit_rate"` // T+1 盘中盈利概率,百分比。
|
||||||
|
LimitNextDataCount int `json:"limit_next_data_count"` // T 日封板股票中具备 T+1 盈利判断数据的数量。
|
||||||
|
LimitNextProfitCount int `json:"limit_next_profit_count"` // T 日封板股票中 T+1 盘中盈利数量。
|
||||||
|
LimitNextProfitRate float64 `json:"limit_next_profit_rate"` // T 日封板股票的 T+1 盘中盈利概率,百分比。
|
||||||
|
BreakNextDataCount int `json:"break_next_data_count"` // T 日炸板股票中具备 T+1 盈利判断数据的数量。
|
||||||
|
BreakNextProfitCount int `json:"break_next_profit_count"` // T 日炸板股票中 T+1 盘中盈利数量。
|
||||||
|
BreakNextProfitRate float64 `json:"break_next_profit_rate"` // T 日炸板股票的 T+1 盘中盈利概率,百分比。
|
||||||
|
}
|
||||||
|
|
||||||
|
// LimitOpenCompareResponse 是开盘价低于前收接口响应。
|
||||||
|
type LimitOpenCompareResponse struct {
|
||||||
|
TradeDate string `json:"trade_date"` // 查询交易日。
|
||||||
|
PreviousTradeDate string `json:"previous_trade_date"` // 上一个交易日。
|
||||||
|
PreviousTwoTradeDate string `json:"previous_two_trade_date"` // 上上个交易日。
|
||||||
|
Summary LimitOpenCompareSummary `json:"summary"` // 汇总统计。
|
||||||
|
Groups []LimitOpenCompareGroup `json:"groups"` // 固定分组结果。
|
||||||
|
}
|
||||||
|
|
||||||
|
// LimitBreakNextPerformanceResponse 是炸板股次日表现接口响应。
|
||||||
|
type LimitBreakNextPerformanceResponse struct {
|
||||||
|
TradeStartDate string `json:"trade_start_date"` // 查询起始交易日。
|
||||||
|
TradeEndDate string `json:"trade_end_date"` // 查询结束交易日。
|
||||||
|
Page int `json:"page"` // 当前页码。
|
||||||
|
PageSize int `json:"page_size"` // 每页数量。
|
||||||
|
Total int `json:"total"` // 命中过滤条件的总记录数。
|
||||||
|
TotalPages int `json:"total_pages"` // 总页数。
|
||||||
|
Summary LimitBreakNextPerformanceSummary `json:"summary"` // 汇总统计。
|
||||||
|
List []LimitBreakNextPerformanceStock `json:"list"` // 当前页记录。
|
||||||
|
}
|
||||||
|
|
||||||
|
// SecondCalDay 表示二板日历中单个交易日的聚合统计。
|
||||||
|
type SecondCalDay struct {
|
||||||
|
TradeDate string `json:"trade_date"` // T 日交易日。
|
||||||
|
PreviousTradeDate string `json:"previous_trade_date"` // T-1 交易日。
|
||||||
|
PreviousTwoTradeDate string `json:"previous_two_trade_date"` // T-2 交易日。
|
||||||
|
NextTradeDate string `json:"next_trade_date"` // T+1 交易日。
|
||||||
|
SecondBoardCount int `json:"second_board_count"` // 当日触及二板涨停价的股票数量。
|
||||||
|
LimitCount int `json:"limit_count"` // 当日二板封板数量。
|
||||||
|
BreakCount int `json:"break_count"` // 当日二板炸板数量。
|
||||||
|
NextTradeDataCount int `json:"next_trade_data_count"` // 当日具备 T+1 盈利判断数据的股票数量。
|
||||||
|
NextIntradayProfitCount int `json:"next_intraday_profit_count"` // 当日 T+1 最高价高于 T 日最高价数量。
|
||||||
|
NextIntradayProfitRate float64 `json:"next_intraday_profit_rate"` // 当日 T+1 盘中盈利概率,百分比。
|
||||||
|
LimitNextDataCount int `json:"limit_next_data_count"` // 当日封板股中具备 T+1 盈利判断数据的数量。
|
||||||
|
LimitNextProfitCount int `json:"limit_next_profit_count"` // 当日封板股 T+1 盘中盈利数量。
|
||||||
|
LimitNextProfitRate float64 `json:"limit_next_profit_rate"` // 当日封板股 T+1 盘中盈利概率,百分比。
|
||||||
|
BreakNextDataCount int `json:"break_next_data_count"` // 当日炸板股中具备 T+1 盈利判断数据的数量。
|
||||||
|
BreakNextProfitCount int `json:"break_next_profit_count"` // 当日炸板股 T+1 盘中盈利数量。
|
||||||
|
BreakNextProfitRate float64 `json:"break_next_profit_rate"` // 当日炸板股 T+1 盘中盈利概率,百分比。
|
||||||
|
Stocks []SecondCalStock `json:"stocks"` // 当日二板股票明细。
|
||||||
|
TotalStocks []SecondCalStock `json:"total_stocks"` // 涨停和炸板合并后按首次涨停时间排序的明细。
|
||||||
|
}
|
||||||
|
|
||||||
|
// SecondCalResponse 是二板日历接口响应。
|
||||||
|
type SecondCalResponse struct {
|
||||||
|
TradeStartDate string `json:"trade_start_date"` // 查询起始 T 日。
|
||||||
|
TradeEndDate string `json:"trade_end_date"` // 查询结束 T 日。
|
||||||
|
Summary SecondCalSummary `json:"summary"` // 日期范围汇总统计。
|
||||||
|
List []SecondCalDay `json:"list"` // 按交易日升序排列的日历统计。
|
||||||
|
}
|
||||||
|
|
||||||
|
// LimitOpenThresholdResponse 是开盘价阈值分组接口响应。
|
||||||
|
type LimitOpenThresholdResponse struct {
|
||||||
|
TradeDate string `json:"trade_date"` // 查询交易日。
|
||||||
|
PreviousTradeDate string `json:"previous_trade_date"` // 上一个交易日。
|
||||||
|
Summary LimitOpenThresholdSummary `json:"summary"` // 汇总统计。
|
||||||
|
Groups []LimitOpenThresholdGroup `json:"groups"` // 固定分组结果。
|
||||||
|
}
|
||||||
|
|
||||||
|
// LimitBoardGroup 是涨停概览按连板数聚合后的分组。
|
||||||
|
type LimitBoardGroup struct {
|
||||||
|
BoardCount int `json:"board_count"` // 连板数。
|
||||||
|
Label string `json:"label"` // 连板展示标签。
|
||||||
|
Count int `json:"count"` // 分组股票数量。
|
||||||
|
Stocks []LimitBoardStock `json:"stocks"` // 分组股票列表。
|
||||||
|
}
|
||||||
|
|
||||||
|
// LimitBoardSummary 汇总涨停概览的核心统计。
|
||||||
|
type LimitBoardSummary struct {
|
||||||
|
MaxBoardCount int `json:"max_board_count"` // 当日最高连板数。
|
||||||
|
LimitCount int `json:"limit_count"` // 当日涨停股票数量。
|
||||||
|
GroupCount int `json:"group_count"` // 连板分组数量。
|
||||||
|
}
|
||||||
|
|
||||||
|
// LimitBoardDailyMax 表示某个交易日的最高连板数。
|
||||||
|
type LimitBoardDailyMax struct {
|
||||||
|
TradeDate string `json:"trade_date"` // 交易日。
|
||||||
|
MaxBoardCount int `json:"max_board_count"` // 当日最高连板数。
|
||||||
|
}
|
||||||
|
|
||||||
|
// LimitBoardResponse 是涨停概览接口响应。
|
||||||
|
type LimitBoardResponse struct {
|
||||||
|
TradeDate string `json:"trade_date"` // 查询交易日。
|
||||||
|
Summary LimitBoardSummary `json:"summary"` // 当日概览统计。
|
||||||
|
RecentMaxBoardStats []LimitBoardDailyMax `json:"recent_max_board_stats"` // 最近窗口内每日最高连板数。
|
||||||
|
Groups []LimitBoardGroup `json:"groups"` // 按连板数倒序排列的股票分组。
|
||||||
|
}
|
||||||
+27
-16
@@ -2,25 +2,36 @@ package model
|
|||||||
|
|
||||||
import "time"
|
import "time"
|
||||||
|
|
||||||
|
// StockDaily 对应数据库中的 stock_daily 表,保存单只股票每日行情。
|
||||||
type StockDaily struct {
|
type StockDaily struct {
|
||||||
TsCode string `gorm:"column:ts_code"`
|
TsCode string `gorm:"column:ts_code" json:"ts_code"` // 股票代码,通常为 TuShare ts_code。
|
||||||
TradeDate time.Time `gorm:"column:trade_date"`
|
TradeDate time.Time `gorm:"column:trade_date" json:"trade_date"` // 交易日期。
|
||||||
Open float64 `gorm:"column:open"`
|
Open float64 `gorm:"column:open" json:"open"` // 开盘价。
|
||||||
High float64 `gorm:"column:high"`
|
High float64 `gorm:"column:high" json:"high"` // 最高价。
|
||||||
Low float64 `gorm:"column:low"`
|
Low float64 `gorm:"column:low" json:"low"` // 最低价。
|
||||||
Close float64 `gorm:"column:close"`
|
Close float64 `gorm:"column:close" json:"close"` // 收盘价。
|
||||||
|
PctChg float64 `gorm:"column:pct_chg" json:"pct_chg"` // 当日涨跌幅。
|
||||||
|
Vol float64 `gorm:"column:vol" json:"vol"` // 成交量。
|
||||||
}
|
}
|
||||||
|
|
||||||
func (StockDaily) TableName() string { return "stock_daily" }
|
// AnalyzeRequest 用于接收前端 POST 请求的分析区间参数。
|
||||||
|
type AnalyzeRequest struct {
|
||||||
|
StartDate string `json:"start_date"` // 起始交易日。
|
||||||
|
EndDate string `json:"end_date"` // 结束交易日。
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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"` // 逻辑最高点日期
|
||||||
PostMinLow float64 `json:"post_min_low"`
|
OverallMaxHigh float64 `json:"overall_max_high"` // 区间绝对最高点
|
||||||
DropPct *float64 `json:"drop_pct"` // 改为指针以支持 null
|
OverallMaxDate string `json:"overall_max_date"` // 区间绝对最高点日期
|
||||||
ReboundPct *float64 `json:"rebound_pct"` // 改为指针以支持 null
|
PostMinLow float64 `json:"post_min_low"` // 涨停日后的最低点
|
||||||
|
DropPct *float64 `json:"drop_pct"` // 降幅 (跌破涨停价才有)
|
||||||
|
ReboundPct *float64 `json:"rebound_pct"` // 涨幅/反弹
|
||||||
|
ClosePrices []float64 `json:"close_prices"` // 用于前端画图的收盘价序列
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package router
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"level_go/internal/api"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NewRouter 创建并配置 API 路由。
|
||||||
|
//
|
||||||
|
// 函数集中注册跨域中间件和 v1 版本接口,调用方只需要注入各业务 Handler。
|
||||||
|
func NewRouter(commonApi *api.CommonHandler, stockApi *api.StockHandler, limitBoardApi *api.LimitBoardHandler) *gin.Engine {
|
||||||
|
r := gin.New()
|
||||||
|
r.Use(gin.Recovery())
|
||||||
|
|
||||||
|
// 跨域中间件允许前端从任意来源访问接口,并暴露 CSV 下载需要的 Content-Disposition。
|
||||||
|
r.Use(func(c *gin.Context) {
|
||||||
|
c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
|
||||||
|
c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, GET, OPTIONS")
|
||||||
|
c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
||||||
|
c.Writer.Header().Set("Access-Control-Expose-Headers", "Content-Disposition")
|
||||||
|
if c.Request.Method == "OPTIONS" {
|
||||||
|
c.AbortWithStatus(204)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Next()
|
||||||
|
})
|
||||||
|
r.Use(cacheableGETResponseMiddleware(newResponseCacheStore()))
|
||||||
|
|
||||||
|
v1 := r.Group("/api/v1")
|
||||||
|
{
|
||||||
|
v1.GET("/common/trade-calendar", commonApi.GetTradeCalendar)
|
||||||
|
v1.POST("/stock/analyze", stockApi.Analyze)
|
||||||
|
v1.GET("/stock/kline/:code", stockApi.GetKlineData)
|
||||||
|
|
||||||
|
limitBoard := v1.Group("/limit-board")
|
||||||
|
{
|
||||||
|
limitBoard.GET("/overview", limitBoardApi.GetOverview)
|
||||||
|
limitBoard.GET("/open-lower-groups", limitBoardApi.GetOpenLowerGroups)
|
||||||
|
limitBoard.GET("/open-threshold", limitBoardApi.GetOpenThresholdGroups)
|
||||||
|
limitBoard.GET("/open-threshold-groups", limitBoardApi.GetOpenThresholdGroups)
|
||||||
|
limitBoard.GET("/second-board-profit", limitBoardApi.ListSecondBoardProfit)
|
||||||
|
limitBoard.GET("/second-board-profit-summary", limitBoardApi.GetSecondBoardProfitSummary)
|
||||||
|
limitBoard.GET("/second-board-profit-detail", limitBoardApi.GetSecondBoardProfitDetail)
|
||||||
|
limitBoard.GET("/second-cal", limitBoardApi.GetSecondCal)
|
||||||
|
limitBoard.GET("/break-next-performance", limitBoardApi.GetBreakNextPerformance)
|
||||||
|
}
|
||||||
|
|
||||||
|
download := v1.Group("/download")
|
||||||
|
{
|
||||||
|
download.GET("/limit-board/second-board-profit-csv", limitBoardApi.DownloadSecondBoardProfitCSV)
|
||||||
|
download.GET("/limit-board/break-next-performance-csv", limitBoardApi.DownloadBreakNextPerformanceCSV)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
@@ -1,128 +0,0 @@
|
|||||||
package service
|
|
||||||
|
|
||||||
import (
|
|
||||||
"level_go/internal/model"
|
|
||||||
"math"
|
|
||||||
"sort"
|
|
||||||
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
|
||||||
|
|
||||||
type StockService struct {
|
|
||||||
DB *gorm.DB
|
|
||||||
}
|
|
||||||
|
|
||||||
func roundTo4(val float64) float64 {
|
|
||||||
return math.Round(val*10000) / 10000
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *StockService) AnalyzePerformance(startDate, endDate string) ([]model.AnalysisResponse, error) {
|
|
||||||
var results []model.AnalysisResponse
|
|
||||||
var limits []struct {
|
|
||||||
TsCode string
|
|
||||||
Name string
|
|
||||||
Open float64
|
|
||||||
High float64
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1. 获取选定日期当天涨停的股票
|
|
||||||
query := `
|
|
||||||
SELECT l.ts_code, l.name, d.open, d.high
|
|
||||||
FROM stock_limit_data l
|
|
||||||
LEFT JOIN stock_daily d ON l.ts_code = d.ts_code AND l.trade_date = d.trade_date
|
|
||||||
WHERE l.trade_date = ? AND l.status = 1`
|
|
||||||
|
|
||||||
if err := s.DB.Raw(query, startDate).Scan(&limits).Error; err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, stock := range limits {
|
|
||||||
var dailies []model.StockDaily
|
|
||||||
s.DB.Where("ts_code = ? AND trade_date BETWEEN ? AND ?", stock.TsCode, startDate, endDate).
|
|
||||||
Order("trade_date ASC").Find(&dailies)
|
|
||||||
|
|
||||||
if len(dailies) == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- 步骤 1:寻找全区间的最高点 (MaxHigh) ---
|
|
||||||
maxPrice := -1.0
|
|
||||||
maxIdx := 0
|
|
||||||
for i, d := range dailies {
|
|
||||||
if d.High > maxPrice {
|
|
||||||
maxPrice = d.High
|
|
||||||
maxIdx = i
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var dropPct, reboundPct *float64
|
|
||||||
var postMinLow float64
|
|
||||||
maxDateStr := dailies[maxIdx].TradeDate.Format("2006-01-02")
|
|
||||||
|
|
||||||
// --- 步骤 2:寻找最高点之后的最低点 (PostMinLow) ---
|
|
||||||
if maxIdx < len(dailies)-1 {
|
|
||||||
postMaxData := dailies[maxIdx+1:]
|
|
||||||
minLow := postMaxData[0].Low
|
|
||||||
minLowIdx := 0
|
|
||||||
|
|
||||||
for i, d := range postMaxData {
|
|
||||||
if d.Low < minLow {
|
|
||||||
minLow = d.Low
|
|
||||||
minLowIdx = i
|
|
||||||
}
|
|
||||||
}
|
|
||||||
postMinLow = minLow
|
|
||||||
|
|
||||||
// 只要出现比 MaxHigh 低的价格,就计算回落幅度
|
|
||||||
if minLow < maxPrice {
|
|
||||||
dVal := roundTo4(((maxPrice - minLow) / maxPrice) * 100)
|
|
||||||
dropPct = &dVal
|
|
||||||
|
|
||||||
// --- 步骤 3:寻找最低点之后的反弹 ---
|
|
||||||
if minLowIdx < len(postMaxData)-1 {
|
|
||||||
lastClose := postMaxData[len(postMaxData)-1].Close
|
|
||||||
if lastClose > minLow {
|
|
||||||
rVal := roundTo4(((lastClose - minLow) / minLow) * 100)
|
|
||||||
reboundPct = &rVal
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- 步骤 4:处理特殊形态(如持续上涨无回落) ---
|
|
||||||
if dropPct == nil {
|
|
||||||
lastClose := dailies[len(dailies)-1].Close
|
|
||||||
firstDayHigh := dailies[0].High
|
|
||||||
if lastClose > firstDayHigh {
|
|
||||||
rVal := roundTo4(((lastClose - firstDayHigh) / firstDayHigh) * 100)
|
|
||||||
reboundPct = &rVal
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
results = append(results, model.AnalysisResponse{
|
|
||||||
Code: stock.TsCode,
|
|
||||||
Name: stock.Name,
|
|
||||||
DayOpen: stock.Open,
|
|
||||||
DayHigh: stock.High,
|
|
||||||
MaxHigh: maxPrice,
|
|
||||||
MaxDate: maxDateStr,
|
|
||||||
PostMinLow: postMinLow,
|
|
||||||
DropPct: dropPct,
|
|
||||||
ReboundPct: reboundPct,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5. 基础排序(仅按回落幅度降序,不处理 null 特殊逻辑)
|
|
||||||
sort.Slice(results, func(i, j int) bool {
|
|
||||||
valI, valJ := -1.0, -1.0
|
|
||||||
if results[i].DropPct != nil {
|
|
||||||
valI = *results[i].DropPct
|
|
||||||
}
|
|
||||||
if results[j].DropPct != nil {
|
|
||||||
valJ = *results[j].DropPct
|
|
||||||
}
|
|
||||||
return valI > valJ
|
|
||||||
})
|
|
||||||
|
|
||||||
return results, nil
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"level_go/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CommonService 提供通用基础数据查询能力。
|
||||||
|
type CommonService struct {
|
||||||
|
DB *gorm.DB // 数据库连接。
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTradeCalendar 查询上交所全部开市日期,并返回最新开市日期。
|
||||||
|
func (s *CommonService) GetTradeCalendar() (model.CalendarResponse, error) {
|
||||||
|
var results []struct {
|
||||||
|
DateStr string `gorm:"column:ds"`
|
||||||
|
}
|
||||||
|
var resp model.CalendarResponse
|
||||||
|
|
||||||
|
err := s.DB.Model(&model.TradeCalendar{}).
|
||||||
|
Select("DATE_FORMAT(cal_date, '%Y-%m-%d') as ds").
|
||||||
|
Where("is_open = ? AND exchange = ?", 1, "SSE").
|
||||||
|
Order("cal_date ASC").Scan(&results).Error
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按日期升序扫描,因此循环中最后一次赋值即为最大开市日期。
|
||||||
|
for _, r := range results {
|
||||||
|
resp.OpenDates = append(resp.OpenDates, r.DateStr)
|
||||||
|
resp.MaxDate = r.DateStr
|
||||||
|
}
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LimitBoardService 提供涨停板概览、分组、收益统计等业务能力。
|
||||||
|
type LimitBoardService struct {
|
||||||
|
DB *gorm.DB // 数据库连接。
|
||||||
|
}
|
||||||
|
|
||||||
|
// stockLimitCombinedTable 是涨停合并数据表名,集中定义便于多个查询复用。
|
||||||
|
const stockLimitCombinedTable = "stock_limit_combined_data"
|
||||||
|
|
||||||
|
// boardLabel 把连续涨停天数转换为中文连板标签。
|
||||||
|
func boardLabel(boardCount int) string {
|
||||||
|
switch boardCount {
|
||||||
|
case 1:
|
||||||
|
return "首板"
|
||||||
|
case 2:
|
||||||
|
return "二板"
|
||||||
|
case 3:
|
||||||
|
return "三板"
|
||||||
|
case 4:
|
||||||
|
return "四板"
|
||||||
|
case 5:
|
||||||
|
return "五板"
|
||||||
|
case 6:
|
||||||
|
return "六板"
|
||||||
|
case 7:
|
||||||
|
return "七板"
|
||||||
|
case 8:
|
||||||
|
return "八板"
|
||||||
|
case 9:
|
||||||
|
return "九板"
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("%d板", boardCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadTradeCalendar 查询不晚于 tradeDate 的上交所开市日,并返回 tradeDate 在切片中的索引。
|
||||||
|
//
|
||||||
|
// dateIndex 为 -1 表示传入日期不是当前交易日历中的开市日。
|
||||||
|
func (s *LimitBoardService) loadTradeCalendar(tradeDate string) ([]string, int, error) {
|
||||||
|
var tradeDates []string
|
||||||
|
if err := s.DB.Table("trade_calendar").
|
||||||
|
Select("DATE_FORMAT(cal_date, '%Y-%m-%d') AS trade_date").
|
||||||
|
Where("exchange = ? AND is_open = ? AND cal_date <= ?", "SSE", 1, tradeDate).
|
||||||
|
Order("cal_date ASC").
|
||||||
|
Scan(&tradeDates).Error; err != nil {
|
||||||
|
return nil, -1, err
|
||||||
|
}
|
||||||
|
|
||||||
|
dateIndex := -1
|
||||||
|
for i, d := range tradeDates {
|
||||||
|
if d == tradeDate {
|
||||||
|
dateIndex = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tradeDates, dateIndex, nil
|
||||||
|
}
|
||||||
@@ -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 // 返回转换后的股票明细。
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"sort"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"level_go/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// StockService 提供股票行情查询和区间表现分析能力。
|
||||||
|
type StockService struct {
|
||||||
|
DB *gorm.DB // 数据库连接。
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetHistoryKline 查询指定股票自固定起点以来的日 K 线。
|
||||||
|
func (s *StockService) GetHistoryKline(code string) ([]model.StockDaily, error) {
|
||||||
|
var dailies []model.StockDaily
|
||||||
|
startDate := "2020-01-02"
|
||||||
|
|
||||||
|
err := s.DB.Table("stock_daily").
|
||||||
|
Where("ts_code = ? AND trade_date >= ?", code, startDate).
|
||||||
|
Order("trade_date ASC").
|
||||||
|
Find(&dailies).Error
|
||||||
|
|
||||||
|
return dailies, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// roundTo2 把浮点数四舍五入到两位小数。
|
||||||
|
func roundTo2(val float64) float64 {
|
||||||
|
return math.Round(val*100) / 100
|
||||||
|
}
|
||||||
|
|
||||||
|
// AnalyzePerformance 分析某个涨停日之后到结束日期之间的下跌和反弹表现。
|
||||||
|
//
|
||||||
|
// startDate 既是涨停筛选日期,也是行情区间起点;endDate 是行情区间终点。
|
||||||
|
func (s *StockService) AnalyzePerformance(startDate, endDate string) ([]model.AnalysisResponse, error) {
|
||||||
|
var results []model.AnalysisResponse
|
||||||
|
var limits []struct {
|
||||||
|
TsCode string
|
||||||
|
Name string
|
||||||
|
Open 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
|
||||||
|
LEFT JOIN stock_daily d ON l.ts_code = d.ts_code AND l.trade_date = d.trade_date
|
||||||
|
WHERE l.trade_date = ? AND l.final_status = 1`
|
||||||
|
|
||||||
|
if err := s.DB.Raw(query, startDate).Scan(&limits).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(limits) == 0 {
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
codes := make([]string, 0, len(limits))
|
||||||
|
for _, stock := range limits {
|
||||||
|
codes = append(codes, stock.TsCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
var allDailies []model.StockDaily
|
||||||
|
if err := s.DB.Table("stock_daily").
|
||||||
|
Select("ts_code", "trade_date", "open", "high", "low", "close").
|
||||||
|
Where("ts_code IN ? AND trade_date BETWEEN ? AND ?", codes, startDate, endDate).
|
||||||
|
Order("ts_code ASC, trade_date ASC").
|
||||||
|
Find(&allDailies).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
dailiesByCode := make(map[string][]model.StockDaily, len(codes))
|
||||||
|
for _, daily := range allDailies {
|
||||||
|
dailiesByCode[daily.TsCode] = append(dailiesByCode[daily.TsCode], daily)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, stock := range limits {
|
||||||
|
dailies := dailiesByCode[stock.TsCode]
|
||||||
|
if len(dailies) <= 1 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// A. 计算区间绝对最高价,用于前端展示真实区间高点。
|
||||||
|
overallMax := dailies[0].High
|
||||||
|
overallMaxDate := dailies[0].TradeDate.Format("2006-01-02")
|
||||||
|
for _, d := range dailies {
|
||||||
|
if d.High > overallMax {
|
||||||
|
overallMax = d.High
|
||||||
|
overallMaxDate = d.TradeDate.Format("2006-01-02")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// B. 逻辑计算以涨停日最高价为分界,区分“未跌破涨停高点”和“跌破后反弹”两类形态。
|
||||||
|
limitHigh := stock.High
|
||||||
|
postDailies := dailies[1:]
|
||||||
|
|
||||||
|
// 找出涨停后的全局最低,用来判断是否跌破涨停日最高价。
|
||||||
|
minLowAfterLimit := postDailies[0].Low
|
||||||
|
for _, d := range postDailies {
|
||||||
|
if d.Low < minLowAfterLimit {
|
||||||
|
minLowAfterLimit = d.Low
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var dropPct, reboundPct *float64
|
||||||
|
var logicMaxHigh float64
|
||||||
|
var logicMaxDate string
|
||||||
|
|
||||||
|
if minLowAfterLimit >= limitHigh {
|
||||||
|
// 形态 1:强势,后续最低价仍高于涨停日最高价,因此只统计向上空间。
|
||||||
|
currMax := limitHigh
|
||||||
|
maxIdx := -1
|
||||||
|
for i, d := range postDailies {
|
||||||
|
if d.High > currMax {
|
||||||
|
currMax = d.High
|
||||||
|
maxIdx = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
logicMaxHigh = currMax
|
||||||
|
logicMaxDate = startDate
|
||||||
|
if maxIdx != -1 {
|
||||||
|
logicMaxDate = postDailies[maxIdx].TradeDate.Format("2006-01-02")
|
||||||
|
val := roundTo2(((logicMaxHigh - limitHigh) / limitHigh) * 100)
|
||||||
|
reboundPct = &val
|
||||||
|
}
|
||||||
|
dropPct = nil
|
||||||
|
} else {
|
||||||
|
// 形态 2:破位,先找到破位最低点,再计算从此前最高点到最低点的降幅。
|
||||||
|
absMinLow := postDailies[0].Low
|
||||||
|
minIdx := 0
|
||||||
|
for i, d := range postDailies {
|
||||||
|
if d.Low < absMinLow {
|
||||||
|
absMinLow = d.Low
|
||||||
|
minIdx = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 找到涨停日到最低点之间的最高价,作为本次回撤的逻辑高点。
|
||||||
|
rangeDailies := dailies[:minIdx+2]
|
||||||
|
rangeMax := dailies[0].High
|
||||||
|
maxD := startDate
|
||||||
|
for _, d := range rangeDailies {
|
||||||
|
if d.High > rangeMax {
|
||||||
|
rangeMax = d.High
|
||||||
|
maxD = d.TradeDate.Format("2006-01-02")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
logicMaxHigh = rangeMax
|
||||||
|
logicMaxDate = maxD
|
||||||
|
|
||||||
|
dVal := roundTo2(((rangeMax - absMinLow) / rangeMax) * 100)
|
||||||
|
dropPct = &dVal
|
||||||
|
|
||||||
|
lastClose := postDailies[len(postDailies)-1].Close
|
||||||
|
rVal := roundTo2(((lastClose - absMinLow) / absMinLow) * 100)
|
||||||
|
reboundPct = &rVal
|
||||||
|
}
|
||||||
|
|
||||||
|
// 收盘价序列给前端画趋势图使用。
|
||||||
|
var closePrices []float64
|
||||||
|
for _, d := range dailies {
|
||||||
|
closePrices = append(closePrices, d.Close)
|
||||||
|
}
|
||||||
|
|
||||||
|
results = append(results, model.AnalysisResponse{
|
||||||
|
Code: stock.TsCode,
|
||||||
|
Name: stock.Name,
|
||||||
|
DayOpen: stock.Open,
|
||||||
|
DayHigh: stock.High,
|
||||||
|
MaxHigh: logicMaxHigh,
|
||||||
|
MaxDate: logicMaxDate,
|
||||||
|
OverallMaxHigh: overallMax,
|
||||||
|
OverallMaxDate: overallMaxDate,
|
||||||
|
PostMinLow: minLowAfterLimit,
|
||||||
|
DropPct: dropPct,
|
||||||
|
ReboundPct: reboundPct,
|
||||||
|
ClosePrices: closePrices,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 优先展示跌幅更大的破位股票;强势股票由于 DropPct 为 nil 排在后面。
|
||||||
|
sort.Slice(results, func(i, j int) bool {
|
||||||
|
if results[i].DropPct == nil && results[j].DropPct != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if results[i].DropPct != nil && results[j].DropPct == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if results[i].DropPct != nil && results[j].DropPct != nil {
|
||||||
|
return *results[i].DropPct > *results[j].DropPct
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
+37
-8
@@ -2,29 +2,42 @@ 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")
|
||||||
viper.AddConfigPath(".") // 在当前目录查找 config.yaml
|
viper.AddConfigPath(".") // 在当前目录查找 config.yaml
|
||||||
|
viper.AddConfigPath("./config") // 兼容多一层目录的情况
|
||||||
|
|
||||||
if err := viper.ReadInConfig(); err != nil {
|
if err := viper.ReadInConfig(); err != nil {
|
||||||
panic(fmt.Errorf("无法读取配置文件: %w", err))
|
panic(fmt.Errorf("无法读取配置文件: %w", err))
|
||||||
@@ -34,3 +47,19 @@ func InitConfig() {
|
|||||||
panic(fmt.Errorf("无法解析配置结构体: %w", err))
|
panic(fmt.Errorf("无法解析配置结构体: %w", err))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetDSN 根据 AppConfig 生成 GORM/MySQL 使用的 DSN 连接字符串。
|
||||||
|
func GetDSN() string {
|
||||||
|
db := AppConfig.Database
|
||||||
|
collation := db.Collation
|
||||||
|
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 返回 Gin Run 所需的端口格式,例如 ":8080"。
|
||||||
|
func GetServerPort() string {
|
||||||
|
return fmt.Sprintf(":%d", AppConfig.Server.Port)
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user