diff --git a/docs/api-overview.md b/docs/api-overview.md index 46bff07..1ea190f 100644 --- a/docs/api-overview.md +++ b/docs/api-overview.md @@ -4,7 +4,7 @@ - 服务前缀:`/api/v1` - 响应格式:`application/json` -- 当前项目接口总数:5 +- 当前项目接口总数:7 ## 2. 接口列表 @@ -15,6 +15,8 @@ | 股票行情 | `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) | +| 涨停开盘阈值分组 | `GET` | `/api/v1/limit-board/open-threshold` | 获取查询日和前一交易日均涨停的股票,并按开盘价是否低于前一交易日收盘价 101% 分组 | [limit-board-open-threshold.md](./limit-board-open-threshold.md) | +| 下载 | `GET` | `/api/v1/download/limit-board/second-board-profit-csv` | 下载符合二板早盘开盘阈值条件的次日盈利 CSV 明细 | [download-second-board-profit-csv.md](./download-second-board-profit-csv.md) | ## 3. 错误响应约定 diff --git a/docs/download-second-board-profit-csv.md b/docs/download-second-board-profit-csv.md new file mode 100644 index 0000000..adf9d31 --- /dev/null +++ b/docs/download-second-board-profit-csv.md @@ -0,0 +1,69 @@ +# 二板早盘次日盈利 CSV 下载接口文档 + +## 1. 接口说明 + +- 接口名称:二板早盘次日盈利 CSV 下载 +- 请求方式:`GET` +- 请求路径:`/api/v1/download/limit-board/second-board-profit-csv` +- 接口用途:下载符合二板早盘开盘阈值条件的股票明细,并查看后一交易日是否盈利。 + +## 2. 请求参数 + +| 参数名 | 类型 | 必填 | 默认值 | 示例 | 说明 | +| --- | --- | --- | --- | --- | --- | +| `start_date` | `string` | 否 | `2020-01-01` | `2026-01-01` | 开始交易日期,格式固定为 `YYYY-MM-DD` | +| `end_date` | `string` | 否 | `2026-12-31` | `2026-12-31` | 结束交易日期,格式固定为 `YYYY-MM-DD` | + +- `start_date` 可以等于 `end_date`,用于下载单日数据。 +- `end_date` 不能早于 `start_date`。 + +请求示例: + +```http +GET /api/v1/download/limit-board/second-board-profit-csv?start_date=2026-01-01&end_date=2026-12-31 +``` + +单日下载示例: + +```http +GET /api/v1/download/limit-board/second-board-profit-csv?start_date=2026-04-29&end_date=2026-04-29 +``` + +## 3. 响应说明 + +- 响应类型:`text/csv; charset=utf-8` +- 响应头:`Content-Disposition: attachment; filename="limit_second_board_profit_YYYYMMDD_YYYYMMDD_daily_detail.csv"` +- CSV 文件带 UTF-8 BOM,便于 Excel 直接打开中文列名。 + +## 4. CSV 字段 + +| 字段名 | 说明 | +| --- | --- | +| `交易日` | 符合条件的交易日 | +| `前一交易日` | 交易日的前一个交易日 | +| `前二交易日` | 交易日的前两个交易日 | +| `后一交易日` | 用于判断盈利的后一交易日 | +| `股票代码` | TS 股票代码 | +| `股票简称` | 股票名称 | +| `市场` | 市场信息 | +| `行业` | 所属行业 | +| `T日开盘价` | 交易日开盘价 | +| `T日收盘价` | 交易日收盘价 | +| `T日涨停价` | 理论涨停价 | +| `T-1收盘价` | 前一交易日收盘价 | +| `开盘/T-1收盘%` | 交易日开盘价相对前一交易日收盘价的百分比 | +| `首次涨停时间` | 交易日首次涨停时间,格式化为 `HH:MM:SS` | +| `T+1开盘收益%` | 后一交易日开盘价相对 T 日收盘价的收益率 | +| `T+1最高收益%` | 后一交易日最高价相对 T 日收盘价的收益率 | +| `T+1收盘收益%` | 后一交易日收盘价相对 T 日收盘价的收益率 | +| `T+1收盘是否盈利` | `T+1收盘收益% > 0` 时为 `是`,否则为 `否` | +| `T+1盘中是否可盈利` | `T+1最高收益% > 0` 时为 `是`,否则为 `否` | + +## 5. 业务规则 + +- T 日涨停。 +- T-1 交易日也涨停。 +- T-2 交易日必须不涨停。 +- T 日开盘价必须低于 T-1 收盘价的 101%。 +- T 日首次涨停时间必须早于 11:00。 +- 盈利口径默认按 T 日收盘价买入,T+1 收盘价卖出。 diff --git a/docs/limit-board-open-lower-groups.md b/docs/limit-board-open-lower-groups.md index d638840..c753d65 100644 --- a/docs/limit-board-open-lower-groups.md +++ b/docs/limit-board-open-lower-groups.md @@ -74,7 +74,7 @@ GET /api/v1/limit-board/open-lower-groups?trade_date=2026-04-24 | `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_first_limit_time` | `string \| null` | Tushare 首次封板时间,可能为 `09:35:12` 或 `93200` 这类紧凑格式 | | `ak_last_limit_time` | `string \| null` | AKShare 最后封板时间 | | `ak_break_limit_count` | `int \| null` | AKShare 炸板次数 | | `ak_limit_up_stat` | `string \| null` | AKShare 涨停统计 | @@ -145,12 +145,12 @@ GET /api/v1/limit-board/open-lower-groups?trade_date=2026-04-24 ## 5. 业务规则 -- 只统计指定交易日 `stock_limit_data.status = 1` 且前一交易日 `stock_limit_data.status = 1` 的股票。 +- 只统计指定交易日 `stock_limit_combined_data.final_status = 1` 且前一交易日 `stock_limit_combined_data.final_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` 设为 `首二一一`。 +- 在 `open_lower_previous_close` 分组内,如果同时满足 `is_first_to_second_board = true` 且 `ak_first_limit_time` 早于 `11:00`,返回 `is_first_second_before_eleven = true`,并将 `highlight_label` 设为 `首二一一`。`93200` 会按 `09:32:00` 处理。 - 每个分组内的 `stocks` 按 `pct_chg` 从大到小排序;如果 `pct_chg` 相同,则按 `ts_code` 升序排序。 ## 6. 错误响应 diff --git a/docs/limit-board-open-threshold.md b/docs/limit-board-open-threshold.md new file mode 100644 index 0000000..1be1af2 --- /dev/null +++ b/docs/limit-board-open-threshold.md @@ -0,0 +1,164 @@ +# 涨停开盘阈值分组接口文档 + +## 1. 接口说明 + +- 接口名称:涨停开盘阈值分组 +- 请求方式:`GET` +- 请求路径:`/api/v1/limit-board/open-threshold` +- 接口用途:查询指定交易日涨停且前一交易日也涨停的股票,并按指定交易日开盘价是否低于前一交易日收盘价的 101% 分为两组。 + +## 2. 请求参数 + +| 参数名 | 类型 | 必填 | 示例 | 说明 | +| --- | --- | --- | --- | --- | +| `trade_date` | `string` | 是 | `2026-04-24` | 交易日,格式固定为 `YYYY-MM-DD` | + +请求示例: + +```http +GET /api/v1/limit-board/open-threshold?trade_date=2026-04-24 +``` + +## 3. 响应说明 + +### 3.1 顶层字段 + +| 字段名 | 类型 | 说明 | +| --- | --- | --- | +| `trade_date` | `string` | 查询的交易日 | +| `previous_trade_date` | `string` | 前一交易日;如果查询日没有前一交易日则为空字符串 | +| `summary` | `object` | 摘要信息 | +| `groups` | `array` | 分组后的股票列表,固定返回两组 | + +### 3.2 summary 字段 + +| 字段名 | 类型 | 说明 | +| --- | --- | --- | +| `limit_count` | `int` | 指定交易日涨停且前一交易日也涨停的股票总数 | +| `open_within_threshold_count` | `int` | 指定交易日开盘价低于前一交易日收盘价 101% 的股票数量 | +| `open_at_or_above_threshold_count` | `int` | 指定交易日开盘价大于等于前一交易日收盘价 101% 的股票数量 | +| `threshold_ratio` | `number` | 分组阈值,固定为 `1.01` | + +### 3.3 groups 字段 + +| 字段名 | 类型 | 说明 | +| --- | --- | --- | +| `key` | `string` | 分组标识,取值为 `open_within_previous_close_101` 或 `open_at_or_above_previous_close_101` | +| `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_close` | `number` | 前一交易日收盘价,优先取前一交易日 `stock_limit_combined_data.calc_close/tushare_close` | +| `open_threshold_price` | `number` | 分组阈值价格,即 `previous_close * 1.01` | +| `open_previous_close_ratio` | `number` | 当日开盘价 / 前一交易日收盘价 | +| `limit_price` | `number` | 理论涨停价 | +| `pct_chg` | `number` | 当日涨跌幅 | +| `ak_amount` | `number \| null` | Tushare 成交额 | +| `ak_float_market_value` | `number \| null` | Tushare 流通市值 | +| `ak_total_market_value` | `number \| null` | Tushare 总市值 | +| `ak_turnover_rate` | `number \| null` | Tushare 换手率 | +| `ak_seal_amount` | `number \| null` | Tushare 封板资金 | +| `ak_first_limit_time` | `string \| null` | Tushare 首次封板时间,可能为 `09:35:12` 或 `93200` 这类紧凑格式 | +| `ak_last_limit_time` | `string \| null` | Tushare 最后封板时间 | +| `ak_break_limit_count` | `int \| null` | Tushare 炸板次数 | +| `ak_limit_up_stat` | `string \| null` | Tushare 涨停统计 | +| `ak_limit_up_days` | `int \| null` | Tushare 连板数 | +| `ak_industry` | `string \| null` | Tushare 所属行业 | +| `vol` | `int \| null` | 成交量 | + +## 4. 响应示例 + +```json +{ + "trade_date": "2026-04-24", + "previous_trade_date": "2026-04-23", + "summary": { + "limit_count": 23, + "open_within_threshold_count": 8, + "open_at_or_above_threshold_count": 15, + "threshold_ratio": 1.01 + }, + "groups": [ + { + "key": "open_within_previous_close_101", + "label": "开盘价低于前收101%", + "count": 8, + "stocks": [ + { + "symbol": "000001", + "ts_code": "000001.SZ", + "name": "示例股份", + "market": "主板", + "trade_date": "2026-04-24", + "open": 10.09, + "close": 11.0, + "previous_trade_date": "2026-04-23", + "previous_close": 10.0, + "open_threshold_price": 10.1, + "open_previous_close_ratio": 1.009, + "limit_price": 11.0, + "pct_chg": 10.0, + "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": "93200", + "ak_last_limit_time": "144208", + "ak_break_limit_count": 2, + "ak_limit_up_stat": "2天2板", + "ak_limit_up_days": 2, + "ak_industry": "软件开发", + "vol": 123456789 + } + ] + }, + { + "key": "open_at_or_above_previous_close_101", + "label": "开盘价大于等于前收101%", + "count": 15, + "stocks": [] + } + ] +} +``` + +## 5. 业务规则 + +- 只统计指定交易日 `stock_limit_combined_data.final_status = 1` 且前一交易日 `stock_limit_combined_data.final_status = 1` 的股票。 +- 前一交易日通过 `trade_calendar` 中 `exchange = 'SSE'` 且 `is_open = 1` 的交易日确定。 +- 分组阈值为 `previous_close * 1.01`。 +- 当 `open < previous_close * 1.01` 时,股票进入 `open_within_previous_close_101` 分组。 +- 当 `open >= previous_close * 1.01` 时,股票进入 `open_at_or_above_previous_close_101` 分组。 +- 如果当日开盘价或前一交易日收盘价缺失,股票进入 `open_at_or_above_previous_close_101` 分组。 +- 每个分组内的 `stocks` 按 `pct_chg` 从大到小排序;如果 `pct_chg` 相同,则按 `ts_code` 升序排序。 + +## 6. 错误响应 + +当缺少 `trade_date` 参数时: + +```json +{ + "error": "trade_date 参数不能为空" +} +``` + +当服务端内部异常时: + +```json +{ + "error": "具体错误信息" +} +``` diff --git a/docs/limit-board-overview.md b/docs/limit-board-overview.md index 8584bcd..3461238 100644 --- a/docs/limit-board-overview.md +++ b/docs/limit-board-overview.md @@ -71,7 +71,7 @@ GET /api/v1/limit-board/overview?trade_date=2026-04-24 | `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_first_limit_time` | `string \| null` | Tushare 首次封板时间,可能为 `09:35:12` 或 `93200` 这类紧凑格式 | | `ak_last_limit_time` | `string \| null` | AKShare 最后封板时间 | | `ak_break_limit_count` | `int \| null` | AKShare 炸板次数 | | `ak_limit_up_stat` | `string \| null` | AKShare 涨停统计 | @@ -152,7 +152,7 @@ GET /api/v1/limit-board/overview?trade_date=2026-04-24 ## 6. 业务规则 -- 只统计 `stock_limit_data.status = 1` 的记录,即涨停股票。 +- 只统计 `stock_limit_combined_data.final_status = 1` 的记录,即涨停股票。 - 连板数的计算会结合 `trade_calendar` 开市日历处理,不会被周末或节假日打断。 - 首板、二板、三板等分组由后端直接计算,前端不需要二次处理。 - `summary.max_board_count` 即当天涨停强度最高的板数。 diff --git a/docs/stock-analyze.md b/docs/stock-analyze.md index 262afd1..e77eeec 100644 --- a/docs/stock-analyze.md +++ b/docs/stock-analyze.md @@ -76,7 +76,7 @@ Content-Type: application/json ## 6. 业务规则 -- 仅统计 `stock_limit_data.status = 1` 的涨停股票。 +- 仅统计 `stock_limit_combined_data.final_status = 1` 的涨停股票。 - `start_date` 用于确定样本池,即当日涨停股票。 - `end_date` 用于限定后续表现统计区间。 - 如果某只股票在区间内只有涨停当天数据,则不会进入最终结果。 diff --git a/internal/api/limit_board_handler.go b/internal/api/limit_board_handler.go index e05af69..78de8c9 100644 --- a/internal/api/limit_board_handler.go +++ b/internal/api/limit_board_handler.go @@ -47,3 +47,19 @@ func (h *LimitBoardHandler) GetOpenLowerGroups(c *gin.Context) { c.JSON(http.StatusOK, resp) } + +func (h *LimitBoardHandler) GetOpenThresholdGroups(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.GetLimitOpenThresholdGroups(req.TradeDate) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, resp) +} diff --git a/internal/api/limit_second_profit_csv_handler.go b/internal/api/limit_second_profit_csv_handler.go new file mode 100644 index 0000000..17d2c01 --- /dev/null +++ b/internal/api/limit_second_profit_csv_handler.go @@ -0,0 +1,329 @@ +package api + +import ( + "database/sql" + "encoding/csv" + "fmt" + "math" + "net/http" + "strconv" + "strings" + "time" + + "github.com/gin-gonic/gin" +) + +const ( + defaultSecondProfitStartDate = "2020-01-01" + defaultSecondProfitEndDate = "2026-12-31" +) + +type secondProfitCSVRow 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"` + TsCode string `gorm:"column:ts_code"` + Symbol string `gorm:"column:symbol"` + Name string `gorm:"column:name"` + Market string `gorm:"column:market"` + Industry string `gorm:"column:industry"` + Open float64 `gorm:"column:open"` + Close float64 `gorm:"column:close_price"` + LimitPrice float64 `gorm:"column:limit_price"` + PreviousClose float64 `gorm:"column:previous_close"` + FirstLimitTime sql.NullString `gorm:"column:tushare_first_time"` + PctChg sql.NullFloat64 `gorm:"column:pct_chg"` + Amount sql.NullFloat64 `gorm:"column:tushare_amount"` + TurnoverRate sql.NullFloat64 `gorm:"column:tushare_turnover_ratio"` + SealAmount sql.NullFloat64 `gorm:"column:tushare_fd_amount"` + BreakLimitCount sql.NullInt64 `gorm:"column:tushare_open_times"` + LimitUpStat string `gorm:"column:limit_up_stat"` + NextOpen sql.NullFloat64 `gorm:"column:next_open"` + NextHigh sql.NullFloat64 `gorm:"column:next_high"` + NextLow sql.NullFloat64 `gorm:"column:next_low"` + NextClose sql.NullFloat64 `gorm:"column:next_close"` + OpenPreviousCloseRatio float64 + NextOpenReturnPct sql.NullFloat64 + NextHighReturnPct sql.NullFloat64 + NextCloseReturnPct sql.NullFloat64 + NextCloseProfitable string + NextIntradayProfitable string +} + +func (h *LimitBoardHandler) DownloadSecondBoardProfitCSV(c *gin.Context) { + startDate := c.DefaultQuery("start_date", defaultSecondProfitStartDate) + endDate := c.DefaultQuery("end_date", defaultSecondProfitEndDate) + if err := validateSecondProfitDateRange(startDate, endDate); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + rows, err := h.loadSecondProfitCSVRows(startDate, endDate) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + filename := fmt.Sprintf("limit_second_board_profit_%s_%s_daily_detail.csv", + strings.ReplaceAll(startDate, "-", ""), + strings.ReplaceAll(endDate, "-", ""), + ) + 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(secondProfitCSVHeaders()) + for _, row := range rows { + if err := writer.Write(secondProfitCSVRecord(row)); err != nil { + return + } + } +} + +func validateSecondProfitDateRange(startDate, endDate string) error { + start, err := time.Parse("2006-01-02", startDate) + if err != nil { + return fmt.Errorf("start_date 格式必须为 YYYY-MM-DD") + } + end, err := time.Parse("2006-01-02", endDate) + if err != nil { + return fmt.Errorf("end_date 格式必须为 YYYY-MM-DD") + } + if end.Before(start) { + return fmt.Errorf("end_date 不能早于 start_date") + } + return nil +} + +func (h *LimitBoardHandler) loadSecondProfitCSVRows(startDate, endDate string) ([]secondProfitCSVRow, error) { + query := ` +WITH open_calendar AS ( + SELECT + DATE_FORMAT(cal_date, '%Y-%m-%d') AS trade_date, + LAG(DATE_FORMAT(cal_date, '%Y-%m-%d'), 1) OVER (ORDER BY cal_date) AS previous_trade_date, + LAG(DATE_FORMAT(cal_date, '%Y-%m-%d'), 2) OVER (ORDER BY cal_date) AS previous_two_trade_date, + LEAD(DATE_FORMAT(cal_date, '%Y-%m-%d'), 1) OVER (ORDER BY cal_date) AS next_trade_date + FROM trade_calendar + WHERE exchange = 'SSE' AND is_open = 1 +) +SELECT + c.trade_date, + c.previous_trade_date, + c.previous_two_trade_date, + COALESCE(c.next_trade_date, '') AS next_trade_date, + l.ts_code, + COALESCE(l.symbol, '') AS symbol, + COALESCE(l.name, '') AS name, + COALESCE(l.market, '') AS market, + COALESCE(l.tushare_industry, '') AS industry, + d.open, + COALESCE(l.calc_close, l.tushare_close, d.close) AS close_price, + COALESCE(l.calc_limit_price, 0) AS limit_price, + COALESCE(pl.calc_close, pl.tushare_close, pd.close) AS previous_close, + l.tushare_first_time, + COALESCE(l.tushare_pct_chg, d.pct_chg) AS pct_chg, + l.tushare_amount, + l.tushare_turnover_ratio, + l.tushare_fd_amount, + l.tushare_open_times, + COALESCE(l.tushare_up_stat, '') AS limit_up_stat, + 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 = 1 +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 = l.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 +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.open IS NOT NULL + AND COALESCE(pl.calc_close, pl.tushare_close, pd.close) > 0 + AND d.open < COALESCE(pl.calc_close, pl.tushare_close, pd.close) * 1.01 +ORDER BY c.trade_date ASC, l.ts_code ASC` + + var rawRows []secondProfitCSVRow + if err := h.Service.DB.Raw(query, startDate, endDate).Scan(&rawRows).Error; err != nil { + return nil, err + } + + rows := make([]secondProfitCSVRow, 0, len(rawRows)) + for _, row := range rawRows { + if !isSecondProfitBeforeEleven(row.FirstLimitTime.String) { + continue + } + row.OpenPreviousCloseRatio = row.Open / row.PreviousClose + if row.Close > 0 { + row.NextOpenReturnPct = secondProfitPctReturn(row.NextOpen, row.Close) + row.NextHighReturnPct = secondProfitPctReturn(row.NextHigh, row.Close) + row.NextCloseReturnPct = secondProfitPctReturn(row.NextClose, row.Close) + } + row.NextCloseProfitable = secondProfitLabel(row.NextCloseReturnPct) + row.NextIntradayProfitable = secondProfitLabel(row.NextHighReturnPct) + rows = append(rows, row) + } + return rows, nil +} + +func secondProfitCSVHeaders() []string { + return []string{ + "交易日", "前一交易日", "前二交易日", "后一交易日", "股票代码", "股票简称", "市场", "行业", + "T日开盘价", "T日收盘价", "T日涨停价", "T-1收盘价", "开盘/T-1收盘%", "首次涨停时间", + "T日涨跌幅%", "成交额", "换手率%", "封单额", "开板次数", "涨停统计", + "T+1开盘价", "T+1最高价", "T+1最低价", "T+1收盘价", + "T+1开盘收益%", "T+1最高收益%", "T+1收盘收益%", "T+1收盘是否盈利", "T+1盘中是否可盈利", + } +} + +func secondProfitCSVRecord(row secondProfitCSVRow) []string { + return []string{ + row.TradeDate, + row.PreviousTradeDate, + row.PreviousTwoTradeDate, + row.NextTradeDate, + row.TsCode, + row.Name, + row.Market, + row.Industry, + secondProfitFormatFloat(row.Open), + secondProfitFormatFloat(row.Close), + secondProfitFormatFloat(row.LimitPrice), + secondProfitFormatFloat(row.PreviousClose), + secondProfitFormatFloat(secondProfitRound2(row.OpenPreviousCloseRatio * 100)), + secondProfitFormatLimitTime(row.FirstLimitTime.String), + secondProfitFormatNullFloat(row.PctChg), + secondProfitFormatNullFloat(row.Amount), + secondProfitFormatNullFloat(row.TurnoverRate), + secondProfitFormatNullFloat(row.SealAmount), + secondProfitFormatNullInt(row.BreakLimitCount), + row.LimitUpStat, + secondProfitFormatNullFloat(row.NextOpen), + secondProfitFormatNullFloat(row.NextHigh), + secondProfitFormatNullFloat(row.NextLow), + secondProfitFormatNullFloat(row.NextClose), + secondProfitFormatNullFloat(row.NextOpenReturnPct), + secondProfitFormatNullFloat(row.NextHighReturnPct), + secondProfitFormatNullFloat(row.NextCloseReturnPct), + row.NextCloseProfitable, + row.NextIntradayProfitable, + } +} + +func isSecondProfitBeforeEleven(limitTime string) bool { + timeText := strings.TrimSpace(limitTime) + if timeText == "" { + return false + } + + hour, minute, second := 0, 0, 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 secondProfitFormatLimitTime(value string) string { + timeText := strings.TrimSpace(value) + if timeText == "" || 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 secondProfitPctReturn(value sql.NullFloat64, base float64) sql.NullFloat64 { + if !value.Valid || base == 0 { + return sql.NullFloat64{} + } + return sql.NullFloat64{Float64: secondProfitRound2((value.Float64 - base) / base * 100), Valid: true} +} + +func secondProfitLabel(value sql.NullFloat64) string { + if !value.Valid { + return "无次日数据" + } + if value.Float64 > 0 { + return "是" + } + return "否" +} + +func secondProfitRound2(value float64) float64 { + return math.Round(value*100) / 100 +} + +func secondProfitFormatFloat(value float64) string { + return strconv.FormatFloat(value, 'f', -1, 64) +} + +func secondProfitFormatNullFloat(value sql.NullFloat64) string { + if !value.Valid { + return "" + } + return secondProfitFormatFloat(value.Float64) +} + +func secondProfitFormatNullInt(value sql.NullInt64) string { + if !value.Valid { + return "" + } + return strconv.FormatInt(value.Int64, 10) +} diff --git a/internal/model/limit_board.go b/internal/model/limit_board.go index 772e441..e487bbf 100644 --- a/internal/model/limit_board.go +++ b/internal/model/limit_board.go @@ -62,6 +62,36 @@ type LimitOpenCompareStock struct { Vol *int64 `json:"vol"` } +type LimitOpenThresholdStock 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"` + HasPreviousClose bool `json:"-"` + PreviousClose float64 `json:"previous_close"` + OpenThresholdPrice float64 `json:"open_threshold_price"` + OpenPreviousCloseRatio float64 `json:"open_previous_close_ratio"` + 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"` @@ -69,6 +99,13 @@ type LimitOpenCompareGroup struct { Stocks []LimitOpenCompareStock `json:"stocks"` } +type LimitOpenThresholdGroup struct { + Key string `json:"key"` + Label string `json:"label"` + Count int `json:"count"` + Stocks []LimitOpenThresholdStock `json:"stocks"` +} + type LimitOpenCompareSummary struct { LimitCount int `json:"limit_count"` OpenLowerPreviousCloseCount int `json:"open_lower_previous_close_count"` @@ -77,6 +114,13 @@ type LimitOpenCompareSummary struct { FirstSecondBeforeElevenCount int `json:"first_second_before_eleven_count"` } +type LimitOpenThresholdSummary struct { + LimitCount int `json:"limit_count"` + OpenWithinThresholdCount int `json:"open_within_threshold_count"` + OpenAtOrAboveThresholdCount int `json:"open_at_or_above_threshold_count"` + ThresholdRatio float64 `json:"threshold_ratio"` +} + type LimitOpenCompareResponse struct { TradeDate string `json:"trade_date"` PreviousTradeDate string `json:"previous_trade_date"` @@ -85,6 +129,13 @@ type LimitOpenCompareResponse struct { Groups []LimitOpenCompareGroup `json:"groups"` } +type LimitOpenThresholdResponse struct { + TradeDate string `json:"trade_date"` + PreviousTradeDate string `json:"previous_trade_date"` + Summary LimitOpenThresholdSummary `json:"summary"` + Groups []LimitOpenThresholdGroup `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 0cff153..6558db3 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -13,6 +13,7 @@ func NewRouter(commonApi *api.CommonHandler, stockApi *api.StockHandler, limitBo c.Writer.Header().Set("Access-Control-Allow-Origin", "*") c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, GET, OPTIONS") c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type") + c.Writer.Header().Set("Access-Control-Expose-Headers", "Content-Disposition") if c.Request.Method == "OPTIONS" { c.AbortWithStatus(204) return @@ -25,8 +26,19 @@ func NewRouter(commonApi *api.CommonHandler, stockApi *api.StockHandler, limitBo 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) - v1.GET("/limit-board/open-lower-groups", limitBoardApi.GetOpenLowerGroups) + + limitBoard := v1.Group("/limit-board") + { + limitBoard.GET("/overview", limitBoardApi.GetOverview) + limitBoard.GET("/open-lower-groups", limitBoardApi.GetOpenLowerGroups) + limitBoard.GET("/open-threshold", limitBoardApi.GetOpenThresholdGroups) + limitBoard.GET("/open-threshold-groups", limitBoardApi.GetOpenThresholdGroups) + } + + download := v1.Group("/download") + { + download.GET("/limit-board/second-board-profit-csv", limitBoardApi.DownloadSecondBoardProfitCSV) + } } return r } diff --git a/internal/service/limit_board_service.go b/internal/service/limit_board_service.go index 78e5164..a672233 100644 --- a/internal/service/limit_board_service.go +++ b/internal/service/limit_board_service.go @@ -14,6 +14,8 @@ type LimitBoardService struct { DB *gorm.DB } +const stockLimitCombinedTable = "stock_limit_combined_data" + type limitBoardHistoryRow struct { TsCode string `gorm:"column:ts_code"` TradeDate string `gorm:"column:trade_date"` @@ -147,6 +149,59 @@ func (s *LimitBoardService) GetLimitOpenCompareGroups(tradeDate string) (model.L 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 @@ -164,10 +219,13 @@ func isBeforeEleven(limitTime *string) bool { if len(timeText) < 4 { return false } - if len(timeText) == 4 { + switch len(timeText) { + case 4: timeText += "00" - } - if len(timeText) != 6 { + case 5: + timeText = "0" + timeText + case 6: + default: return false } var err error @@ -202,9 +260,9 @@ func (s *LimitBoardService) buildRecentMaxBoardStats(tradeDates []string, dateIn } var history []limitBoardHistoryRow - if err := s.DB.Table("stock_limit_data"). + 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 status = ?", windowDates[0], windowDates[len(windowDates)-1], 1). + 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 @@ -283,25 +341,25 @@ func (s *LimitBoardService) loadCurrentLimitStocks(tradeDate string) ([]model.Li 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, - 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 + 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 d.ts_code = l.ts_code AND d.trade_date = l.trade_date - WHERE l.trade_date = ? AND l.status = 1 + 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 @@ -318,38 +376,38 @@ func (s *LimitBoardService) loadLimitOpenCompareStocks(tradeDate, previousTradeD 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, + 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_data ppl - WHERE ppl.ts_code = l.ts_code AND ppl.trade_date = ? AND ppl.status = 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.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 + 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 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 + 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 == "" { @@ -360,6 +418,52 @@ func (s *LimitBoardService) loadLimitOpenCompareStocks(tradeDate, previousTradeD 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 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_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 { @@ -367,9 +471,9 @@ func (s *LimitBoardService) loadLimitHistory(stocks []model.LimitBoardStock, tra } var history []limitBoardHistoryRow - if err := s.DB.Table("stock_limit_data"). + 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 status = ?", codes, tradeDate, 1). + Where("ts_code IN ? AND trade_date <= ? AND final_status = ?", codes, tradeDate, 1). Scan(&history).Error; err != nil { return nil, err } @@ -470,6 +574,33 @@ func buildLimitOpenCompareGroups(openLowerStocks, otherStocks []model.LimitOpenC } } +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 { @@ -478,3 +609,12 @@ func sortLimitOpenCompareStocks(stocks []model.LimitOpenCompareStock) { 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 + }) +} diff --git a/internal/service/stock_service.go b/internal/service/stock_service.go index ee9ddaa..6bd93ae 100644 --- a/internal/service/stock_service.go +++ b/internal/service/stock_service.go @@ -14,7 +14,7 @@ type StockService struct { func (s *StockService) GetHistoryKline(code string) ([]model.StockDaily, error) { var dailies []model.StockDaily - startDate := "2024-09-24" + startDate := "2020-01-02" err := s.DB.Table("stock_daily"). Where("ts_code = ? AND trade_date >= ?", code, startDate). @@ -38,9 +38,9 @@ func (s *StockService) AnalyzePerformance(startDate, endDate string) ([]model.An } // 查询涨停股 - query := `SELECT l.ts_code, l.name, d.open, d.high FROM stock_limit_data l + query := `SELECT l.ts_code, l.name, d.open, COALESCE(l.calc_high, d.high, 0) AS high FROM stock_limit_combined_data l LEFT JOIN stock_daily d ON l.ts_code = d.ts_code AND l.trade_date = d.trade_date - WHERE l.trade_date = ? AND l.status = 1` + WHERE l.trade_date = ? AND l.final_status = 1` if err := s.DB.Raw(query, startDate).Scan(&limits).Error; err != nil { return nil, err