diff --git a/docs/api-overview.md b/docs/api-overview.md index d97b773..0e5dbdc 100644 --- a/docs/api-overview.md +++ b/docs/api-overview.md @@ -4,7 +4,7 @@ - 服务前缀:`/api/v1` - 响应格式:`application/json` -- 当前项目接口总数:8 +- 当前项目接口总数:10 ## 2. 接口列表 @@ -17,7 +17,9 @@ | 涨停低开分组 | `GET` | `/api/v1/limit-board/open-lower-groups` | 获取查询日和前一交易日均涨停的股票,并按是否低于前一交易日收盘价分组 | [limit-board-open-lower-groups.md](./limit-board-open-lower-groups.md) | | 涨停开盘阈值分组 | `GET` | `/api/v1/limit-board/open-threshold` | 获取查询日和前一交易日均涨停的股票,并按开盘价是否低于前一交易日收盘价 101% 分组 | [limit-board-open-threshold.md](./limit-board-open-threshold.md) | | 二板早盘次日盈利 | `GET` | `/api/v1/limit-board/second-board-profit` | 分页查询 T-2 无涨停、T-1 涨停、T 涨停的次日盈利明细 | [download-second-board-profit-csv.md](./download-second-board-profit-csv.md) | +| 炸板次日表现 | `GET` | `/api/v1/limit-board/break-next-performance` | 分页查询 T-2 未涨停、T-1 涨停、T 日炸板股票及 T+1 表现 | [limit-board-break-next-performance.md](./limit-board-break-next-performance.md) | | 下载 | `GET` | `/api/v1/download/limit-board/second-board-profit-csv` | 下载筛选条件范围内的二板早盘次日盈利 CSV 明细 | [download-second-board-profit-csv.md](./download-second-board-profit-csv.md) | +| 下载 | `GET` | `/api/v1/download/limit-board/break-next-performance-csv` | 下载筛选条件范围内的炸板次日表现 CSV 明细 | [limit-board-break-next-performance.md](./limit-board-break-next-performance.md) | ## 3. 错误响应约定 diff --git a/docs/limit-board-break-next-performance.md b/docs/limit-board-break-next-performance.md new file mode 100644 index 0000000..c1a9418 --- /dev/null +++ b/docs/limit-board-break-next-performance.md @@ -0,0 +1,160 @@ +# 炸板次日表现接口文档 + +## 1. 基本信息 + +- 接口名称:炸板次日表现 +- 请求方法:`GET` +- 请求路径:`/api/v1/limit-board/break-next-performance` +- 接口用途:按 T 日范围查询 T-2 未涨停、T-1 涨停、T 日触及涨停价但收盘未封住的炸板股票,并返回 T+1 交易日表现。 +- 导出路径:`/api/v1/download/limit-board/break-next-performance-csv` + +## 2. 请求参数 + +| 参数 | 类型 | 必填 | 默认值 | 说明 | +| --- | --- | --- | --- | --- | +| `tradeStartDate` | `string` | 是 | 无 | T 日起始日期,格式 `YYYY-MM-DD` | +| `tradeEndDate` | `string` | 是 | 无 | T 日结束日期,格式 `YYYY-MM-DD` | +| `page` | `int` | 否 | `1` | 页码 | +| `pageSize` | `int` | 否 | `20` | 每页数量 | +| `tOpenGainLt101` | `bool` | 否 | 无 | 筛选 T 日开盘价是否小于 T-1 收盘价的 101%,支持别名 `tOpenLessThan101`、`t_open_gain_lt_101` | +| `breakTimeBefore1100` | `bool` | 否 | 无 | 筛选 T 日炸板时间是否在 `11:00:00` 前,空炸板时间按 `true` 处理,支持别名 `tBreakTimeBefore1100`、`break_time_before_1100` | + +兼容 `start_date`、`end_date` 作为日期参数别名。 + +示例: + +```http +GET /api/v1/limit-board/break-next-performance?tradeStartDate=2026-05-07&tradeEndDate=2026-05-07&tOpenGainLt101=true&breakTimeBefore1100=true&page=1&pageSize=20 +``` + +导出示例: + +```http +GET /api/v1/download/limit-board/break-next-performance-csv?tradeStartDate=2026-05-07&tradeEndDate=2026-05-07&tOpenGainLt101=true&breakTimeBefore1100=true +``` + +## 3. 统计规则 + +- 以 `trade_calendar` 中 `exchange = 'SSE'` 且 `is_open = 1` 的记录推导 T-2、T-1、T、T+1 交易日。 +- 样本池来自 `stock_limit_combined_data`:T 日必须为炸板记录,即 `final_status = 2`;T-1 必须为涨停记录,即 `final_status = 1`;T-2 同一股票不能存在 `final_status = 1`。 +- T 日和 T+1 日 OHLC 来自 `stock_daily`。 +- 股票板块来自 `stock_limit_combined_data.market`。 +- T 日开盘涨幅以 T-1 收盘价为基准计算。 +- 如果 T 日有 Tushare 炸板记录,则返回具体的 Tushare 炸板时间 `tushare_first_time` 并格式化为 `HH:MM:SS`;无 Tushare 炸板记录或无具体时间时 `first_time` 为空。 +- T 日炸板时间为空时,`break_time_before_1100` 默认返回 `true`。 +- T+1 开盘涨幅、最高获利、最大亏损、收盘涨跌幅均以 T 日收盘价为基准计算。 +- T+1 盘中是否可以获利表示:T+1 盘中最高价是否能弥补 T 日最高价买入后到 T 日收盘价的差价,按 `next_high >= t_high` 判断;缺少 T+1 最高价、T 日最高价或 T 日收盘价时返回 `null`。 + +## 4. 响应字段 + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `trade_start_date` | `string` | 查询起始 T 日 | +| `trade_end_date` | `string` | 查询结束 T 日 | +| `page` | `int` | 当前页 | +| `page_size` | `int` | 每页数量 | +| `total` | `int` | 命中总数 | +| `total_pages` | `int` | 总页数 | +| `summary.break_count` | `int` | T 日炸板股票总数 | +| `summary.next_trade_data_count` | `int` | 有 T+1 行情数据的股票数 | +| `list` | `array` | 明细列表 | + +明细核心字段: + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `trade_date` | `string` | T 日 | +| `ts_code` / `symbol` / `name` | `string` | 股票标识 | +| `market` / `industry` | `string` | 板块和行业 | +| `t_open` / `t_high` / `t_close` | `number \| null` | T 日行情 | +| `t_open_pct_chg` | `number \| null` | T 日开盘涨幅,基于 T-1 收盘价 | +| `t_open_gain_lt_101` | `boolean` | T 日开盘价是否小于 T-1 收盘价的 101% | +| `first_time` | `string` | T 日 Tushare 炸板时间,格式化为 `HH:MM:SS`,无 Tushare 炸板记录或无具体时间时为空 | +| `break_time_before_1100` | `boolean` | T 日炸板时间是否在 `11:00:00` 前;空炸板时间默认为 `true` | +| `has_tushare_break_data` | `boolean` | T 日是否存在 Tushare 炸板记录,即 `tushare_limit_type = 'Z'` | +| `next_open` / `next_high` / `next_low` / `next_close` | `number \| null` | T+1 行情 | +| `next_open_pct_chg` | `number \| null` | T+1 开盘涨幅,基于 T 日收盘价 | +| `next_intraday_profitable` | `boolean \| null` | T+1 盘中是否可以获利,按 T+1 最高价是否能弥补 T 日最高价买入后到 T 日收盘价的差价判断,即 `next_high >= t_high` | +| `next_high_profit_pct` | `number \| null` | T+1 最高获利百分比,基于 T 日收盘价 | +| `next_low_loss_pct` | `number \| null` | T+1 最大亏损百分比,基于 T 日收盘价 | +| `next_close_pct_chg` | `number \| null` | T+1 收盘涨跌幅,基于 T 日收盘价 | + +## 5. 响应示例 + +```json +{ + "trade_start_date": "2026-05-07", + "trade_end_date": "2026-05-07", + "page": 1, + "page_size": 20, + "total": 3, + "total_pages": 1, + "summary": { + "break_count": 3, + "next_trade_data_count": 3 + }, + "list": [ + { + "trade_date": "2026-05-07", + "symbol": "000060", + "ts_code": "000060.SZ", + "name": "中金岭南", + "market": "主板", + "industry": "铅锌", + "t_open": 7.71, + "t_open_pct_chg": 3.77, + "t_open_gain_lt_101": false, + "t_high": 8.17, + "t_close": 8.08, + "t_pct_chg": 8.7483, + "first_time": "13:55:30", + "break_time_before_1100": false, + "has_tushare_break_data": true, + "next_open": 7.81, + "next_high": 8.23, + "next_low": 7.77, + "next_close": 7.95, + "next_open_pct_chg": -3.34, + "next_intraday_profitable": true, + "next_high_profit_pct": 1.86, + "next_low_loss_pct": -3.84, + "next_close_pct_chg": -1.61 + } + ] +} +``` + +## 6. CSV 导出 + +- 响应类型:`text/csv; charset=utf-8` +- 响应头:`Content-Disposition: attachment; filename="limit_break_next_performance_YYYYMMDD_YYYYMMDD_daily_detail.csv"` +- 文件包含 UTF-8 BOM,便于 Excel 直接打开中文列名。 +- 导出接口复用列表接口的日期、`tOpenGainLt101`、`breakTimeBefore1100` 筛选条件,不使用分页参数。 + +列顺序: + +| 列名 | 来源字段 | +| --- | --- | +| `T日` | `trade_date` | +| `股票代码` | `ts_code` | +| `股票简称` | `name` | +| `T涨跌幅` | `t_pct_chg` | +| `T开盘<1%` | `t_open_gain_lt_101`,导出为 `是`/`否` | +| `tushare炸板时间` | `first_time` | +| `11点前炸板` | `break_time_before_1100`,导出为 `是`/`否` | +| `T+1盘中可盈利` | `next_intraday_profitable`,导出为 `是`/`否`/`无次日数据` | +| `T+1最高获利` | `next_high_profit_pct` | +| `T+1开盘涨幅` | `next_open_pct_chg` | +| `T+1收盘涨跌` | `next_close_pct_chg` | +| `市场` | `market` | +| `行业` | `industry` | +| `T开盘` | `t_open` | +| `T开盘涨幅` | `t_open_pct_chg` | +| `T最高` | `t_high` | +| `T收盘` | `t_close` | +| `tushare是否炸板` | `has_tushare_break_data`,导出为 `是`/`否` | +| `T+1开盘` | `next_open` | +| `T+1最高` | `next_high` | +| `T+1最低` | `next_low` | +| `T+1收盘` | `next_close` | +| `T+1最大亏损` | `next_low_loss_pct` | diff --git a/internal/api/limit_board_break_next_performance_csv_handler.go b/internal/api/limit_board_break_next_performance_csv_handler.go new file mode 100644 index 0000000..6d2fe2b --- /dev/null +++ b/internal/api/limit_board_break_next_performance_csv_handler.go @@ -0,0 +1,125 @@ +package api + +import ( + "encoding/csv" + "fmt" + "net/http" + "strconv" + "strings" + + "github.com/gin-gonic/gin" + "level_go/internal/model" +) + +func (h *LimitBoardHandler) DownloadBreakNextPerformanceCSV(c *gin.Context) { + req, err := parseLimitBreakNextPerformanceRequest(c, false) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + rows, _, err := h.Service.ListLimitBreakNextPerformanceStocks(req) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + filename := fmt.Sprintf("limit_break_next_performance_%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(breakNextPerformanceCSVHeaders()) + for _, row := range rows { + if err := writer.Write(breakNextPerformanceCSVRecord(row)); err != nil { + return + } + } +} + +func breakNextPerformanceCSVHeaders() []string { + return []string{ + "T日", + "股票代码", + "股票简称", + "T涨跌幅", + "T开盘<1%", + "tushare炸板时间", + "11点前炸板", + "T+1盘中可盈利", + "T+1最高获利", + "T+1开盘涨幅", + "T+1收盘涨跌", + "市场", + "行业", + "T开盘", + "T开盘涨幅", + "T最高", + "T收盘", + "tushare是否炸板", + "T+1开盘", + "T+1最高", + "T+1最低", + "T+1收盘", + "T+1最大亏损", + } +} + +func breakNextPerformanceCSVRecord(row model.LimitBreakNextPerformanceStock) []string { + return []string{ + row.TradeDate, + row.TsCode, + row.Name, + breakNextPerformanceFormatFloatPtr(row.TPctChg), + breakNextPerformanceBoolLabel(row.TOpenGainLt101), + row.FirstTime, + breakNextPerformanceBoolLabel(row.BreakTimeBefore1100), + breakNextPerformanceBoolPtrLabel(row.NextIntradayProfitable), + breakNextPerformanceFormatFloatPtr(row.NextHighProfitPct), + breakNextPerformanceFormatFloatPtr(row.NextOpenPctChg), + breakNextPerformanceFormatFloatPtr(row.NextClosePctChg), + row.Market, + row.Industry, + breakNextPerformanceFormatFloatPtr(row.TOpen), + breakNextPerformanceFormatFloatPtr(row.TOpenPctChg), + breakNextPerformanceFormatFloatPtr(row.THigh), + breakNextPerformanceFormatFloatPtr(row.TClose), + breakNextPerformanceBoolLabel(row.HasTushareBreakData), + breakNextPerformanceFormatFloatPtr(row.NextOpen), + breakNextPerformanceFormatFloatPtr(row.NextHigh), + breakNextPerformanceFormatFloatPtr(row.NextLow), + breakNextPerformanceFormatFloatPtr(row.NextClose), + breakNextPerformanceFormatFloatPtr(row.NextLowLossPct), + } +} + +func breakNextPerformanceFormatFloatPtr(value *float64) string { + if value == nil { + return "" + } + return strconv.FormatFloat(*value, 'f', -1, 64) +} + +func breakNextPerformanceBoolLabel(value bool) string { + if value { + return "是" + } + return "否" +} + +func breakNextPerformanceBoolPtrLabel(value *bool) string { + if value == nil { + return "无次日数据" + } + return breakNextPerformanceBoolLabel(*value) +} diff --git a/internal/api/limit_board_handler.go b/internal/api/limit_board_handler.go index 78de8c9..e8f4a41 100644 --- a/internal/api/limit_board_handler.go +++ b/internal/api/limit_board_handler.go @@ -1,7 +1,10 @@ package api import ( + "errors" "net/http" + "strconv" + "strings" "github.com/gin-gonic/gin" "level_go/internal/model" @@ -48,6 +51,90 @@ func (h *LimitBoardHandler) GetOpenLowerGroups(c *gin.Context) { c.JSON(http.StatusOK, resp) } +func (h *LimitBoardHandler) GetBreakNextPerformance(c *gin.Context) { + req, err := parseLimitBreakNextPerformanceRequest(c, true) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + resp, err := h.Service.GetLimitBreakNextPerformance(req) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, resp) +} + +func parseLimitBreakNextPerformanceRequest(c *gin.Context, includePagination bool) (model.LimitBreakNextPerformanceRequest, error) { + req := model.LimitBreakNextPerformanceRequest{ + TradeStartDate: strings.TrimSpace(c.DefaultQuery("tradeStartDate", c.Query("start_date"))), + TradeEndDate: strings.TrimSpace(c.DefaultQuery("tradeEndDate", c.Query("end_date"))), + Page: 1, + PageSize: 20, + } + + if req.TradeStartDate == "" { + return req, errors.New("tradeStartDate 参数不能为空") + } + if req.TradeEndDate == "" { + return req, errors.New("tradeEndDate 参数不能为空") + } + if err := validateSecondProfitDateRange(req.TradeStartDate, req.TradeEndDate); err != nil { + return req, err + } + + if includePagination { + pageText := strings.TrimSpace(c.DefaultQuery("page", "1")) + page, err := strconv.Atoi(pageText) + if err != nil || page <= 0 { + return req, errors.New("page 必须为正整数") + } + req.Page = page + + pageSizeText := strings.TrimSpace(c.DefaultQuery("pageSize", "20")) + pageSize, err := strconv.Atoi(pageSizeText) + if err != nil || pageSize <= 0 { + return req, errors.New("pageSize 必须为正整数") + } + req.PageSize = pageSize + } + + tOpenGainLt101, err := parseOptionalBoolQuery(c, "tOpenGainLt101", "tOpenLessThan101", "t_open_gain_lt_101") + if err != nil { + return req, errors.New("tOpenGainLt101 必须为布尔值") + } + req.TOpenGainLt101 = tOpenGainLt101 + + breakTimeBefore1100, err := parseOptionalBoolQuery(c, "breakTimeBefore1100", "tBreakTimeBefore1100", "break_time_before_1100") + if err != nil { + return req, errors.New("breakTimeBefore1100 必须为布尔值") + } + req.BreakTimeBefore1100 = breakTimeBefore1100 + + return req, nil +} + +func parseOptionalBoolQuery(c *gin.Context, keys ...string) (*bool, error) { + for _, key := range keys { + valueText, exists := c.GetQuery(key) + if !exists { + continue + } + valueText = strings.TrimSpace(valueText) + if valueText == "" { + continue + } + value, err := strconv.ParseBool(valueText) + if err != nil { + return nil, err + } + return &value, nil + } + return nil, nil +} + func (h *LimitBoardHandler) GetOpenThresholdGroups(c *gin.Context) { var req model.LimitBoardRequest if err := c.ShouldBindQuery(&req); err != nil || req.TradeDate == "" { diff --git a/internal/model/limit_board.go b/internal/model/limit_board.go index e487bbf..e6b222c 100644 --- a/internal/model/limit_board.go +++ b/internal/model/limit_board.go @@ -4,6 +4,15 @@ type LimitBoardRequest struct { TradeDate string `form:"trade_date" json:"trade_date"` } +type LimitBreakNextPerformanceRequest struct { + TradeStartDate string `form:"tradeStartDate" json:"trade_start_date"` + TradeEndDate string `form:"tradeEndDate" json:"trade_end_date"` + Page int `form:"page" json:"page"` + PageSize int `form:"pageSize" json:"page_size"` + TOpenGainLt101 *bool `json:"t_open_gain_lt_101,omitempty"` + BreakTimeBefore1100 *bool `json:"break_time_before_1100,omitempty"` +} + type LimitBoardStock struct { Symbol string `json:"symbol"` TsCode string `json:"ts_code"` @@ -92,6 +101,33 @@ type LimitOpenThresholdStock struct { Vol *int64 `json:"vol"` } +type LimitBreakNextPerformanceStock struct { + TradeDate string `json:"trade_date"` + Symbol string `json:"symbol"` + TsCode string `json:"ts_code"` + Name string `json:"name"` + Market string `json:"market"` + Industry string `json:"industry"` + TOpen *float64 `json:"t_open"` + TOpenPctChg *float64 `json:"t_open_pct_chg"` + TOpenGainLt101 bool `json:"t_open_gain_lt_101"` + THigh *float64 `json:"t_high"` + TClose *float64 `json:"t_close"` + TPctChg *float64 `json:"t_pct_chg"` + FirstTime string `json:"first_time"` + BreakTimeBefore1100 bool `json:"break_time_before_1100"` + HasTushareBreakData bool `json:"has_tushare_break_data"` + NextOpen *float64 `json:"next_open"` + NextHigh *float64 `json:"next_high"` + NextLow *float64 `json:"next_low"` + NextClose *float64 `json:"next_close"` + NextOpenPctChg *float64 `json:"next_open_pct_chg"` + NextIntradayProfitable *bool `json:"next_intraday_profitable"` + NextHighProfitPct *float64 `json:"next_high_profit_pct"` + NextLowLossPct *float64 `json:"next_low_loss_pct"` + NextClosePctChg *float64 `json:"next_close_pct_chg"` +} + type LimitOpenCompareGroup struct { Key string `json:"key"` Label string `json:"label"` @@ -121,6 +157,11 @@ type LimitOpenThresholdSummary struct { ThresholdRatio float64 `json:"threshold_ratio"` } +type LimitBreakNextPerformanceSummary struct { + BreakCount int `json:"break_count"` + NextTradeDataCount int `json:"next_trade_data_count"` +} + type LimitOpenCompareResponse struct { TradeDate string `json:"trade_date"` PreviousTradeDate string `json:"previous_trade_date"` @@ -129,6 +170,17 @@ type LimitOpenCompareResponse struct { Groups []LimitOpenCompareGroup `json:"groups"` } +type LimitBreakNextPerformanceResponse struct { + TradeStartDate string `json:"trade_start_date"` + TradeEndDate string `json:"trade_end_date"` + Page int `json:"page"` + PageSize int `json:"page_size"` + Total int `json:"total"` + TotalPages int `json:"total_pages"` + Summary LimitBreakNextPerformanceSummary `json:"summary"` + List []LimitBreakNextPerformanceStock `json:"list"` +} + type LimitOpenThresholdResponse struct { TradeDate string `json:"trade_date"` PreviousTradeDate string `json:"previous_trade_date"` diff --git a/internal/router/router.go b/internal/router/router.go index bd533a8..543d1d5 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -34,11 +34,13 @@ func NewRouter(commonApi *api.CommonHandler, stockApi *api.StockHandler, limitBo limitBoard.GET("/open-threshold", limitBoardApi.GetOpenThresholdGroups) limitBoard.GET("/open-threshold-groups", limitBoardApi.GetOpenThresholdGroups) limitBoard.GET("/second-board-profit", limitBoardApi.ListSecondBoardProfit) + limitBoard.GET("/break-next-performance", limitBoardApi.GetBreakNextPerformance) } download := v1.Group("/download") { download.GET("/limit-board/second-board-profit-csv", limitBoardApi.DownloadSecondBoardProfitCSV) + download.GET("/limit-board/break-next-performance-csv", limitBoardApi.DownloadBreakNextPerformanceCSV) } } return r diff --git a/internal/service/limit_board_break_next_performance_service.go b/internal/service/limit_board_break_next_performance_service.go new file mode 100644 index 0000000..dd4b71c --- /dev/null +++ b/internal/service/limit_board_break_next_performance_service.go @@ -0,0 +1,241 @@ +package service + +import ( + "math" + "strings" + + "level_go/internal/model" +) + +type limitBreakNextPerformanceRow 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"` + Symbol string `gorm:"column:symbol"` + TsCode string `gorm:"column:ts_code"` + Name string `gorm:"column:name"` + Market string `gorm:"column:market"` + Industry string `gorm:"column:industry"` + PreviousClose *float64 `gorm:"column:previous_close"` + TPreClose *float64 `gorm:"column:t_pre_close"` + TOpen *float64 `gorm:"column:t_open"` + THigh *float64 `gorm:"column:t_high"` + TClose *float64 `gorm:"column:t_close"` + TPctChg *float64 `gorm:"column:t_pct_chg"` + FirstTime *string `gorm:"column:first_time"` + HasTushareBreakData bool `gorm:"column:has_tushare_break_data"` + NextOpen *float64 `gorm:"column:next_open"` + NextHigh *float64 `gorm:"column:next_high"` + NextLow *float64 `gorm:"column:next_low"` + NextClose *float64 `gorm:"column:next_close"` +} + +func (s *LimitBoardService) GetLimitBreakNextPerformance(req model.LimitBreakNextPerformanceRequest) (model.LimitBreakNextPerformanceResponse, error) { + resp := model.LimitBreakNextPerformanceResponse{ + TradeStartDate: req.TradeStartDate, + TradeEndDate: req.TradeEndDate, + Page: req.Page, + PageSize: req.PageSize, + List: []model.LimitBreakNextPerformanceStock{}, + } + + items, nextTradeDataCount, err := s.ListLimitBreakNextPerformanceStocks(req) + if err != nil { + return resp, err + } + + resp.Total = len(items) + if resp.Total > 0 { + resp.TotalPages = (resp.Total + req.PageSize - 1) / req.PageSize + } + resp.Summary = model.LimitBreakNextPerformanceSummary{ + BreakCount: resp.Total, + NextTradeDataCount: nextTradeDataCount, + } + + start := (req.Page - 1) * req.PageSize + if start > resp.Total { + start = resp.Total + } + end := start + req.PageSize + if end > resp.Total { + end = resp.Total + } + resp.List = items[start:end] + + return resp, nil +} + +func (s *LimitBoardService) ListLimitBreakNextPerformanceStocks(req model.LimitBreakNextPerformanceRequest) ([]model.LimitBreakNextPerformanceStock, int, error) { + rows, err := s.loadLimitBreakNextPerformanceRows(req.TradeStartDate, req.TradeEndDate) + if err != nil { + return nil, 0, err + } + + items := make([]model.LimitBreakNextPerformanceStock, 0, len(rows)) + nextTradeDataCount := 0 + for _, row := range rows { + item := buildLimitBreakNextPerformanceStock(row) + if req.TOpenGainLt101 != nil && item.TOpenGainLt101 != *req.TOpenGainLt101 { + continue + } + if req.BreakTimeBefore1100 != nil && item.BreakTimeBefore1100 != *req.BreakTimeBefore1100 { + continue + } + if item.NextOpen != nil || item.NextHigh != nil || item.NextLow != nil || item.NextClose != nil { + nextTradeDataCount++ + } + items = append(items, item) + } + return items, nextTradeDataCount, nil +} + +func (s *LimitBoardService) loadLimitBreakNextPerformanceRows(startDate, endDate string) ([]limitBreakNextPerformanceRow, error) { + query := ` +WITH open_calendar AS ( + SELECT + cal_date AS trade_date, + LAG(cal_date, 1) 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, 1) OVER (ORDER BY cal_date) AS next_trade_date + FROM trade_calendar + WHERE exchange = 'SSE' AND is_open = 1 +), +candidate AS ( + 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.symbol, + l.ts_code, + l.name, + COALESCE(NULLIF(l.market, ''), '') AS market, + COALESCE(NULLIF(l.tushare_industry, ''), NULLIF(sb.industry, ''), '') AS industry, + COALESCE(d.pre_close, l.calc_pre_close, pd.close, pl.calc_close, pl.tushare_close) AS previous_close, + COALESCE(d.pre_close, l.calc_pre_close, pd.close, pl.calc_close, pl.tushare_close) AS t_pre_close, + d.open AS t_open, + d.high AS t_high, + COALESCE(l.calc_close, l.tushare_close, d.close) AS t_close, + COALESCE(l.tushare_pct_chg, d.pct_chg) AS t_pct_chg, + CASE + WHEN l.tushare_limit_type = 'Z' + THEN NULLIF(NULLIF(TRIM(l.tushare_first_time), ''), 'None') + ELSE NULL + END AS first_time, + (l.tushare_limit_type = 'Z') AS has_tushare_break_data, + 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 = 2 + 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 = c.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 + LEFT JOIN stock_basic sb + ON sb.ts_code = l.ts_code COLLATE utf8mb4_unicode_ci + 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 +) +SELECT * +FROM candidate +WHERE previous_close IS NOT NULL + AND t_close IS NOT NULL +ORDER BY trade_date ASC, ts_code ASC` + + var rows []limitBreakNextPerformanceRow + err := s.DB.Raw(query, startDate, endDate).Scan(&rows).Error + return rows, err +} + +func buildLimitBreakNextPerformanceStock(row limitBreakNextPerformanceRow) model.LimitBreakNextPerformanceStock { + item := model.LimitBreakNextPerformanceStock{ + TradeDate: row.TradeDate, + Symbol: row.Symbol, + TsCode: row.TsCode, + Name: row.Name, + Market: row.Market, + Industry: row.Industry, + TOpen: row.TOpen, + THigh: row.THigh, + TClose: row.TClose, + TPctChg: row.TPctChg, + HasTushareBreakData: row.HasTushareBreakData, + NextOpen: row.NextOpen, + NextHigh: row.NextHigh, + NextLow: row.NextLow, + NextClose: row.NextClose, + } + if row.FirstTime != nil { + item.FirstTime = formatLimitTime(*row.FirstTime) + } + item.BreakTimeBefore1100 = isLimitBreakTimeBeforeEleven(item.FirstTime) + if row.TPreClose != nil && *row.TPreClose > 0 { + item.TOpenPctChg = pctChangePtr(row.TOpen, *row.TPreClose) + if row.TOpen != nil { + item.TOpenGainLt101 = *row.TOpen < *row.TPreClose*1.01 + } + } + if row.TClose != nil && *row.TClose > 0 { + item.NextOpenPctChg = pctChangePtr(row.NextOpen, *row.TClose) + item.NextHighProfitPct = pctChangePtr(row.NextHigh, *row.TClose) + item.NextLowLossPct = pctChangePtr(row.NextLow, *row.TClose) + item.NextClosePctChg = pctChangePtr(row.NextClose, *row.TClose) + if row.NextHigh != nil && row.THigh != nil { + nextIntradayProfitable := *row.NextHigh >= *row.THigh + item.NextIntradayProfitable = &nextIntradayProfitable + } + } + return item +} + +func pctChangePtr(value *float64, base float64) *float64 { + if value == nil || base == 0 { + return nil + } + result := roundLimitBreakPct((*value - base) / base * 100) + return &result +} + +func roundLimitBreakPct(value float64) float64 { + return math.Round(value*100) / 100 +} + +func formatLimitTime(value string) string { + timeText := strings.TrimSpace(value) + if timeText == "" || strings.EqualFold(timeText, "none") || strings.EqualFold(timeText, "null") { + return "" + } + if 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 isLimitBreakTimeBeforeEleven(limitTime string) bool { + if strings.TrimSpace(limitTime) == "" { + return true + } + return isBeforeEleven(&limitTime) +} diff --git a/internal/service/limit_board_break_next_performance_service_test.go b/internal/service/limit_board_break_next_performance_service_test.go new file mode 100644 index 0000000..75ed7ed --- /dev/null +++ b/internal/service/limit_board_break_next_performance_service_test.go @@ -0,0 +1,61 @@ +package service + +import "testing" + +func TestBuildLimitBreakNextPerformanceStockIntradayProfitableUsesTHigh(t *testing.T) { + tClose := 9.50 + tHigh := 10.00 + + tests := []struct { + name string + nextHigh *float64 + want *bool + }{ + { + name: "next high below T high cannot cover T high buy loss", + nextHigh: float64Ptr(9.99), + want: boolPtr(false), + }, + { + name: "next high equal T high covers T high buy loss", + nextHigh: float64Ptr(10.00), + want: boolPtr(true), + }, + { + name: "missing next high returns nil", + nextHigh: nil, + want: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + item := buildLimitBreakNextPerformanceStock(limitBreakNextPerformanceRow{ + TClose: &tClose, + THigh: &tHigh, + NextHigh: tt.nextHigh, + }) + + if tt.want == nil { + if item.NextIntradayProfitable != nil { + t.Fatalf("NextIntradayProfitable = %v, want nil", *item.NextIntradayProfitable) + } + return + } + if item.NextIntradayProfitable == nil { + t.Fatalf("NextIntradayProfitable = nil, want %v", *tt.want) + } + if *item.NextIntradayProfitable != *tt.want { + t.Fatalf("NextIntradayProfitable = %v, want %v", *item.NextIntradayProfitable, *tt.want) + } + }) + } +} + +func float64Ptr(value float64) *float64 { + return &value +} + +func boolPtr(value bool) *bool { + return &value +} diff --git a/internal/service/limit_board_open_compare_service.go b/internal/service/limit_board_open_compare_service.go new file mode 100644 index 0000000..b7a6698 --- /dev/null +++ b/internal/service/limit_board_open_compare_service.go @@ -0,0 +1,208 @@ +package service + +import ( + "fmt" + "sort" + "strconv" + "strings" + + "level_go/internal/model" +) + +func (s *LimitBoardService) GetLimitOpenCompareGroups(tradeDate string) (model.LimitOpenCompareResponse, error) { + resp := model.LimitOpenCompareResponse{TradeDate: tradeDate} + + tradeDates, dateIndex, err := s.loadTradeCalendar(tradeDate) + if err != nil { + return resp, err + } + if dateIndex == -1 { + resp.Groups = buildLimitOpenCompareGroups(nil, nil) + return resp, nil + } + + previousTradeDate := "" + if dateIndex > 0 { + previousTradeDate = tradeDates[dateIndex-1] + } + resp.PreviousTradeDate = previousTradeDate + + previousTwoTradeDate := "" + if dateIndex > 1 { + previousTwoTradeDate = tradeDates[dateIndex-2] + } + resp.PreviousTwoTradeDate = previousTwoTradeDate + + stocks, err := s.loadLimitOpenCompareStocks(tradeDate, previousTradeDate, previousTwoTradeDate) + if err != nil { + return resp, err + } + + openLowerStocks := make([]model.LimitOpenCompareStock, 0) + otherStocks := make([]model.LimitOpenCompareStock, 0) + firstToSecondBoardCount := 0 + firstSecondBeforeElevenCount := 0 + for _, stock := range stocks { + if stock.IsFirstToSecondBoard { + stock.HighlightLabel = "首板进二板" + firstToSecondBoardCount++ + } + isOpenLower := previousTradeDate != "" && stock.HasOpen && stock.HasPreviousClose && stock.Open < stock.PreviousClose + if isOpenLower && stock.IsFirstToSecondBoard && isBeforeEleven(stock.AkFirstLimitTime) { + stock.IsFirstSecondBeforeEleven = true + stock.HighlightLabel = "首二一一" + firstSecondBeforeElevenCount++ + } + if isOpenLower { + openLowerStocks = append(openLowerStocks, stock) + continue + } + otherStocks = append(otherStocks, stock) + } + + resp.Summary = model.LimitOpenCompareSummary{ + LimitCount: len(stocks), + OpenLowerPreviousCloseCount: len(openLowerStocks), + OthersCount: len(otherStocks), + FirstToSecondBoardCount: firstToSecondBoardCount, + FirstSecondBeforeElevenCount: firstSecondBeforeElevenCount, + } + resp.Groups = buildLimitOpenCompareGroups(openLowerStocks, otherStocks) + + return resp, nil +} + +func isBeforeEleven(limitTime *string) bool { + if limitTime == nil || *limitTime == "" { + return false + } + + timeText := strings.TrimSpace(*limitTime) + hour := 0 + minute := 0 + second := 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 (s *LimitBoardService) loadLimitOpenCompareStocks(tradeDate, previousTradeDate, previousTwoTradeDate string) ([]model.LimitOpenCompareStock, error) { + var stocks []model.LimitOpenCompareStock + query := `SELECT + l.symbol, + l.ts_code, + l.name, + COALESCE(l.market, '') AS market, + DATE_FORMAT(l.trade_date, '%Y-%m-%d') AS trade_date, + d.open IS NOT NULL AS has_open, + COALESCE(d.open, 0) AS open, + COALESCE(l.calc_close, l.tushare_close, d.close, 0) AS close, + ? AS previous_trade_date, + ? AS previous_two_trade_date, + pd.close IS NOT NULL AS has_previous_close, + COALESCE(pd.close, 0) AS previous_close, + (? <> '' AND NOT EXISTS ( + SELECT 1 + FROM stock_limit_combined_data ppl + WHERE ppl.ts_code = l.ts_code AND ppl.trade_date = ? AND ppl.final_status = 1 + )) AS is_first_to_second_board, + COALESCE(l.calc_limit_price, 0) AS limit_price, + COALESCE(l.tushare_pct_chg, d.pct_chg, 0) AS pct_chg, + l.tushare_amount AS ak_amount, + l.tushare_float_mv AS ak_float_market_value, + l.tushare_total_mv AS ak_total_market_value, + l.tushare_turnover_ratio AS ak_turnover_rate, + l.tushare_fd_amount AS ak_seal_amount, + l.tushare_first_time AS ak_first_limit_time, + l.tushare_last_time AS ak_last_limit_time, + l.tushare_open_times AS ak_break_limit_count, + l.tushare_up_stat AS ak_limit_up_stat, + l.tushare_limit_times AS ak_limit_up_days, + l.tushare_industry AS ak_industry, + l.calc_vol AS vol + FROM stock_limit_combined_data l + 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 = ? + INNER JOIN stock_limit_combined_data pl + ON pl.ts_code = l.ts_code AND pl.trade_date = ? AND pl.final_status = 1 + WHERE l.trade_date = ? AND l.final_status = 1 + ORDER BY d.pct_chg DESC, l.ts_code ASC` + + if previousTradeDate == "" { + return stocks, nil + } + + err := s.DB.Raw(query, previousTradeDate, previousTwoTradeDate, previousTwoTradeDate, previousTwoTradeDate, previousTradeDate, previousTradeDate, tradeDate).Scan(&stocks).Error + return stocks, err +} + +func buildLimitOpenCompareGroups(openLowerStocks, otherStocks []model.LimitOpenCompareStock) []model.LimitOpenCompareGroup { + if openLowerStocks == nil { + openLowerStocks = []model.LimitOpenCompareStock{} + } + if otherStocks == nil { + otherStocks = []model.LimitOpenCompareStock{} + } + + sortLimitOpenCompareStocks(openLowerStocks) + sortLimitOpenCompareStocks(otherStocks) + + return []model.LimitOpenCompareGroup{ + { + Key: "open_lower_previous_close", + Label: "开盘低于前收", + Count: len(openLowerStocks), + Stocks: openLowerStocks, + }, + { + Key: "others", + Label: "其他涨停股", + Count: len(otherStocks), + Stocks: otherStocks, + }, + } +} + +func sortLimitOpenCompareStocks(stocks []model.LimitOpenCompareStock) { + sort.Slice(stocks, func(i, j int) bool { + if stocks[i].PctChg == stocks[j].PctChg { + return stocks[i].TsCode < stocks[j].TsCode + } + return stocks[i].PctChg > stocks[j].PctChg + }) +} diff --git a/internal/service/limit_board_open_threshold_service.go b/internal/service/limit_board_open_threshold_service.go new file mode 100644 index 0000000..dfc455b --- /dev/null +++ b/internal/service/limit_board_open_threshold_service.go @@ -0,0 +1,142 @@ +package service + +import ( + "sort" + + "level_go/internal/model" +) + +func (s *LimitBoardService) GetLimitOpenThresholdGroups(tradeDate string) (model.LimitOpenThresholdResponse, error) { + resp := model.LimitOpenThresholdResponse{TradeDate: tradeDate} + const thresholdRatio = 1.01 + + tradeDates, dateIndex, err := s.loadTradeCalendar(tradeDate) + if err != nil { + return resp, err + } + if dateIndex == -1 { + resp.Summary.ThresholdRatio = thresholdRatio + resp.Groups = buildLimitOpenThresholdGroups(nil, nil) + return resp, nil + } + + previousTradeDate := "" + if dateIndex > 0 { + previousTradeDate = tradeDates[dateIndex-1] + } + resp.PreviousTradeDate = previousTradeDate + + stocks, err := s.loadLimitOpenThresholdStocks(tradeDate, previousTradeDate) + if err != nil { + return resp, err + } + + withinThresholdStocks := make([]model.LimitOpenThresholdStock, 0) + atOrAboveThresholdStocks := make([]model.LimitOpenThresholdStock, 0) + for _, stock := range stocks { + if stock.HasPreviousClose && stock.PreviousClose > 0 { + stock.OpenThresholdPrice = stock.PreviousClose * thresholdRatio + } + if stock.HasOpen && stock.HasPreviousClose && stock.PreviousClose > 0 { + stock.OpenPreviousCloseRatio = stock.Open / stock.PreviousClose + } + + if stock.HasOpen && stock.HasPreviousClose && stock.Open < stock.OpenThresholdPrice { + withinThresholdStocks = append(withinThresholdStocks, stock) + continue + } + atOrAboveThresholdStocks = append(atOrAboveThresholdStocks, stock) + } + + resp.Summary = model.LimitOpenThresholdSummary{ + LimitCount: len(stocks), + OpenWithinThresholdCount: len(withinThresholdStocks), + OpenAtOrAboveThresholdCount: len(atOrAboveThresholdStocks), + ThresholdRatio: thresholdRatio, + } + resp.Groups = buildLimitOpenThresholdGroups(withinThresholdStocks, atOrAboveThresholdStocks) + + return resp, nil +} + +func (s *LimitBoardService) loadLimitOpenThresholdStocks(tradeDate, previousTradeDate string) ([]model.LimitOpenThresholdStock, error) { + var stocks []model.LimitOpenThresholdStock + query := `SELECT + l.symbol, + l.ts_code, + l.name, + COALESCE(l.market, '') AS market, + DATE_FORMAT(l.trade_date, '%Y-%m-%d') AS trade_date, + d.open IS NOT NULL AS has_open, + COALESCE(d.open, 0) AS open, + COALESCE(l.calc_close, l.tushare_close, d.close, 0) AS close, + ? AS previous_trade_date, + (pl.calc_close IS NOT NULL OR pl.tushare_close IS NOT NULL OR pd.close IS NOT NULL) AS has_previous_close, + COALESCE(pl.calc_close, pl.tushare_close, pd.close, 0) AS previous_close, + COALESCE(l.calc_limit_price, 0) AS limit_price, + COALESCE(l.tushare_pct_chg, d.pct_chg, 0) AS pct_chg, + l.tushare_amount AS ak_amount, + l.tushare_float_mv AS ak_float_market_value, + l.tushare_total_mv AS ak_total_market_value, + l.tushare_turnover_ratio AS ak_turnover_rate, + l.tushare_fd_amount AS ak_seal_amount, + l.tushare_first_time AS ak_first_limit_time, + l.tushare_last_time AS ak_last_limit_time, + l.tushare_open_times AS ak_break_limit_count, + l.tushare_up_stat AS ak_limit_up_stat, + l.tushare_limit_times AS ak_limit_up_days, + l.tushare_industry AS ak_industry, + l.calc_vol AS vol + FROM stock_limit_combined_data l + 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 = ? + INNER JOIN stock_limit_combined_data pl + ON pl.ts_code = l.ts_code AND pl.trade_date = ? AND pl.final_status = 1 + WHERE l.trade_date = ? AND l.final_status = 1 + ORDER BY d.pct_chg DESC, l.ts_code ASC` + + if previousTradeDate == "" { + return stocks, nil + } + + err := s.DB.Raw(query, previousTradeDate, previousTradeDate, previousTradeDate, tradeDate).Scan(&stocks).Error + return stocks, err +} + +func buildLimitOpenThresholdGroups(withinThresholdStocks, atOrAboveThresholdStocks []model.LimitOpenThresholdStock) []model.LimitOpenThresholdGroup { + if withinThresholdStocks == nil { + withinThresholdStocks = []model.LimitOpenThresholdStock{} + } + if atOrAboveThresholdStocks == nil { + atOrAboveThresholdStocks = []model.LimitOpenThresholdStock{} + } + + sortLimitOpenThresholdStocks(withinThresholdStocks) + sortLimitOpenThresholdStocks(atOrAboveThresholdStocks) + + return []model.LimitOpenThresholdGroup{ + { + Key: "open_within_previous_close_101", + Label: "开盘价低于前收101%", + Count: len(withinThresholdStocks), + Stocks: withinThresholdStocks, + }, + { + Key: "open_at_or_above_previous_close_101", + Label: "开盘价大于等于前收101%", + Count: len(atOrAboveThresholdStocks), + Stocks: atOrAboveThresholdStocks, + }, + } +} + +func sortLimitOpenThresholdStocks(stocks []model.LimitOpenThresholdStock) { + sort.Slice(stocks, func(i, j int) bool { + if stocks[i].PctChg == stocks[j].PctChg { + return stocks[i].TsCode < stocks[j].TsCode + } + return stocks[i].PctChg > stocks[j].PctChg + }) +} diff --git a/internal/service/limit_board_overview_service.go b/internal/service/limit_board_overview_service.go new file mode 100644 index 0000000..22fa905 --- /dev/null +++ b/internal/service/limit_board_overview_service.go @@ -0,0 +1,231 @@ +package service + +import ( + "sort" + + "level_go/internal/model" +) + +type limitBoardHistoryRow struct { + TsCode string `gorm:"column:ts_code"` + TradeDate string `gorm:"column:trade_date"` +} + +func (s *LimitBoardService) GetLimitBoardOverview(tradeDate string) (model.LimitBoardResponse, error) { + resp := model.LimitBoardResponse{TradeDate: tradeDate} + + tradeDates, dateIndex, err := s.loadTradeCalendar(tradeDate) + if err != nil { + return resp, err + } + if dateIndex == -1 { + return resp, nil + } + + resp.RecentMaxBoardStats, err = s.buildRecentMaxBoardStats(tradeDates, dateIndex) + if err != nil { + return resp, err + } + + limitStocks, err := s.loadCurrentLimitStocks(tradeDate) + if err != nil { + return resp, err + } + if len(limitStocks) == 0 { + return resp, nil + } + + historyMap, err := s.loadLimitHistory(limitStocks, tradeDate) + if err != nil { + return resp, err + } + + groupMap, maxBoardCount := buildLimitBoardGroups(limitStocks, tradeDates, dateIndex, historyMap) + resp.Groups = sortLimitBoardGroups(groupMap) + resp.Summary = model.LimitBoardSummary{ + MaxBoardCount: maxBoardCount, + LimitCount: len(limitStocks), + GroupCount: len(resp.Groups), + } + + return resp, nil +} + +func (s *LimitBoardService) buildRecentMaxBoardStats(tradeDates []string, dateIndex int) ([]model.LimitBoardDailyMax, error) { + startIndex := 0 + if dateIndex >= 29 { + startIndex = dateIndex - 29 + } + windowDates := tradeDates[startIndex : dateIndex+1] + if len(windowDates) == 0 { + return []model.LimitBoardDailyMax{}, nil + } + + var history []limitBoardHistoryRow + if err := s.DB.Table(stockLimitCombinedTable). + Select("ts_code, DATE_FORMAT(trade_date, '%Y-%m-%d') AS trade_date"). + Where("trade_date BETWEEN ? AND ? AND final_status = ?", windowDates[0], windowDates[len(windowDates)-1], 1). + Order("ts_code ASC, trade_date ASC"). + Scan(&history).Error; err != nil { + return nil, err + } + + dateByCode := make(map[string]map[string]struct{}) + for _, item := range history { + if _, ok := dateByCode[item.TsCode]; !ok { + dateByCode[item.TsCode] = make(map[string]struct{}) + } + dateByCode[item.TsCode][item.TradeDate] = struct{}{} + } + + maxBoardByDate := make(map[string]int, len(windowDates)) + for _, tradeDate := range windowDates { + maxBoardByDate[tradeDate] = 0 + } + + for _, limitDates := range dateByCode { + for idx, tradeDate := range windowDates { + if _, ok := limitDates[tradeDate]; !ok { + continue + } + + boardCount := 0 + for j := idx; j >= 0; j-- { + if _, ok := limitDates[windowDates[j]]; !ok { + break + } + boardCount++ + } + + if boardCount > maxBoardByDate[tradeDate] { + maxBoardByDate[tradeDate] = boardCount + } + } + } + + stats := make([]model.LimitBoardDailyMax, 0, len(windowDates)) + for _, tradeDate := range windowDates { + stats = append(stats, model.LimitBoardDailyMax{ + TradeDate: tradeDate, + MaxBoardCount: maxBoardByDate[tradeDate], + }) + } + + return stats, nil +} + +func (s *LimitBoardService) loadCurrentLimitStocks(tradeDate string) ([]model.LimitBoardStock, error) { + var stocks []model.LimitBoardStock + query := `SELECT + l.symbol, + l.ts_code, + l.name, + COALESCE(l.market, '') AS market, + DATE_FORMAT(l.trade_date, '%Y-%m-%d') AS trade_date, + COALESCE(l.calc_close, l.tushare_close, d.close, 0) AS close, + COALESCE(l.calc_limit_price, 0) AS limit_price, + COALESCE(l.tushare_pct_chg, d.pct_chg, 0) AS pct_chg, + l.tushare_amount AS ak_amount, + l.tushare_float_mv AS ak_float_market_value, + l.tushare_total_mv AS ak_total_market_value, + l.tushare_turnover_ratio AS ak_turnover_rate, + l.tushare_fd_amount AS ak_seal_amount, + l.tushare_first_time AS ak_first_limit_time, + l.tushare_last_time AS ak_last_limit_time, + l.tushare_open_times AS ak_break_limit_count, + l.tushare_up_stat AS ak_limit_up_stat, + l.tushare_limit_times AS ak_limit_up_days, + l.tushare_industry AS ak_industry, + l.calc_vol AS vol + FROM stock_limit_combined_data l + LEFT JOIN stock_daily d + ON l.ts_code = d.ts_code AND d.trade_date = l.trade_date + WHERE l.trade_date = ? AND l.final_status = 1 + ORDER BY d.pct_chg DESC, l.ts_code ASC` + + err := s.DB.Raw(query, tradeDate).Scan(&stocks).Error + return stocks, err +} + +func (s *LimitBoardService) loadLimitHistory(stocks []model.LimitBoardStock, tradeDate string) (map[string]map[string]struct{}, error) { + codes := make([]string, 0, len(stocks)) + for _, stock := range stocks { + codes = append(codes, stock.TsCode) + } + + var history []limitBoardHistoryRow + if err := s.DB.Table(stockLimitCombinedTable). + Select("ts_code, DATE_FORMAT(trade_date, '%Y-%m-%d') AS trade_date"). + Where("ts_code IN ? AND trade_date <= ? AND final_status = ?", codes, tradeDate, 1). + Scan(&history).Error; err != nil { + return nil, err + } + + historyMap := make(map[string]map[string]struct{}, len(codes)) + for _, item := range history { + if _, ok := historyMap[item.TsCode]; !ok { + historyMap[item.TsCode] = make(map[string]struct{}) + } + historyMap[item.TsCode][item.TradeDate] = struct{}{} + } + + return historyMap, nil +} + +func buildLimitBoardGroups(stocks []model.LimitBoardStock, tradeDates []string, dateIndex int, historyMap map[string]map[string]struct{}) (map[int][]model.LimitBoardStock, int) { + groupMap := make(map[int][]model.LimitBoardStock) + maxBoardCount := 0 + + for i := range stocks { + boardCount := calcBoardCount(tradeDates, dateIndex, historyMap[stocks[i].TsCode]) + stocks[i].BoardCount = boardCount + stocks[i].BoardLabel = boardLabel(boardCount) + stocks[i].DisplayStatus = boardLabel(boardCount) + + if boardCount > maxBoardCount { + maxBoardCount = boardCount + } + groupMap[boardCount] = append(groupMap[boardCount], stocks[i]) + } + + return groupMap, maxBoardCount +} + +func calcBoardCount(tradeDates []string, dateIndex int, limitDates map[string]struct{}) int { + boardCount := 0 + for i := dateIndex; i >= 0; i-- { + if _, ok := limitDates[tradeDates[i]]; !ok { + break + } + boardCount++ + } + return boardCount +} + +func sortLimitBoardGroups(groupMap map[int][]model.LimitBoardStock) []model.LimitBoardGroup { + boardCounts := make([]int, 0, len(groupMap)) + for boardCount := range groupMap { + boardCounts = append(boardCounts, boardCount) + } + sort.Sort(sort.Reverse(sort.IntSlice(boardCounts))) + + groups := make([]model.LimitBoardGroup, 0, len(boardCounts)) + for _, boardCount := range boardCounts { + stocks := groupMap[boardCount] + sort.Slice(stocks, func(i, j int) bool { + if stocks[i].PctChg == stocks[j].PctChg { + return stocks[i].TsCode < stocks[j].TsCode + } + return stocks[i].PctChg > stocks[j].PctChg + }) + + groups = append(groups, model.LimitBoardGroup{ + BoardCount: boardCount, + Label: boardLabel(boardCount), + Count: len(stocks), + Stocks: stocks, + }) + } + + return groups +} diff --git a/internal/service/limit_board_service.go b/internal/service/limit_board_service.go index a83e483..0aac900 100644 --- a/internal/service/limit_board_service.go +++ b/internal/service/limit_board_service.go @@ -2,12 +2,8 @@ package service import ( "fmt" - "sort" - "strconv" - "strings" "gorm.io/gorm" - "level_go/internal/model" ) type LimitBoardService struct { @@ -16,11 +12,6 @@ type LimitBoardService struct { const stockLimitCombinedTable = "stock_limit_combined_data" -type limitBoardHistoryRow struct { - TsCode string `gorm:"column:ts_code"` - TradeDate string `gorm:"column:trade_date"` -} - func boardLabel(boardCount int) string { switch boardCount { case 1: @@ -46,272 +37,6 @@ func boardLabel(boardCount int) string { } } -func (s *LimitBoardService) GetLimitBoardOverview(tradeDate string) (model.LimitBoardResponse, error) { - resp := model.LimitBoardResponse{TradeDate: tradeDate} - - tradeDates, dateIndex, err := s.loadTradeCalendar(tradeDate) - if err != nil { - return resp, err - } - if dateIndex == -1 { - return resp, nil - } - - resp.RecentMaxBoardStats, err = s.buildRecentMaxBoardStats(tradeDates, dateIndex) - if err != nil { - return resp, err - } - - limitStocks, err := s.loadCurrentLimitStocks(tradeDate) - if err != nil { - return resp, err - } - if len(limitStocks) == 0 { - return resp, nil - } - - historyMap, err := s.loadLimitHistory(limitStocks, tradeDate) - if err != nil { - return resp, err - } - - groupMap, maxBoardCount := buildLimitBoardGroups(limitStocks, tradeDates, dateIndex, historyMap) - resp.Groups = sortLimitBoardGroups(groupMap) - resp.Summary = model.LimitBoardSummary{ - MaxBoardCount: maxBoardCount, - LimitCount: len(limitStocks), - GroupCount: len(resp.Groups), - } - - return resp, nil -} - -func (s *LimitBoardService) GetLimitOpenCompareGroups(tradeDate string) (model.LimitOpenCompareResponse, error) { - resp := model.LimitOpenCompareResponse{TradeDate: tradeDate} - - tradeDates, dateIndex, err := s.loadTradeCalendar(tradeDate) - if err != nil { - return resp, err - } - if dateIndex == -1 { - resp.Groups = buildLimitOpenCompareGroups(nil, nil) - return resp, nil - } - - previousTradeDate := "" - if dateIndex > 0 { - previousTradeDate = tradeDates[dateIndex-1] - } - resp.PreviousTradeDate = previousTradeDate - - previousTwoTradeDate := "" - if dateIndex > 1 { - previousTwoTradeDate = tradeDates[dateIndex-2] - } - resp.PreviousTwoTradeDate = previousTwoTradeDate - - stocks, err := s.loadLimitOpenCompareStocks(tradeDate, previousTradeDate, previousTwoTradeDate) - if err != nil { - return resp, err - } - - openLowerStocks := make([]model.LimitOpenCompareStock, 0) - otherStocks := make([]model.LimitOpenCompareStock, 0) - firstToSecondBoardCount := 0 - firstSecondBeforeElevenCount := 0 - for _, stock := range stocks { - if stock.IsFirstToSecondBoard { - stock.HighlightLabel = "首板进二板" - firstToSecondBoardCount++ - } - isOpenLower := previousTradeDate != "" && stock.HasOpen && stock.HasPreviousClose && stock.Open < stock.PreviousClose - if isOpenLower && stock.IsFirstToSecondBoard && isBeforeEleven(stock.AkFirstLimitTime) { - stock.IsFirstSecondBeforeEleven = true - stock.HighlightLabel = "首二一一" - firstSecondBeforeElevenCount++ - } - if isOpenLower { - openLowerStocks = append(openLowerStocks, stock) - continue - } - otherStocks = append(otherStocks, stock) - } - - resp.Summary = model.LimitOpenCompareSummary{ - LimitCount: len(stocks), - OpenLowerPreviousCloseCount: len(openLowerStocks), - OthersCount: len(otherStocks), - FirstToSecondBoardCount: firstToSecondBoardCount, - FirstSecondBeforeElevenCount: firstSecondBeforeElevenCount, - } - resp.Groups = buildLimitOpenCompareGroups(openLowerStocks, otherStocks) - - return resp, nil -} - -func (s *LimitBoardService) GetLimitOpenThresholdGroups(tradeDate string) (model.LimitOpenThresholdResponse, error) { - resp := model.LimitOpenThresholdResponse{TradeDate: tradeDate} - const thresholdRatio = 1.01 - - tradeDates, dateIndex, err := s.loadTradeCalendar(tradeDate) - if err != nil { - return resp, err - } - if dateIndex == -1 { - resp.Summary.ThresholdRatio = thresholdRatio - resp.Groups = buildLimitOpenThresholdGroups(nil, nil) - return resp, nil - } - - previousTradeDate := "" - if dateIndex > 0 { - previousTradeDate = tradeDates[dateIndex-1] - } - resp.PreviousTradeDate = previousTradeDate - - stocks, err := s.loadLimitOpenThresholdStocks(tradeDate, previousTradeDate) - if err != nil { - return resp, err - } - - withinThresholdStocks := make([]model.LimitOpenThresholdStock, 0) - atOrAboveThresholdStocks := make([]model.LimitOpenThresholdStock, 0) - for _, stock := range stocks { - if stock.HasPreviousClose && stock.PreviousClose > 0 { - stock.OpenThresholdPrice = stock.PreviousClose * thresholdRatio - } - if stock.HasOpen && stock.HasPreviousClose && stock.PreviousClose > 0 { - stock.OpenPreviousCloseRatio = stock.Open / stock.PreviousClose - } - - if stock.HasOpen && stock.HasPreviousClose && stock.Open < stock.OpenThresholdPrice { - withinThresholdStocks = append(withinThresholdStocks, stock) - continue - } - atOrAboveThresholdStocks = append(atOrAboveThresholdStocks, stock) - } - - resp.Summary = model.LimitOpenThresholdSummary{ - LimitCount: len(stocks), - OpenWithinThresholdCount: len(withinThresholdStocks), - OpenAtOrAboveThresholdCount: len(atOrAboveThresholdStocks), - ThresholdRatio: thresholdRatio, - } - resp.Groups = buildLimitOpenThresholdGroups(withinThresholdStocks, atOrAboveThresholdStocks) - - return resp, nil -} - -func isBeforeEleven(limitTime *string) bool { - if limitTime == nil || *limitTime == "" { - return false - } - - timeText := strings.TrimSpace(*limitTime) - hour := 0 - minute := 0 - second := 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 (s *LimitBoardService) buildRecentMaxBoardStats(tradeDates []string, dateIndex int) ([]model.LimitBoardDailyMax, error) { - startIndex := 0 - if dateIndex >= 29 { - startIndex = dateIndex - 29 - } - windowDates := tradeDates[startIndex : dateIndex+1] - if len(windowDates) == 0 { - return []model.LimitBoardDailyMax{}, nil - } - - var history []limitBoardHistoryRow - if err := s.DB.Table(stockLimitCombinedTable). - Select("ts_code, DATE_FORMAT(trade_date, '%Y-%m-%d') AS trade_date"). - Where("trade_date BETWEEN ? AND ? AND final_status = ?", windowDates[0], windowDates[len(windowDates)-1], 1). - Order("ts_code ASC, trade_date ASC"). - Scan(&history).Error; err != nil { - return nil, err - } - - dateByCode := make(map[string]map[string]struct{}) - for _, item := range history { - if _, ok := dateByCode[item.TsCode]; !ok { - dateByCode[item.TsCode] = make(map[string]struct{}) - } - dateByCode[item.TsCode][item.TradeDate] = struct{}{} - } - - maxBoardByDate := make(map[string]int, len(windowDates)) - for _, tradeDate := range windowDates { - maxBoardByDate[tradeDate] = 0 - } - - for _, limitDates := range dateByCode { - for idx, tradeDate := range windowDates { - if _, ok := limitDates[tradeDate]; !ok { - continue - } - - boardCount := 0 - for j := idx; j >= 0; j-- { - if _, ok := limitDates[windowDates[j]]; !ok { - break - } - boardCount++ - } - - if boardCount > maxBoardByDate[tradeDate] { - maxBoardByDate[tradeDate] = boardCount - } - } - } - - stats := make([]model.LimitBoardDailyMax, 0, len(windowDates)) - for _, tradeDate := range windowDates { - stats = append(stats, model.LimitBoardDailyMax{ - TradeDate: tradeDate, - MaxBoardCount: maxBoardByDate[tradeDate], - }) - } - - return stats, nil -} - func (s *LimitBoardService) loadTradeCalendar(tradeDate string) ([]string, int, error) { var tradeDates []string if err := s.DB.Table("trade_calendar"). @@ -332,289 +57,3 @@ func (s *LimitBoardService) loadTradeCalendar(tradeDate string) ([]string, int, return tradeDates, dateIndex, nil } - -func (s *LimitBoardService) loadCurrentLimitStocks(tradeDate string) ([]model.LimitBoardStock, error) { - var stocks []model.LimitBoardStock - query := `SELECT - l.symbol, - l.ts_code, - l.name, - COALESCE(l.market, '') AS market, - DATE_FORMAT(l.trade_date, '%Y-%m-%d') AS trade_date, - COALESCE(l.calc_close, l.tushare_close, d.close, 0) AS close, - COALESCE(l.calc_limit_price, 0) AS limit_price, - COALESCE(l.tushare_pct_chg, d.pct_chg, 0) AS pct_chg, - l.tushare_amount AS ak_amount, - l.tushare_float_mv AS ak_float_market_value, - l.tushare_total_mv AS ak_total_market_value, - l.tushare_turnover_ratio AS ak_turnover_rate, - l.tushare_fd_amount AS ak_seal_amount, - l.tushare_first_time AS ak_first_limit_time, - l.tushare_last_time AS ak_last_limit_time, - l.tushare_open_times AS ak_break_limit_count, - l.tushare_up_stat AS ak_limit_up_stat, - l.tushare_limit_times AS ak_limit_up_days, - l.tushare_industry AS ak_industry, - l.calc_vol AS vol - FROM stock_limit_combined_data l - LEFT JOIN stock_daily d - ON l.ts_code = d.ts_code AND d.trade_date = l.trade_date - WHERE l.trade_date = ? AND l.final_status = 1 - ORDER BY d.pct_chg DESC, l.ts_code ASC` - - err := s.DB.Raw(query, tradeDate).Scan(&stocks).Error - return stocks, err -} - -func (s *LimitBoardService) loadLimitOpenCompareStocks(tradeDate, previousTradeDate, previousTwoTradeDate string) ([]model.LimitOpenCompareStock, error) { - var stocks []model.LimitOpenCompareStock - query := `SELECT - l.symbol, - l.ts_code, - l.name, - COALESCE(l.market, '') AS market, - DATE_FORMAT(l.trade_date, '%Y-%m-%d') AS trade_date, - d.open IS NOT NULL AS has_open, - COALESCE(d.open, 0) AS open, - COALESCE(l.calc_close, l.tushare_close, d.close, 0) AS close, - ? AS previous_trade_date, - ? AS previous_two_trade_date, - pd.close IS NOT NULL AS has_previous_close, - COALESCE(pd.close, 0) AS previous_close, - (? <> '' AND NOT EXISTS ( - SELECT 1 - FROM stock_limit_combined_data ppl - WHERE ppl.ts_code = l.ts_code AND ppl.trade_date = ? AND ppl.final_status = 1 - )) AS is_first_to_second_board, - COALESCE(l.calc_limit_price, 0) AS limit_price, - COALESCE(l.tushare_pct_chg, d.pct_chg, 0) AS pct_chg, - l.tushare_amount AS ak_amount, - l.tushare_float_mv AS ak_float_market_value, - l.tushare_total_mv AS ak_total_market_value, - l.tushare_turnover_ratio AS ak_turnover_rate, - l.tushare_fd_amount AS ak_seal_amount, - l.tushare_first_time AS ak_first_limit_time, - l.tushare_last_time AS ak_last_limit_time, - l.tushare_open_times AS ak_break_limit_count, - l.tushare_up_stat AS ak_limit_up_stat, - l.tushare_limit_times AS ak_limit_up_days, - l.tushare_industry AS ak_industry, - l.calc_vol AS vol - FROM stock_limit_combined_data l - 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 = ? - INNER JOIN stock_limit_combined_data pl - ON pl.ts_code = l.ts_code AND pl.trade_date = ? AND pl.final_status = 1 - WHERE l.trade_date = ? AND l.final_status = 1 - ORDER BY d.pct_chg DESC, l.ts_code ASC` - - if previousTradeDate == "" { - return stocks, nil - } - - err := s.DB.Raw(query, previousTradeDate, previousTwoTradeDate, previousTwoTradeDate, previousTwoTradeDate, previousTradeDate, previousTradeDate, tradeDate).Scan(&stocks).Error - return stocks, err -} - -func (s *LimitBoardService) loadLimitOpenThresholdStocks(tradeDate, previousTradeDate string) ([]model.LimitOpenThresholdStock, error) { - var stocks []model.LimitOpenThresholdStock - query := `SELECT - l.symbol, - l.ts_code, - l.name, - COALESCE(l.market, '') AS market, - DATE_FORMAT(l.trade_date, '%Y-%m-%d') AS trade_date, - d.open IS NOT NULL AS has_open, - COALESCE(d.open, 0) AS open, - COALESCE(l.calc_close, l.tushare_close, d.close, 0) AS close, - ? AS previous_trade_date, - (pl.calc_close IS NOT NULL OR pl.tushare_close IS NOT NULL OR pd.close IS NOT NULL) AS has_previous_close, - COALESCE(pl.calc_close, pl.tushare_close, pd.close, 0) AS previous_close, - COALESCE(l.calc_limit_price, 0) AS limit_price, - COALESCE(l.tushare_pct_chg, d.pct_chg, 0) AS pct_chg, - l.tushare_amount AS ak_amount, - l.tushare_float_mv AS ak_float_market_value, - l.tushare_total_mv AS ak_total_market_value, - l.tushare_turnover_ratio AS ak_turnover_rate, - l.tushare_fd_amount AS ak_seal_amount, - l.tushare_first_time AS ak_first_limit_time, - l.tushare_last_time AS ak_last_limit_time, - l.tushare_open_times AS ak_break_limit_count, - l.tushare_up_stat AS ak_limit_up_stat, - l.tushare_limit_times AS ak_limit_up_days, - l.tushare_industry AS ak_industry, - l.calc_vol AS vol - FROM stock_limit_combined_data l - 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 = ? - INNER JOIN stock_limit_combined_data pl - ON pl.ts_code = l.ts_code AND pl.trade_date = ? AND pl.final_status = 1 - WHERE l.trade_date = ? AND l.final_status = 1 - ORDER BY d.pct_chg DESC, l.ts_code ASC` - - if previousTradeDate == "" { - return stocks, nil - } - - err := s.DB.Raw(query, previousTradeDate, previousTradeDate, previousTradeDate, tradeDate).Scan(&stocks).Error - return stocks, err -} - -func (s *LimitBoardService) loadLimitHistory(stocks []model.LimitBoardStock, tradeDate string) (map[string]map[string]struct{}, error) { - codes := make([]string, 0, len(stocks)) - for _, stock := range stocks { - codes = append(codes, stock.TsCode) - } - - var history []limitBoardHistoryRow - if err := s.DB.Table(stockLimitCombinedTable). - Select("ts_code, DATE_FORMAT(trade_date, '%Y-%m-%d') AS trade_date"). - Where("ts_code IN ? AND trade_date <= ? AND final_status = ?", codes, tradeDate, 1). - Scan(&history).Error; err != nil { - return nil, err - } - - historyMap := make(map[string]map[string]struct{}, len(codes)) - for _, item := range history { - if _, ok := historyMap[item.TsCode]; !ok { - historyMap[item.TsCode] = make(map[string]struct{}) - } - historyMap[item.TsCode][item.TradeDate] = struct{}{} - } - - return historyMap, nil -} - -func buildLimitBoardGroups(stocks []model.LimitBoardStock, tradeDates []string, dateIndex int, historyMap map[string]map[string]struct{}) (map[int][]model.LimitBoardStock, int) { - groupMap := make(map[int][]model.LimitBoardStock) - maxBoardCount := 0 - - for i := range stocks { - boardCount := calcBoardCount(tradeDates, dateIndex, historyMap[stocks[i].TsCode]) - stocks[i].BoardCount = boardCount - stocks[i].BoardLabel = boardLabel(boardCount) - stocks[i].DisplayStatus = boardLabel(boardCount) - - if boardCount > maxBoardCount { - maxBoardCount = boardCount - } - groupMap[boardCount] = append(groupMap[boardCount], stocks[i]) - } - - return groupMap, maxBoardCount -} - -func calcBoardCount(tradeDates []string, dateIndex int, limitDates map[string]struct{}) int { - boardCount := 0 - for i := dateIndex; i >= 0; i-- { - if _, ok := limitDates[tradeDates[i]]; !ok { - break - } - boardCount++ - } - return boardCount -} - -func sortLimitBoardGroups(groupMap map[int][]model.LimitBoardStock) []model.LimitBoardGroup { - boardCounts := make([]int, 0, len(groupMap)) - for boardCount := range groupMap { - boardCounts = append(boardCounts, boardCount) - } - sort.Sort(sort.Reverse(sort.IntSlice(boardCounts))) - - groups := make([]model.LimitBoardGroup, 0, len(boardCounts)) - for _, boardCount := range boardCounts { - stocks := groupMap[boardCount] - sort.Slice(stocks, func(i, j int) bool { - if stocks[i].PctChg == stocks[j].PctChg { - return stocks[i].TsCode < stocks[j].TsCode - } - return stocks[i].PctChg > stocks[j].PctChg - }) - - groups = append(groups, model.LimitBoardGroup{ - BoardCount: boardCount, - Label: boardLabel(boardCount), - Count: len(stocks), - Stocks: stocks, - }) - } - - return groups -} - -func buildLimitOpenCompareGroups(openLowerStocks, otherStocks []model.LimitOpenCompareStock) []model.LimitOpenCompareGroup { - if openLowerStocks == nil { - openLowerStocks = []model.LimitOpenCompareStock{} - } - if otherStocks == nil { - otherStocks = []model.LimitOpenCompareStock{} - } - - sortLimitOpenCompareStocks(openLowerStocks) - sortLimitOpenCompareStocks(otherStocks) - - return []model.LimitOpenCompareGroup{ - { - Key: "open_lower_previous_close", - Label: "开盘低于前收", - Count: len(openLowerStocks), - Stocks: openLowerStocks, - }, - { - Key: "others", - Label: "其他涨停股", - Count: len(otherStocks), - Stocks: otherStocks, - }, - } -} - -func buildLimitOpenThresholdGroups(withinThresholdStocks, atOrAboveThresholdStocks []model.LimitOpenThresholdStock) []model.LimitOpenThresholdGroup { - if withinThresholdStocks == nil { - withinThresholdStocks = []model.LimitOpenThresholdStock{} - } - if atOrAboveThresholdStocks == nil { - atOrAboveThresholdStocks = []model.LimitOpenThresholdStock{} - } - - sortLimitOpenThresholdStocks(withinThresholdStocks) - sortLimitOpenThresholdStocks(atOrAboveThresholdStocks) - - return []model.LimitOpenThresholdGroup{ - { - Key: "open_within_previous_close_101", - Label: "开盘价低于前收101%", - Count: len(withinThresholdStocks), - Stocks: withinThresholdStocks, - }, - { - Key: "open_at_or_above_previous_close_101", - Label: "开盘价大于等于前收101%", - Count: len(atOrAboveThresholdStocks), - Stocks: atOrAboveThresholdStocks, - }, - } -} - -func sortLimitOpenCompareStocks(stocks []model.LimitOpenCompareStock) { - sort.Slice(stocks, func(i, j int) bool { - if stocks[i].PctChg == stocks[j].PctChg { - return stocks[i].TsCode < stocks[j].TsCode - } - return stocks[i].PctChg > stocks[j].PctChg - }) -} - -func sortLimitOpenThresholdStocks(stocks []model.LimitOpenThresholdStock) { - sort.Slice(stocks, func(i, j int) bool { - if stocks[i].PctChg == stocks[j].PctChg { - return stocks[i].TsCode < stocks[j].TsCode - } - return stocks[i].PctChg > stocks[j].PctChg - }) -}