337 lines
12 KiB
Go
337 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
|
|
}
|
|
c.JSON(http.StatusOK, 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
|
|
}
|