Compare commits

...

3 Commits

8 changed files with 566 additions and 120 deletions
+2 -1
View File
@@ -9,4 +9,5 @@
*.iml *.iml
# 依赖目录 (如果使用 vendor 模式) # 依赖目录 (如果使用 vendor 模式)
# vendor/ # vendor/
config.yaml
+3 -1
View File
@@ -6,6 +6,7 @@
# port: 10836 # port: 10836
# dbname: "level" # dbname: "level"
# charset: "utf8mb4" # charset: "utf8mb4"
# collation: "utf8mb4_unicode_ci"
# 本地数据库 # 本地数据库
database: database:
@@ -15,6 +16,7 @@ database:
port: 3306 port: 3306
dbname: "level" dbname: "level"
charset: "utf8mb4" charset: "utf8mb4"
collation: "utf8mb4_unicode_ci"
server: server:
port: 18080 port: 18080
+3 -2
View File
@@ -4,7 +4,7 @@
- 服务前缀:`/api/v1` - 服务前缀:`/api/v1`
- 响应格式:`application/json` - 响应格式:`application/json`
- 当前项目接口总数:7 - 当前项目接口总数:8
## 2. 接口列表 ## 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/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-lower-groups` | 获取查询日和前一交易日均涨停的股票,并按是否低于前一交易日收盘价分组 | [limit-board-open-lower-groups.md](./limit-board-open-lower-groups.md) |
| 涨停开盘阈值分组 | `GET` | `/api/v1/limit-board/open-threshold` | 获取查询日和前一交易日均涨停的股票,并按开盘价是否低于前一交易日收盘价 101% 分组 | [limit-board-open-threshold.md](./limit-board-open-threshold.md) | | 涨停开盘阈值分组 | `GET` | `/api/v1/limit-board/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. 错误响应约定 ## 3. 错误响应约定
+155 -39
View File
@@ -1,69 +1,185 @@
# 二板早盘次日盈利 CSV 下载接口文档 # 二板早盘次日盈利接口文档
## 1. 接口说明 ## 1. 接口说明
- 接口名称:二板早盘次日盈利 CSV 下载 本页包含二板早盘次日盈利的列表查询接口和 CSV 下载接口。两个接口使用同一套查询条件和同一套返回字段;列表接口分页,CSV 下载接口导出筛选后的全量数据。
- 请求方式:`GET`
- 请求路径:`/api/v1/download/limit-board/second-board-profit-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` | | `tradeStartDate` | `string` | 否 | `2020-01-01` | `2026-01-01` | 交易日开始日期,格式固定为 `YYYY-MM-DD` |
| `end_date` | `string` | 否 | `2026-12-31` | `2026-12-31` | 结束交易日期,格式固定为 `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`,用于下载单日数据。 兼容说明:CSV 下载接口仍兼容旧参数 `start_date` 和 `end_date`,建议新调用统一使用 `tradeStartDate` 和 `tradeEndDate`。
- `end_date` 不能早于 `start_date`。
请求示例: 筛选规则:
- 每个筛选项最多支持两组条件,第一组为 `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 ```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 ```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` | `行业` | 所属行业 |
| `open_pct_chg` | `number \| null` | `T日开盘涨跌幅%` | T 日开盘价相对 T-1 收盘价的涨跌幅百分比 |
| `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": "软件开发",
"open_pct_chg": 1.36,
"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` - 响应类型:`text/csv; charset=utf-8`
- 响应头:`Content-Disposition: attachment; filename="limit_second_board_profit_YYYYMMDD_YYYYMMDD_daily_detail.csv"` - 响应头:`Content-Disposition: attachment; filename="limit_second_board_profit_YYYYMMDD_YYYYMMDD_daily_detail.csv"`
- CSV 文件带 UTF-8 BOM,便于 Excel 直接打开中文列名。 - CSV 文件带 UTF-8 BOM,便于 Excel 直接打开中文列名。
- CSV 下载接口不分页,默认导出查询条件范围内的全部数据。
## 4. CSV 字段 CSV 字段顺序:
| 字段名 | 说明 | | 字段名 | 说明 |
| --- | --- | | --- | --- |
| `交易日` | 符合条件的交易日 | | `交易日` | T 日交易日 |
| `前一交易日` | 交易日的前一个交易日 |
| `前二交易日` | 交易日的前两个交易日 |
| `后一交易日` | 用于判断盈利的后一交易日 |
| `股票代码` | TS 股票代码 | | `股票代码` | TS 股票代码 |
| `股票简称` | 股票名称 | | `股票简称` | 股票简称 |
| `市场` | 市场信息 | | `市场` | 市场信息 |
| `行业` | 所属行业 | | `行业` | 所属行业 |
| `T日开盘价` | 交易日开盘价 | | `T日开盘涨跌幅%` | T 日开盘价相对 T-1 收盘价的涨跌幅百分比 |
| `T日收盘价` | 交易日收盘价 | | `T+1开盘是否盈利` | T+1 开盘收益大于 0 时为 `是`,否则为 `否`;无次日数据时为 `无次日数据` |
| `T日涨停价` | 理论涨停价 | | `T+1收盘是否盈利` | T+1 收盘收益大于 0 时为 `是`,否则为 `否`;无次日数据时为 `无次日数据` |
| `T-1收盘价` | 前一交易日收盘价 | | `T+1盘中是否可盈利` | T+1 最高收益大于 0 时为 `是`,否则为 `否`;无次日数据时为 `无次日数据` |
| `开盘/T-1收盘%` | 交易日开盘价相对前一交易日收盘价的百分比 | | `T+1最高盈利%` | T+1 最高价相对 T 日收盘价的收益率百分比 |
| `首次涨停时间` | 交易日首次涨停时间,格式化为 `HH:MM:SS` | | `T+1最低盈利%` | T+1 最低价相对 T 日收盘价的收益率百分比 |
| `T+1开盘收益%` | 后一交易日开盘价相对 T 日收盘价的收益率 | | `开板次数` | T 日开板次数 |
| `T+1最高收益%` | 后一交易日最高价相对 T 日收盘价的收益率 | | `换手率%` | T 日换手率 |
| `T+1收盘收益%` | 后一交易日收盘价相对 T 日收盘价的收益率 | | `首次涨停时间` | T 日首次涨停时间,格式化为 `HH:MM:SS` |
| `T+1收盘是否盈利` | `T+1收盘收益% > 0` 时为 `是`,否则为 `否` | | `封单额` | T 日封单额 |
| `T+1盘中是否可盈利` | `T+1最高收益% > 0` 时为 `是`,否则为 `否` | | `成交额` | T 日成交额 |
## 5. 业务规则 ## 7. 错误响应
- T 日涨停。 ```json
- T-1 交易日也涨停。 {
- T-2 交易日必须不涨停。 "error": "参数错误"
- T 日开盘价必须低于 T-1 收盘价的 101%。 }
- T 日首次涨停时间必须早于 11:00。 ```
- 盈利口径默认按 T 日收盘价买入,T+1 收盘价卖出。
+384 -64
View File
@@ -28,10 +28,10 @@ type secondProfitCSVRow struct {
Name string `gorm:"column:name"` Name string `gorm:"column:name"`
Market string `gorm:"column:market"` Market string `gorm:"column:market"`
Industry string `gorm:"column:industry"` Industry string `gorm:"column:industry"`
Open float64 `gorm:"column:open"` Open sql.NullFloat64 `gorm:"column:open"`
Close float64 `gorm:"column:close_price"` Close sql.NullFloat64 `gorm:"column:close_price"`
LimitPrice float64 `gorm:"column:limit_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"` FirstLimitTime sql.NullString `gorm:"column:tushare_first_time"`
PctChg sql.NullFloat64 `gorm:"column:pct_chg"` PctChg sql.NullFloat64 `gorm:"column:pct_chg"`
Amount sql.NullFloat64 `gorm:"column:tushare_amount"` Amount sql.NullFloat64 `gorm:"column:tushare_amount"`
@@ -44,30 +44,129 @@ type secondProfitCSVRow struct {
NextLow sql.NullFloat64 `gorm:"column:next_low"` NextLow sql.NullFloat64 `gorm:"column:next_low"`
NextClose sql.NullFloat64 `gorm:"column:next_close"` NextClose sql.NullFloat64 `gorm:"column:next_close"`
OpenPreviousCloseRatio float64 OpenPreviousCloseRatio float64
OpenPctChg sql.NullFloat64
NextOpenReturnPct sql.NullFloat64 NextOpenReturnPct sql.NullFloat64
NextHighReturnPct sql.NullFloat64 NextHighReturnPct sql.NullFloat64
NextLowReturnPct sql.NullFloat64
NextCloseReturnPct sql.NullFloat64 NextCloseReturnPct sql.NullFloat64
NextOpenProfitable string
NextCloseProfitable string NextCloseProfitable string
NextIntradayProfitable string NextIntradayProfitable string
} }
func (h *LimitBoardHandler) DownloadSecondBoardProfitCSV(c *gin.Context) { type secondProfitQuery struct {
startDate := c.DefaultQuery("start_date", defaultSecondProfitStartDate) TradeStartDate string
endDate := c.DefaultQuery("end_date", defaultSecondProfitEndDate) TradeEndDate string
if err := validateSecondProfitDateRange(startDate, endDate); err != nil { 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"`
OpenPctChg *float64 `json:"open_pct_chg"`
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()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return 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 { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
} }
filename := fmt.Sprintf("limit_second_board_profit_%s_%s_daily_detail.csv", filename := fmt.Sprintf("limit_second_board_profit_%s_%s_daily_detail.csv",
strings.ReplaceAll(startDate, "-", ""), strings.ReplaceAll(req.TradeStartDate, "-", ""),
strings.ReplaceAll(endDate, "-", ""), strings.ReplaceAll(req.TradeEndDate, "-", ""),
) )
c.Header("Content-Type", "text/csv; charset=utf-8") c.Header("Content-Type", "text/csv; charset=utf-8")
c.Header("Content-Disposition", `attachment; filename="`+filename+`"`) c.Header("Content-Disposition", `attachment; filename="`+filename+`"`)
@@ -88,22 +187,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 { func validateSecondProfitDateRange(startDate, endDate string) error {
start, err := time.Parse("2006-01-02", startDate) start, err := time.Parse("2006-01-02", startDate)
if err != nil { 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) end, err := time.Parse("2006-01-02", endDate)
if err != nil { if err != nil {
return fmt.Errorf("end_date 格式必须为 YYYY-MM-DD") return fmt.Errorf("tradeEndDate 格式必须为 YYYY-MM-DD")
} }
if end.Before(start) { if end.Before(start) {
return fmt.Errorf("end_date 不能早于 start_date") return fmt.Errorf("tradeEndDate 不能早于 tradeStartDate")
} }
return nil return nil
} }
func (h *LimitBoardHandler) loadSecondProfitCSVRows(startDate, endDate string) ([]secondProfitCSVRow, error) { func (h *LimitBoardHandler) loadSecondProfitRows(req secondProfitQuery) ([]secondProfitCSVRow, error) {
query := ` query := `
WITH open_calendar AS ( WITH open_calendar AS (
SELECT SELECT
@@ -147,36 +345,49 @@ INNER JOIN stock_limit_combined_data pl
LEFT JOIN stock_limit_combined_data ppl 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 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 LEFT JOIN stock_daily d
ON d.ts_code = l.ts_code AND d.trade_date = l.trade_date ON l.ts_code = d.ts_code AND d.trade_date = l.trade_date
LEFT JOIN stock_daily pd LEFT JOIN stock_daily pd
ON pd.ts_code = l.ts_code AND pd.trade_date = c.previous_trade_date ON l.ts_code = pd.ts_code AND pd.trade_date = c.previous_trade_date
LEFT JOIN stock_daily nd LEFT JOIN stock_daily nd
ON nd.ts_code = l.ts_code AND nd.trade_date = c.next_trade_date ON l.ts_code = nd.ts_code AND nd.trade_date = c.next_trade_date
WHERE c.trade_date BETWEEN ? AND ? WHERE c.trade_date BETWEEN ? AND ?
AND c.previous_trade_date IS NOT NULL AND c.previous_trade_date IS NOT NULL
AND c.previous_two_trade_date IS NOT NULL AND c.previous_two_trade_date IS NOT NULL
AND ppl.ts_code IS 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` ORDER BY c.trade_date ASC, l.ts_code ASC`
var rawRows []secondProfitCSVRow 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 return nil, err
} }
rows := make([]secondProfitCSVRow, 0, len(rawRows)) rows := make([]secondProfitCSVRow, 0, len(rawRows))
for _, row := range 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 continue
} }
row.OpenPreviousCloseRatio = row.Open / row.PreviousClose
if row.Close > 0 { if row.Close.Valid && row.Close.Float64 > 0 {
row.NextOpenReturnPct = secondProfitPctReturn(row.NextOpen, row.Close) row.NextOpenReturnPct = secondProfitPctReturn(row.NextOpen, row.Close.Float64)
row.NextHighReturnPct = secondProfitPctReturn(row.NextHigh, row.Close) row.NextHighReturnPct = secondProfitPctReturn(row.NextHigh, row.Close.Float64)
row.NextCloseReturnPct = secondProfitPctReturn(row.NextClose, row.Close) 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.NextCloseProfitable = secondProfitLabel(row.NextCloseReturnPct)
row.NextIntradayProfitable = secondProfitLabel(row.NextHighReturnPct) row.NextIntradayProfitable = secondProfitLabel(row.NextHighReturnPct)
rows = append(rows, row) rows = append(rows, row)
@@ -186,62 +397,155 @@ ORDER BY c.trade_date ASC, l.ts_code ASC`
func secondProfitCSVHeaders() []string { func secondProfitCSVHeaders() []string {
return []string{ return []string{
"交易日", "前一交易日", "前二交易日", "后一交易日", "股票代码", "股票简称", "市场", "行业", "交易日", "股票代码", "股票简称", "市场", "行业",
"T日开盘价", "T日收盘价", "T日涨停价", "T-1收盘价", "开盘/T-1收盘%", "首次涨停时间", "T日开盘涨跌幅%",
"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 { func secondProfitCSVRecord(row secondProfitCSVRow) []string {
return []string{ return []string{
row.TradeDate, row.TradeDate,
row.PreviousTradeDate,
row.PreviousTwoTradeDate,
row.NextTradeDate,
row.TsCode, row.TsCode,
row.Name, row.Name,
row.Market, row.Market,
row.Industry, row.Industry,
secondProfitFormatFloat(row.Open), secondProfitFormatNullFloat(row.OpenPctChg),
secondProfitFormatFloat(row.Close), row.NextOpenProfitable,
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.NextCloseProfitable,
row.NextIntradayProfitable, 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,
OpenPctChg: secondProfitNullFloatPtr(row.OpenPctChg),
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) timeText := strings.TrimSpace(limitTime)
if timeText == "" { if timeText == "" {
return false return 0, false
} }
hour, minute, second := 0, 0, 0 hour, minute, second := 0, 0, 0
if strings.Contains(timeText, ":") { if strings.Contains(timeText, ":") {
if n, _ := fmt.Sscanf(timeText, "%d:%d:%d", &hour, &minute, &second); n < 2 { if n, _ := fmt.Sscanf(timeText, "%d:%d:%d", &hour, &minute, &second); n < 2 {
return false return 0, false
} }
} else { } else {
if len(timeText) < 4 { if len(timeText) < 4 {
return false return 0, false
} }
switch len(timeText) { switch len(timeText) {
case 4: case 4:
@@ -250,26 +554,26 @@ func isSecondProfitBeforeEleven(limitTime string) bool {
timeText = "0" + timeText timeText = "0" + timeText
case 6: case 6:
default: default:
return false return 0, false
} }
var err error var err error
hour, err = strconv.Atoi(timeText[0:2]) hour, err = strconv.Atoi(timeText[0:2])
if err != nil { if err != nil {
return false return 0, false
} }
minute, err = strconv.Atoi(timeText[2:4]) minute, err = strconv.Atoi(timeText[2:4])
if err != nil { if err != nil {
return false return 0, false
} }
second, err = strconv.Atoi(timeText[4:6]) second, err = strconv.Atoi(timeText[4:6])
if err != nil { if err != nil {
return false return 0, false
} }
} }
if hour < 0 || hour > 23 || minute < 0 || minute > 59 || second < 0 || second > 59 { 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 { func secondProfitFormatLimitTime(value string) string {
@@ -327,3 +631,19 @@ func secondProfitFormatNullInt(value sql.NullInt64) string {
} }
return strconv.FormatInt(value.Int64, 10) 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
}
+1
View File
@@ -33,6 +33,7 @@ func NewRouter(commonApi *api.CommonHandler, stockApi *api.StockHandler, limitBo
limitBoard.GET("/open-lower-groups", limitBoardApi.GetOpenLowerGroups) limitBoard.GET("/open-lower-groups", limitBoardApi.GetOpenLowerGroups)
limitBoard.GET("/open-threshold", limitBoardApi.GetOpenThresholdGroups) limitBoard.GET("/open-threshold", limitBoardApi.GetOpenThresholdGroups)
limitBoard.GET("/open-threshold-groups", limitBoardApi.GetOpenThresholdGroups) limitBoard.GET("/open-threshold-groups", limitBoardApi.GetOpenThresholdGroups)
limitBoard.GET("/second-board-profit", limitBoardApi.ListSecondBoardProfit)
} }
download := v1.Group("/download") download := v1.Group("/download")
+5 -5
View File
@@ -358,7 +358,7 @@ func (s *LimitBoardService) loadCurrentLimitStocks(tradeDate string) ([]model.Li
l.calc_vol AS vol l.calc_vol AS vol
FROM stock_limit_combined_data l FROM stock_limit_combined_data l
LEFT JOIN stock_daily d LEFT JOIN stock_daily d
ON d.ts_code = l.ts_code AND d.trade_date = l.trade_date ON l.ts_code = d.ts_code AND d.trade_date = l.trade_date
WHERE l.trade_date = ? AND l.final_status = 1 WHERE l.trade_date = ? AND l.final_status = 1
ORDER BY d.pct_chg DESC, l.ts_code ASC` 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 l.calc_vol AS vol
FROM stock_limit_combined_data l FROM stock_limit_combined_data l
LEFT JOIN stock_daily d LEFT JOIN stock_daily d
ON d.ts_code = l.ts_code AND d.trade_date = l.trade_date ON l.ts_code = d.ts_code AND d.trade_date = l.trade_date
LEFT JOIN stock_daily pd LEFT JOIN stock_daily pd
ON pd.ts_code = l.ts_code AND pd.trade_date = ? ON l.ts_code = pd.ts_code AND pd.trade_date = ?
INNER JOIN stock_limit_combined_data pl INNER JOIN stock_limit_combined_data pl
ON pl.ts_code = l.ts_code AND pl.trade_date = ? AND pl.final_status = 1 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 WHERE l.trade_date = ? AND l.final_status = 1
@@ -448,9 +448,9 @@ func (s *LimitBoardService) loadLimitOpenThresholdStocks(tradeDate, previousTrad
l.calc_vol AS vol l.calc_vol AS vol
FROM stock_limit_combined_data l FROM stock_limit_combined_data l
LEFT JOIN stock_daily d LEFT JOIN stock_daily d
ON d.ts_code = l.ts_code AND d.trade_date = l.trade_date ON l.ts_code = d.ts_code AND d.trade_date = l.trade_date
LEFT JOIN stock_daily pd LEFT JOIN stock_daily pd
ON pd.ts_code = l.ts_code AND pd.trade_date = ? ON l.ts_code = pd.ts_code AND pd.trade_date = ?
INNER JOIN stock_limit_combined_data pl INNER JOIN stock_limit_combined_data pl
ON pl.ts_code = l.ts_code AND pl.trade_date = ? AND pl.final_status = 1 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 WHERE l.trade_date = ? AND l.final_status = 1
+13 -8
View File
@@ -7,12 +7,13 @@ import (
type Config struct { type Config struct {
Database struct { Database struct {
User string User string
Password string Password string
Host string Host string
Port int Port int
Dbname string Dbname string
Charset string Charset string
Collation string
} }
Server struct { Server struct {
Port int Port int
@@ -39,8 +40,12 @@ func InitConfig() {
// GetDSN 生成 MySQL 连接字符串 // GetDSN 生成 MySQL 连接字符串
func GetDSN() string { func GetDSN() string {
db := AppConfig.Database db := AppConfig.Database
return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=%s&parseTime=True&loc=Local", collation := db.Collation
db.User, db.Password, db.Host, db.Port, db.Dbname, db.Charset) 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 获取服务器端口字符串 // GetServerPort 获取服务器端口字符串