Files
go/internal/api/limit_second_profit_csv_handler.go
T

330 lines
10 KiB
Go

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)
}