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 }