diff --git a/docs/api-overview.md b/docs/api-overview.md index 82a0216..46bff07 100644 --- a/docs/api-overview.md +++ b/docs/api-overview.md @@ -4,7 +4,7 @@ - 服务前缀:`/api/v1` - 响应格式:`application/json` -- 当前项目接口总数:4 +- 当前项目接口总数:5 ## 2. 接口列表 @@ -14,6 +14,7 @@ | 股票分析 | `POST` | `/api/v1/stock/analyze` | 分析指定涨停日股票后续表现 | [stock-analyze.md](./stock-analyze.md) | | 股票行情 | `GET` | `/api/v1/stock/kline/:code` | 获取指定股票历史 K 线 | [stock-kline.md](./stock-kline.md) | | 涨停连板 | `GET` | `/api/v1/limit-board/overview` | 获取交易日涨停分组与近 30 日最高板趋势 | [limit-board-overview.md](./limit-board-overview.md) | +| 涨停低开分组 | `GET` | `/api/v1/limit-board/open-lower-groups` | 获取查询日和前一交易日均涨停的股票,并按是否低于前一交易日收盘价分组 | [limit-board-open-lower-groups.md](./limit-board-open-lower-groups.md) | ## 3. 错误响应约定 diff --git a/docs/limit-board-open-lower-groups.md b/docs/limit-board-open-lower-groups.md new file mode 100644 index 0000000..d638840 --- /dev/null +++ b/docs/limit-board-open-lower-groups.md @@ -0,0 +1,172 @@ +# 涨停低开分组接口文档 + +## 1. 接口说明 + +- 接口名称:涨停低开分组 +- 请求方式:`GET` +- 请求路径:`/api/v1/limit-board/open-lower-groups` +- 接口用途:查询指定交易日涨停且前一交易日也涨停的股票,并按指定交易日开盘价是否低于前一交易日收盘价分为两组。 + +## 2. 请求参数 + +| 参数名 | 类型 | 必填 | 示例 | 说明 | +| --- | --- | --- | --- | --- | +| `trade_date` | `string` | 是 | `2026-04-24` | 交易日,格式固定为 `YYYY-MM-DD` | + +请求示例: + +```http +GET /api/v1/limit-board/open-lower-groups?trade_date=2026-04-24 +``` + +## 3. 响应说明 + +### 3.1 顶层字段 + +| 字段名 | 类型 | 说明 | +| --- | --- | --- | +| `trade_date` | `string` | 查询的交易日 | +| `previous_trade_date` | `string` | 前一交易日;如果查询日没有前一交易日则为空字符串 | +| `previous_two_trade_date` | `string` | 前二交易日;如果查询日没有前二交易日则为空字符串 | +| `summary` | `object` | 当日涨停摘要信息 | +| `groups` | `array` | 分组后的股票列表,固定返回两组 | + +### 3.2 summary 字段 + +| 字段名 | 类型 | 说明 | +| --- | --- | --- | +| `limit_count` | `int` | 指定交易日涨停且前一交易日也涨停的股票总数 | +| `open_lower_previous_close_count` | `int` | 指定交易日开盘价低于前一交易日收盘价的股票数量 | +| `others_count` | `int` | 样本池内其他股票数量 | +| `first_to_second_board_count` | `int` | 前一交易日是首板、指定交易日是二板的股票数量 | +| `first_second_before_eleven_count` | `int` | 开盘低于前收、首板进二板、且指定交易日首次涨停时间早于 11:00 的股票数量 | + +### 3.3 groups 字段 + +| 字段名 | 类型 | 说明 | +| --- | --- | --- | +| `key` | `string` | 分组标识,取值为 `open_lower_previous_close` 或 `others` | +| `label` | `string` | 分组展示文案 | +| `count` | `int` | 当前分组内股票数量 | +| `stocks` | `array` | 当前分组内的股票列表 | + +### 3.4 groups[].stocks 字段 + +| 字段名 | 类型 | 说明 | +| --- | --- | --- | +| `symbol` | `string` | 股票代码 | +| `ts_code` | `string` | TS 股票代码 | +| `name` | `string` | 股票名称 | +| `market` | `string` | 所属板块或市场信息 | +| `trade_date` | `string` | 交易日 | +| `open` | `number` | 当日开盘价 | +| `close` | `number` | 当日收盘价 | +| `previous_trade_date` | `string` | 前一交易日 | +| `previous_two_trade_date` | `string` | 前二交易日 | +| `previous_close` | `number` | 前一交易日收盘价 | +| `is_first_to_second_board` | `boolean` | 是否为前一交易日首板、指定交易日二板 | +| `is_first_second_before_eleven` | `boolean` | 是否满足开盘低于前收、首板进二板、首次涨停时间早于 11:00 | +| `highlight_label` | `string` | 重点标记文案;优先返回 `首二一一`,其次返回 `首板进二板`,否则为空字符串 | +| `limit_price` | `number` | 理论涨停价 | +| `pct_chg` | `number` | 当日涨跌幅 | +| `ak_amount` | `number \| null` | AKShare 成交额 | +| `ak_float_market_value` | `number \| null` | AKShare 流通市值 | +| `ak_total_market_value` | `number \| null` | AKShare 总市值 | +| `ak_turnover_rate` | `number \| null` | AKShare 换手率 | +| `ak_seal_amount` | `number \| null` | AKShare 封板资金 | +| `ak_first_limit_time` | `string \| null` | AKShare 首次封板时间 | +| `ak_last_limit_time` | `string \| null` | AKShare 最后封板时间 | +| `ak_break_limit_count` | `int \| null` | AKShare 炸板次数 | +| `ak_limit_up_stat` | `string \| null` | AKShare 涨停统计 | +| `ak_limit_up_days` | `int \| null` | AKShare 连板数 | +| `ak_industry` | `string \| null` | AKShare 所属行业 | +| `vol` | `int \| null` | 成交量 | + +## 4. 响应示例 + +```json +{ + "trade_date": "2026-04-24", + "previous_trade_date": "2026-04-23", + "previous_two_trade_date": "2026-04-22", + "summary": { + "limit_count": 23, + "open_lower_previous_close_count": 5, + "others_count": 18, + "first_to_second_board_count": 3, + "first_second_before_eleven_count": 2 + }, + "groups": [ + { + "key": "open_lower_previous_close", + "label": "开盘低于前收", + "count": 5, + "stocks": [ + { + "symbol": "000001", + "ts_code": "000001.SZ", + "name": "示例股份", + "market": "主板", + "trade_date": "2026-04-24", + "open": 11.1, + "close": 12.35, + "previous_trade_date": "2026-04-23", + "previous_two_trade_date": "2026-04-22", + "previous_close": 11.2, + "is_first_to_second_board": true, + "is_first_second_before_eleven": true, + "highlight_label": "首二一一", + "limit_price": 12.35, + "pct_chg": 10.01, + "ak_amount": 1523000000, + "ak_float_market_value": 8245000000, + "ak_total_market_value": 12678000000, + "ak_turnover_rate": 18.64, + "ak_seal_amount": 286000000, + "ak_first_limit_time": "09:35:12", + "ak_last_limit_time": "14:42:08", + "ak_break_limit_count": 2, + "ak_limit_up_stat": "1天1板", + "ak_limit_up_days": 1, + "ak_industry": "软件开发", + "vol": 123456789 + } + ] + }, + { + "key": "others", + "label": "其他涨停股", + "count": 18, + "stocks": [] + } + ] +} +``` + +## 5. 业务规则 + +- 只统计指定交易日 `stock_limit_data.status = 1` 且前一交易日 `stock_limit_data.status = 1` 的股票。 +- 前一交易日、前二交易日通过 `trade_calendar` 中 `exchange = 'SSE'` 且 `is_open = 1` 的交易日确定。 +- 只有当日 `stock_daily.open` 和前一交易日 `stock_daily.close` 都存在,且 `open < previous_close` 时,股票才会进入 `open_lower_previous_close` 分组。 +- 行情缺失、没有前一交易日、或不满足 `open < previous_close` 的股票都会进入 `others` 分组。 +- 当指定交易日涨停、前一交易日涨停、前二交易日不涨停时,返回 `is_first_to_second_board = true`,并设置 `highlight_label = "首板进二板"`。 +- 在 `open_lower_previous_close` 分组内,如果同时满足 `is_first_to_second_board = true` 且 `ak_first_limit_time` 早于 `11:00`,返回 `is_first_second_before_eleven = true`,并将 `highlight_label` 设为 `首二一一`。 +- 每个分组内的 `stocks` 按 `pct_chg` 从大到小排序;如果 `pct_chg` 相同,则按 `ts_code` 升序排序。 + +## 6. 错误响应 + +当缺少 `trade_date` 参数时: + +```json +{ + "error": "trade_date 参数不能为空" +} +``` + +当服务端内部异常时: + +```json +{ + "error": "具体错误信息" +} +``` diff --git a/internal/api/limit_board_handler.go b/internal/api/limit_board_handler.go index 925627e..e05af69 100644 --- a/internal/api/limit_board_handler.go +++ b/internal/api/limit_board_handler.go @@ -31,3 +31,19 @@ func (h *LimitBoardHandler) GetOverview(c *gin.Context) { c.JSON(http.StatusOK, resp) } + +func (h *LimitBoardHandler) GetOpenLowerGroups(c *gin.Context) { + var req model.LimitBoardRequest + if err := c.ShouldBindQuery(&req); err != nil || req.TradeDate == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "trade_date 参数不能为空"}) + return + } + + resp, err := h.Service.GetLimitOpenCompareGroups(req.TradeDate) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, resp) +} diff --git a/internal/model/limit_board.go b/internal/model/limit_board.go index fe1f87c..772e441 100644 --- a/internal/model/limit_board.go +++ b/internal/model/limit_board.go @@ -30,6 +30,61 @@ type LimitBoardStock struct { DisplayStatus string `json:"display_status"` } +type LimitOpenCompareStock struct { + Symbol string `json:"symbol"` + TsCode string `json:"ts_code"` + Name string `json:"name"` + Market string `json:"market"` + TradeDate string `json:"trade_date"` + HasOpen bool `json:"-"` + Open float64 `json:"open"` + Close float64 `json:"close"` + PreviousTradeDate string `json:"previous_trade_date"` + PreviousTwoTradeDate string `json:"previous_two_trade_date"` + HasPreviousClose bool `json:"-"` + PreviousClose float64 `json:"previous_close"` + IsFirstToSecondBoard bool `json:"is_first_to_second_board"` + IsFirstSecondBeforeEleven bool `json:"is_first_second_before_eleven"` + HighlightLabel string `json:"highlight_label"` + LimitPrice float64 `json:"limit_price"` + PctChg float64 `json:"pct_chg"` + AkAmount *float64 `json:"ak_amount"` + AkFloatMarketValue *float64 `json:"ak_float_market_value"` + AkTotalMarketValue *float64 `json:"ak_total_market_value"` + AkTurnoverRate *float64 `json:"ak_turnover_rate"` + AkSealAmount *float64 `json:"ak_seal_amount"` + AkFirstLimitTime *string `json:"ak_first_limit_time"` + AkLastLimitTime *string `json:"ak_last_limit_time"` + AkBreakLimitCount *int `json:"ak_break_limit_count"` + AkLimitUpStat *string `json:"ak_limit_up_stat"` + AkLimitUpDays *int `json:"ak_limit_up_days"` + AkIndustry *string `json:"ak_industry"` + Vol *int64 `json:"vol"` +} + +type LimitOpenCompareGroup struct { + Key string `json:"key"` + Label string `json:"label"` + Count int `json:"count"` + Stocks []LimitOpenCompareStock `json:"stocks"` +} + +type LimitOpenCompareSummary struct { + LimitCount int `json:"limit_count"` + OpenLowerPreviousCloseCount int `json:"open_lower_previous_close_count"` + OthersCount int `json:"others_count"` + FirstToSecondBoardCount int `json:"first_to_second_board_count"` + FirstSecondBeforeElevenCount int `json:"first_second_before_eleven_count"` +} + +type LimitOpenCompareResponse struct { + TradeDate string `json:"trade_date"` + PreviousTradeDate string `json:"previous_trade_date"` + PreviousTwoTradeDate string `json:"previous_two_trade_date"` + Summary LimitOpenCompareSummary `json:"summary"` + Groups []LimitOpenCompareGroup `json:"groups"` +} + type LimitBoardGroup struct { BoardCount int `json:"board_count"` Label string `json:"label"` diff --git a/internal/router/router.go b/internal/router/router.go index db5cbd8..0cff153 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -26,6 +26,7 @@ func NewRouter(commonApi *api.CommonHandler, stockApi *api.StockHandler, limitBo v1.POST("/stock/analyze", stockApi.Analyze) v1.GET("/stock/kline/:code", stockApi.GetKlineData) v1.GET("/limit-board/overview", limitBoardApi.GetOverview) + v1.GET("/limit-board/open-lower-groups", limitBoardApi.GetOpenLowerGroups) } return r } diff --git a/internal/service/limit_board_service.go b/internal/service/limit_board_service.go index a295039..78e5164 100644 --- a/internal/service/limit_board_service.go +++ b/internal/service/limit_board_service.go @@ -3,6 +3,8 @@ package service import ( "fmt" "sort" + "strconv" + "strings" "gorm.io/gorm" "level_go/internal/model" @@ -82,6 +84,113 @@ func (s *LimitBoardService) GetLimitBoardOverview(tradeDate string) (model.Limit 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 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 + } + if len(timeText) == 4 { + timeText += "00" + } + if len(timeText) != 6 { + 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 { @@ -199,6 +308,58 @@ func (s *LimitBoardService) loadCurrentLimitStocks(tradeDate string) ([]model.Li 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(d.close, l.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_data ppl + WHERE ppl.ts_code = l.ts_code AND ppl.trade_date = ? AND ppl.status = 1 + )) AS is_first_to_second_board, + COALESCE(l.limit_price, 0) AS limit_price, + COALESCE(d.pct_chg, 0) AS pct_chg, + l.ak_amount, + l.ak_float_market_value, + l.ak_total_market_value, + l.ak_turnover_rate, + l.ak_seal_amount, + l.ak_first_limit_time, + l.ak_last_limit_time, + l.ak_break_limit_count, + l.ak_limit_up_stat, + l.ak_limit_up_days, + l.ak_industry, + l.vol + FROM stock_limit_data l + LEFT JOIN stock_daily d + ON d.ts_code = l.ts_code AND d.trade_date = l.trade_date + LEFT JOIN stock_daily pd + ON pd.ts_code = l.ts_code AND pd.trade_date = ? + INNER JOIN stock_limit_data pl + ON pl.ts_code = l.ts_code AND pl.trade_date = ? AND pl.status = 1 + WHERE l.trade_date = ? AND l.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) loadLimitHistory(stocks []model.LimitBoardStock, tradeDate string) (map[string]map[string]struct{}, error) { codes := make([]string, 0, len(stocks)) for _, stock := range stocks { @@ -281,3 +442,39 @@ func sortLimitBoardGroups(groupMap map[int][]model.LimitBoardStock) []model.Limi 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 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 + }) +}