From ee1707c17ff2cf9a3cb809dddc6a5b688cd07fd6 Mon Sep 17 00:00:00 2001 From: mianyang <784310601@qq.com> Date: Mon, 27 Apr 2026 00:32:09 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E6=8E=A5=E5=8F=A3=E5=8F=8A?= =?UTF-8?q?=E6=8E=A5=E5=8F=A3=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/server/main.go | 4 +- docs/api-overview.md | 36 ++++ docs/common-trade-calendar.md | 56 +++++ docs/limit-board-overview.md | 194 +++++++++++++++++ docs/stock-analyze.md | 108 ++++++++++ docs/stock-kline.md | 83 ++++++++ internal/api/limit_board_handler.go | 33 +++ internal/model/limit_board.go | 44 ++++ internal/router/router.go | 3 +- internal/service/limit_board_service.go | 271 ++++++++++++++++++++++++ 10 files changed, 830 insertions(+), 2 deletions(-) create mode 100644 docs/api-overview.md create mode 100644 docs/common-trade-calendar.md create mode 100644 docs/limit-board-overview.md create mode 100644 docs/stock-analyze.md create mode 100644 docs/stock-kline.md create mode 100644 internal/api/limit_board_handler.go create mode 100644 internal/model/limit_board.go create mode 100644 internal/service/limit_board_service.go diff --git a/cmd/server/main.go b/cmd/server/main.go index 7043d43..661cd9d 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -24,12 +24,14 @@ func main() { // 3. 依赖注入 commonSrv := &service.CommonService{DB: db} stockSrv := &service.StockService{DB: db} + limitBoardSrv := &service.LimitBoardService{DB: db} commonHandler := &api.CommonHandler{Service: commonSrv} stockHandler := &api.StockHandler{Service: stockSrv} + limitBoardHandler := api.NewLimitBoardHandler(limitBoardSrv) // 4. 启动路由 - r := router.NewRouter(commonHandler, stockHandler) + r := router.NewRouter(commonHandler, stockHandler, limitBoardHandler) port := config.GetServerPort() log.Printf("服务启动于端口 %s", port) diff --git a/docs/api-overview.md b/docs/api-overview.md new file mode 100644 index 0000000..82a0216 --- /dev/null +++ b/docs/api-overview.md @@ -0,0 +1,36 @@ +# 接口文档总览 + +## 1. 基础说明 + +- 服务前缀:`/api/v1` +- 响应格式:`application/json` +- 当前项目接口总数:4 + +## 2. 接口列表 + +| 模块 | 方法 | 路径 | 说明 | 文档 | +| --- | --- | --- | --- | --- | +| 公共 | `GET` | `/api/v1/common/trade-calendar` | 获取交易日历 | [common-trade-calendar.md](./common-trade-calendar.md) | +| 股票分析 | `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) | + +## 3. 错误响应约定 + +当前项目未统一封装错误码,接口错误响应以 `error` 字段返回文本信息。 + +示例: + +```json +{ + "error": "参数错误" +} +``` + +## 4. 跨域说明 + +服务已开启基础跨域头: + +- `Access-Control-Allow-Origin: *` +- `Access-Control-Allow-Methods: POST, GET, OPTIONS` +- `Access-Control-Allow-Headers: Content-Type` diff --git a/docs/common-trade-calendar.md b/docs/common-trade-calendar.md new file mode 100644 index 0000000..926f952 --- /dev/null +++ b/docs/common-trade-calendar.md @@ -0,0 +1,56 @@ +# 交易日历接口文档 + +## 1. 接口说明 + +- 接口名称:交易日历 +- 请求方式:`GET` +- 请求路径:`/api/v1/common/trade-calendar` +- 接口用途:获取上交所开市交易日列表,并返回当前数据中的最大交易日。 + +## 2. 请求参数 + +无 + +请求示例: + +```http +GET /api/v1/common/trade-calendar +``` + +## 3. 响应说明 + +| 字段名 | 类型 | 说明 | +| --- | --- | --- | +| `open_dates` | `array[string]` | 全部开市日期列表,格式 `YYYY-MM-DD` | +| `max_date` | `string` | 开市日期列表中的最大日期 | + +## 4. 响应示例 + +```json +{ + "open_dates": [ + "2026-04-20", + "2026-04-21", + "2026-04-22", + "2026-04-23", + "2026-04-24" + ], + "max_date": "2026-04-24" +} +``` + +## 5. 业务规则 + +- 只返回 `trade_calendar` 表中 `exchange = 'SSE'` 且 `is_open = 1` 的交易日。 +- 日期按升序返回。 +- `max_date` 等于列表中的最后一个日期。 + +## 6. 错误响应 + +服务端异常时: + +```json +{ + "error": "具体错误信息" +} +``` diff --git a/docs/limit-board-overview.md b/docs/limit-board-overview.md new file mode 100644 index 0000000..f5f9154 --- /dev/null +++ b/docs/limit-board-overview.md @@ -0,0 +1,194 @@ +# 涨停连板概览接口文档 + +## 1. 接口说明 + +- 接口名称:涨停连板概览 +- 请求方式:`GET` +- 请求路径:`/api/v1/limit-board/overview` +- 接口用途:查询指定交易日的涨停股票,按连板高度分组返回,并附带近 30 个交易日的每日最高连板数。 + +## 2. 请求参数 + +| 参数名 | 类型 | 必填 | 示例 | 说明 | +| --- | --- | --- | --- | --- | +| `trade_date` | `string` | 是 | `2026-04-24` | 交易日,格式固定为 `YYYY-MM-DD` | + +请求示例: + +```http +GET /api/v1/limit-board/overview?trade_date=2026-04-24 +``` + +## 3. 响应说明 + +### 3.1 顶层字段 + +| 字段名 | 类型 | 说明 | +| --- | --- | --- | +| `trade_date` | `string` | 查询的交易日 | +| `summary` | `object` | 当日涨停摘要信息 | +| `recent_max_board_stats` | `array` | 近 30 个交易日每天的最高连板数 | +| `groups` | `array` | 按连板高度分组后的股票列表,已按板数从高到低排序 | + +### 3.2 summary 字段 + +| 字段名 | 类型 | 说明 | +| --- | --- | --- | +| `max_board_count` | `int` | 当天最高连板数 | +| `limit_count` | `int` | 当天涨停股票总数 | +| `group_count` | `int` | 当天连板分组数量 | + +### 3.3 recent_max_board_stats 字段 + +| 字段名 | 类型 | 说明 | +| --- | --- | --- | +| `trade_date` | `string` | 交易日 | +| `max_board_count` | `int` | 该交易日市场最高连板数 | + +### 3.4 groups 字段 + +| 字段名 | 类型 | 说明 | +| --- | --- | --- | +| `board_count` | `int` | 当前分组对应的连板数 | +| `label` | `string` | 当前分组显示文案,如 `首板`、`二板`、`三板` | +| `count` | `int` | 当前分组内股票数量 | +| `stocks` | `array` | 当前分组内的股票列表 | + +### 3.5 groups[].stocks 字段 + +| 字段名 | 类型 | 说明 | +| --- | --- | --- | +| `symbol` | `string` | 股票代码 | +| `ts_code` | `string` | TS 股票代码 | +| `name` | `string` | 股票名称 | +| `market` | `string` | 所属板块或市场信息 | +| `trade_date` | `string` | 交易日 | +| `close` | `number` | 收盘价 | +| `limit_price` | `number` | 理论涨停价 | +| `pct_chg` | `number` | 当日涨跌幅 | +| `board_count` | `int` | 该股票对应的连续涨停板数 | +| `board_label` | `string` | 连板展示文案,如 `首板`、`二板` | +| `display_status` | `string` | 页面可直接使用的状态文案,当前与 `board_label` 一致 | + +## 4. 响应示例 + +```json +{ + "trade_date": "2026-04-24", + "summary": { + "max_board_count": 4, + "limit_count": 23, + "group_count": 4 + }, + "recent_max_board_stats": [ + { + "trade_date": "2026-03-16", + "max_board_count": 2 + }, + { + "trade_date": "2026-03-17", + "max_board_count": 3 + }, + { + "trade_date": "2026-04-24", + "max_board_count": 4 + } + ], + "groups": [ + { + "board_count": 4, + "label": "四板", + "count": 1, + "stocks": [ + { + "symbol": "000001", + "ts_code": "000001.SZ", + "name": "示例股份", + "market": "主板", + "trade_date": "2026-04-24", + "close": 12.35, + "limit_price": 12.35, + "pct_chg": 10.01, + "board_count": 4, + "board_label": "四板", + "display_status": "四板" + } + ] + } + ] +} +``` + +## 5. 排序规则 + +- `groups` 按 `board_count` 从大到小排序。 +- 每个分组内的 `stocks` 按 `pct_chg` 从大到小排序。 +- 如果 `pct_chg` 相同,则按 `ts_code` 升序排序。 +- `recent_max_board_stats` 按交易日升序排序。 + +## 6. 业务规则 + +- 只统计 `stock_limit_data.status = 1` 的记录,即涨停股票。 +- 连板数的计算会结合 `trade_calendar` 开市日历处理,不会被周末或节假日打断。 +- 首板、二板、三板等分组由后端直接计算,前端不需要二次处理。 +- `summary.max_board_count` 即当天涨停强度最高的板数。 +- `recent_max_board_stats` 返回截至查询日近 30 个交易日的每日最高连板数。 + +## 7. 空数据说明 + +当指定交易日没有涨停数据时,返回: + +```json +{ + "trade_date": "2026-04-24", + "summary": { + "max_board_count": 0, + "limit_count": 0, + "group_count": 0 + }, + "recent_max_board_stats": [ + { + "trade_date": "2026-03-16", + "max_board_count": 2 + } + ], + "groups": [] +} +``` + +说明: + +- 如果 `trade_date` 是有效交易日,即使当天没有涨停数据,仍会返回近 30 个交易日的 `recent_max_board_stats`。 +- 如果 `trade_date` 本身不是有效交易日,则返回空的 `recent_max_board_stats` 和空的 `groups`。 + +## 8. 前端对接建议 + +- 页面顶部可直接使用 `summary` 展示摘要卡片: + - 当天最高连板数 + - 当天涨停总数 + - 当天分组数 +- 趋势图可直接使用 `recent_max_board_stats`: + - X 轴使用 `trade_date` + - Y 轴使用 `max_board_count` +- 页面主体直接遍历 `groups` 渲染分组区块: + - 分组标题使用 `label` + - 分组数量使用 `count` + - 股票列表使用 `stocks` + +## 9. 错误响应 + +当缺少 `trade_date` 参数时: + +```json +{ + "error": "trade_date 参数不能为空" +} +``` + +当服务端内部异常时: + +```json +{ + "error": "具体错误信息" +} +``` diff --git a/docs/stock-analyze.md b/docs/stock-analyze.md new file mode 100644 index 0000000..262afd1 --- /dev/null +++ b/docs/stock-analyze.md @@ -0,0 +1,108 @@ +# 涨停表现分析接口文档 + +## 1. 接口说明 + +- 接口名称:涨停表现分析 +- 请求方式:`POST` +- 请求路径:`/api/v1/stock/analyze` +- 接口用途:基于指定涨停日,分析当日涨停股票在后续区间内的表现。 + +## 2. 请求参数 + +请求体类型:`application/json` + +| 参数名 | 类型 | 必填 | 示例 | 说明 | +| --- | --- | --- | --- | --- | +| `start_date` | `string` | 是 | `2026-04-24` | 涨停发生日,格式 `YYYY-MM-DD` | +| `end_date` | `string` | 是 | `2026-05-30` | 统计截止日,格式 `YYYY-MM-DD` | + +请求示例: + +```http +POST /api/v1/stock/analyze +Content-Type: application/json + +{ + "start_date": "2026-04-24", + "end_date": "2026-05-30" +} +``` + +## 3. 响应说明 + +返回值为数组,每个元素代表一只在 `start_date` 涨停的股票。 + +| 字段名 | 类型 | 说明 | +| --- | --- | --- | +| `code` | `string` | TS 股票代码 | +| `name` | `string` | 股票名称 | +| `day_open` | `number` | 涨停当日开盘价 | +| `day_high` | `number` | 涨停当日最高价 | +| `max_high` | `number` | 逻辑最高点价格 | +| `max_date` | `string` | 逻辑最高点对应日期 | +| `overall_max_high` | `number` | 统计区间内绝对最高价 | +| `overall_max_date` | `string` | 统计区间内绝对最高价对应日期 | +| `post_min_low` | `number` | 涨停日之后的最低价 | +| `drop_pct` | `number \| null` | 从逻辑高点到最低点的降幅,未跌破时为空 | +| `rebound_pct` | `number \| null` | 强势形态下的上涨幅度,或破位后的反弹幅度 | +| `close_prices` | `array[number]` | 区间内收盘价序列,用于前端绘图 | + +## 4. 响应示例 + +```json +[ + { + "code": "000001.SZ", + "name": "示例股份", + "day_open": 10.12, + "day_high": 11.13, + "max_high": 12.65, + "max_date": "2026-04-29", + "overall_max_high": 12.88, + "overall_max_date": "2026-05-06", + "post_min_low": 9.94, + "drop_pct": 21.42, + "rebound_pct": 8.36, + "close_prices": [11.13, 10.88, 10.25, 11.02, 10.77] + } +] +``` + +## 5. 排序规则 + +- 返回数组优先按 `drop_pct` 从大到小排序。 +- `drop_pct` 不为空的股票排在前面。 +- `drop_pct` 为空的股票排在后面。 + +## 6. 业务规则 + +- 仅统计 `stock_limit_data.status = 1` 的涨停股票。 +- `start_date` 用于确定样本池,即当日涨停股票。 +- `end_date` 用于限定后续表现统计区间。 +- 如果某只股票在区间内只有涨停当天数据,则不会进入最终结果。 + +## 7. 空数据说明 + +当指定交易日没有涨停股票,返回空数组: + +```json +[] +``` + +## 8. 错误响应 + +当请求体绑定失败时: + +```json +{ + "error": "参数错误" +} +``` + +当服务端内部异常时: + +```json +{ + "error": "具体错误信息" +} +``` diff --git a/docs/stock-kline.md b/docs/stock-kline.md new file mode 100644 index 0000000..968200e --- /dev/null +++ b/docs/stock-kline.md @@ -0,0 +1,83 @@ +# 股票历史 K 线接口文档 + +## 1. 接口说明 + +- 接口名称:股票历史 K 线 +- 请求方式:`GET` +- 请求路径:`/api/v1/stock/kline/:code` +- 接口用途:获取指定股票自 `2024-09-24` 起的历史日线数据。 + +## 2. 请求参数 + +路径参数: + +| 参数名 | 类型 | 必填 | 示例 | 说明 | +| --- | --- | --- | --- | --- | +| `code` | `string` | 是 | `000001.SZ` | TS 股票代码 | + +请求示例: + +```http +GET /api/v1/stock/kline/000001.SZ +``` + +## 3. 响应说明 + +返回值为数组,每个元素代表一个交易日的 K 线数据。 + +| 字段名 | 类型 | 说明 | +| --- | --- | --- | +| `ts_code` | `string` | TS 股票代码 | +| `trade_date` | `string` | 交易日时间 | +| `open` | `number` | 开盘价 | +| `high` | `number` | 最高价 | +| `low` | `number` | 最低价 | +| `close` | `number` | 收盘价 | +| `pct_chg` | `number` | 涨跌幅 | + +## 4. 响应示例 + +```json +[ + { + "ts_code": "000001.SZ", + "trade_date": "2026-04-21T00:00:00+08:00", + "open": 10.01, + "high": 10.55, + "low": 9.98, + "close": 10.43, + "pct_chg": 3.24 + } +] +``` + +## 5. 业务规则 + +- 查询起始日期固定为 `2024-09-24`。 +- 返回结果按 `trade_date` 升序排序。 + +## 6. 空数据说明 + +若指定股票无数据,返回空数组: + +```json +[] +``` + +## 7. 错误响应 + +当路径参数为空时: + +```json +{ + "error": "股票代码不能为空" +} +``` + +当服务端内部异常时: + +```json +{ + "error": "具体错误信息" +} +``` diff --git a/internal/api/limit_board_handler.go b/internal/api/limit_board_handler.go new file mode 100644 index 0000000..925627e --- /dev/null +++ b/internal/api/limit_board_handler.go @@ -0,0 +1,33 @@ +package api + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "level_go/internal/model" + "level_go/internal/service" +) + +type LimitBoardHandler struct { + Service *service.LimitBoardService +} + +func NewLimitBoardHandler(s *service.LimitBoardService) *LimitBoardHandler { + return &LimitBoardHandler{Service: s} +} + +func (h *LimitBoardHandler) GetOverview(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.GetLimitBoardOverview(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 new file mode 100644 index 0000000..14b8d94 --- /dev/null +++ b/internal/model/limit_board.go @@ -0,0 +1,44 @@ +package model + +type LimitBoardRequest struct { + TradeDate string `form:"trade_date" json:"trade_date"` +} + +type LimitBoardStock struct { + Symbol string `json:"symbol"` + TsCode string `json:"ts_code"` + Name string `json:"name"` + Market string `json:"market"` + TradeDate string `json:"trade_date"` + Close float64 `json:"close"` + LimitPrice float64 `json:"limit_price"` + PctChg float64 `json:"pct_chg"` + BoardCount int `json:"board_count"` + BoardLabel string `json:"board_label"` + DisplayStatus string `json:"display_status"` +} + +type LimitBoardGroup struct { + BoardCount int `json:"board_count"` + Label string `json:"label"` + Count int `json:"count"` + Stocks []LimitBoardStock `json:"stocks"` +} + +type LimitBoardSummary struct { + MaxBoardCount int `json:"max_board_count"` + LimitCount int `json:"limit_count"` + GroupCount int `json:"group_count"` +} + +type LimitBoardDailyMax struct { + TradeDate string `json:"trade_date"` + MaxBoardCount int `json:"max_board_count"` +} + +type LimitBoardResponse struct { + TradeDate string `json:"trade_date"` + Summary LimitBoardSummary `json:"summary"` + RecentMaxBoardStats []LimitBoardDailyMax `json:"recent_max_board_stats"` + Groups []LimitBoardGroup `json:"groups"` +} diff --git a/internal/router/router.go b/internal/router/router.go index ad82e4b..db5cbd8 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -5,7 +5,7 @@ import ( "level_go/internal/api" ) -func NewRouter(commonApi *api.CommonHandler, stockApi *api.StockHandler) *gin.Engine { +func NewRouter(commonApi *api.CommonHandler, stockApi *api.StockHandler, limitBoardApi *api.LimitBoardHandler) *gin.Engine { r := gin.Default() // 跨域中间件 (根据需要开启) @@ -25,6 +25,7 @@ func NewRouter(commonApi *api.CommonHandler, stockApi *api.StockHandler) *gin.En v1.GET("/common/trade-calendar", commonApi.GetTradeCalendar) v1.POST("/stock/analyze", stockApi.Analyze) v1.GET("/stock/kline/:code", stockApi.GetKlineData) + v1.GET("/limit-board/overview", limitBoardApi.GetOverview) } return r } diff --git a/internal/service/limit_board_service.go b/internal/service/limit_board_service.go new file mode 100644 index 0000000..e576071 --- /dev/null +++ b/internal/service/limit_board_service.go @@ -0,0 +1,271 @@ +package service + +import ( + "fmt" + "sort" + + "gorm.io/gorm" + "level_go/internal/model" +) + +type LimitBoardService struct { + DB *gorm.DB +} + +type limitBoardHistoryRow struct { + TsCode string `gorm:"column:ts_code"` + TradeDate string `gorm:"column:trade_date"` +} + +func boardLabel(boardCount int) string { + switch boardCount { + case 1: + return "首板" + case 2: + return "二板" + case 3: + return "三板" + case 4: + return "四板" + case 5: + return "五板" + case 6: + return "六板" + case 7: + return "七板" + case 8: + return "八板" + case 9: + return "九板" + default: + return fmt.Sprintf("%d板", boardCount) + } +} + +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("stock_limit_data"). + Select("ts_code, DATE_FORMAT(trade_date, '%Y-%m-%d') AS trade_date"). + Where("trade_date BETWEEN ? AND ? AND 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"). + Select("DATE_FORMAT(cal_date, '%Y-%m-%d') AS trade_date"). + Where("exchange = ? AND is_open = ? AND cal_date <= ?", "SSE", 1, tradeDate). + Order("cal_date ASC"). + Scan(&tradeDates).Error; err != nil { + return nil, -1, err + } + + dateIndex := -1 + for i, d := range tradeDates { + if d == tradeDate { + dateIndex = i + break + } + } + + 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(d.close, l.close, 0) AS close, + COALESCE(l.limit_price, 0) AS limit_price, + COALESCE(d.pct_chg, 0) AS pct_chg + FROM stock_limit_data l + LEFT JOIN stock_daily d + ON d.ts_code = l.ts_code AND d.trade_date = l.trade_date + WHERE l.trade_date = ? AND l.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("stock_limit_data"). + Select("ts_code, DATE_FORMAT(trade_date, '%Y-%m-%d') AS trade_date"). + Where("ts_code IN ? AND trade_date <= ? AND 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 +} -- 2.43.0