新增注释

This commit is contained in:
2026-05-09 17:50:40 +08:00
parent d5c053e59d
commit 8504838ccb
19 changed files with 404 additions and 217 deletions
@@ -14,10 +14,15 @@ import (
)
const (
// defaultSecondProfitStartDate 是二板收益统计的默认起始日期。
defaultSecondProfitStartDate = "2020-01-01"
defaultSecondProfitEndDate = "2026-12-31"
// defaultSecondProfitEndDate 是二板收益统计的默认结束日期。
defaultSecondProfitEndDate = "2026-12-31"
)
// secondProfitCSVRow 是二板收益查询的内部行结构。
//
// 前半部分字段直接由 SQL 扫描得到,后半部分字段在 loadSecondProfitRows 中根据行情计算。
type secondProfitCSVRow struct {
TradeDate string `gorm:"column:trade_date"`
PreviousTradeDate string `gorm:"column:previous_trade_date"`
@@ -57,6 +62,7 @@ type secondProfitCSVRow struct {
NextIntradayProfitable string
}
// secondProfitQuery 保存二板收益接口和 CSV 导出共用的查询条件。
type secondProfitQuery struct {
TradeStartDate string
TradeEndDate string
@@ -74,15 +80,20 @@ type secondProfitQuery struct {
PageSize int
}
// secondProfitCompareFilter 表示多个比较条件的组合过滤器。
//
// 当前逻辑使用 AND 关系:所有 Conditions 均满足时才认为匹配。
type secondProfitCompareFilter struct {
Conditions []secondProfitCompareCondition
}
// secondProfitCompareCondition 表示一个形如 value < 10 或 value >= 09:30 的比较条件。
type secondProfitCompareCondition struct {
Op string
Value float64
Op string // 比较操作符,支持 <、>、<=、>=、=。
Value float64 // 参与比较的标准化数值;时间会转换为秒数。
}
// secondProfitListItem 是二板收益列表接口返回给前端的单行数据。
type secondProfitListItem struct {
TradeDate string `json:"trade_date"`
TsCode string `json:"ts_code"`
@@ -104,6 +115,9 @@ type secondProfitListItem struct {
Amount *float64 `json:"amount"`
}
// ListSecondBoardProfit 返回二板收益明细的分页 JSON 数据。
//
// 与 CSV 导出使用同一套查询和筛选逻辑,区别是这里会按 page/pageSize 截取结果。
func (h *LimitBoardHandler) ListSecondBoardProfit(c *gin.Context) {
req, err := parseSecondProfitQuery(c, true)
if err != nil {
@@ -156,6 +170,9 @@ func (h *LimitBoardHandler) ListSecondBoardProfit(c *gin.Context) {
})
}
// DownloadSecondBoardProfitCSV 导出二板收益明细 CSV。
//
// 输出 UTF-8 BOM,便于 Excel 直接打开中文表头和内容。
func (h *LimitBoardHandler) DownloadSecondBoardProfitCSV(c *gin.Context) {
req, err := parseSecondProfitQuery(c, false)
if err != nil {
@@ -192,6 +209,9 @@ func (h *LimitBoardHandler) DownloadSecondBoardProfitCSV(c *gin.Context) {
}
}
// 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))
@@ -261,6 +281,9 @@ func parseSecondProfitQuery(c *gin.Context, includePagination bool) (secondProfi
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"))
@@ -284,6 +307,7 @@ func secondProfitFilterParts(c *gin.Context, base string) (string, string, strin
return "", "", "", ""
}
// secondProfitFilterArrayValue 安全读取数组参数中的指定位置。
func secondProfitFilterArrayValue(values []string, index int) string {
if index >= len(values) {
return ""
@@ -291,6 +315,7 @@ func secondProfitFilterArrayValue(values []string, index int) string {
return strings.TrimSpace(values[index])
}
// validateSecondProfitDateRange 校验查询日期格式和先后顺序。
func validateSecondProfitDateRange(startDate, endDate string) error {
start, err := time.Parse("2006-01-02", startDate)
if err != nil {
@@ -306,6 +331,10 @@ func validateSecondProfitDateRange(startDate, endDate string) error {
return nil
}
// loadSecondProfitRows 查询并计算二板收益明细。
//
// SQL 先找出“前一日涨停、前两日未涨停、当日继续涨停”的股票,再补齐 T+1 行情;
// Go 代码负责应用动态过滤条件并计算开盘、最高、最低、收盘相对 T 日收盘的收益率。
func (h *LimitBoardHandler) loadSecondProfitRows(req secondProfitQuery) ([]secondProfitCSVRow, error) {
query := `
WITH open_calendar AS (
@@ -372,6 +401,7 @@ ORDER BY c.trade_date ASC, l.ts_code ASC`
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)) {
@@ -379,6 +409,7 @@ ORDER BY c.trade_date ASC, l.ts_code ASC`
}
}
// 开盘涨跌幅依赖前收价格,只有行情完整且前收大于 0 时才计算。
if row.Open.Valid && row.PreviousClose.Valid && row.PreviousClose.Float64 > 0 {
row.OpenPreviousCloseRatio = row.Open.Float64 / row.PreviousClose.Float64
row.OpenPctChg = sql.NullFloat64{
@@ -396,6 +427,7 @@ ORDER BY c.trade_date ASC, l.ts_code ASC`
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)
@@ -410,6 +442,7 @@ ORDER BY c.trade_date ASC, l.ts_code ASC`
return rows, nil
}
// secondProfitCSVHeaders 返回二板收益 CSV 表头。
func secondProfitCSVHeaders() []string {
return []string{
"交易日", "股票代码", "股票简称", "市场", "行业",
@@ -421,6 +454,7 @@ func secondProfitCSVHeaders() []string {
}
}
// secondProfitCSVRecord 把内部行结构转换为 CSV 字段。
func secondProfitCSVRecord(row secondProfitCSVRow) []string {
return []string{
row.TradeDate,
@@ -444,6 +478,7 @@ func secondProfitCSVRecord(row secondProfitCSVRow) []string {
}
}
// secondProfitBuildItem 把内部行结构转换为 JSON 列表项。
func secondProfitBuildItem(row secondProfitCSVRow) secondProfitListItem {
return secondProfitListItem{
TradeDate: row.TradeDate,
@@ -467,6 +502,7 @@ func secondProfitBuildItem(row secondProfitCSVRow) secondProfitListItem {
}
}
// 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 {
@@ -478,6 +514,7 @@ func parseSecondProfitCompareFilter(op1, value1, op2, value2 string, parseValue
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)
@@ -498,6 +535,7 @@ func appendSecondProfitCompareCondition(filter *secondProfitCompareFilter, op, r
return nil
}
// isSecondProfitCompareOp 判断操作符是否在白名单内。
func isSecondProfitCompareOp(op string) bool {
switch op {
case "<", ">", "<=", ">=", "=":
@@ -507,10 +545,12 @@ func isSecondProfitCompareOp(op string) bool {
}
}
// Enabled 表示过滤器中是否存在有效条件。
func (f secondProfitCompareFilter) Enabled() bool {
return len(f.Conditions) > 0
}
// Match 判断输入值是否满足过滤器中的所有条件。
func (f secondProfitCompareFilter) Match(value float64) bool {
if !f.Enabled() {
return true
@@ -523,6 +563,7 @@ func (f secondProfitCompareFilter) Match(value float64) bool {
return true
}
// Match 判断输入值是否满足单个比较条件。
func (c secondProfitCompareCondition) Match(value float64) bool {
switch c.Op {
case "<":
@@ -540,6 +581,7 @@ func (c secondProfitCompareCondition) Match(value float64) bool {
}
}
// parseSecondProfitTimeValue 把时间字符串转换为当天秒数。
func parseSecondProfitTimeValue(value string) (float64, error) {
seconds, ok := secondProfitTimeSeconds(value)
if !ok {
@@ -548,10 +590,12 @@ func parseSecondProfitTimeValue(value string) (float64, error) {
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 == "" {
@@ -596,6 +640,7 @@ func secondProfitTimeSeconds(limitTime string) (int, bool) {
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, ":") {
@@ -613,6 +658,7 @@ func secondProfitFormatLimitTime(value string) string {
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{}
@@ -620,6 +666,7 @@ func secondProfitPctReturn(value sql.NullFloat64, base float64) 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 "无次日数据"
@@ -630,14 +677,17 @@ func secondProfitLabel(value sql.NullFloat64) string {
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 ""
@@ -645,6 +695,7 @@ func secondProfitFormatNullFloat(value sql.NullFloat64) string {
return secondProfitFormatFloat(value.Float64)
}
// secondProfitFormatNullInt 格式化可空整数,空值输出为空字符串。
func secondProfitFormatNullInt(value sql.NullInt64) string {
if !value.Valid {
return ""
@@ -652,6 +703,7 @@ func secondProfitFormatNullInt(value sql.NullInt64) string {
return strconv.FormatInt(value.Int64, 10)
}
// secondProfitNullFloatPtr 把 sql.NullFloat64 转换为 JSON 友好的 *float64。
func secondProfitNullFloatPtr(value sql.NullFloat64) *float64 {
if !value.Valid {
return nil
@@ -660,6 +712,7 @@ func secondProfitNullFloatPtr(value sql.NullFloat64) *float64 {
return &v
}
// secondProfitNullIntPtr 把 sql.NullInt64 转换为 JSON 友好的 *int64。
func secondProfitNullIntPtr(value sql.NullInt64) *int64 {
if !value.Valid {
return nil