新增当日开盘价低于前一交易日涨停价格 101% 的判断。新增下载csv 文件接口。 #3

Merged
yang merged 2 commits from main into master 2026-05-05 19:33:14 +08:00
3 changed files with 562 additions and 0 deletions
Showing only changes of commit cd25b65314 - Show all commits
+69
View File
@@ -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 收盘价卖出。
+164
View File
@@ -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": "具体错误信息"
}
```
@@ -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)
}