332 lines
12 KiB
Go
332 lines
12 KiB
Go
package service
|
|
|
|
import (
|
|
"math"
|
|
|
|
"level_go/internal/dbcompat"
|
|
"level_go/internal/model"
|
|
)
|
|
|
|
// secondCalRow 是二板日历 SQL 查询扫描出的单股基础数据。
|
|
type secondCalRow struct {
|
|
TradeDate string `gorm:"column:trade_date"` // T 日交易日。
|
|
PreviousTradeDate string `gorm:"column:previous_trade_date"` // T-1 交易日。
|
|
PreviousTwoTradeDate string `gorm:"column:previous_two_trade_date"` // T-2 交易日。
|
|
NextTradeDate string `gorm:"column:next_trade_date"` // T+1 交易日。
|
|
Symbol string `gorm:"column:symbol"` // 股票代码。
|
|
TsCode string `gorm:"column:ts_code"` // TuShare 股票代码。
|
|
Name string `gorm:"column:name"` // 股票简称。
|
|
Market string `gorm:"column:market"` // 所属板块。
|
|
Industry string `gorm:"column:industry"` // 所属行业。
|
|
PreviousClose *float64 `gorm:"column:previous_close"` // T-1 收盘价。
|
|
LimitPrice *float64 `gorm:"column:limit_price"` // T 日涨停价。
|
|
THigh *float64 `gorm:"column:t_high"` // T 日最高价。
|
|
TClose *float64 `gorm:"column:t_close"` // T 日收盘价。
|
|
TPctChg *float64 `gorm:"column:t_pct_chg"` // T 日收盘涨跌幅。
|
|
Status string `gorm:"column:status"` // T 日二板状态。
|
|
FirstLimitTime *string `gorm:"column:first_limit_time"` // T 日首次涨停时间。
|
|
OpenTimes *int `gorm:"column:open_times"` // T 日开板次数。
|
|
NextHigh *float64 `gorm:"column:next_high"` // T+1 日最高价。
|
|
NextClose *float64 `gorm:"column:next_close"` // T+1 日收盘价。
|
|
}
|
|
|
|
// secondCalDaySeed 是日期范围内每个交易日的前后交易日信息。
|
|
type secondCalDaySeed struct {
|
|
TradeDate string `gorm:"column:trade_date"` // T 日交易日。
|
|
PreviousTradeDate string `gorm:"column:previous_trade_date"` // T-1 交易日。
|
|
PreviousTwoTradeDate string `gorm:"column:previous_two_trade_date"` // T-2 交易日。
|
|
NextTradeDate string `gorm:"column:next_trade_date"` // T+1 交易日。
|
|
}
|
|
|
|
// GetSecondCal 返回按交易日聚合的二板日历统计。
|
|
func (s *LimitBoardService) GetSecondCal(req model.SecondCalRequest) (model.SecondCalResponse, error) {
|
|
resp := model.SecondCalResponse{
|
|
TradeStartDate: req.TradeStartDate,
|
|
TradeEndDate: req.TradeEndDate,
|
|
List: []model.SecondCalDay{},
|
|
}
|
|
|
|
// 先加载完整交易日骨架,确保没有二板的交易日也能出现在日历中。
|
|
days, dayIndex, err := s.loadSecondCalDays(req.TradeStartDate, req.TradeEndDate)
|
|
if err != nil {
|
|
return resp, err
|
|
}
|
|
for _, day := range days {
|
|
resp.List = append(resp.List, model.SecondCalDay{
|
|
TradeDate: day.TradeDate,
|
|
PreviousTradeDate: day.PreviousTradeDate,
|
|
PreviousTwoTradeDate: day.PreviousTwoTradeDate,
|
|
NextTradeDate: day.NextTradeDate,
|
|
Stocks: []model.SecondCalStock{},
|
|
})
|
|
}
|
|
|
|
// 再加载命中二板口径的单股明细,并累加到对应交易日。
|
|
rows, err := s.loadSecondCalRows(req.TradeStartDate, req.TradeEndDate)
|
|
if err != nil {
|
|
return resp, err
|
|
}
|
|
for _, row := range rows {
|
|
item := buildSecondCalStock(row)
|
|
idx, ok := dayIndex[item.TradeDate]
|
|
if !ok {
|
|
continue
|
|
}
|
|
accumulateSecondCalDay(&resp.List[idx], item)
|
|
}
|
|
|
|
// 最后汇总整个日期范围,并补齐各交易日的概率字段。
|
|
resp.Summary.TradeDayCount = len(resp.List)
|
|
for i := range resp.List {
|
|
finalizeSecondCalDay(&resp.List[i])
|
|
accumulateSecondCalSummary(&resp.Summary, resp.List[i])
|
|
}
|
|
finalizeSecondCalSummary(&resp.Summary)
|
|
|
|
return resp, nil
|
|
}
|
|
|
|
// loadSecondCalDays 查询日期范围内的开市交易日和相邻交易日。
|
|
func (s *LimitBoardService) loadSecondCalDays(startDate, endDate string) ([]secondCalDaySeed, map[string]int, error) {
|
|
seeds, err := dbcompat.LoadSSEOpenCalendarSeeds(s.DB, startDate, endDate)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
days := make([]secondCalDaySeed, 0, len(seeds))
|
|
for _, seed := range seeds {
|
|
days = append(days, secondCalDaySeed{
|
|
TradeDate: seed.TradeDate,
|
|
PreviousTradeDate: seed.PreviousTradeDate,
|
|
PreviousTwoTradeDate: seed.PreviousTwoTradeDate,
|
|
NextTradeDate: seed.NextTradeDate,
|
|
})
|
|
}
|
|
|
|
// 构建交易日到响应数组下标的映射,后续聚合明细时可以 O(1) 定位。
|
|
dayIndex := make(map[string]int, len(days))
|
|
for i, day := range days {
|
|
dayIndex[day.TradeDate] = i
|
|
}
|
|
return days, dayIndex, nil
|
|
}
|
|
|
|
// loadSecondCalRows 查询 T-1 涨停、T-2 未涨停、T 日再次触及涨停价的二板股票。
|
|
func (s *LimitBoardService) loadSecondCalRows(startDate, endDate string) ([]secondCalRow, error) {
|
|
seeds, err := dbcompat.LoadSSEOpenCalendarSeeds(s.DB, startDate, endDate)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(seeds) == 0 {
|
|
return []secondCalRow{}, nil
|
|
}
|
|
calendarTable, calendarArgs := dbcompat.CalendarSeedTable(seeds)
|
|
stockDailyTsCodeCollation, err := dbcompat.ColumnCollation(s.DB, "stock_daily", "ts_code")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
query := `
|
|
SELECT
|
|
DATE_FORMAT(trade_date, '%Y-%m-%d') AS trade_date,
|
|
DATE_FORMAT(previous_trade_date, '%Y-%m-%d') AS previous_trade_date,
|
|
DATE_FORMAT(previous_two_trade_date, '%Y-%m-%d') AS previous_two_trade_date,
|
|
COALESCE(DATE_FORMAT(next_trade_date, '%Y-%m-%d'), '') AS next_trade_date,
|
|
COALESCE(symbol, '') AS symbol,
|
|
ts_code,
|
|
name,
|
|
market,
|
|
industry,
|
|
previous_close,
|
|
limit_price,
|
|
t_high,
|
|
t_close,
|
|
t_pct_chg,
|
|
status,
|
|
first_limit_time,
|
|
open_times,
|
|
next_high,
|
|
next_close
|
|
FROM (
|
|
SELECT
|
|
c.trade_date,
|
|
c.previous_trade_date,
|
|
c.previous_two_trade_date,
|
|
c.next_trade_date,
|
|
d.symbol,
|
|
pl.ts_code,
|
|
COALESCE(NULLIF(sb.name, ''), NULLIF(pl.name, ''), '') AS name,
|
|
COALESCE(NULLIF(sb.market, ''), '') AS market,
|
|
COALESCE(NULLIF(sb.industry, ''), NULLIF(pl.industry, ''), '') AS industry,
|
|
COALESCE(d.pre_close, pl.close) AS previous_close,
|
|
ROUND(
|
|
COALESCE(d.pre_close, pl.close) *
|
|
CASE
|
|
WHEN COALESCE(sb.market, '') = '北交所' THEN 1.30
|
|
WHEN COALESCE(sb.market, '') IN ('创业板', '科创板') THEN 1.20
|
|
WHEN COALESCE(sb.name, pl.name, '') LIKE '%ST%' THEN 1.05
|
|
ELSE 1.10
|
|
END,
|
|
2
|
|
) AS limit_price,
|
|
d.high AS t_high,
|
|
d.close AS t_close,
|
|
d.pct_chg AS t_pct_chg,
|
|
CASE
|
|
WHEN ABS(d.close - d.high) < 0.0001 THEN 'limit'
|
|
WHEN d.close < d.high THEN 'break'
|
|
ELSE 'unknown'
|
|
END AS status,
|
|
CASE
|
|
WHEN ABS(d.close - d.high) < 0.0001
|
|
THEN NULLIF(NULLIF(TRIM(tlu.first_time), ''), 'None')
|
|
WHEN d.close < d.high
|
|
THEN NULLIF(NULLIF(TRIM(tlz.first_time), ''), 'None')
|
|
ELSE NULL
|
|
END AS first_limit_time,
|
|
CASE
|
|
WHEN ABS(d.close - d.high) < 0.0001 THEN tlu.open_times
|
|
WHEN d.close < d.high THEN tlz.open_times
|
|
ELSE NULL
|
|
END AS open_times,
|
|
nd.high AS next_high,
|
|
nd.close AS next_close
|
|
FROM (` + calendarTable + `) c
|
|
INNER JOIN limit_list_d pl
|
|
ON pl.trade_date = c.previous_trade_date AND pl.` + "`limit`" + ` = 'U'
|
|
LEFT JOIN limit_list_d ppl
|
|
ON ` + dbcompat.TsCodeEqual("ppl.ts_code", "pl.ts_code") + ` AND ppl.trade_date = c.previous_two_trade_date AND ppl.` + "`limit`" + ` = 'U'
|
|
INNER JOIN stock_daily d
|
|
ON ` + dbcompat.TsCodeEqualRightCollation("d.ts_code", "pl.ts_code", stockDailyTsCodeCollation) + ` AND d.trade_date = c.trade_date
|
|
LEFT JOIN stock_daily nd
|
|
ON ` + dbcompat.TsCodeEqualRightCollation("nd.ts_code", "pl.ts_code", stockDailyTsCodeCollation) + ` AND nd.trade_date = c.next_trade_date
|
|
LEFT JOIN limit_list_d tlu
|
|
ON ` + dbcompat.TsCodeEqual("tlu.ts_code", "pl.ts_code") + ` AND tlu.trade_date = c.trade_date AND tlu.` + "`limit`" + ` = 'U'
|
|
LEFT JOIN limit_list_d tlz
|
|
ON ` + dbcompat.TsCodeEqual("tlz.ts_code", "pl.ts_code") + ` AND tlz.trade_date = c.trade_date AND tlz.` + "`limit`" + ` = 'Z'
|
|
LEFT JOIN stock_basic sb
|
|
ON ` + dbcompat.TsCodeEqual("sb.ts_code", "pl.ts_code") + `
|
|
WHERE c.previous_trade_date IS NOT NULL
|
|
AND c.previous_two_trade_date IS NOT NULL
|
|
AND ppl.ts_code IS NULL
|
|
AND d.high IS NOT NULL
|
|
AND d.close IS NOT NULL
|
|
) candidate
|
|
WHERE previous_close IS NOT NULL
|
|
AND limit_price IS NOT NULL
|
|
AND limit_price > 0
|
|
AND t_high >= limit_price - 0.0001
|
|
AND status IN ('limit', 'break')
|
|
ORDER BY trade_date ASC, ts_code ASC`
|
|
|
|
var rows []secondCalRow
|
|
err = s.DB.Raw(query, calendarArgs...).Scan(&rows).Error
|
|
return rows, err
|
|
}
|
|
|
|
// buildSecondCalStock 把数据库行转换成接口明细,并计算 T+1 盘中收益。
|
|
func buildSecondCalStock(row secondCalRow) model.SecondCalStock {
|
|
item := model.SecondCalStock{
|
|
TradeDate: row.TradeDate,
|
|
PreviousTradeDate: row.PreviousTradeDate,
|
|
PreviousTwoTradeDate: row.PreviousTwoTradeDate,
|
|
NextTradeDate: row.NextTradeDate,
|
|
Symbol: row.Symbol,
|
|
TsCode: row.TsCode,
|
|
Name: row.Name,
|
|
Market: row.Market,
|
|
Industry: row.Industry,
|
|
PreviousClose: row.PreviousClose,
|
|
LimitPrice: row.LimitPrice,
|
|
THigh: row.THigh,
|
|
TClose: row.TClose,
|
|
TPctChg: row.TPctChg,
|
|
Status: row.Status,
|
|
OpenTimes: row.OpenTimes,
|
|
NextHigh: row.NextHigh,
|
|
NextClose: row.NextClose,
|
|
}
|
|
if row.FirstLimitTime != nil {
|
|
item.FirstLimitTime = formatLimitTime(*row.FirstLimitTime)
|
|
}
|
|
|
|
if row.THigh != nil && *row.THigh > 0 && row.NextHigh != nil {
|
|
// T+1 盘中盈利统一按 T+1 最高价是否高于 T 日最高价判断。
|
|
profitable := *row.NextHigh > *row.THigh
|
|
item.NextIntradayProfitable = &profitable
|
|
profitPct := roundSecondCalPct((*row.NextHigh - *row.THigh) / *row.THigh * 100)
|
|
item.NextHighProfitPct = &profitPct
|
|
}
|
|
return item
|
|
}
|
|
|
|
// accumulateSecondCalDay 把单只股票累加到对应交易日统计中。
|
|
func accumulateSecondCalDay(day *model.SecondCalDay, item model.SecondCalStock) {
|
|
day.SecondBoardCount++
|
|
switch item.Status {
|
|
case "limit":
|
|
day.LimitCount++
|
|
case "break":
|
|
day.BreakCount++
|
|
}
|
|
|
|
if item.NextIntradayProfitable != nil {
|
|
day.NextTradeDataCount++
|
|
if item.Status == "limit" {
|
|
day.LimitNextDataCount++
|
|
}
|
|
if item.Status == "break" {
|
|
day.BreakNextDataCount++
|
|
}
|
|
if *item.NextIntradayProfitable {
|
|
day.NextIntradayProfitCount++
|
|
if item.Status == "limit" {
|
|
day.LimitNextProfitCount++
|
|
}
|
|
if item.Status == "break" {
|
|
day.BreakNextProfitCount++
|
|
}
|
|
}
|
|
}
|
|
day.Stocks = append(day.Stocks, item)
|
|
}
|
|
|
|
// finalizeSecondCalDay 计算单个交易日的概率字段。
|
|
func finalizeSecondCalDay(day *model.SecondCalDay) {
|
|
day.NextIntradayProfitRate = secondCalRate(day.NextIntradayProfitCount, day.NextTradeDataCount)
|
|
day.LimitNextProfitRate = secondCalRate(day.LimitNextProfitCount, day.LimitNextDataCount)
|
|
day.BreakNextProfitRate = secondCalRate(day.BreakNextProfitCount, day.BreakNextDataCount)
|
|
}
|
|
|
|
// accumulateSecondCalSummary 把单日统计累加到日期范围汇总中。
|
|
func accumulateSecondCalSummary(summary *model.SecondCalSummary, day model.SecondCalDay) {
|
|
summary.SecondBoardCount += day.SecondBoardCount
|
|
summary.LimitCount += day.LimitCount
|
|
summary.BreakCount += day.BreakCount
|
|
summary.NextTradeDataCount += day.NextTradeDataCount
|
|
summary.NextIntradayProfitCount += day.NextIntradayProfitCount
|
|
summary.LimitNextDataCount += day.LimitNextDataCount
|
|
summary.LimitNextProfitCount += day.LimitNextProfitCount
|
|
summary.BreakNextDataCount += day.BreakNextDataCount
|
|
summary.BreakNextProfitCount += day.BreakNextProfitCount
|
|
}
|
|
|
|
// finalizeSecondCalSummary 计算日期范围汇总的概率字段。
|
|
func finalizeSecondCalSummary(summary *model.SecondCalSummary) {
|
|
summary.NextIntradayProfitRate = secondCalRate(summary.NextIntradayProfitCount, summary.NextTradeDataCount)
|
|
summary.LimitNextProfitRate = secondCalRate(summary.LimitNextProfitCount, summary.LimitNextDataCount)
|
|
summary.BreakNextProfitRate = secondCalRate(summary.BreakNextProfitCount, summary.BreakNextDataCount)
|
|
}
|
|
|
|
// secondCalRate 计算百分比概率并保留两位小数。
|
|
func secondCalRate(numerator, denominator int) float64 {
|
|
if denominator == 0 {
|
|
return 0
|
|
}
|
|
return roundSecondCalPct(float64(numerator) / float64(denominator) * 100)
|
|
}
|
|
|
|
// roundSecondCalPct 对二板日历中的百分比做两位小数舍入。
|
|
func roundSecondCalPct(value float64) float64 {
|
|
return math.Round(value*100) / 100
|
|
}
|