From d4bb849f7913a877b2c3adb53ad4c350a48411c1 Mon Sep 17 00:00:00 2001 From: mianyang <784310601@qq.com> Date: Tue, 12 May 2026 00:36:25 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E9=A6=96=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/api-overview.md | 3 +- docs/second-cal.md | 147 ++++++++++++ internal/api/limit_board_handler.go | 35 +++ internal/model/limit_board.go | 75 ++++++ internal/router/router.go | 1 + internal/service/second_cal_service.go | 314 +++++++++++++++++++++++++ 6 files changed, 574 insertions(+), 1 deletion(-) create mode 100644 docs/second-cal.md create mode 100644 internal/service/second_cal_service.go diff --git a/docs/api-overview.md b/docs/api-overview.md index 0e5dbdc..f583e42 100644 --- a/docs/api-overview.md +++ b/docs/api-overview.md @@ -4,7 +4,7 @@ - 服务前缀:`/api/v1` - 响应格式:`application/json` -- 当前项目接口总数:10 +- 当前项目接口总数:11 ## 2. 接口列表 @@ -17,6 +17,7 @@ | 涨停低开分组 | `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/second-cal` | 按交易日统计二板数量、封板/炸板数量和 T+1 盘中盈利概率 | [second-cal.md](./second-cal.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) | diff --git a/docs/second-cal.md b/docs/second-cal.md new file mode 100644 index 0000000..aab9e71 --- /dev/null +++ b/docs/second-cal.md @@ -0,0 +1,147 @@ +# 二板日历接口文档 + +## 1. 基本信息 + +- 接口名称:二板日历 +- 请求方法:`GET` +- 请求路径:`/api/v1/limit-board/second-cal` +- 接口用途:按交易日统计每日二板数量、二板封板数量、二板炸板数量,以及二板股票 T+1 盘中盈利概率。 + +## 2. 请求参数 + +| 参数 | 类型 | 必填 | 默认值 | 说明 | +| --- | --- | --- | --- | --- | +| `tradeStartDate` | `string` | 是 | 无 | T 日起始日期,格式 `YYYY-MM-DD` | +| `tradeEndDate` | `string` | 是 | 无 | T 日结束日期,格式 `YYYY-MM-DD` | + +兼容 `start_date`、`end_date` 作为日期参数别名。 + +请求示例: + +```http +GET /api/v1/limit-board/second-cal?tradeStartDate=2026-05-07&tradeEndDate=2026-05-11 +``` + +## 3. 统计规则 + +- 以 `trade_calendar` 中 `exchange = 'SSE'` 且 `is_open = 1` 的记录推导 T-2、T-1、T、T+1 交易日。 +- T-1 涨停样本来自 `limit_list_d`,条件为 `limit = 'U'`。 +- T-2 同一股票不能在 `limit_list_d` 中存在 `limit = 'U'` 的记录。 +- 股票所属板块和行业来自 `stock_basic.market`、`stock_basic.industry`。 +- T 日行情来自 `stock_daily`,当 T 日最高价 `high` 触及按 T-1 收盘价和板块涨跌幅规则计算的涨停价时,计为二板。 +- 涨停价比例按板块计算:北交所 30%,创业板/科创板 20%,其余名称包含 ST 的股票 5%,其他股票 10%。 +- T 日 `close` 与 `high` 一致时,计为二板封板,`status = "limit"`。 +- T 日 `close` 小于 `high` 时,计为二板炸板,`status = "break"`。 +- T+1 盘中盈利统一按 `T+1 high > T high` 判断,概率分母只统计具备 T 日最高价和 T+1 最高价的数据。 + +## 4. 响应字段 + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `trade_start_date` | `string` | 查询起始 T 日 | +| `trade_end_date` | `string` | 查询结束 T 日 | +| `summary` | `object` | 日期范围汇总统计 | +| `list` | `array` | 每个交易日的二板日历统计 | + +### 4.1 summary/list 单日统计字段 + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `second_board_count` | `int` | 二板股票数量 | +| `limit_count` | `int` | 二板封板数量 | +| `break_count` | `int` | 二板炸板数量 | +| `next_trade_data_count` | `int` | 具备 T+1 盈利判断数据的数量 | +| `next_intraday_profit_count` | `int` | T+1 盘中盈利数量 | +| `next_intraday_profit_rate` | `number` | T+1 盘中盈利概率,百分比 | +| `limit_next_data_count` | `int` | 封板股中具备 T+1 盈利判断数据的数量 | +| `limit_next_profit_count` | `int` | 封板股 T+1 盘中盈利数量 | +| `limit_next_profit_rate` | `number` | 封板股 T+1 盘中盈利概率,百分比 | +| `break_next_data_count` | `int` | 炸板股中具备 T+1 盈利判断数据的数量 | +| `break_next_profit_count` | `int` | 炸板股 T+1 盘中盈利数量 | +| `break_next_profit_rate` | `number` | 炸板股 T+1 盘中盈利概率,百分比 | + +### 4.2 list[].stocks 明细字段 + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `trade_date` | `string` | T 日 | +| `previous_trade_date` | `string` | T-1 交易日 | +| `previous_two_trade_date` | `string` | T-2 交易日 | +| `next_trade_date` | `string` | T+1 交易日 | +| `symbol` / `ts_code` / `name` | `string` | 股票标识 | +| `market` / `industry` | `string` | 板块和行业 | +| `previous_close` | `number \| null` | T-1 收盘价 | +| `limit_price` | `number \| null` | T 日涨停价 | +| `t_high` / `t_close` | `number \| null` | T 日最高价和收盘价 | +| `t_pct_chg` | `number \| null` | T 日收盘涨跌幅 | +| `status` | `string` | `limit` 表示封板,`break` 表示炸板 | +| `next_high` / `next_close` | `number \| null` | T+1 最高价和收盘价 | +| `next_intraday_profitable` | `boolean \| null` | T+1 盘中是否盈利 | +| `next_high_profit_pct` | `number \| null` | T+1 最高价相对 T 日最高价的收益率 | + +## 5. 响应示例 + +```json +{ + "trade_start_date": "2026-05-07", + "trade_end_date": "2026-05-11", + "summary": { + "trade_day_count": 3, + "second_board_count": 51, + "limit_count": 41, + "break_count": 10, + "next_trade_data_count": 35, + "next_intraday_profit_count": 34, + "next_intraday_profit_rate": 97.14, + "limit_next_data_count": 29, + "limit_next_profit_count": 28, + "limit_next_profit_rate": 96.55, + "break_next_data_count": 6, + "break_next_profit_count": 6, + "break_next_profit_rate": 100 + }, + "list": [ + { + "trade_date": "2026-05-07", + "previous_trade_date": "2026-05-06", + "previous_two_trade_date": "2026-04-30", + "next_trade_date": "2026-05-08", + "second_board_count": 19, + "limit_count": 16, + "break_count": 3, + "next_trade_data_count": 19, + "next_intraday_profit_count": 18, + "next_intraday_profit_rate": 94.74, + "limit_next_data_count": 16, + "limit_next_profit_count": 15, + "limit_next_profit_rate": 93.75, + "break_next_data_count": 3, + "break_next_profit_count": 3, + "break_next_profit_rate": 100, + "stocks": [ + { + "trade_date": "2026-05-07", + "previous_trade_date": "2026-05-06", + "previous_two_trade_date": "2026-04-30", + "next_trade_date": "2026-05-08", + "symbol": "000001", + "ts_code": "000001.SZ", + "name": "示例股份", + "market": "主板", + "industry": "银行", + "previous_close": 10, + "limit_price": 11, + "t_high": 11, + "t_close": 11, + "t_pct_chg": 10, + "status": "limit", + "next_high": 11.2, + "next_close": 11.1, + "next_intraday_profitable": true, + "next_high_profit_pct": 1.82 + } + ] + } + ] +} +``` diff --git a/internal/api/limit_board_handler.go b/internal/api/limit_board_handler.go index 583d9a8..3a2fb5e 100644 --- a/internal/api/limit_board_handler.go +++ b/internal/api/limit_board_handler.go @@ -72,6 +72,41 @@ func (h *LimitBoardHandler) GetBreakNextPerformance(c *gin.Context) { c.JSON(http.StatusOK, resp) } +// GetSecondCal 返回日期范围内的二板日历统计。 +func (h *LimitBoardHandler) GetSecondCal(c *gin.Context) { + req, err := parseSecondCalRequest(c) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + resp, err := h.Service.GetSecondCal(req) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, resp) +} + +// parseSecondCalRequest 解析二板日历接口的日期范围参数。 +func parseSecondCalRequest(c *gin.Context) (model.SecondCalRequest, error) { + req := model.SecondCalRequest{ + TradeStartDate: strings.TrimSpace(c.DefaultQuery("tradeStartDate", c.Query("start_date"))), + TradeEndDate: strings.TrimSpace(c.DefaultQuery("tradeEndDate", c.Query("end_date"))), + } + 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 + } + return req, nil +} + // parseLimitBreakNextPerformanceRequest 解析炸板次日表现接口的查询参数。 // // includePagination 为 false 时跳过分页参数,供 CSV 导出复用同一套日期和过滤条件解析逻辑。 diff --git a/internal/model/limit_board.go b/internal/model/limit_board.go index 9af961b..77ceace 100644 --- a/internal/model/limit_board.go +++ b/internal/model/limit_board.go @@ -15,6 +15,12 @@ type LimitBreakNextPerformanceRequest struct { BreakTimeBefore1100 *bool `json:"break_time_before_1100,omitempty"` // 可选过滤:炸板时间是否早于 11:00。 } +// SecondCalRequest 是二板日历接口的日期范围请求。 +type SecondCalRequest struct { + TradeStartDate string `form:"tradeStartDate" json:"trade_start_date"` // 查询起始 T 日。 + TradeEndDate string `form:"tradeEndDate" json:"trade_end_date"` // 查询结束 T 日。 +} + // LimitBoardStock 表示单只涨停股在涨停概览中的展示数据。 type LimitBoardStock struct { Symbol string `json:"symbol"` // 市场展示用股票代码。 @@ -134,6 +140,29 @@ type LimitBreakNextPerformanceStock struct { NextClosePctChg *float64 `json:"next_close_pct_chg"` // T+1 收盘相对 T 日收盘的收益率。 } +// SecondCalStock 表示二板日历中单只二板股票的 T 日和 T+1 表现。 +type SecondCalStock struct { + TradeDate string `json:"trade_date"` // T 日交易日。 + PreviousTradeDate string `json:"previous_trade_date"` // T-1 交易日。 + PreviousTwoTradeDate string `json:"previous_two_trade_date"` // T-2 交易日。 + NextTradeDate string `json:"next_trade_date"` // T+1 交易日。 + Symbol string `json:"symbol"` // 股票代码。 + TsCode string `json:"ts_code"` // TuShare 股票代码。 + Name string `json:"name"` // 股票简称。 + Market string `json:"market"` // 所属板块,来自 stock_basic.market。 + Industry string `json:"industry"` // 所属行业,来自 stock_basic.industry。 + PreviousClose *float64 `json:"previous_close"` // T-1 收盘价。 + LimitPrice *float64 `json:"limit_price"` // 按 T-1 收盘价和板块涨跌幅规则计算的 T 日涨停价。 + THigh *float64 `json:"t_high"` // T 日最高价。 + TClose *float64 `json:"t_close"` // T 日收盘价。 + TPctChg *float64 `json:"t_pct_chg"` // T 日收盘涨跌幅。 + Status string `json:"status"` // T 日二板状态:limit 表示封板,break 表示炸板。 + NextHigh *float64 `json:"next_high"` // T+1 日最高价。 + NextClose *float64 `json:"next_close"` // T+1 日收盘价。 + NextIntradayProfitable *bool `json:"next_intraday_profitable"` // T+1 盘中最高价是否高于 T 日最高价。 + NextHighProfitPct *float64 `json:"next_high_profit_pct"` // T+1 最高价相对 T 日最高价的收益率。 +} + // LimitOpenCompareGroup 是开盘价低于前收接口的分组结果。 type LimitOpenCompareGroup struct { Key string `json:"key"` // 分组唯一标识。 @@ -173,6 +202,23 @@ type LimitBreakNextPerformanceSummary struct { NextTradeDataCount int `json:"next_trade_data_count"` // 具备 T+1 行情数据的股票数量。 } +// SecondCalSummary 汇总日期范围内所有二板日历指标。 +type SecondCalSummary struct { + TradeDayCount int `json:"trade_day_count"` // 返回的交易日数量。 + SecondBoardCount int `json:"second_board_count"` // 触及二板涨停价的股票总数。 + LimitCount int `json:"limit_count"` // 二板中封住涨停的股票数量。 + BreakCount int `json:"break_count"` // 二板中炸板的股票数量。 + NextTradeDataCount int `json:"next_trade_data_count"` // 具备 T+1 最高价和 T 日最高价的股票数量。 + NextIntradayProfitCount int `json:"next_intraday_profit_count"` // T+1 最高价高于 T 日最高价的股票数量。 + NextIntradayProfitRate float64 `json:"next_intraday_profit_rate"` // T+1 盘中盈利概率,百分比。 + LimitNextDataCount int `json:"limit_next_data_count"` // T 日封板股票中具备 T+1 盈利判断数据的数量。 + LimitNextProfitCount int `json:"limit_next_profit_count"` // T 日封板股票中 T+1 盘中盈利数量。 + LimitNextProfitRate float64 `json:"limit_next_profit_rate"` // T 日封板股票的 T+1 盘中盈利概率,百分比。 + BreakNextDataCount int `json:"break_next_data_count"` // T 日炸板股票中具备 T+1 盈利判断数据的数量。 + BreakNextProfitCount int `json:"break_next_profit_count"` // T 日炸板股票中 T+1 盘中盈利数量。 + BreakNextProfitRate float64 `json:"break_next_profit_rate"` // T 日炸板股票的 T+1 盘中盈利概率,百分比。 +} + // LimitOpenCompareResponse 是开盘价低于前收接口响应。 type LimitOpenCompareResponse struct { TradeDate string `json:"trade_date"` // 查询交易日。 @@ -194,6 +240,35 @@ type LimitBreakNextPerformanceResponse struct { List []LimitBreakNextPerformanceStock `json:"list"` // 当前页记录。 } +// SecondCalDay 表示二板日历中单个交易日的聚合统计。 +type SecondCalDay struct { + TradeDate string `json:"trade_date"` // T 日交易日。 + PreviousTradeDate string `json:"previous_trade_date"` // T-1 交易日。 + PreviousTwoTradeDate string `json:"previous_two_trade_date"` // T-2 交易日。 + NextTradeDate string `json:"next_trade_date"` // T+1 交易日。 + SecondBoardCount int `json:"second_board_count"` // 当日触及二板涨停价的股票数量。 + LimitCount int `json:"limit_count"` // 当日二板封板数量。 + BreakCount int `json:"break_count"` // 当日二板炸板数量。 + NextTradeDataCount int `json:"next_trade_data_count"` // 当日具备 T+1 盈利判断数据的股票数量。 + NextIntradayProfitCount int `json:"next_intraday_profit_count"` // 当日 T+1 最高价高于 T 日最高价数量。 + NextIntradayProfitRate float64 `json:"next_intraday_profit_rate"` // 当日 T+1 盘中盈利概率,百分比。 + LimitNextDataCount int `json:"limit_next_data_count"` // 当日封板股中具备 T+1 盈利判断数据的数量。 + LimitNextProfitCount int `json:"limit_next_profit_count"` // 当日封板股 T+1 盘中盈利数量。 + LimitNextProfitRate float64 `json:"limit_next_profit_rate"` // 当日封板股 T+1 盘中盈利概率,百分比。 + BreakNextDataCount int `json:"break_next_data_count"` // 当日炸板股中具备 T+1 盈利判断数据的数量。 + BreakNextProfitCount int `json:"break_next_profit_count"` // 当日炸板股 T+1 盘中盈利数量。 + BreakNextProfitRate float64 `json:"break_next_profit_rate"` // 当日炸板股 T+1 盘中盈利概率,百分比。 + Stocks []SecondCalStock `json:"stocks"` // 当日二板股票明细。 +} + +// SecondCalResponse 是二板日历接口响应。 +type SecondCalResponse struct { + TradeStartDate string `json:"trade_start_date"` // 查询起始 T 日。 + TradeEndDate string `json:"trade_end_date"` // 查询结束 T 日。 + Summary SecondCalSummary `json:"summary"` // 日期范围汇总统计。 + List []SecondCalDay `json:"list"` // 按交易日升序排列的日历统计。 +} + // LimitOpenThresholdResponse 是开盘价阈值分组接口响应。 type LimitOpenThresholdResponse struct { TradeDate string `json:"trade_date"` // 查询交易日。 diff --git a/internal/router/router.go b/internal/router/router.go index f2ecc5e..e1c03d7 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -37,6 +37,7 @@ 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("/second-cal", limitBoardApi.GetSecondCal) limitBoard.GET("/break-next-performance", limitBoardApi.GetBreakNextPerformance) } diff --git a/internal/service/second_cal_service.go b/internal/service/second_cal_service.go new file mode 100644 index 0000000..70e818a --- /dev/null +++ b/internal/service/second_cal_service.go @@ -0,0 +1,314 @@ +package service + +import ( + "math" + + "level_go/internal/model" +) + +// secondCalRow 是二板日历 SQL 查询扫描出的单股基础数据。 +type secondCalRow struct { + TradeDate string `gorm:"column:trade_date"` // T 日交易日。 + PreviousTradeDate string `gorm:"column:previous_trade_date"` // T-1 交易日。 + PreviousTwoTradeDate string `gorm:"column:previous_two_trade_date"` // T-2 交易日。 + NextTradeDate string `gorm:"column:next_trade_date"` // T+1 交易日。 + Symbol string `gorm:"column:symbol"` // 股票代码。 + TsCode string `gorm:"column:ts_code"` // TuShare 股票代码。 + Name string `gorm:"column:name"` // 股票简称。 + Market string `gorm:"column:market"` // 所属板块。 + Industry string `gorm:"column:industry"` // 所属行业。 + PreviousClose *float64 `gorm:"column:previous_close"` // T-1 收盘价。 + LimitPrice *float64 `gorm:"column:limit_price"` // T 日涨停价。 + THigh *float64 `gorm:"column:t_high"` // T 日最高价。 + TClose *float64 `gorm:"column:t_close"` // T 日收盘价。 + TPctChg *float64 `gorm:"column:t_pct_chg"` // T 日收盘涨跌幅。 + Status string `gorm:"column:status"` // T 日二板状态。 + NextHigh *float64 `gorm:"column:next_high"` // T+1 日最高价。 + NextClose *float64 `gorm:"column:next_close"` // T+1 日收盘价。 +} + +// secondCalDaySeed 是日期范围内每个交易日的前后交易日信息。 +type secondCalDaySeed struct { + TradeDate string `gorm:"column:trade_date"` // T 日交易日。 + PreviousTradeDate string `gorm:"column:previous_trade_date"` // T-1 交易日。 + PreviousTwoTradeDate string `gorm:"column:previous_two_trade_date"` // T-2 交易日。 + NextTradeDate string `gorm:"column:next_trade_date"` // T+1 交易日。 +} + +// GetSecondCal 返回按交易日聚合的二板日历统计。 +func (s *LimitBoardService) GetSecondCal(req model.SecondCalRequest) (model.SecondCalResponse, error) { + resp := model.SecondCalResponse{ + TradeStartDate: req.TradeStartDate, + TradeEndDate: req.TradeEndDate, + List: []model.SecondCalDay{}, + } + + // 先加载完整交易日骨架,确保没有二板的交易日也能出现在日历中。 + days, dayIndex, err := s.loadSecondCalDays(req.TradeStartDate, req.TradeEndDate) + if err != nil { + return resp, err + } + for _, day := range days { + resp.List = append(resp.List, model.SecondCalDay{ + TradeDate: day.TradeDate, + PreviousTradeDate: day.PreviousTradeDate, + PreviousTwoTradeDate: day.PreviousTwoTradeDate, + NextTradeDate: day.NextTradeDate, + Stocks: []model.SecondCalStock{}, + }) + } + + // 再加载命中二板口径的单股明细,并累加到对应交易日。 + rows, err := s.loadSecondCalRows(req.TradeStartDate, req.TradeEndDate) + if err != nil { + return resp, err + } + for _, row := range rows { + item := buildSecondCalStock(row) + idx, ok := dayIndex[item.TradeDate] + if !ok { + continue + } + accumulateSecondCalDay(&resp.List[idx], item) + } + + // 最后汇总整个日期范围,并补齐各交易日的概率字段。 + resp.Summary.TradeDayCount = len(resp.List) + for i := range resp.List { + finalizeSecondCalDay(&resp.List[i]) + accumulateSecondCalSummary(&resp.Summary, resp.List[i]) + } + finalizeSecondCalSummary(&resp.Summary) + + return resp, nil +} + +// loadSecondCalDays 查询日期范围内的开市交易日和相邻交易日。 +func (s *LimitBoardService) loadSecondCalDays(startDate, endDate string) ([]secondCalDaySeed, map[string]int, 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 +) +SELECT + DATE_FORMAT(trade_date, '%Y-%m-%d') AS trade_date, + COALESCE(DATE_FORMAT(previous_trade_date, '%Y-%m-%d'), '') AS previous_trade_date, + COALESCE(DATE_FORMAT(previous_two_trade_date, '%Y-%m-%d'), '') AS previous_two_trade_date, + COALESCE(DATE_FORMAT(next_trade_date, '%Y-%m-%d'), '') AS next_trade_date +FROM open_calendar +WHERE trade_date BETWEEN ? AND ? +ORDER BY trade_date ASC` + + var days []secondCalDaySeed + if err := s.DB.Raw(query, startDate, endDate).Scan(&days).Error; err != nil { + return nil, nil, err + } + + // 构建交易日到响应数组下标的映射,后续聚合明细时可以 O(1) 定位。 + dayIndex := make(map[string]int, len(days)) + for i, day := range days { + dayIndex[day.TradeDate] = i + } + return days, dayIndex, nil +} + +// loadSecondCalRows 查询 T-1 涨停、T-2 未涨停、T 日再次触及涨停价的二板股票。 +func (s *LimitBoardService) loadSecondCalRows(startDate, endDate string) ([]secondCalRow, 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 + c.trade_date, + c.previous_trade_date, + c.previous_two_trade_date, + c.next_trade_date, + d.symbol, + pl.ts_code, + COALESCE(NULLIF(sb.name, ''), NULLIF(pl.name, ''), '') AS name, + COALESCE(NULLIF(sb.market, ''), '') AS market, + COALESCE(NULLIF(sb.industry, ''), NULLIF(pl.industry, ''), '') AS industry, + COALESCE(d.pre_close, pl.close) AS previous_close, + ROUND( + COALESCE(d.pre_close, pl.close) * + CASE + WHEN COALESCE(sb.market, '') = '北交所' THEN 1.30 + WHEN COALESCE(sb.market, '') IN ('创业板', '科创板') THEN 1.20 + WHEN COALESCE(sb.name, pl.name, '') LIKE '%ST%' THEN 1.05 + ELSE 1.10 + END, + 2 + ) AS limit_price, + d.high AS t_high, + d.close AS t_close, + d.pct_chg AS t_pct_chg, + CASE + WHEN ABS(d.close - d.high) < 0.0001 THEN 'limit' + WHEN d.close < d.high THEN 'break' + ELSE 'unknown' + END AS status, + nd.high AS next_high, + nd.close AS next_close + FROM open_calendar c + INNER JOIN limit_list_d pl + ON pl.trade_date = c.previous_trade_date AND pl.` + "`limit`" + ` = 'U' + LEFT JOIN limit_list_d ppl + ON ppl.ts_code = pl.ts_code AND ppl.trade_date = c.previous_two_trade_date AND ppl.` + "`limit`" + ` = 'U' + INNER JOIN stock_daily d + ON d.ts_code = pl.ts_code AND d.trade_date = c.trade_date + LEFT JOIN stock_daily nd + ON nd.ts_code = pl.ts_code AND nd.trade_date = c.next_trade_date + LEFT JOIN stock_basic sb + ON sb.ts_code = pl.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 + AND d.high IS NOT NULL + AND d.close IS NOT NULL +) +SELECT + DATE_FORMAT(trade_date, '%Y-%m-%d') AS trade_date, + DATE_FORMAT(previous_trade_date, '%Y-%m-%d') AS previous_trade_date, + DATE_FORMAT(previous_two_trade_date, '%Y-%m-%d') AS previous_two_trade_date, + COALESCE(DATE_FORMAT(next_trade_date, '%Y-%m-%d'), '') AS next_trade_date, + COALESCE(symbol, '') AS symbol, + ts_code, + name, + market, + industry, + previous_close, + limit_price, + t_high, + t_close, + t_pct_chg, + status, + next_high, + next_close +FROM candidate +WHERE previous_close IS NOT NULL + AND limit_price IS NOT NULL + AND limit_price > 0 + AND t_high >= limit_price - 0.0001 + AND status IN ('limit', 'break') +ORDER BY trade_date ASC, ts_code ASC` + + var rows []secondCalRow + err := s.DB.Raw(query, startDate, endDate).Scan(&rows).Error + return rows, err +} + +// buildSecondCalStock 把数据库行转换成接口明细,并计算 T+1 盘中收益。 +func buildSecondCalStock(row secondCalRow) model.SecondCalStock { + item := model.SecondCalStock{ + TradeDate: row.TradeDate, + PreviousTradeDate: row.PreviousTradeDate, + PreviousTwoTradeDate: row.PreviousTwoTradeDate, + NextTradeDate: row.NextTradeDate, + Symbol: row.Symbol, + TsCode: row.TsCode, + Name: row.Name, + Market: row.Market, + Industry: row.Industry, + PreviousClose: row.PreviousClose, + LimitPrice: row.LimitPrice, + THigh: row.THigh, + TClose: row.TClose, + TPctChg: row.TPctChg, + Status: row.Status, + NextHigh: row.NextHigh, + NextClose: row.NextClose, + } + + if row.THigh != nil && *row.THigh > 0 && row.NextHigh != nil { + // T+1 盘中盈利统一按 T+1 最高价是否高于 T 日最高价判断。 + profitable := *row.NextHigh > *row.THigh + item.NextIntradayProfitable = &profitable + profitPct := roundSecondCalPct((*row.NextHigh - *row.THigh) / *row.THigh * 100) + item.NextHighProfitPct = &profitPct + } + return item +} + +// accumulateSecondCalDay 把单只股票累加到对应交易日统计中。 +func accumulateSecondCalDay(day *model.SecondCalDay, item model.SecondCalStock) { + day.SecondBoardCount++ + switch item.Status { + case "limit": + day.LimitCount++ + case "break": + day.BreakCount++ + } + + if item.NextIntradayProfitable != nil { + day.NextTradeDataCount++ + if item.Status == "limit" { + day.LimitNextDataCount++ + } + if item.Status == "break" { + day.BreakNextDataCount++ + } + if *item.NextIntradayProfitable { + day.NextIntradayProfitCount++ + if item.Status == "limit" { + day.LimitNextProfitCount++ + } + if item.Status == "break" { + day.BreakNextProfitCount++ + } + } + } + day.Stocks = append(day.Stocks, item) +} + +// finalizeSecondCalDay 计算单个交易日的概率字段。 +func finalizeSecondCalDay(day *model.SecondCalDay) { + day.NextIntradayProfitRate = secondCalRate(day.NextIntradayProfitCount, day.NextTradeDataCount) + day.LimitNextProfitRate = secondCalRate(day.LimitNextProfitCount, day.LimitNextDataCount) + day.BreakNextProfitRate = secondCalRate(day.BreakNextProfitCount, day.BreakNextDataCount) +} + +// accumulateSecondCalSummary 把单日统计累加到日期范围汇总中。 +func accumulateSecondCalSummary(summary *model.SecondCalSummary, day model.SecondCalDay) { + summary.SecondBoardCount += day.SecondBoardCount + summary.LimitCount += day.LimitCount + summary.BreakCount += day.BreakCount + summary.NextTradeDataCount += day.NextTradeDataCount + summary.NextIntradayProfitCount += day.NextIntradayProfitCount + summary.LimitNextDataCount += day.LimitNextDataCount + summary.LimitNextProfitCount += day.LimitNextProfitCount + summary.BreakNextDataCount += day.BreakNextDataCount + summary.BreakNextProfitCount += day.BreakNextProfitCount +} + +// finalizeSecondCalSummary 计算日期范围汇总的概率字段。 +func finalizeSecondCalSummary(summary *model.SecondCalSummary) { + summary.NextIntradayProfitRate = secondCalRate(summary.NextIntradayProfitCount, summary.NextTradeDataCount) + summary.LimitNextProfitRate = secondCalRate(summary.LimitNextProfitCount, summary.LimitNextDataCount) + summary.BreakNextProfitRate = secondCalRate(summary.BreakNextProfitCount, summary.BreakNextDataCount) +} + +// secondCalRate 计算百分比概率并保留两位小数。 +func secondCalRate(numerator, denominator int) float64 { + if denominator == 0 { + return 0 + } + return roundSecondCalPct(float64(numerator) / float64(denominator) * 100) +} + +// roundSecondCalPct 对二板日历中的百分比做两位小数舍入。 +func roundSecondCalPct(value float64) float64 { + return math.Round(value*100) / 100 +}