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 }