330 lines
10 KiB
Go
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)
|
|
}
|