Files
stock/backend/internal/handlers/stock.go
T

243 lines
7.8 KiB
Go

package handlers
import (
"context"
"net/http"
"time"
"stock-user-system/internal/models"
"stock-user-system/internal/services"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
var coreIndexSymbols = map[string]bool{
"000001.SH": true,
"399001.SZ": true,
"399006.SZ": true,
"000688.SH": true,
}
// StockHandler 处理股票看板与同步接口。
type StockHandler struct {
DB *gorm.DB
Svc *services.StockSyncService
}
type stockRow struct {
Symbol string `json:"symbol"`
Name string `json:"name"`
Close float64 `json:"close"`
PrevClose float64 `json:"prev_close,omitempty"`
ChangePct float64 `json:"change_pct"`
Amount float64 `json:"amount,omitempty"`
}
type overviewResponse struct {
LatestTradeDate string `json:"latest_trade_date"`
Counts marketCounts `json:"counts"`
AvgChangePct float64 `json:"avg_change_pct"`
Indices []stockRow `json:"indices"`
TopGainers []stockRow `json:"top_gainers"`
TopLosers []stockRow `json:"top_losers"`
TurnoverLeaders []stockRow `json:"turnover_leaders"`
}
type marketCounts struct {
Up int64 `json:"up"`
Down int64 `json:"down"`
Flat int64 `json:"flat"`
Total int64 `json:"total"`
}
// Overview 返回最新交易日的市场概览。
func (h *StockHandler) Overview(c *gin.Context) {
ctx := context.Background()
var latestDate time.Time
if err := h.DB.WithContext(ctx).Model(&models.StockDailyQuote{}).
Select("MAX(trade_date)").Scan(&latestDate).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "查询最新交易日失败"})
return
}
if latestDate.IsZero() {
c.JSON(http.StatusOK, overviewResponse{})
return
}
baseWhere := "trade_date = ? AND symbol NOT IN ? AND NOT (volume = 0 AND change_pct = 0)"
var counts marketCounts
if err := h.DB.WithContext(ctx).Model(&models.StockDailyQuote{}).
Select(`
COUNT(*) AS total,
SUM(CASE WHEN close > prev_close THEN 1 ELSE 0 END) AS up,
SUM(CASE WHEN close < prev_close THEN 1 ELSE 0 END) AS down,
SUM(CASE WHEN close = prev_close THEN 1 ELSE 0 END) AS flat
`).
Where(baseWhere, latestDate, indexSymbolsList()).
Scan(&counts).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "统计涨跌失败"})
return
}
var avgChange float64
h.DB.WithContext(ctx).Model(&models.StockDailyQuote{}).
Select("COALESCE(AVG(change_pct), 0)").
Where(baseWhere, latestDate, indexSymbolsList()).
Scan(&avgChange)
indices := h.topRows(ctx, latestDate, "symbol IN ?", indexSymbolsList(), 10, false)
gainers := h.topRows(ctx, latestDate, "symbol NOT IN ? AND NOT (volume = 0 AND change_pct = 0)", indexSymbolsList(), 10, true)
losers := h.topRows(ctx, latestDate, "symbol NOT IN ? AND NOT (volume = 0 AND change_pct = 0)", indexSymbolsList(), 10, false)
turnover := h.topRowsByAmount(ctx, latestDate)
c.JSON(http.StatusOK, overviewResponse{
LatestTradeDate: latestDate.Format("2006-01-02"),
Counts: counts,
AvgChangePct: avgChange,
Indices: indices,
TopGainers: gainers,
TopLosers: losers,
TurnoverLeaders: turnover,
})
}
func (h *StockHandler) topRows(ctx context.Context, tradeDate time.Time, condition string, args any, limit int, desc bool) []stockRow {
var rows []stockRow
order := "change_pct ASC"
if desc {
order = "change_pct DESC"
}
h.DB.WithContext(ctx).Model(&models.StockDailyQuote{}).
Select("symbol, name, close, change_pct").
Where("trade_date = ?", tradeDate).
Where(condition, args).
Order(order).
Limit(limit).
Scan(&rows)
return rows
}
func (h *StockHandler) topRowsByAmount(ctx context.Context, tradeDate time.Time) []stockRow {
var rows []stockRow
h.DB.WithContext(ctx).Model(&models.StockDailyQuote{}).
Select("symbol, name, close, change_pct, amount").
Where("trade_date = ? AND symbol NOT IN ? AND NOT (volume = 0 AND change_pct = 0)", tradeDate, indexSymbolsList()).
Order("amount DESC").
Limit(10).
Scan(&rows)
return rows
}
// SyncStatus 返回最近一次同步任务状态。
func (h *StockHandler) SyncStatus(c *gin.Context) {
job, err := h.Svc.LatestJob(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "查询同步状态失败"})
return
}
if job == nil {
c.JSON(http.StatusOK, gin.H{"success": true, "data": nil})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": job})
}
// TriggerSync 手动触发同步(管理员)。
func (h *StockHandler) TriggerSync(c *gin.Context) {
count, err := h.Svc.Sync(c.Request.Context(), "manual")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "records_count": count})
}
type debugStatsResponse struct {
TradeDate string `json:"trade_date"`
Total int64 `json:"total"`
IndexCount int64 `json:"index_count"`
SuspendedCount int64 `json:"suspended_count"`
UpByPrice int64 `json:"up_by_price"`
DownByPrice int64 `json:"down_by_price"`
FlatByPrice int64 `json:"flat_by_price"`
UpByPct int64 `json:"up_by_pct"`
DownByPct int64 `json:"down_by_pct"`
FlatByPct int64 `json:"flat_by_pct"`
FlatSamples []stockRow `json:"flat_samples"`
BoundarySamples []stockRow `json:"boundary_samples"`
}
// DebugStats 返回最新交易日的详细统计与边界样本,便于对齐第三方口径。
func (h *StockHandler) DebugStats(c *gin.Context) {
ctx := context.Background()
var latestDate time.Time
if err := h.DB.WithContext(ctx).Model(&models.StockDailyQuote{}).
Select("MAX(trade_date)").Scan(&latestDate).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "查询最新交易日失败"})
return
}
if latestDate.IsZero() {
c.JSON(http.StatusOK, gin.H{"success": true, "data": debugStatsResponse{}})
return
}
idxList := indexSymbolsList()
baseWhere := "trade_date = ? AND symbol NOT IN ? AND NOT (volume = 0 AND change_pct = 0)"
var stats debugStatsResponse
h.DB.WithContext(ctx).Model(&models.StockDailyQuote{}).
Select(`
COUNT(*) AS total,
SUM(CASE WHEN close > prev_close THEN 1 ELSE 0 END) AS up_by_price,
SUM(CASE WHEN close < prev_close THEN 1 ELSE 0 END) AS down_by_price,
SUM(CASE WHEN close = prev_close THEN 1 ELSE 0 END) AS flat_by_price,
SUM(CASE WHEN change_pct > 0 THEN 1 ELSE 0 END) AS up_by_pct,
SUM(CASE WHEN change_pct < 0 THEN 1 ELSE 0 END) AS down_by_pct,
SUM(CASE WHEN change_pct = 0 THEN 1 ELSE 0 END) AS flat_by_pct
`).
Where(baseWhere, latestDate, idxList).
Scan(&stats)
h.DB.WithContext(ctx).Model(&models.StockDailyQuote{}).
Where("trade_date = ? AND symbol IN ?", latestDate, idxList).
Count(&stats.IndexCount)
h.DB.WithContext(ctx).Model(&models.StockDailyQuote{}).
Where("trade_date = ? AND symbol NOT IN ? AND volume = 0 AND change_pct = 0", latestDate, idxList).
Count(&stats.SuspendedCount)
stats.TradeDate = latestDate.Format("2006-01-02")
var flatSamples []stockRow
h.DB.WithContext(ctx).Model(&models.StockDailyQuote{}).
Select("symbol, name, close, prev_close, change_pct").
Where("trade_date = ? AND symbol NOT IN ? AND close = prev_close", latestDate, idxList).
Order("amount DESC").
Limit(20).
Scan(&flatSamples)
stats.FlatSamples = flatSamples
var boundarySamples []stockRow
h.DB.WithContext(ctx).Model(&models.StockDailyQuote{}).
Select("symbol, name, close, prev_close, change_pct").
Where("trade_date = ? AND symbol NOT IN ?", latestDate, idxList).
Order("ABS(change_pct) ASC").
Limit(20).
Scan(&boundarySamples)
stats.BoundarySamples = boundarySamples
c.JSON(http.StatusOK, gin.H{"success": true, "data": stats})
}
func indexSymbolsList() []string {
list := make([]string, 0, len(coreIndexSymbols))
for s := range coreIndexSymbols {
list = append(list, s)
}
return list
}