新增当日开盘价低于前一交易日涨停价格 101% 的判断。新增下载csv 文件接口。
This commit is contained in:
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user