diff --git a/.gitignore b/.gitignore index e5d2ac2..fe70846 100644 --- a/.gitignore +++ b/.gitignore @@ -9,4 +9,5 @@ *.iml # 依赖目录 (如果使用 vendor 模式) -# vendor/ \ No newline at end of file +# vendor/ +config.yaml \ No newline at end of file diff --git a/config.yaml b/config.yaml index 0aa60d5..47d78c0 100644 --- a/config.yaml +++ b/config.yaml @@ -6,6 +6,7 @@ # port: 10836 # dbname: "level" # charset: "utf8mb4" +# collation: "utf8mb4_unicode_ci" # 本地数据库 database: @@ -15,6 +16,7 @@ database: port: 3306 dbname: "level" charset: "utf8mb4" + collation: "utf8mb4_unicode_ci" server: - port: 18080 \ No newline at end of file + port: 18080 diff --git a/docs/api-overview.md b/docs/api-overview.md index 1ea190f..d97b773 100644 --- a/docs/api-overview.md +++ b/docs/api-overview.md @@ -4,7 +4,7 @@ - 服务前缀:`/api/v1` - 响应格式:`application/json` -- 当前项目接口总数:7 +- 当前项目接口总数:8 ## 2. 接口列表 @@ -16,7 +16,8 @@ | 涨停连板 | `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) | +| 二板早盘次日盈利 | `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/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 index adf9d31..ce86189 100644 --- a/docs/download-second-board-profit-csv.md +++ b/docs/download-second-board-profit-csv.md @@ -1,69 +1,182 @@ -# 二板早盘次日盈利 CSV 下载接口文档 +# 二板早盘次日盈利接口文档 ## 1. 接口说明 -- 接口名称:二板早盘次日盈利 CSV 下载 -- 请求方式:`GET` -- 请求路径:`/api/v1/download/limit-board/second-board-profit-csv` -- 接口用途:下载符合二板早盘开盘阈值条件的股票明细,并查看后一交易日是否盈利。 +本页包含二板早盘次日盈利的列表查询接口和 CSV 下载接口。两个接口使用同一套查询条件和同一套返回字段;列表接口分页,CSV 下载接口导出筛选后的全量数据。 -## 2. 请求参数 +业务筛选基础条件: + +- T-2 交易日无涨停。 +- T-1 交易日涨停。 +- T 交易日涨停。 + +## 2. 接口地址 + +| 接口名称 | 请求方式 | 请求路径 | 说明 | +| --- | --- | --- | --- | +| 二板早盘次日盈利列表查询 | `GET` | `/api/v1/limit-board/second-board-profit` | 前端页面分页展示数据 | +| 二板早盘次日盈利 CSV 下载 | `GET` | `/api/v1/download/limit-board/second-board-profit-csv` | 下载筛选条件范围内的全量 CSV 数据 | + +## 3. 请求参数 | 参数名 | 类型 | 必填 | 默认值 | 示例 | 说明 | | --- | --- | --- | --- | --- | --- | -| `start_date` | `string` | 否 | `2020-01-01` | `2026-01-01` | 开始交易日期,格式固定为 `YYYY-MM-DD` | -| `end_date` | `string` | 否 | `2026-12-31` | `2026-12-31` | 结束交易日期,格式固定为 `YYYY-MM-DD` | +| `tradeStartDate` | `string` | 否 | `2020-01-01` | `2026-01-01` | 交易日开始日期,格式固定为 `YYYY-MM-DD` | +| `tradeEndDate` | `string` | 否 | `2026-12-31` | `2026-12-31` | 交易日结束日期,格式固定为 `YYYY-MM-DD` | +| `limitUpTimeFilterType1` | `string` | 否 | 空 | `>=` | T 日首次涨停时间第一组筛选操作符,支持 `<`、`>`、`<=`、`>=`、`=` | +| `limitUpTimeFilterValue1` | `string` | 否 | 空 | `09:30:00` | T 日首次涨停时间第一组筛选值 | +| `limitUpTimeFilterType2` | `string` | 否 | 空 | `<` | T 日首次涨停时间第二组筛选操作符,支持 `<`、`>`、`<=`、`>=`、`=` | +| `limitUpTimeFilterValue2` | `string` | 否 | 空 | `11:00:00` | T 日首次涨停时间第二组筛选值 | +| `openPctChgFilterType1` | `string` | 否 | 空 | `>=` | T 日开盘涨跌幅第一组筛选操作符,支持 `<`、`>`、`<=`、`>=`、`=` | +| `openPctChgFilterValue1` | `number` | 否 | 空 | `-2` | T 日开盘涨跌幅第一组筛选值,按 `(T日开盘价 - T-1收盘价) / T-1收盘价 * 100` 计算 | +| `openPctChgFilterType2` | `string` | 否 | 空 | `<=` | T 日开盘涨跌幅第二组筛选操作符,支持 `<`、`>`、`<=`、`>=`、`=` | +| `openPctChgFilterValue2` | `number` | 否 | 空 | `3` | T 日开盘涨跌幅第二组筛选值,按 `(T日开盘价 - T-1收盘价) / T-1收盘价 * 100` 计算 | +| `page` | `int` | 否 | `1` | `1` | 仅列表接口支持,页码从 1 开始 | +| `pageSize` | `int` | 否 | `10` | `20` | 仅列表接口支持,每页数量 | -- `start_date` 可以等于 `end_date`,用于下载单日数据。 -- `end_date` 不能早于 `start_date`。 +兼容说明:CSV 下载接口仍兼容旧参数 `start_date` 和 `end_date`,建议新调用统一使用 `tradeStartDate` 和 `tradeEndDate`。 -请求示例: +筛选规则: + +- 每个筛选项最多支持两组条件,第一组为 `Type1 + Value1`,第二组为 `Type2 + Value2`。 +- 四个值都可以为空;当某组 `Type` 和 `Value` 都为空时,该组条件不生效。 +- 如果某组条件生效,`Type` 和 `Value` 必须同时填写。 +- 两组条件同时存在时按 AND 关系过滤,可用于表达区间,例如 `>=09:30:00` 且 `<11:00:00`。 +- 时间值支持 `HH:MM`、`HH:MM:SS`、`HHMM`、`HHMMSS` 格式。 +- 实际请求 URL 中 `<`、`>`、`=` 等字符建议做 URL 编码,例如 `>=` 编码为 `%3E%3D`,`<` 编码为 `%3C`。 +- 后端也兼容数组式传参,例如 `limitUpTimeFilter[]=%3E%3D&limitUpTimeFilter[]=09:30:00&limitUpTimeFilter[]=%3C&limitUpTimeFilter[]=11:00:00`,数组四个值的含义依次等同于 `Type1`、`Value1`、`Type2`、`Value2`。 + +## 4. 请求示例 + +列表查询: ```http -GET /api/v1/download/limit-board/second-board-profit-csv?start_date=2026-01-01&end_date=2026-12-31 +GET /api/v1/limit-board/second-board-profit?tradeStartDate=2026-01-01&tradeEndDate=2026-12-31&page=1&pageSize=10 ``` -单日下载示例: +带筛选条件的列表查询: ```http -GET /api/v1/download/limit-board/second-board-profit-csv?start_date=2026-04-29&end_date=2026-04-29 +GET /api/v1/limit-board/second-board-profit?tradeStartDate=2026-04-01&tradeEndDate=2026-04-30&limitUpTimeFilterType1=%3E%3D&limitUpTimeFilterValue1=09:30:00&limitUpTimeFilterType2=%3C&limitUpTimeFilterValue2=11:00:00&openPctChgFilterType1=%3E%3D&openPctChgFilterValue1=-2&openPctChgFilterType2=%3C%3D&openPctChgFilterValue2=3&page=1&pageSize=20 ``` -## 3. 响应说明 +CSV 下载: + +```http +GET /api/v1/download/limit-board/second-board-profit-csv?tradeStartDate=2026-04-01&tradeEndDate=2026-04-30&limitUpTimeFilterType1=%3E%3D&limitUpTimeFilterValue1=09:30:00&limitUpTimeFilterType2=%3C&limitUpTimeFilterValue2=11:00:00&openPctChgFilterType1=%3E%3D&openPctChgFilterValue1=-2&openPctChgFilterType2=%3C%3D&openPctChgFilterValue2=3 +``` + +## 5. 列表响应 + +| 字段名 | 类型 | 说明 | +| --- | --- | --- | +| `trade_start_date` | `string` | 查询交易日开始日期 | +| `trade_end_date` | `string` | 查询交易日结束日期 | +| `limit_up_time_filter_type1` | `string` | 首次涨停时间第一组筛选操作符 | +| `limit_up_time_filter_value1` | `string` | 首次涨停时间第一组筛选值 | +| `limit_up_time_filter_type2` | `string` | 首次涨停时间第二组筛选操作符 | +| `limit_up_time_filter_value2` | `string` | 首次涨停时间第二组筛选值 | +| `open_pct_chg_filter_type1` | `string` | T 日开盘涨跌幅第一组筛选操作符 | +| `open_pct_chg_filter_value1` | `string` | T 日开盘涨跌幅第一组筛选值 | +| `open_pct_chg_filter_type2` | `string` | T 日开盘涨跌幅第二组筛选操作符 | +| `open_pct_chg_filter_value2` | `string` | T 日开盘涨跌幅第二组筛选值 | +| `page` | `int` | 当前页码 | +| `page_size` | `int` | 每页数量 | +| `total` | `int` | 筛选后的总记录数 | +| `total_pages` | `int` | 总页数 | +| `list` | `array` | 当前页数据 | + +`list[]` 字段: + +| 字段名 | 类型 | CSV 列名 | 说明 | +| --- | --- | --- | --- | +| `trade_date` | `string` | `交易日` | T 日交易日 | +| `ts_code` | `string` | `股票代码` | TS 股票代码 | +| `name` | `string` | `股票简称` | 股票简称 | +| `market` | `string` | `市场` | 市场信息 | +| `industry` | `string` | `行业` | 所属行业 | +| `next_open_profitable` | `string` | `T+1开盘是否盈利` | T+1 开盘收益大于 0 时为 `是`,否则为 `否`;无次日数据时为 `无次日数据` | +| `next_close_profitable` | `string` | `T+1收盘是否盈利` | T+1 收盘收益大于 0 时为 `是`,否则为 `否`;无次日数据时为 `无次日数据` | +| `next_intraday_profitable` | `string` | `T+1盘中是否可盈利` | T+1 最高收益大于 0 时为 `是`,否则为 `否`;无次日数据时为 `无次日数据` | +| `next_high_profit_pct` | `number \| null` | `T+1最高盈利%` | T+1 最高价相对 T 日收盘价的收益率百分比 | +| `next_low_profit_pct` | `number \| null` | `T+1最低盈利%` | T+1 最低价相对 T 日收盘价的收益率百分比 | +| `break_limit_count` | `int \| null` | `开板次数` | T 日开板次数 | +| `turnover_rate_pct` | `number \| null` | `换手率%` | T 日换手率 | +| `first_limit_time` | `string` | `首次涨停时间` | T 日首次涨停时间,格式化为 `HH:MM:SS` | +| `seal_amount` | `number \| null` | `封单额` | T 日封单额 | +| `amount` | `number \| null` | `成交额` | T 日成交额 | + +响应示例: + +```json +{ + "trade_start_date": "2026-04-01", + "trade_end_date": "2026-04-30", + "limit_up_time_filter_type1": ">=", + "limit_up_time_filter_value1": "09:30:00", + "limit_up_time_filter_type2": "<", + "limit_up_time_filter_value2": "11:00:00", + "open_pct_chg_filter_type1": ">=", + "open_pct_chg_filter_value1": "-2", + "open_pct_chg_filter_type2": "<=", + "open_pct_chg_filter_value2": "3", + "page": 1, + "page_size": 10, + "total": 26, + "total_pages": 3, + "list": [ + { + "trade_date": "2026-04-24", + "ts_code": "000001.SZ", + "name": "示例股份", + "market": "主板", + "industry": "软件开发", + "next_open_profitable": "是", + "next_close_profitable": "否", + "next_intraday_profitable": "是", + "next_high_profit_pct": 4.21, + "next_low_profit_pct": -2.15, + "break_limit_count": 2, + "turnover_rate_pct": 18.64, + "first_limit_time": "09:35:12", + "seal_amount": 286000000, + "amount": 1523000000 + } + ] +} +``` + +## 6. CSV 响应 - 响应类型:`text/csv; charset=utf-8` - 响应头:`Content-Disposition: attachment; filename="limit_second_board_profit_YYYYMMDD_YYYYMMDD_daily_detail.csv"` - CSV 文件带 UTF-8 BOM,便于 Excel 直接打开中文列名。 +- CSV 下载接口不分页,默认导出查询条件范围内的全部数据。 -## 4. CSV 字段 +CSV 字段顺序: | 字段名 | 说明 | | --- | --- | -| `交易日` | 符合条件的交易日 | -| `前一交易日` | 交易日的前一个交易日 | -| `前二交易日` | 交易日的前两个交易日 | -| `后一交易日` | 用于判断盈利的后一交易日 | +| `交易日` | T 日交易日 | | `股票代码` | 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` 时为 `是`,否则为 `否` | +| `T+1开盘是否盈利` | T+1 开盘收益大于 0 时为 `是`,否则为 `否`;无次日数据时为 `无次日数据` | +| `T+1收盘是否盈利` | T+1 收盘收益大于 0 时为 `是`,否则为 `否`;无次日数据时为 `无次日数据` | +| `T+1盘中是否可盈利` | T+1 最高收益大于 0 时为 `是`,否则为 `否`;无次日数据时为 `无次日数据` | +| `T+1最高盈利%` | T+1 最高价相对 T 日收盘价的收益率百分比 | +| `T+1最低盈利%` | T+1 最低价相对 T 日收盘价的收益率百分比 | +| `开板次数` | T 日开板次数 | +| `换手率%` | T 日换手率 | +| `首次涨停时间` | T 日首次涨停时间,格式化为 `HH:MM:SS` | +| `封单额` | T 日封单额 | +| `成交额` | T 日成交额 | -## 5. 业务规则 +## 7. 错误响应 -- T 日涨停。 -- T-1 交易日也涨停。 -- T-2 交易日必须不涨停。 -- T 日开盘价必须低于 T-1 收盘价的 101%。 -- T 日首次涨停时间必须早于 11:00。 -- 盈利口径默认按 T 日收盘价买入,T+1 收盘价卖出。 +```json +{ + "error": "参数错误" +} +``` diff --git a/internal/api/limit_second_profit_csv_handler.go b/internal/api/limit_second_profit_csv_handler.go index 17d2c01..666a8c8 100644 --- a/internal/api/limit_second_profit_csv_handler.go +++ b/internal/api/limit_second_profit_csv_handler.go @@ -28,10 +28,10 @@ type secondProfitCSVRow struct { 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"` + Open sql.NullFloat64 `gorm:"column:open"` + Close sql.NullFloat64 `gorm:"column:close_price"` LimitPrice float64 `gorm:"column:limit_price"` - PreviousClose float64 `gorm:"column:previous_close"` + PreviousClose sql.NullFloat64 `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"` @@ -44,30 +44,128 @@ type secondProfitCSVRow struct { NextLow sql.NullFloat64 `gorm:"column:next_low"` NextClose sql.NullFloat64 `gorm:"column:next_close"` OpenPreviousCloseRatio float64 + OpenPctChg sql.NullFloat64 NextOpenReturnPct sql.NullFloat64 NextHighReturnPct sql.NullFloat64 + NextLowReturnPct sql.NullFloat64 NextCloseReturnPct sql.NullFloat64 + NextOpenProfitable string 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 { +type secondProfitQuery struct { + TradeStartDate string + TradeEndDate string + LimitUpTimeFilterType1 string + LimitUpTimeFilterValue1 string + LimitUpTimeFilterType2 string + LimitUpTimeFilterValue2 string + OpenPctChgFilterType1 string + OpenPctChgFilterValue1 string + OpenPctChgFilterType2 string + OpenPctChgFilterValue2 string + LimitUpTimeRule secondProfitCompareFilter + OpenPctChgRule secondProfitCompareFilter + Page int + PageSize int +} + +type secondProfitCompareFilter struct { + Conditions []secondProfitCompareCondition +} + +type secondProfitCompareCondition struct { + Op string + Value float64 +} + +type secondProfitListItem struct { + TradeDate string `json:"trade_date"` + TsCode string `json:"ts_code"` + Name string `json:"name"` + Market string `json:"market"` + Industry string `json:"industry"` + NextOpenProfitable string `json:"next_open_profitable"` + NextCloseProfitable string `json:"next_close_profitable"` + NextIntradayProfitable string `json:"next_intraday_profitable"` + NextHighProfitPct *float64 `json:"next_high_profit_pct"` + NextLowProfitPct *float64 `json:"next_low_profit_pct"` + BreakLimitCount *int64 `json:"break_limit_count"` + TurnoverRatePct *float64 `json:"turnover_rate_pct"` + FirstLimitTime string `json:"first_limit_time"` + SealAmount *float64 `json:"seal_amount"` + Amount *float64 `json:"amount"` +} + +func (h *LimitBoardHandler) ListSecondBoardProfit(c *gin.Context) { + req, err := parseSecondProfitQuery(c, true) + if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - rows, err := h.loadSecondProfitCSVRows(startDate, endDate) + rows, err := h.loadSecondProfitRows(req) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + total := len(rows) + start := (req.Page - 1) * req.PageSize + if start > total { + start = total + } + end := start + req.PageSize + if end > total { + end = total + } + + items := make([]secondProfitListItem, 0, end-start) + for _, row := range rows[start:end] { + items = append(items, secondProfitBuildItem(row)) + } + + totalPages := 0 + if total > 0 { + totalPages = (total + req.PageSize - 1) / req.PageSize + } + + c.JSON(http.StatusOK, gin.H{ + "trade_start_date": req.TradeStartDate, + "trade_end_date": req.TradeEndDate, + "limit_up_time_filter_type1": req.LimitUpTimeFilterType1, + "limit_up_time_filter_value1": req.LimitUpTimeFilterValue1, + "limit_up_time_filter_type2": req.LimitUpTimeFilterType2, + "limit_up_time_filter_value2": req.LimitUpTimeFilterValue2, + "open_pct_chg_filter_type1": req.OpenPctChgFilterType1, + "open_pct_chg_filter_value1": req.OpenPctChgFilterValue1, + "open_pct_chg_filter_type2": req.OpenPctChgFilterType2, + "open_pct_chg_filter_value2": req.OpenPctChgFilterValue2, + "page": req.Page, + "page_size": req.PageSize, + "total": total, + "total_pages": totalPages, + "list": items, + }) +} + +func (h *LimitBoardHandler) DownloadSecondBoardProfitCSV(c *gin.Context) { + req, err := parseSecondProfitQuery(c, false) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + rows, err := h.loadSecondProfitRows(req) 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, "-", ""), + strings.ReplaceAll(req.TradeStartDate, "-", ""), + strings.ReplaceAll(req.TradeEndDate, "-", ""), ) c.Header("Content-Type", "text/csv; charset=utf-8") c.Header("Content-Disposition", `attachment; filename="`+filename+`"`) @@ -88,22 +186,121 @@ func (h *LimitBoardHandler) DownloadSecondBoardProfitCSV(c *gin.Context) { } } +func parseSecondProfitQuery(c *gin.Context, includePagination bool) (secondProfitQuery, error) { + startDate := c.DefaultQuery("tradeStartDate", c.DefaultQuery("start_date", defaultSecondProfitStartDate)) + endDate := c.DefaultQuery("tradeEndDate", c.DefaultQuery("end_date", defaultSecondProfitEndDate)) + limitUpTimeType1, limitUpTimeValue1, limitUpTimeType2, limitUpTimeValue2 := secondProfitFilterParts(c, "limitUpTimeFilter") + openPctChgType1, openPctChgValue1, openPctChgType2, openPctChgValue2 := secondProfitFilterParts(c, "openPctChgFilter") + req := secondProfitQuery{ + TradeStartDate: startDate, + TradeEndDate: endDate, + LimitUpTimeFilterType1: limitUpTimeType1, + LimitUpTimeFilterValue1: limitUpTimeValue1, + LimitUpTimeFilterType2: limitUpTimeType2, + LimitUpTimeFilterValue2: limitUpTimeValue2, + OpenPctChgFilterType1: openPctChgType1, + OpenPctChgFilterValue1: openPctChgValue1, + OpenPctChgFilterType2: openPctChgType2, + OpenPctChgFilterValue2: openPctChgValue2, + Page: 1, + PageSize: 10, + } + + if err := validateSecondProfitDateRange(req.TradeStartDate, req.TradeEndDate); err != nil { + return req, err + } + + timeRule, err := parseSecondProfitCompareFilter( + req.LimitUpTimeFilterType1, + req.LimitUpTimeFilterValue1, + req.LimitUpTimeFilterType2, + req.LimitUpTimeFilterValue2, + parseSecondProfitTimeValue, + "limitUpTimeFilter", + ) + if err != nil { + return req, err + } + req.LimitUpTimeRule = timeRule + + pctRule, err := parseSecondProfitCompareFilter( + req.OpenPctChgFilterType1, + req.OpenPctChgFilterValue1, + req.OpenPctChgFilterType2, + req.OpenPctChgFilterValue2, + parseSecondProfitFloatValue, + "openPctChgFilter", + ) + if err != nil { + return req, err + } + req.OpenPctChgRule = pctRule + + if includePagination { + pageText := strings.TrimSpace(c.DefaultQuery("page", "1")) + page, err := strconv.Atoi(pageText) + if err != nil || page <= 0 { + return req, fmt.Errorf("page 必须为正整数") + } + req.Page = page + + pageSizeText := strings.TrimSpace(c.DefaultQuery("pageSize", "10")) + pageSize, err := strconv.Atoi(pageSizeText) + if err != nil || pageSize <= 0 { + return req, fmt.Errorf("pageSize 必须为正整数") + } + req.PageSize = pageSize + } + + return req, nil +} + +func secondProfitFilterParts(c *gin.Context, base string) (string, string, string, string) { + type1 := strings.TrimSpace(c.Query(base + "Type1")) + value1 := strings.TrimSpace(c.Query(base + "Value1")) + type2 := strings.TrimSpace(c.Query(base + "Type2")) + value2 := strings.TrimSpace(c.Query(base + "Value2")) + if type1 != "" || value1 != "" || type2 != "" || value2 != "" { + return type1, value1, type2, value2 + } + + values := c.QueryArray(base) + if len(values) == 0 { + values = c.QueryArray(base + "[]") + } + if len(values) > 0 { + return secondProfitFilterArrayValue(values, 0), + secondProfitFilterArrayValue(values, 1), + secondProfitFilterArrayValue(values, 2), + secondProfitFilterArrayValue(values, 3) + } + + return "", "", "", "" +} + +func secondProfitFilterArrayValue(values []string, index int) string { + if index >= len(values) { + return "" + } + return strings.TrimSpace(values[index]) +} + 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") + return fmt.Errorf("tradeStartDate 格式必须为 YYYY-MM-DD") } end, err := time.Parse("2006-01-02", endDate) if err != nil { - return fmt.Errorf("end_date 格式必须为 YYYY-MM-DD") + return fmt.Errorf("tradeEndDate 格式必须为 YYYY-MM-DD") } if end.Before(start) { - return fmt.Errorf("end_date 不能早于 start_date") + return fmt.Errorf("tradeEndDate 不能早于 tradeStartDate") } return nil } -func (h *LimitBoardHandler) loadSecondProfitCSVRows(startDate, endDate string) ([]secondProfitCSVRow, error) { +func (h *LimitBoardHandler) loadSecondProfitRows(req secondProfitQuery) ([]secondProfitCSVRow, error) { query := ` WITH open_calendar AS ( SELECT @@ -147,36 +344,49 @@ INNER JOIN stock_limit_combined_data pl 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 + ON l.ts_code COLLATE utf8mb4_0900_ai_ci = d.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 + ON l.ts_code COLLATE utf8mb4_0900_ai_ci = pd.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 + ON l.ts_code COLLATE utf8mb4_0900_ai_ci = nd.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 { + if err := h.Service.DB.Raw(query, req.TradeStartDate, req.TradeEndDate).Scan(&rawRows).Error; err != nil { return nil, err } rows := make([]secondProfitCSVRow, 0, len(rawRows)) for _, row := range rawRows { - if !isSecondProfitBeforeEleven(row.FirstLimitTime.String) { + if req.LimitUpTimeRule.Enabled() { + limitTimeValue, ok := secondProfitTimeSeconds(row.FirstLimitTime.String) + if !ok || !req.LimitUpTimeRule.Match(float64(limitTimeValue)) { + continue + } + } + + if row.Open.Valid && row.PreviousClose.Valid && row.PreviousClose.Float64 > 0 { + row.OpenPreviousCloseRatio = row.Open.Float64 / row.PreviousClose.Float64 + row.OpenPctChg = sql.NullFloat64{ + Float64: secondProfitRound2((row.Open.Float64 - row.PreviousClose.Float64) / row.PreviousClose.Float64 * 100), + Valid: true, + } + } + if req.OpenPctChgRule.Enabled() && (!row.OpenPctChg.Valid || !req.OpenPctChgRule.Match(row.OpenPctChg.Float64)) { 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) + + if row.Close.Valid && row.Close.Float64 > 0 { + row.NextOpenReturnPct = secondProfitPctReturn(row.NextOpen, row.Close.Float64) + row.NextHighReturnPct = secondProfitPctReturn(row.NextHigh, row.Close.Float64) + row.NextLowReturnPct = secondProfitPctReturn(row.NextLow, row.Close.Float64) + row.NextCloseReturnPct = secondProfitPctReturn(row.NextClose, row.Close.Float64) } + row.NextOpenProfitable = secondProfitLabel(row.NextOpenReturnPct) row.NextCloseProfitable = secondProfitLabel(row.NextCloseReturnPct) row.NextIntradayProfitable = secondProfitLabel(row.NextHighReturnPct) rows = append(rows, row) @@ -186,62 +396,152 @@ ORDER BY c.trade_date ASC, l.ts_code ASC` 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盘中是否可盈利", + "交易日", "股票代码", "股票简称", "市场", "行业", + "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.NextOpenProfitable, row.NextCloseProfitable, row.NextIntradayProfitable, + secondProfitFormatNullFloat(row.NextHighReturnPct), + secondProfitFormatNullFloat(row.NextLowReturnPct), + secondProfitFormatNullInt(row.BreakLimitCount), + secondProfitFormatNullFloat(row.TurnoverRate), + secondProfitFormatLimitTime(row.FirstLimitTime.String), + secondProfitFormatNullFloat(row.SealAmount), + secondProfitFormatNullFloat(row.Amount), } } -func isSecondProfitBeforeEleven(limitTime string) bool { +func secondProfitBuildItem(row secondProfitCSVRow) secondProfitListItem { + return secondProfitListItem{ + TradeDate: row.TradeDate, + TsCode: row.TsCode, + Name: row.Name, + Market: row.Market, + Industry: row.Industry, + NextOpenProfitable: row.NextOpenProfitable, + NextCloseProfitable: row.NextCloseProfitable, + NextIntradayProfitable: row.NextIntradayProfitable, + NextHighProfitPct: secondProfitNullFloatPtr(row.NextHighReturnPct), + NextLowProfitPct: secondProfitNullFloatPtr(row.NextLowReturnPct), + BreakLimitCount: secondProfitNullIntPtr(row.BreakLimitCount), + TurnoverRatePct: secondProfitNullFloatPtr(row.TurnoverRate), + FirstLimitTime: secondProfitFormatLimitTime(row.FirstLimitTime.String), + SealAmount: secondProfitNullFloatPtr(row.SealAmount), + Amount: secondProfitNullFloatPtr(row.Amount), + } +} + +func parseSecondProfitCompareFilter(op1, value1, op2, value2 string, parseValue func(string) (float64, error), fieldName string) (secondProfitCompareFilter, error) { + filter := secondProfitCompareFilter{} + if err := appendSecondProfitCompareCondition(&filter, op1, value1, parseValue, fieldName, "第一组"); err != nil { + return filter, err + } + if err := appendSecondProfitCompareCondition(&filter, op2, value2, parseValue, fieldName, "第二组"); err != nil { + return filter, err + } + return filter, nil +} + +func appendSecondProfitCompareCondition(filter *secondProfitCompareFilter, op, rawValue string, parseValue func(string) (float64, error), fieldName, groupName string) error { + op = strings.TrimSpace(op) + rawValue = strings.TrimSpace(rawValue) + if op == "" && rawValue == "" { + return nil + } + if op == "" || rawValue == "" { + return fmt.Errorf("%s %s操作符和值必须同时填写", fieldName, groupName) + } + if !isSecondProfitCompareOp(op) { + return fmt.Errorf("%s %s操作符必须为 <、>、<=、>=、=", fieldName, groupName) + } + value, err := parseValue(rawValue) + if err != nil { + return fmt.Errorf("%s %s值格式错误", fieldName, groupName) + } + filter.Conditions = append(filter.Conditions, secondProfitCompareCondition{Op: op, Value: value}) + return nil +} + +func isSecondProfitCompareOp(op string) bool { + switch op { + case "<", ">", "<=", ">=", "=": + return true + default: + return false + } +} + +func (f secondProfitCompareFilter) Enabled() bool { + return len(f.Conditions) > 0 +} + +func (f secondProfitCompareFilter) Match(value float64) bool { + if !f.Enabled() { + return true + } + for _, condition := range f.Conditions { + if !condition.Match(value) { + return false + } + } + return true +} + +func (c secondProfitCompareCondition) Match(value float64) bool { + switch c.Op { + case "<": + return value < c.Value + case "<=": + return value <= c.Value + case ">": + return value > c.Value + case ">=": + return value >= c.Value + case "=": + return value == c.Value + default: + return false + } +} + +func parseSecondProfitTimeValue(value string) (float64, error) { + seconds, ok := secondProfitTimeSeconds(value) + if !ok { + return 0, fmt.Errorf("invalid time") + } + return float64(seconds), nil +} + +func parseSecondProfitFloatValue(value string) (float64, error) { + return strconv.ParseFloat(strings.TrimSpace(value), 64) +} + +func secondProfitTimeSeconds(limitTime string) (int, bool) { timeText := strings.TrimSpace(limitTime) if timeText == "" { - return false + return 0, 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 + return 0, false } } else { if len(timeText) < 4 { - return false + return 0, false } switch len(timeText) { case 4: @@ -250,26 +550,26 @@ func isSecondProfitBeforeEleven(limitTime string) bool { timeText = "0" + timeText case 6: default: - return false + return 0, false } var err error hour, err = strconv.Atoi(timeText[0:2]) if err != nil { - return false + return 0, false } minute, err = strconv.Atoi(timeText[2:4]) if err != nil { - return false + return 0, false } second, err = strconv.Atoi(timeText[4:6]) if err != nil { - return false + return 0, false } } if hour < 0 || hour > 23 || minute < 0 || minute > 59 || second < 0 || second > 59 { - return false + return 0, false } - return hour < 11 + return hour*3600 + minute*60 + second, true } func secondProfitFormatLimitTime(value string) string { @@ -327,3 +627,19 @@ func secondProfitFormatNullInt(value sql.NullInt64) string { } return strconv.FormatInt(value.Int64, 10) } + +func secondProfitNullFloatPtr(value sql.NullFloat64) *float64 { + if !value.Valid { + return nil + } + v := value.Float64 + return &v +} + +func secondProfitNullIntPtr(value sql.NullInt64) *int64 { + if !value.Valid { + return nil + } + v := value.Int64 + return &v +} diff --git a/internal/router/router.go b/internal/router/router.go index 6558db3..bd533a8 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -33,6 +33,7 @@ func NewRouter(commonApi *api.CommonHandler, stockApi *api.StockHandler, limitBo limitBoard.GET("/open-lower-groups", limitBoardApi.GetOpenLowerGroups) limitBoard.GET("/open-threshold", limitBoardApi.GetOpenThresholdGroups) limitBoard.GET("/open-threshold-groups", limitBoardApi.GetOpenThresholdGroups) + limitBoard.GET("/second-board-profit", limitBoardApi.ListSecondBoardProfit) } download := v1.Group("/download") diff --git a/internal/service/limit_board_service.go b/internal/service/limit_board_service.go index a672233..18a7447 100644 --- a/internal/service/limit_board_service.go +++ b/internal/service/limit_board_service.go @@ -358,7 +358,7 @@ func (s *LimitBoardService) loadCurrentLimitStocks(tradeDate string) ([]model.Li 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 + ON l.ts_code COLLATE utf8mb4_0900_ai_ci = d.ts_code AND d.trade_date = l.trade_date WHERE l.trade_date = ? AND l.final_status = 1 ORDER BY d.pct_chg DESC, l.ts_code ASC` @@ -402,9 +402,9 @@ func (s *LimitBoardService) loadLimitOpenCompareStocks(tradeDate, previousTradeD 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 + ON l.ts_code COLLATE utf8mb4_0900_ai_ci = d.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 = ? + ON l.ts_code COLLATE utf8mb4_0900_ai_ci = pd.ts_code AND pd.trade_date = ? INNER JOIN stock_limit_combined_data pl ON pl.ts_code = l.ts_code AND pl.trade_date = ? AND pl.final_status = 1 WHERE l.trade_date = ? AND l.final_status = 1 @@ -448,9 +448,9 @@ func (s *LimitBoardService) loadLimitOpenThresholdStocks(tradeDate, previousTrad 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 + ON l.ts_code COLLATE utf8mb4_0900_ai_ci = d.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 = ? + ON l.ts_code COLLATE utf8mb4_0900_ai_ci = pd.ts_code AND pd.trade_date = ? INNER JOIN stock_limit_combined_data pl ON pl.ts_code = l.ts_code AND pl.trade_date = ? AND pl.final_status = 1 WHERE l.trade_date = ? AND l.final_status = 1 diff --git a/internal/service/stock_service.go b/internal/service/stock_service.go index 6bd93ae..3ad54c9 100644 --- a/internal/service/stock_service.go +++ b/internal/service/stock_service.go @@ -39,7 +39,7 @@ func (s *StockService) AnalyzePerformance(startDate, endDate string) ([]model.An // 查询涨停股 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 + LEFT JOIN stock_daily d ON l.ts_code COLLATE utf8mb4_0900_ai_ci = d.ts_code AND l.trade_date = d.trade_date WHERE l.trade_date = ? AND l.final_status = 1` if err := s.DB.Raw(query, startDate).Scan(&limits).Error; err != nil { diff --git a/pkg/config/config.go b/pkg/config/config.go index 614c3ad..116af2b 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -7,12 +7,13 @@ import ( type Config struct { Database struct { - User string - Password string - Host string - Port int - Dbname string - Charset string + User string + Password string + Host string + Port int + Dbname string + Charset string + Collation string } Server struct { Port int @@ -39,8 +40,12 @@ func InitConfig() { // GetDSN 生成 MySQL 连接字符串 func GetDSN() string { db := AppConfig.Database - return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=%s&parseTime=True&loc=Local", - db.User, db.Password, db.Host, db.Port, db.Dbname, db.Charset) + collation := db.Collation + if collation == "" { + collation = "utf8mb4_unicode_ci" + } + return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=%s&collation=%s&parseTime=True&loc=Local", + db.User, db.Password, db.Host, db.Port, db.Dbname, db.Charset, collation) } // GetServerPort 获取服务器端口字符串