package api import ( "database/sql" "encoding/csv" "fmt" "math" "net/http" "strconv" "strings" "time" "github.com/gin-gonic/gin" ) const ( // defaultSecondProfitStartDate 是二板收益统计的默认起始日期。 defaultSecondProfitStartDate = "2020-01-01" // defaultSecondProfitEndDate 是二板收益统计的默认结束日期。 defaultSecondProfitEndDate = "2026-12-31" ) // secondProfitCSVRow 是二板收益查询的内部行结构。 // // 前半部分字段直接由 SQL 扫描得到,后半部分字段在 loadSecondProfitRows 中根据行情计算。 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"` PreviousOpen sql.NullFloat64 `gorm:"column:previous_open"` PreviousTwoClose sql.NullFloat64 `gorm:"column:previous_two_close"` Open sql.NullFloat64 `gorm:"column:open"` Close sql.NullFloat64 `gorm:"column:close_price"` LimitPrice float64 `gorm:"column:limit_price"` PreviousClose sql.NullFloat64 `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 PreviousOpenPctChg sql.NullFloat64 OpenPctChg sql.NullFloat64 NextOpenReturnPct sql.NullFloat64 NextHighReturnPct sql.NullFloat64 NextLowReturnPct sql.NullFloat64 NextCloseReturnPct sql.NullFloat64 NextOpenProfitable string NextCloseProfitable string NextIntradayProfitable string } // secondProfitQuery 保存二板收益接口和 CSV 导出共用的查询条件。 type secondProfitQuery struct { TradeStartDate string TradeEndDate string LimitUpTimeFilterType1 string LimitUpTimeFilterValue1 string LimitUpTimeFilterType2 string LimitUpTimeFilterValue2 string OpenPctChgFilterType1 string OpenPctChgFilterValue1 string OpenPctChgFilterType2 string OpenPctChgFilterValue2 string LimitUpTimeRule secondProfitCompareFilter OpenPctChgRule secondProfitCompareFilter Page int PageSize int } // secondProfitCompareFilter 表示多个比较条件的组合过滤器。 // // 当前逻辑使用 AND 关系:所有 Conditions 均满足时才认为匹配。 type secondProfitCompareFilter struct { Conditions []secondProfitCompareCondition } // secondProfitCompareCondition 表示一个形如 value < 10 或 value >= 09:30 的比较条件。 type secondProfitCompareCondition struct { Op string // 比较操作符,支持 <、>、<=、>=、=。 Value float64 // 参与比较的标准化数值;时间会转换为秒数。 } // secondProfitListItem 是二板收益列表接口返回给前端的单行数据。 type secondProfitListItem struct { TradeDate string `json:"trade_date"` TsCode string `json:"ts_code"` Name string `json:"name"` Market string `json:"market"` Industry string `json:"industry"` PreviousOpenPctChg *float64 `json:"previous_open_pct_chg"` OpenPctChg *float64 `json:"open_pct_chg"` NextOpenPctChg *float64 `json:"next_open_pct_chg"` NextOpenProfitable string `json:"next_open_profitable"` NextCloseProfitable string `json:"next_close_profitable"` NextIntradayProfitable string `json:"next_intraday_profitable"` NextHighProfitPct *float64 `json:"next_high_profit_pct"` NextLowProfitPct *float64 `json:"next_low_profit_pct"` BreakLimitCount *int64 `json:"break_limit_count"` TurnoverRatePct *float64 `json:"turnover_rate_pct"` FirstLimitTime string `json:"first_limit_time"` SealAmount *float64 `json:"seal_amount"` Amount *float64 `json:"amount"` } // ListSecondBoardProfit 返回二板收益明细的分页 JSON 数据。 // // 与 CSV 导出使用同一套查询和筛选逻辑,区别是这里会按 page/pageSize 截取结果。 func (h *LimitBoardHandler) ListSecondBoardProfit(c *gin.Context) { req, err := parseSecondProfitQuery(c, true) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } rows, err := h.loadSecondProfitRows(req) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } total := len(rows) start := (req.Page - 1) * req.PageSize if start > total { start = total } end := start + req.PageSize if end > total { end = total } items := make([]secondProfitListItem, 0, end-start) for _, row := range rows[start:end] { items = append(items, secondProfitBuildItem(row)) } totalPages := 0 if total > 0 { totalPages = (total + req.PageSize - 1) / req.PageSize } c.JSON(http.StatusOK, gin.H{ "trade_start_date": req.TradeStartDate, "trade_end_date": req.TradeEndDate, "limit_up_time_filter_type1": req.LimitUpTimeFilterType1, "limit_up_time_filter_value1": req.LimitUpTimeFilterValue1, "limit_up_time_filter_type2": req.LimitUpTimeFilterType2, "limit_up_time_filter_value2": req.LimitUpTimeFilterValue2, "open_pct_chg_filter_type1": req.OpenPctChgFilterType1, "open_pct_chg_filter_value1": req.OpenPctChgFilterValue1, "open_pct_chg_filter_type2": req.OpenPctChgFilterType2, "open_pct_chg_filter_value2": req.OpenPctChgFilterValue2, "page": req.Page, "page_size": req.PageSize, "total": total, "total_pages": totalPages, "list": items, }) } // DownloadSecondBoardProfitCSV 导出二板收益明细 CSV。 // // 输出 UTF-8 BOM,便于 Excel 直接打开中文表头和内容。 func (h *LimitBoardHandler) DownloadSecondBoardProfitCSV(c *gin.Context) { req, err := parseSecondProfitQuery(c, false) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } rows, err := h.loadSecondProfitRows(req) 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(req.TradeStartDate, "-", ""), strings.ReplaceAll(req.TradeEndDate, "-", ""), ) 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 } } } // parseSecondProfitQuery 解析二板收益接口查询参数。 // // includePagination 为 false 时跳过分页解析,供 CSV 导出复用筛选条件。 func parseSecondProfitQuery(c *gin.Context, includePagination bool) (secondProfitQuery, error) { startDate := c.DefaultQuery("tradeStartDate", c.DefaultQuery("start_date", defaultSecondProfitStartDate)) endDate := c.DefaultQuery("tradeEndDate", c.DefaultQuery("end_date", defaultSecondProfitEndDate)) limitUpTimeType1, limitUpTimeValue1, limitUpTimeType2, limitUpTimeValue2 := secondProfitFilterParts(c, "limitUpTimeFilter") openPctChgType1, openPctChgValue1, openPctChgType2, openPctChgValue2 := secondProfitFilterParts(c, "openPctChgFilter") req := secondProfitQuery{ TradeStartDate: startDate, TradeEndDate: endDate, LimitUpTimeFilterType1: limitUpTimeType1, LimitUpTimeFilterValue1: limitUpTimeValue1, LimitUpTimeFilterType2: limitUpTimeType2, LimitUpTimeFilterValue2: limitUpTimeValue2, OpenPctChgFilterType1: openPctChgType1, OpenPctChgFilterValue1: openPctChgValue1, OpenPctChgFilterType2: openPctChgType2, OpenPctChgFilterValue2: openPctChgValue2, Page: 1, PageSize: 10, } if err := validateSecondProfitDateRange(req.TradeStartDate, req.TradeEndDate); err != nil { return req, err } timeRule, err := parseSecondProfitCompareFilter( req.LimitUpTimeFilterType1, req.LimitUpTimeFilterValue1, req.LimitUpTimeFilterType2, req.LimitUpTimeFilterValue2, parseSecondProfitTimeValue, "limitUpTimeFilter", ) if err != nil { return req, err } req.LimitUpTimeRule = timeRule pctRule, err := parseSecondProfitCompareFilter( req.OpenPctChgFilterType1, req.OpenPctChgFilterValue1, req.OpenPctChgFilterType2, req.OpenPctChgFilterValue2, parseSecondProfitFloatValue, "openPctChgFilter", ) if err != nil { return req, err } req.OpenPctChgRule = pctRule if includePagination { pageText := strings.TrimSpace(c.DefaultQuery("page", "1")) page, err := strconv.Atoi(pageText) if err != nil || page <= 0 { return req, fmt.Errorf("page 必须为正整数") } req.Page = page pageSizeText := strings.TrimSpace(c.DefaultQuery("pageSize", "10")) pageSize, err := strconv.Atoi(pageSizeText) if err != nil || pageSize <= 0 { return req, fmt.Errorf("pageSize 必须为正整数") } req.PageSize = pageSize } return req, nil } // secondProfitFilterParts 兼容两种过滤参数格式。 // // 既支持 limitUpTimeFilterType1/Value1 这种具名参数,也支持 limitUpTimeFilter[]=... 数组格式。 func secondProfitFilterParts(c *gin.Context, base string) (string, string, string, string) { type1 := strings.TrimSpace(c.Query(base + "Type1")) value1 := strings.TrimSpace(c.Query(base + "Value1")) type2 := strings.TrimSpace(c.Query(base + "Type2")) value2 := strings.TrimSpace(c.Query(base + "Value2")) if type1 != "" || value1 != "" || type2 != "" || value2 != "" { return type1, value1, type2, value2 } values := c.QueryArray(base) if len(values) == 0 { values = c.QueryArray(base + "[]") } if len(values) > 0 { return secondProfitFilterArrayValue(values, 0), secondProfitFilterArrayValue(values, 1), secondProfitFilterArrayValue(values, 2), secondProfitFilterArrayValue(values, 3) } return "", "", "", "" } // secondProfitFilterArrayValue 安全读取数组参数中的指定位置。 func secondProfitFilterArrayValue(values []string, index int) string { if index >= len(values) { return "" } return strings.TrimSpace(values[index]) } // validateSecondProfitDateRange 校验查询日期格式和先后顺序。 func validateSecondProfitDateRange(startDate, endDate string) error { start, err := time.Parse("2006-01-02", startDate) if err != nil { return fmt.Errorf("tradeStartDate 格式必须为 YYYY-MM-DD") } end, err := time.Parse("2006-01-02", endDate) if err != nil { return fmt.Errorf("tradeEndDate 格式必须为 YYYY-MM-DD") } if end.Before(start) { return fmt.Errorf("tradeEndDate 不能早于 tradeStartDate") } return nil } // loadSecondProfitRows 查询并计算二板收益明细。 // // SQL 先找出“前一日涨停、前两日未涨停、当日继续涨停”的股票,再补齐 T+1 行情; // Go 代码负责应用动态过滤条件并计算开盘、最高、最低、收盘相对 T 日收盘的收益率。 func (h *LimitBoardHandler) loadSecondProfitRows(req secondProfitQuery) ([]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, pd.open AS previous_open, COALESCE(pd.pre_close, ppd.close) AS previous_two_close, 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 l.ts_code = d.ts_code AND d.trade_date = l.trade_date LEFT JOIN stock_daily pd ON l.ts_code = pd.ts_code AND pd.trade_date = c.previous_trade_date LEFT JOIN stock_daily ppd ON l.ts_code = ppd.ts_code AND ppd.trade_date = c.previous_two_trade_date LEFT JOIN stock_daily nd ON l.ts_code = nd.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 ORDER BY c.trade_date ASC, l.ts_code ASC` var rawRows []secondProfitCSVRow if err := h.Service.DB.Raw(query, req.TradeStartDate, req.TradeEndDate).Scan(&rawRows).Error; err != nil { return nil, err } rows := make([]secondProfitCSVRow, 0, len(rawRows)) for _, row := range rawRows { // 首次涨停时间过滤在内存中执行,便于兼容 0930、93000、09:30:00 等多种时间格式。 if req.LimitUpTimeRule.Enabled() { limitTimeValue, ok := secondProfitTimeSeconds(row.FirstLimitTime.String) if !ok || !req.LimitUpTimeRule.Match(float64(limitTimeValue)) { continue } } // 开盘涨跌幅依赖前收价格,只有行情完整且前收大于 0 时才计算。 if row.Open.Valid && row.PreviousClose.Valid && row.PreviousClose.Float64 > 0 { row.OpenPreviousCloseRatio = row.Open.Float64 / row.PreviousClose.Float64 row.OpenPctChg = sql.NullFloat64{ Float64: secondProfitRound2((row.Open.Float64 - row.PreviousClose.Float64) / row.PreviousClose.Float64 * 100), Valid: true, } } if row.PreviousOpen.Valid && row.PreviousTwoClose.Valid && row.PreviousTwoClose.Float64 > 0 { row.PreviousOpenPctChg = sql.NullFloat64{ Float64: secondProfitRound2((row.PreviousOpen.Float64 - row.PreviousTwoClose.Float64) / row.PreviousTwoClose.Float64 * 100), Valid: true, } } if req.OpenPctChgRule.Enabled() && (!row.OpenPctChg.Valid || !req.OpenPctChgRule.Match(row.OpenPctChg.Float64)) { continue } // T+1 收益统一以 T 日收盘价为基准,缺失的次日行情会保留为 NullFloat64。 if row.Close.Valid && row.Close.Float64 > 0 { row.NextOpenReturnPct = secondProfitPctReturn(row.NextOpen, row.Close.Float64) row.NextHighReturnPct = secondProfitPctReturn(row.NextHigh, row.Close.Float64) row.NextLowReturnPct = secondProfitPctReturn(row.NextLow, row.Close.Float64) row.NextCloseReturnPct = secondProfitPctReturn(row.NextClose, row.Close.Float64) } row.NextOpenProfitable = secondProfitLabel(row.NextOpenReturnPct) row.NextCloseProfitable = secondProfitLabel(row.NextCloseReturnPct) row.NextIntradayProfitable = secondProfitLabel(row.NextHighReturnPct) rows = append(rows, row) } return rows, nil } // secondProfitCSVHeaders 返回二板收益 CSV 表头。 func secondProfitCSVHeaders() []string { return []string{ "交易日", "股票代码", "股票简称", "市场", "行业", "T-1日开盘涨跌幅%", "T日开盘涨跌幅%", "T+1日开盘涨跌幅%", "T+1开盘是否盈利", "T+1收盘是否盈利", "T+1盘中是否可盈利", "T+1最高盈利%", "T+1最低盈利%", "开板次数", "换手率%", "首次涨停时间", "封单额", "成交额", } } // secondProfitCSVRecord 把内部行结构转换为 CSV 字段。 func secondProfitCSVRecord(row secondProfitCSVRow) []string { return []string{ row.TradeDate, row.TsCode, row.Name, row.Market, row.Industry, secondProfitFormatNullFloat(row.PreviousOpenPctChg), secondProfitFormatNullFloat(row.OpenPctChg), secondProfitFormatNullFloat(row.NextOpenReturnPct), row.NextOpenProfitable, row.NextCloseProfitable, row.NextIntradayProfitable, secondProfitFormatNullFloat(row.NextHighReturnPct), secondProfitFormatNullFloat(row.NextLowReturnPct), secondProfitFormatNullInt(row.BreakLimitCount), secondProfitFormatNullFloat(row.TurnoverRate), secondProfitFormatLimitTime(row.FirstLimitTime.String), secondProfitFormatNullFloat(row.SealAmount), secondProfitFormatNullFloat(row.Amount), } } // secondProfitBuildItem 把内部行结构转换为 JSON 列表项。 func secondProfitBuildItem(row secondProfitCSVRow) secondProfitListItem { return secondProfitListItem{ TradeDate: row.TradeDate, TsCode: row.TsCode, Name: row.Name, Market: row.Market, Industry: row.Industry, PreviousOpenPctChg: secondProfitNullFloatPtr(row.PreviousOpenPctChg), OpenPctChg: secondProfitNullFloatPtr(row.OpenPctChg), NextOpenPctChg: secondProfitNullFloatPtr(row.NextOpenReturnPct), NextOpenProfitable: row.NextOpenProfitable, NextCloseProfitable: row.NextCloseProfitable, NextIntradayProfitable: row.NextIntradayProfitable, NextHighProfitPct: secondProfitNullFloatPtr(row.NextHighReturnPct), NextLowProfitPct: secondProfitNullFloatPtr(row.NextLowReturnPct), BreakLimitCount: secondProfitNullIntPtr(row.BreakLimitCount), TurnoverRatePct: secondProfitNullFloatPtr(row.TurnoverRate), FirstLimitTime: secondProfitFormatLimitTime(row.FirstLimitTime.String), SealAmount: secondProfitNullFloatPtr(row.SealAmount), Amount: secondProfitNullFloatPtr(row.Amount), } } // parseSecondProfitCompareFilter 根据两组操作符和值构造比较过滤器。 func parseSecondProfitCompareFilter(op1, value1, op2, value2 string, parseValue func(string) (float64, error), fieldName string) (secondProfitCompareFilter, error) { filter := secondProfitCompareFilter{} if err := appendSecondProfitCompareCondition(&filter, op1, value1, parseValue, fieldName, "第一组"); err != nil { return filter, err } if err := appendSecondProfitCompareCondition(&filter, op2, value2, parseValue, fieldName, "第二组"); err != nil { return filter, err } return filter, nil } // appendSecondProfitCompareCondition 校验并追加一个比较条件。 func appendSecondProfitCompareCondition(filter *secondProfitCompareFilter, op, rawValue string, parseValue func(string) (float64, error), fieldName, groupName string) error { op = strings.TrimSpace(op) rawValue = strings.TrimSpace(rawValue) if op == "" && rawValue == "" { return nil } if op == "" || rawValue == "" { return fmt.Errorf("%s %s操作符和值必须同时填写", fieldName, groupName) } if !isSecondProfitCompareOp(op) { return fmt.Errorf("%s %s操作符必须为 <、>、<=、>=、=", fieldName, groupName) } value, err := parseValue(rawValue) if err != nil { return fmt.Errorf("%s %s值格式错误", fieldName, groupName) } filter.Conditions = append(filter.Conditions, secondProfitCompareCondition{Op: op, Value: value}) return nil } // isSecondProfitCompareOp 判断操作符是否在白名单内。 func isSecondProfitCompareOp(op string) bool { switch op { case "<", ">", "<=", ">=", "=": return true default: return false } } // Enabled 表示过滤器中是否存在有效条件。 func (f secondProfitCompareFilter) Enabled() bool { return len(f.Conditions) > 0 } // Match 判断输入值是否满足过滤器中的所有条件。 func (f secondProfitCompareFilter) Match(value float64) bool { if !f.Enabled() { return true } for _, condition := range f.Conditions { if !condition.Match(value) { return false } } return true } // Match 判断输入值是否满足单个比较条件。 func (c secondProfitCompareCondition) Match(value float64) bool { switch c.Op { case "<": return value < c.Value case "<=": return value <= c.Value case ">": return value > c.Value case ">=": return value >= c.Value case "=": return value == c.Value default: return false } } // parseSecondProfitTimeValue 把时间字符串转换为当天秒数。 func parseSecondProfitTimeValue(value string) (float64, error) { seconds, ok := secondProfitTimeSeconds(value) if !ok { return 0, fmt.Errorf("invalid time") } return float64(seconds), nil } // parseSecondProfitFloatValue 解析普通浮点过滤值。 func parseSecondProfitFloatValue(value string) (float64, error) { return strconv.ParseFloat(strings.TrimSpace(value), 64) } // secondProfitTimeSeconds 兼容多种涨停时间格式并转换为当天秒数。 func secondProfitTimeSeconds(limitTime string) (int, bool) { timeText := strings.TrimSpace(limitTime) if timeText == "" { return 0, 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 0, false } } else { if len(timeText) < 4 { return 0, false } switch len(timeText) { case 4: timeText += "00" case 5: timeText = "0" + timeText case 6: default: return 0, false } var err error hour, err = strconv.Atoi(timeText[0:2]) if err != nil { return 0, false } minute, err = strconv.Atoi(timeText[2:4]) if err != nil { return 0, false } second, err = strconv.Atoi(timeText[4:6]) if err != nil { return 0, false } } if hour < 0 || hour > 23 || minute < 0 || minute > 59 || second < 0 || second > 59 { return 0, false } return hour*3600 + minute*60 + second, true } // secondProfitFormatLimitTime 把紧凑时间格式格式化为 HH:MM:SS。 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] } // secondProfitPctReturn 计算相对基准价的百分比收益。 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} } // secondProfitLabel 根据收益是否大于 0 输出中文盈利标签。 func secondProfitLabel(value sql.NullFloat64) string { if !value.Valid { return "无次日数据" } if value.Float64 > 0 { return "是" } return "否" } // secondProfitRound2 保留两位小数。 func secondProfitRound2(value float64) float64 { return math.Round(value*100) / 100 } // secondProfitFormatFloat 使用最短十进制格式输出浮点数。 func secondProfitFormatFloat(value float64) string { return strconv.FormatFloat(value, 'f', -1, 64) } // secondProfitFormatNullFloat 格式化可空浮点数,空值输出为空字符串。 func secondProfitFormatNullFloat(value sql.NullFloat64) string { if !value.Valid { return "" } return secondProfitFormatFloat(value.Float64) } // secondProfitFormatNullInt 格式化可空整数,空值输出为空字符串。 func secondProfitFormatNullInt(value sql.NullInt64) string { if !value.Valid { return "" } return strconv.FormatInt(value.Int64, 10) } // secondProfitNullFloatPtr 把 sql.NullFloat64 转换为 JSON 友好的 *float64。 func secondProfitNullFloatPtr(value sql.NullFloat64) *float64 { if !value.Valid { return nil } v := value.Float64 return &v } // secondProfitNullIntPtr 把 sql.NullInt64 转换为 JSON 友好的 *int64。 func secondProfitNullIntPtr(value sql.NullInt64) *int64 { if !value.Valid { return nil } v := value.Int64 return &v }