SHA256
+1
-1
@@ -8,7 +8,7 @@ RUN npm run build
|
||||
|
||||
# ---- 后端构建 ----
|
||||
FROM golang:1.24-alpine AS go-builder
|
||||
ENV GOTOOLCHAIN=auto
|
||||
ENV GOPROXY=https://goproxy.cn,direct
|
||||
WORKDIR /app/server
|
||||
COPY server/go.mod server/go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
module ashareview-server
|
||||
|
||||
go 1.26.2
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/gin-gonic/gin v1.12.0
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"ashareview-server/service"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/driver/mysql"
|
||||
@@ -23,6 +24,24 @@ func main() {
|
||||
}
|
||||
fmt.Println("Database connected successfully")
|
||||
|
||||
// Create indexes in background (10M rows, may take minutes)
|
||||
go func() {
|
||||
time.Sleep(3 * time.Second)
|
||||
indexes := []string{
|
||||
"ALTER TABLE stock_daily ADD INDEX idx_sd_date (date)",
|
||||
"ALTER TABLE stock_daily ADD INDEX idx_sd_code_date (code, date)",
|
||||
"ALTER TABLE stock_daily ADD INDEX idx_sd_date_pct (date, pct_change)",
|
||||
"ALTER TABLE stock_daily ADD INDEX idx_sd_code_date_close (code, date, close)",
|
||||
}
|
||||
for _, idx := range indexes {
|
||||
if err := db.Exec(idx).Error; err != nil {
|
||||
fmt.Printf("index: %v (may already exist)\n", err)
|
||||
} else {
|
||||
fmt.Printf("index created: %s\n", idx)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
stockRepo := repository.NewStockRepo(db)
|
||||
dashboardSvc := service.NewDashboardService(stockRepo)
|
||||
dashboardHandler := handler.NewDashboardHandler(dashboardSvc)
|
||||
|
||||
+12
-11
@@ -38,17 +38,18 @@ type StockRank struct {
|
||||
}
|
||||
|
||||
type DailySummary struct {
|
||||
Date string `gorm:"column:date" json:"date"`
|
||||
TotalStocks int `gorm:"column:total_stocks" json:"total_stocks"`
|
||||
UpCount int `gorm:"column:up_count" json:"up_count"`
|
||||
DownCount int `gorm:"column:down_count" json:"down_count"`
|
||||
FlatCount int `gorm:"column:flat_count" json:"flat_count"`
|
||||
TotalTurnover float64 `gorm:"column:total_turnover" json:"total_turnover"`
|
||||
AvgPctChange float64 `gorm:"column:avg_pct_change" json:"avg_pct_change"`
|
||||
LimitUp10 int `gorm:"column:limit_up_10" json:"limit_up_10"`
|
||||
LimitDown10 int `gorm:"column:limit_down_10" json:"limit_down_10"`
|
||||
LimitUp20 int `gorm:"column:limit_up_20" json:"limit_up_20"`
|
||||
LimitDown20 int `gorm:"column:limit_down_20" json:"limit_down_20"`
|
||||
Date string `gorm:"column:date" json:"date"`
|
||||
TotalStocks int `gorm:"column:total_stocks" json:"total_stocks"`
|
||||
UpCount int `gorm:"column:up_count" json:"up_count"`
|
||||
DownCount int `gorm:"column:down_count" json:"down_count"`
|
||||
FlatCount int `gorm:"column:flat_count" json:"flat_count"`
|
||||
TotalTurnover float64 `gorm:"column:total_turnover" json:"total_turnover"`
|
||||
AvgPctChange float64 `gorm:"column:avg_pct_change" json:"avg_pct_change"`
|
||||
LimitUp10 int `gorm:"column:limit_up_10" json:"limit_up_10"`
|
||||
LimitDown10 int `gorm:"column:limit_down_10" json:"limit_down_10"`
|
||||
LimitUp20 int `gorm:"column:limit_up_20" json:"limit_up_20"`
|
||||
LimitDown20 int `gorm:"column:limit_down_20" json:"limit_down_20"`
|
||||
ConsecutiveLimitCount int `gorm:"column:consecutive_limit_count" json:"consecutive_limit_count"`
|
||||
}
|
||||
|
||||
type DashboardSummary struct {
|
||||
|
||||
+158
-87
@@ -2,131 +2,203 @@ package repository
|
||||
|
||||
import (
|
||||
"ashareview-server/model"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type StockRepo struct {
|
||||
db *gorm.DB
|
||||
db *gorm.DB
|
||||
dateCache struct {
|
||||
mu sync.RWMutex
|
||||
date string
|
||||
expires time.Time
|
||||
}
|
||||
}
|
||||
|
||||
func NewStockRepo(db *gorm.DB) *StockRepo {
|
||||
return &StockRepo{db: db}
|
||||
}
|
||||
|
||||
func (r *StockRepo) GetLatestTradeDate() (string, error) {
|
||||
// getLatestDate returns the latest trade date, cached for 5 minutes
|
||||
func (r *StockRepo) getLatestDate() (string, error) {
|
||||
r.dateCache.mu.RLock()
|
||||
if r.dateCache.date != "" && time.Now().Before(r.dateCache.expires) {
|
||||
d := r.dateCache.date
|
||||
r.dateCache.mu.RUnlock()
|
||||
return d, nil
|
||||
}
|
||||
r.dateCache.mu.RUnlock()
|
||||
|
||||
var result struct {
|
||||
MaxDate string
|
||||
}
|
||||
err := r.db.Model(&model.StockDaily{}).
|
||||
Select("MAX(date) as max_date").
|
||||
Scan(&result).Error
|
||||
return result.MaxDate, err
|
||||
err := r.db.Raw("SELECT MAX(date) as max_date FROM stock_daily").Scan(&result).Error
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
r.dateCache.mu.Lock()
|
||||
r.dateCache.date = result.MaxDate
|
||||
r.dateCache.expires = time.Now().Add(5 * time.Minute)
|
||||
r.dateCache.mu.Unlock()
|
||||
|
||||
return result.MaxDate, nil
|
||||
}
|
||||
|
||||
func (r *StockRepo) GetLatestTradeDate() (string, error) {
|
||||
return r.getLatestDate()
|
||||
}
|
||||
|
||||
func (r *StockRepo) GetSummaryByDate(date string) (*model.DashboardSummary, error) {
|
||||
summary := &model.DashboardSummary{}
|
||||
|
||||
r.db.Model(&model.StockDaily{}).
|
||||
Where("date = ?", date).
|
||||
Count(&summary.TotalStocks)
|
||||
|
||||
r.db.Model(&model.StockDaily{}).
|
||||
Where("date = ? AND pct_change > 0", date).
|
||||
Count(&summary.UpCount)
|
||||
|
||||
r.db.Model(&model.StockDaily{}).
|
||||
Where("date = ? AND pct_change < 0", date).
|
||||
Count(&summary.DownCount)
|
||||
|
||||
r.db.Model(&model.StockDaily{}).
|
||||
Where("date = ? AND pct_change = 0", date).
|
||||
Count(&summary.FlatCount)
|
||||
|
||||
var amtResult struct {
|
||||
Total float64
|
||||
Avg float64
|
||||
// Single aggregation query instead of 6 separate queries
|
||||
var agg struct {
|
||||
Total int64 `gorm:"column:total"`
|
||||
Up int64 `gorm:"column:up"`
|
||||
Down int64 `gorm:"column:down"`
|
||||
Flat int64 `gorm:"column:flat"`
|
||||
Turnover float64 `gorm:"column:turnover"`
|
||||
AvgPct float64 `gorm:"column:avg_pct"`
|
||||
}
|
||||
r.db.Model(&model.StockDaily{}).
|
||||
Where("date = ?", date).
|
||||
Select("COALESCE(SUM(turnover), 0) as total, COALESCE(AVG(pct_change), 0) as avg").
|
||||
Scan(&amtResult)
|
||||
summary.TotalTurnover = amtResult.Total
|
||||
summary.AvgPctChange = amtResult.Avg
|
||||
r.db.Raw(`
|
||||
SELECT
|
||||
COUNT(*) as total,
|
||||
SUM(CASE WHEN pct_change > 0 THEN 1 ELSE 0 END) as up,
|
||||
SUM(CASE WHEN pct_change < 0 THEN 1 ELSE 0 END) as down,
|
||||
SUM(CASE WHEN pct_change = 0 OR pct_change IS NULL THEN 1 ELSE 0 END) as flat,
|
||||
COALESCE(SUM(turnover), 0) as turnover,
|
||||
COALESCE(AVG(pct_change), 0) as avg_pct
|
||||
FROM stock_daily WHERE date = ?
|
||||
`, date).Scan(&agg)
|
||||
|
||||
r.db.Table("stock_daily as sd").
|
||||
Select("sd.code, si.name, sd.pct_change, sd.close").
|
||||
Joins("LEFT JOIN stock_info si ON sd.code = si.code").
|
||||
Where("sd.date = ? AND sd.pct_change >= 9.5", date).
|
||||
Order("sd.pct_change DESC").
|
||||
Find(&summary.LimitUpList)
|
||||
|
||||
r.db.Table("stock_daily as sd").
|
||||
Select("sd.code, si.name, sd.pct_change, sd.close").
|
||||
Joins("LEFT JOIN stock_info si ON sd.code = si.code").
|
||||
Where("sd.date = ? AND sd.pct_change <= -9.5", date).
|
||||
Order("sd.pct_change ASC").
|
||||
Find(&summary.LimitDownList)
|
||||
summary.TotalStocks = agg.Total
|
||||
summary.UpCount = agg.Up
|
||||
summary.DownCount = agg.Down
|
||||
summary.FlatCount = agg.Flat
|
||||
summary.TotalTurnover = agg.Turnover
|
||||
summary.AvgPctChange = agg.AvgPct
|
||||
|
||||
r.db.Raw(`
|
||||
SELECT sd.code, si.name, sd.pct_change, sd.close
|
||||
FROM stock_daily sd
|
||||
LEFT JOIN stock_info si ON sd.code = si.code
|
||||
WHERE sd.date = ?
|
||||
AND sd.close = (
|
||||
SELECT MAX(sd2.close)
|
||||
FROM stock_daily sd2
|
||||
WHERE sd2.code = sd.code
|
||||
AND sd2.date <= sd.date
|
||||
AND sd2.date >= DATE_SUB(sd.date, INTERVAL 150 DAY)
|
||||
)
|
||||
AND sd.close > 0
|
||||
WHERE sd.date = ? AND sd.pct_change >= 9.5
|
||||
ORDER BY sd.pct_change DESC
|
||||
`, date).Scan(&summary.High100List)
|
||||
`, date).Scan(&summary.LimitUpList)
|
||||
|
||||
r.db.Raw(`
|
||||
SELECT sd.code, si.name, sd.pct_change, sd.close
|
||||
FROM stock_daily sd
|
||||
LEFT JOIN stock_info si ON sd.code = si.code
|
||||
WHERE sd.date = ? AND sd.pct_change <= -9.5
|
||||
ORDER BY sd.pct_change ASC
|
||||
`, date).Scan(&summary.LimitDownList)
|
||||
|
||||
// 100-day new high: NOT EXISTS stops early when a higher close is found
|
||||
r.db.Raw(`
|
||||
SELECT sd.code, si.name, sd.pct_change, sd.close
|
||||
FROM stock_daily sd
|
||||
LEFT JOIN stock_info si ON sd.code = si.code
|
||||
WHERE sd.date = ? AND sd.close > 0
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM stock_daily sd2
|
||||
WHERE sd2.code = sd.code
|
||||
AND sd2.date BETWEEN DATE_SUB(?, INTERVAL 150 DAY) AND ?
|
||||
AND sd2.close > sd.close
|
||||
LIMIT 1
|
||||
)
|
||||
ORDER BY sd.pct_change DESC
|
||||
`, date, date, date).Scan(&summary.High100List)
|
||||
|
||||
return summary, nil
|
||||
}
|
||||
|
||||
func (r *StockRepo) GetDailySummary(days int) ([]model.DailySummary, error) {
|
||||
latest, err := r.getLatestDate()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var result []model.DailySummary
|
||||
r.db.Raw(`
|
||||
SELECT
|
||||
date,
|
||||
COUNT(*) as total_stocks,
|
||||
SUM(CASE WHEN pct_change > 0 THEN 1 ELSE 0 END) as up_count,
|
||||
SUM(CASE WHEN pct_change < 0 THEN 1 ELSE 0 END) as down_count,
|
||||
SUM(CASE WHEN pct_change = 0 OR pct_change IS NULL THEN 1 ELSE 0 END) as flat_count,
|
||||
COALESCE(SUM(turnover), 0) as total_turnover,
|
||||
COALESCE(AVG(pct_change), 0) as avg_pct_change,
|
||||
SUM(CASE WHEN pct_change >= 9.5 AND pct_change < 19.5 THEN 1 ELSE 0 END) as limit_up_10,
|
||||
SUM(CASE WHEN pct_change <= -9.5 AND pct_change > -19.5 THEN 1 ELSE 0 END) as limit_down_10,
|
||||
SUM(CASE WHEN pct_change >= 19.5 THEN 1 ELSE 0 END) as limit_up_20,
|
||||
SUM(CASE WHEN pct_change <= -19.5 THEN 1 ELSE 0 END) as limit_down_20
|
||||
FROM stock_daily
|
||||
WHERE date >= DATE_SUB(?, INTERVAL ? DAY)
|
||||
GROUP BY date
|
||||
ORDER BY date ASC
|
||||
`, latest, days-1).Scan(&result)
|
||||
|
||||
sql := `SELECT
|
||||
date,
|
||||
COUNT(*) as total_stocks,
|
||||
SUM(CASE WHEN pct_change > 0 THEN 1 ELSE 0 END) as up_count,
|
||||
SUM(CASE WHEN pct_change < 0 THEN 1 ELSE 0 END) as down_count,
|
||||
SUM(CASE WHEN pct_change = 0 OR pct_change IS NULL THEN 1 ELSE 0 END) as flat_count,
|
||||
COALESCE(SUM(turnover), 0) as total_turnover,
|
||||
COALESCE(AVG(pct_change), 0) as avg_pct_change,
|
||||
SUM(CASE WHEN pct_change >= 9.5 AND pct_change < 19.5 THEN 1 ELSE 0 END) as limit_up_10,
|
||||
SUM(CASE WHEN pct_change <= -9.5 AND pct_change > -19.5 THEN 1 ELSE 0 END) as limit_down_10,
|
||||
SUM(CASE WHEN pct_change >= 19.5 THEN 1 ELSE 0 END) as limit_up_20,
|
||||
SUM(CASE WHEN pct_change <= -19.5 THEN 1 ELSE 0 END) as limit_down_20
|
||||
FROM stock_daily
|
||||
WHERE date >= (SELECT MAX(date) FROM stock_daily) - INTERVAL ? DAY
|
||||
GROUP BY date
|
||||
ORDER BY date ASC`
|
||||
// Consecutive limit-up: get trading date pairs, then batch query
|
||||
type datePair struct {
|
||||
CurDate string `gorm:"column:cur_date"`
|
||||
PrevDate string `gorm:"column:prev_date"`
|
||||
}
|
||||
var pairs []datePair
|
||||
r.db.Raw(`
|
||||
SELECT d1.date AS cur_date, MAX(d2.date) AS prev_date
|
||||
FROM (SELECT DISTINCT date FROM stock_daily WHERE date >= DATE_SUB(?, INTERVAL ? DAY)) d1
|
||||
JOIN (SELECT DISTINCT date FROM stock_daily WHERE date >= DATE_SUB(?, INTERVAL ? DAY)) d2 ON d2.date < d1.date
|
||||
GROUP BY d1.date
|
||||
`, latest, days-1, latest, days).Scan(&pairs)
|
||||
|
||||
err := r.db.Raw(sql, days-1).Scan(&result).Error
|
||||
type consecRow struct {
|
||||
Date string `gorm:"column:date"`
|
||||
Cnt int `gorm:"column:cnt"`
|
||||
}
|
||||
var consecResults []consecRow
|
||||
if len(pairs) > 0 {
|
||||
// Use UNION ALL for better index utilization than OR
|
||||
unionSQL := ""
|
||||
args := []interface{}{}
|
||||
for _, p := range pairs {
|
||||
if unionSQL != "" {
|
||||
unionSQL += " UNION ALL "
|
||||
}
|
||||
unionSQL += "SELECT ? as date, COUNT(DISTINCT cur.code) as cnt FROM stock_daily cur JOIN stock_daily prev ON prev.code = cur.code AND prev.pct_change >= 9.5 WHERE cur.pct_change >= 9.5 AND cur.date = ? AND prev.date = ?"
|
||||
args = append(args, p.CurDate, p.CurDate, p.PrevDate)
|
||||
}
|
||||
r.db.Raw(unionSQL, args...).Scan(&consecResults)
|
||||
}
|
||||
|
||||
return result, err
|
||||
consecMap := make(map[string]int)
|
||||
for _, row := range consecResults {
|
||||
consecMap[row.Date] = row.Cnt
|
||||
}
|
||||
for i := range result {
|
||||
if v, ok := consecMap[result[i].Date]; ok {
|
||||
result[i].ConsecutiveLimitCount = v
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (r *StockRepo) GetConsecutiveLimits() ([]model.ConsecutiveLimit, error) {
|
||||
// Get latest trade date from stock_daily
|
||||
type dateResult struct {
|
||||
D string `gorm:"column:d"`
|
||||
latest, err := r.getLatestDate()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var dr dateResult
|
||||
r.db.Raw("SELECT MAX(DATE(date)) as d FROM stock_daily").Scan(&dr)
|
||||
latest := dr.D
|
||||
if latest == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Get recent trading dates up to latest
|
||||
// Get recent trading dates with date range to use index
|
||||
var dates []string
|
||||
r.db.Raw(`SELECT DATE(date) as d FROM stock_daily WHERE date <= ? GROUP BY date ORDER BY date DESC LIMIT 20`, latest).Scan(&dates)
|
||||
r.db.Raw(`SELECT date FROM stock_daily WHERE date BETWEEN DATE_SUB(?, INTERVAL 30 DAY) AND ? GROUP BY date ORDER BY date DESC LIMIT 20`, latest, latest).Scan(&dates)
|
||||
if len(dates) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -134,16 +206,15 @@ func (r *StockRepo) GetConsecutiveLimits() ([]model.ConsecutiveLimit, error) {
|
||||
|
||||
// Get all limit-up records for recent dates
|
||||
type limitRow struct {
|
||||
Code string
|
||||
D string
|
||||
Code string `gorm:"column:code"`
|
||||
D string `gorm:"column:date"`
|
||||
}
|
||||
var rows []limitRow
|
||||
r.db.Raw(`SELECT code, DATE(date) as d FROM stock_daily WHERE pct_change >= 9.5 AND date >= ?`, earliest).Scan(&rows)
|
||||
r.db.Raw(`SELECT code, date FROM stock_daily WHERE pct_change >= 9.5 AND date >= ?`, earliest).Scan(&rows)
|
||||
|
||||
// Build set of (code, date) for limit-up
|
||||
limitSet := make(map[string]bool)
|
||||
for _, row := range rows {
|
||||
limitSet[row.Code+"_"+row.D] = true
|
||||
limitSet[row.Code+"_"+row.D[:10]] = true
|
||||
}
|
||||
|
||||
// Get today's limit-up stocks with info
|
||||
@@ -162,12 +233,12 @@ func (r *StockRepo) GetConsecutiveLimits() ([]model.ConsecutiveLimit, error) {
|
||||
ORDER BY sd.pct_change DESC
|
||||
`, latest).Scan(&todayLimit)
|
||||
|
||||
// For each stock, count consecutive limit-up days backwards from latest
|
||||
var result []model.ConsecutiveLimit
|
||||
for _, s := range todayLimit {
|
||||
streak := 0
|
||||
for _, d := range dates {
|
||||
if limitSet[s.Code+"_"+d] {
|
||||
ds := d[:10]
|
||||
if limitSet[s.Code+"_"+ds] {
|
||||
streak++
|
||||
} else {
|
||||
break
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>web</title>
|
||||
<script type="module" crossorigin src="/assets/index-hDKBor3F.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-Dq_0ARKI.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DGNrK5qb.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -35,6 +35,7 @@ export interface DailySummary {
|
||||
limit_down_10: number;
|
||||
limit_up_20: number;
|
||||
limit_down_20: number;
|
||||
consecutive_limit_count: number;
|
||||
}
|
||||
|
||||
export async function getDashboardSummary(): Promise<DashboardSummary> {
|
||||
|
||||
@@ -31,11 +31,11 @@ export default function DailyChart({ data }: Props) {
|
||||
},
|
||||
legend: {
|
||||
top: 0,
|
||||
data: ['上涨数', '下跌数', '成交额(亿)', '涨停10%', '跌停10%', '涨停20%', '跌停20%'],
|
||||
data: ['上涨数', '下跌数', '成交额(亿)', '涨停10%', '跌停10%', '涨停20%', '跌停20%', '连板家数'],
|
||||
},
|
||||
grid: [
|
||||
{ left: 60, right: 70, top: 60, height: '30%' },
|
||||
{ left: 60, right: 70, top: '50%', height: '44%' },
|
||||
{ left: 60, right: 70, top: 60, height: '25%' },
|
||||
{ left: 60, right: 70, top: '45%', height: '49%' },
|
||||
],
|
||||
xAxis: [
|
||||
{ type: 'category', data: dates, gridIndex: 0, boundaryGap: false },
|
||||
@@ -46,6 +46,7 @@ export default function DailyChart({ data }: Props) {
|
||||
{ type: 'value', name: '亿', gridIndex: 0, position: 'right', splitLine: { show: false } },
|
||||
{ type: 'value', name: '10%家', gridIndex: 1, position: 'left' },
|
||||
{ type: 'value', name: '20%家', gridIndex: 1, position: 'right', splitLine: { show: false } },
|
||||
{ type: 'value', name: '连板', gridIndex: 1, position: 'right', offset: 60, splitLine: { show: false } },
|
||||
],
|
||||
dataZoom: [
|
||||
{ type: 'inside', xAxisIndex: [0, 1], start: 0, end: 100 },
|
||||
@@ -126,6 +127,17 @@ export default function DailyChart({ data }: Props) {
|
||||
symbol: 'circle',
|
||||
symbolSize: 5,
|
||||
},
|
||||
{
|
||||
name: '连板家数',
|
||||
type: 'line',
|
||||
xAxisIndex: 1,
|
||||
yAxisIndex: 4,
|
||||
data: data.map((d) => d.consecutive_limit_count),
|
||||
itemStyle: { color: '#fa8c16' },
|
||||
lineStyle: { width: 3 },
|
||||
symbol: 'circle',
|
||||
symbolSize: 6,
|
||||
},
|
||||
],
|
||||
}, true);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user