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

727 lines
25 KiB
Go

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 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(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.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 calendar_window 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.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
}