package api import ( "database/sql" "encoding/csv" "fmt" "math" "net/http" "strconv" "strings" "time" "github.com/gin-gonic/gin" ) const ( defaultSecondProfitStartDate = "2020-01-01" defaultSecondProfitEndDate = "2026-12-31" ) 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"` Open float64 `gorm:"column:open"` Close float64 `gorm:"column:close_price"` LimitPrice float64 `gorm:"column:limit_price"` PreviousClose float64 `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 NextOpenReturnPct sql.NullFloat64 NextHighReturnPct sql.NullFloat64 NextCloseReturnPct sql.NullFloat64 NextCloseProfitable string NextIntradayProfitable string } func (h *LimitBoardHandler) DownloadSecondBoardProfitCSV(c *gin.Context) { startDate := c.DefaultQuery("start_date", defaultSecondProfitStartDate) endDate := c.DefaultQuery("end_date", defaultSecondProfitEndDate) if err := validateSecondProfitDateRange(startDate, endDate); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } rows, err := h.loadSecondProfitCSVRows(startDate, endDate) 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(startDate, "-", ""), strings.ReplaceAll(endDate, "-", ""), ) 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 } } } func validateSecondProfitDateRange(startDate, endDate string) error { start, err := time.Parse("2006-01-02", startDate) if err != nil { return fmt.Errorf("start_date 格式必须为 YYYY-MM-DD") } end, err := time.Parse("2006-01-02", endDate) if err != nil { return fmt.Errorf("end_date 格式必须为 YYYY-MM-DD") } if end.Before(start) { return fmt.Errorf("end_date 不能早于 start_date") } return nil } func (h *LimitBoardHandler) loadSecondProfitCSVRows(startDate, endDate string) ([]secondProfitCSVRow, error) { query := ` WITH open_calendar AS ( SELECT DATE_FORMAT(cal_date, '%Y-%m-%d') AS trade_date, LAG(DATE_FORMAT(cal_date, '%Y-%m-%d'), 1) OVER (ORDER BY cal_date) AS previous_trade_date, LAG(DATE_FORMAT(cal_date, '%Y-%m-%d'), 2) OVER (ORDER BY cal_date) AS previous_two_trade_date, LEAD(DATE_FORMAT(cal_date, '%Y-%m-%d'), 1) OVER (ORDER BY cal_date) AS next_trade_date FROM trade_calendar WHERE exchange = 'SSE' AND is_open = 1 ) SELECT c.trade_date, c.previous_trade_date, c.previous_two_trade_date, COALESCE(c.next_trade_date, '') 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, 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 open_calendar 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 d.ts_code = l.ts_code AND d.trade_date = l.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 WHERE c.trade_date BETWEEN ? AND ? AND c.previous_trade_date IS NOT NULL AND c.previous_two_trade_date IS NOT NULL AND ppl.ts_code IS NULL AND d.open IS NOT NULL AND COALESCE(pl.calc_close, pl.tushare_close, pd.close) > 0 AND d.open < COALESCE(pl.calc_close, pl.tushare_close, pd.close) * 1.01 ORDER BY c.trade_date ASC, l.ts_code ASC` var rawRows []secondProfitCSVRow if err := h.Service.DB.Raw(query, startDate, endDate).Scan(&rawRows).Error; err != nil { return nil, err } rows := make([]secondProfitCSVRow, 0, len(rawRows)) for _, row := range rawRows { if !isSecondProfitBeforeEleven(row.FirstLimitTime.String) { continue } row.OpenPreviousCloseRatio = row.Open / row.PreviousClose if row.Close > 0 { row.NextOpenReturnPct = secondProfitPctReturn(row.NextOpen, row.Close) row.NextHighReturnPct = secondProfitPctReturn(row.NextHigh, row.Close) row.NextCloseReturnPct = secondProfitPctReturn(row.NextClose, row.Close) } row.NextCloseProfitable = secondProfitLabel(row.NextCloseReturnPct) row.NextIntradayProfitable = secondProfitLabel(row.NextHighReturnPct) rows = append(rows, row) } return rows, nil } func secondProfitCSVHeaders() []string { return []string{ "交易日", "前一交易日", "前二交易日", "后一交易日", "股票代码", "股票简称", "市场", "行业", "T日开盘价", "T日收盘价", "T日涨停价", "T-1收盘价", "开盘/T-1收盘%", "首次涨停时间", "T日涨跌幅%", "成交额", "换手率%", "封单额", "开板次数", "涨停统计", "T+1开盘价", "T+1最高价", "T+1最低价", "T+1收盘价", "T+1开盘收益%", "T+1最高收益%", "T+1收盘收益%", "T+1收盘是否盈利", "T+1盘中是否可盈利", } } func secondProfitCSVRecord(row secondProfitCSVRow) []string { return []string{ row.TradeDate, row.PreviousTradeDate, row.PreviousTwoTradeDate, row.NextTradeDate, row.TsCode, row.Name, row.Market, row.Industry, secondProfitFormatFloat(row.Open), secondProfitFormatFloat(row.Close), secondProfitFormatFloat(row.LimitPrice), secondProfitFormatFloat(row.PreviousClose), secondProfitFormatFloat(secondProfitRound2(row.OpenPreviousCloseRatio * 100)), secondProfitFormatLimitTime(row.FirstLimitTime.String), secondProfitFormatNullFloat(row.PctChg), secondProfitFormatNullFloat(row.Amount), secondProfitFormatNullFloat(row.TurnoverRate), secondProfitFormatNullFloat(row.SealAmount), secondProfitFormatNullInt(row.BreakLimitCount), row.LimitUpStat, secondProfitFormatNullFloat(row.NextOpen), secondProfitFormatNullFloat(row.NextHigh), secondProfitFormatNullFloat(row.NextLow), secondProfitFormatNullFloat(row.NextClose), secondProfitFormatNullFloat(row.NextOpenReturnPct), secondProfitFormatNullFloat(row.NextHighReturnPct), secondProfitFormatNullFloat(row.NextCloseReturnPct), row.NextCloseProfitable, row.NextIntradayProfitable, } } func isSecondProfitBeforeEleven(limitTime string) bool { timeText := strings.TrimSpace(limitTime) if timeText == "" { return 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 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 } 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] } 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} } func secondProfitLabel(value sql.NullFloat64) string { if !value.Valid { return "无次日数据" } if value.Float64 > 0 { return "是" } return "否" } func secondProfitRound2(value float64) float64 { return math.Round(value*100) / 100 } func secondProfitFormatFloat(value float64) string { return strconv.FormatFloat(value, 'f', -1, 64) } func secondProfitFormatNullFloat(value sql.NullFloat64) string { if !value.Valid { return "" } return secondProfitFormatFloat(value.Float64) } func secondProfitFormatNullInt(value sql.NullInt64) string { if !value.Valid { return "" } return strconv.FormatInt(value.Int64, 10) }