Files
go/internal/service/limit_board_break_next_performance_service.go
T
2026-05-13 21:26:22 +08:00

293 lines
10 KiB
Go

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
}