新增二板历史角度

This commit is contained in:
2026-05-17 13:19:18 +08:00
parent 507b79f257
commit a3292c3dd9
6 changed files with 612 additions and 1 deletions
@@ -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
}
+53
View File
@@ -21,6 +21,14 @@ type SecondCalRequest struct {
TradeEndDate string `form:"tradeEndDate" json:"trade_end_date"` // 查询结束 T 日。
}
// SecondBoardProfitRequest 是二板获利综合和详情接口的日期范围请求。
type SecondBoardProfitRequest struct {
TradeStartDate string `form:"tradeStartDate" json:"trade_start_date"` // 查询起始 T 日,默认 2020-01-01。
TradeEndDate string `form:"tradeEndDate" json:"trade_end_date"` // 查询结束 T 日,默认当天。
TsCode string `form:"tsCode" json:"ts_code"` // 可选 TuShare 股票代码,详情接口必填。
Symbol string `form:"symbol" json:"symbol"` // 可选市场股票代码,详情接口可替代 tsCode。
}
// LimitBoardStock 表示单只涨停股在涨停概览中的展示数据。
type LimitBoardStock struct {
Symbol string `json:"symbol"` // 市场展示用股票代码。
@@ -165,6 +173,51 @@ type SecondCalStock struct {
NextHighProfitPct *float64 `json:"next_high_profit_pct"` // T+1 最高价相对 T 日最高价的收益率。
}
// SecondBoardProfitSummaryItem 表示单只股票二板后的次日最大获利汇总。
type SecondBoardProfitSummaryItem struct {
Symbol string `json:"symbol"` // 股票代码。
TsCode string `json:"ts_code"` // TuShare 股票代码。
Name string `json:"name"` // 股票简称。
Market string `json:"market"` // 所属板块。
Industry string `json:"industry"` // 所属行业。
SecondBoardCount int `json:"second_board_count"` // 09:31:00-09:59:59 期间命中的二板次数。
LimitCount int `json:"limit_count"` // 二板封板次数。
BreakCount int `json:"break_count"` // 二板炸板次数。
ProfitDataCount int `json:"profit_data_count"` // 具备 T+1 最大获利百分比的次数。
TotalNextHighProfitPct float64 `json:"total_next_high_profit_pct"` // T+1 最大获利百分比累计值。
AverageNextHighProfitPct float64 `json:"average_next_high_profit_pct"` // T+1 最大获利百分比平均值。
FirstTradeDate string `json:"first_trade_date"` // 首次命中交易日。
LastTradeDate string `json:"last_trade_date"` // 最近命中交易日。
}
// SecondBoardProfitSummaryResponse 是二板获利综合接口响应。
type SecondBoardProfitSummaryResponse struct {
TradeStartDate string `json:"trade_start_date"` // 查询起始 T 日。
TradeEndDate string `json:"trade_end_date"` // 查询结束 T 日。
StartTime string `json:"start_time"` // 二板首次涨停时间筛选起点。
EndTime string `json:"end_time"` // 二板首次涨停时间筛选终点。
Total int `json:"total"` // 返回的股票数量。
List []SecondBoardProfitSummaryItem `json:"list"` // 按累计获利从高到低排序。
}
// SecondBoardProfitDetailResponse 是单只股票二板获利详情接口响应。
type SecondBoardProfitDetailResponse struct {
TradeStartDate string `json:"trade_start_date"` // 查询起始 T 日。
TradeEndDate string `json:"trade_end_date"` // 查询结束 T 日。
StartTime string `json:"start_time"` // 二板首次涨停时间筛选起点。
EndTime string `json:"end_time"` // 二板首次涨停时间筛选终点。
Symbol string `json:"symbol"` // 股票代码。
TsCode string `json:"ts_code"` // TuShare 股票代码。
Name string `json:"name"` // 股票简称。
Market string `json:"market"` // 所属板块。
Industry string `json:"industry"` // 所属行业。
SecondBoardCount int `json:"second_board_count"` // 命中二板次数。
ProfitDataCount int `json:"profit_data_count"` // 具备 T+1 最大获利百分比的次数。
TotalNextHighProfitPct float64 `json:"total_next_high_profit_pct"` // T+1 最大获利百分比累计值。
AverageNextHighProfitPct float64 `json:"average_next_high_profit_pct"` // T+1 最大获利百分比平均值。
List []SecondCalStock `json:"list"` // 每次二板获利详情。
}
// LimitOpenCompareGroup 是开盘价低于前收接口的分组结果。
type LimitOpenCompareGroup struct {
Key string `json:"key"` // 分组唯一标识。
+2
View File
@@ -39,6 +39,8 @@ func NewRouter(commonApi *api.CommonHandler, stockApi *api.StockHandler, limitBo
limitBoard.GET("/open-threshold", limitBoardApi.GetOpenThresholdGroups)
limitBoard.GET("/open-threshold-groups", limitBoardApi.GetOpenThresholdGroups)
limitBoard.GET("/second-board-profit", limitBoardApi.ListSecondBoardProfit)
limitBoard.GET("/second-board-profit-summary", limitBoardApi.GetSecondBoardProfitSummary)
limitBoard.GET("/second-board-profit-detail", limitBoardApi.GetSecondBoardProfitDetail)
limitBoard.GET("/second-cal", limitBoardApi.GetSecondCal)
limitBoard.GET("/break-next-performance", limitBoardApi.GetBreakNextPerformance)
}
@@ -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 // 返回转换后的股票明细。
}