727 lines
25 KiB
Go
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
|
|
}
|