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

341 lines
12 KiB
Go

package handlers
import (
"context"
"net/http"
"time"
"stock-user-system/internal/middleware"
"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) {
user, ok := middleware.GetCurrentUser(c)
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "error": "未登录"})
return
}
var job *models.StockSyncJob
var err error
if user.Role == models.RoleSystemAdmin {
job, err = h.Svc.StartSyncHistory(c.Request.Context(), "manual", 6)
} else {
job, err = h.Svc.StartSync(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, "job": job})
}
type distributionRow struct {
Label string `json:"label"`
Count int64 `json:"count"`
}
type distributionScan struct {
SH int64 `gorm:"column:sh"`
SZ int64 `gorm:"column:sz"`
BJ int64 `gorm:"column:bj"`
Up0_3 int64 `gorm:"column:up_0_3"`
Up3_5 int64 `gorm:"column:up_3_5"`
Up5_7 int64 `gorm:"column:up_5_7"`
Up7_10 int64 `gorm:"column:up_7_10"`
Up10 int64 `gorm:"column:up_10"`
Down0_3 int64 `gorm:"column:down_0_3"`
Down3_5 int64 `gorm:"column:down_3_5"`
Down5_7 int64 `gorm:"column:down_5_7"`
Down7_10 int64 `gorm:"column:down_7_10"`
Down10 int64 `gorm:"column:down_10"`
}
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"`
ExchangeCounts map[string]int64 `json:"exchange_counts"`
UpDistribution []distributionRow `json:"up_distribution"`
DownDistribution []distributionRow `json:"down_distribution"`
FlatSamples []stockRow `json:"flat_samples"`
BoundarySamples []stockRow `json:"boundary_samples"`
UpBoundarySamples []stockRow `json:"up_boundary_samples"`
DownBoundarySamples []stockRow `json:"down_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 dist distributionScan
h.DB.WithContext(ctx).Model(&models.StockDailyQuote{}).
Select(`
SUM(CASE WHEN symbol LIKE '%.SH' THEN 1 ELSE 0 END) AS sh,
SUM(CASE WHEN symbol LIKE '%.SZ' THEN 1 ELSE 0 END) AS sz,
SUM(CASE WHEN symbol LIKE '%.BJ' THEN 1 ELSE 0 END) AS bj,
SUM(CASE WHEN change_pct > 0 AND change_pct <= 0.03 THEN 1 ELSE 0 END) AS up_0_3,
SUM(CASE WHEN change_pct > 0.03 AND change_pct <= 0.05 THEN 1 ELSE 0 END) AS up_3_5,
SUM(CASE WHEN change_pct > 0.05 AND change_pct <= 0.07 THEN 1 ELSE 0 END) AS up_5_7,
SUM(CASE WHEN change_pct > 0.07 AND change_pct <= 0.10 THEN 1 ELSE 0 END) AS up_7_10,
SUM(CASE WHEN change_pct > 0.10 THEN 1 ELSE 0 END) AS up_10,
SUM(CASE WHEN change_pct < 0 AND change_pct >= -0.03 THEN 1 ELSE 0 END) AS down_0_3,
SUM(CASE WHEN change_pct < -0.03 AND change_pct >= -0.05 THEN 1 ELSE 0 END) AS down_3_5,
SUM(CASE WHEN change_pct < -0.05 AND change_pct >= -0.07 THEN 1 ELSE 0 END) AS down_5_7,
SUM(CASE WHEN change_pct < -0.07 AND change_pct >= -0.10 THEN 1 ELSE 0 END) AS down_7_10,
SUM(CASE WHEN change_pct < -0.10 THEN 1 ELSE 0 END) AS down_10
`).
Where(baseWhere, latestDate, idxList).
Scan(&dist)
stats.ExchangeCounts = map[string]int64{
"sh": dist.SH,
"sz": dist.SZ,
"bj": dist.BJ,
}
stats.UpDistribution = []distributionRow{
{Label: "0~3%", Count: dist.Up0_3},
{Label: "3~5%", Count: dist.Up3_5},
{Label: "5~7%", Count: dist.Up5_7},
{Label: "7~10%", Count: dist.Up7_10},
{Label: ">10%", Count: dist.Up10},
}
stats.DownDistribution = []distributionRow{
{Label: "3~0%", Count: dist.Down0_3},
{Label: "5~3%", Count: dist.Down3_5},
{Label: "7~5%", Count: dist.Down5_7},
{Label: "10~7%", Count: dist.Down7_10},
{Label: ">10%", Count: dist.Down10},
}
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
var upBoundarySamples []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("change_pct ASC").
Limit(50).
Scan(&upBoundarySamples)
stats.UpBoundarySamples = upBoundarySamples
var downBoundarySamples []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("change_pct DESC").
Limit(50).
Scan(&downBoundarySamples)
stats.DownBoundarySamples = downBoundarySamples
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
}