SHA256
叠加显示
This commit is contained in:
+18
-10
@@ -1,3 +1,4 @@
|
||||
// Package config 读取应用配置,支持 YAML 配置文件和环境变量覆盖
|
||||
package config
|
||||
|
||||
import (
|
||||
@@ -7,29 +8,34 @@ import (
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// DBConfig MySQL 数据库连接配置
|
||||
type DBConfig struct {
|
||||
Host string `yaml:"host"`
|
||||
Port string `yaml:"port"`
|
||||
User string `yaml:"user"`
|
||||
Password string `yaml:"password"`
|
||||
DBName string `yaml:"database"`
|
||||
Charset string `yaml:"charset"`
|
||||
Host string `yaml:"host"` // 数据库主机地址
|
||||
Port string `yaml:"port"` // 数据库端口
|
||||
User string `yaml:"user"` // 用户名
|
||||
Password string `yaml:"password"` // 密码
|
||||
DBName string `yaml:"database"` // 数据库名
|
||||
Charset string `yaml:"charset"` // 字符集,默认 utf8mb4
|
||||
}
|
||||
|
||||
// ServerConfig HTTP 服务器配置
|
||||
type ServerConfig struct {
|
||||
Port string `yaml:"port"`
|
||||
Port string `yaml:"port"` // 监听端口,默认 8080
|
||||
}
|
||||
|
||||
// Config 应用总配置
|
||||
type Config struct {
|
||||
DB DBConfig `yaml:"mysql"`
|
||||
Server ServerConfig `yaml:"server"`
|
||||
DB DBConfig `yaml:"mysql"` // MySQL 配置段
|
||||
Server ServerConfig `yaml:"server"` // 服务器配置段
|
||||
}
|
||||
|
||||
// Load 加载配置,优先级: 环境变量 > config.yaml > 默认值
|
||||
func Load() *Config {
|
||||
cfg := &Config{}
|
||||
|
||||
data, err := os.ReadFile("config.yaml")
|
||||
if err != nil {
|
||||
// 配置文件不存在时使用环境变量或默认值
|
||||
fmt.Println("config.yaml not found, using env defaults")
|
||||
cfg.DB.Host = getEnv("DB_HOST", "127.0.0.1")
|
||||
cfg.DB.Port = getEnv("DB_PORT", "3306")
|
||||
@@ -53,7 +59,7 @@ func Load() *Config {
|
||||
return cfg
|
||||
}
|
||||
|
||||
// Environment variables override config file
|
||||
// 环境变量覆盖配置文件中的值
|
||||
if v := os.Getenv("DB_HOST"); v != "" {
|
||||
cfg.DB.Host = v
|
||||
}
|
||||
@@ -76,6 +82,7 @@ func Load() *Config {
|
||||
return cfg
|
||||
}
|
||||
|
||||
// DSN 生成 MySQL 连接字符串
|
||||
func (c *DBConfig) DSN() string {
|
||||
charset := c.Charset
|
||||
if charset == "" {
|
||||
@@ -85,6 +92,7 @@ func (c *DBConfig) DSN() string {
|
||||
c.User, c.Password, c.Host, c.Port, c.DBName, charset)
|
||||
}
|
||||
|
||||
// getEnv 获取环境变量,不存在时返回默认值
|
||||
func getEnv(key, fallback string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Package handler HTTP 请求处理层,负责参数解析、调用 Service、返回 JSON 响应
|
||||
package handler
|
||||
|
||||
import (
|
||||
@@ -8,6 +9,7 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// DashboardHandler 市场概览相关的 HTTP handler
|
||||
type DashboardHandler struct {
|
||||
svc *service.DashboardService
|
||||
}
|
||||
@@ -16,6 +18,8 @@ func NewDashboardHandler(svc *service.DashboardService) *DashboardHandler {
|
||||
return &DashboardHandler{svc: svc}
|
||||
}
|
||||
|
||||
// GetSummary 获取最新交易日的市场概览数据
|
||||
// GET /api/dashboard/summary
|
||||
func (h *DashboardHandler) GetSummary(c *gin.Context) {
|
||||
summary, err := h.svc.GetSummary()
|
||||
if err != nil {
|
||||
@@ -25,6 +29,8 @@ func (h *DashboardHandler) GetSummary(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"data": summary})
|
||||
}
|
||||
|
||||
// GetDailySummary 获取近 N 天的每日市场统计数据
|
||||
// GET /api/dashboard/daily?days=30
|
||||
func (h *DashboardHandler) GetDailySummary(c *gin.Context) {
|
||||
days, _ := strconv.Atoi(c.DefaultQuery("days", "30"))
|
||||
if days <= 0 || days > 365 {
|
||||
@@ -38,6 +44,8 @@ func (h *DashboardHandler) GetDailySummary(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"data": data})
|
||||
}
|
||||
|
||||
// GetConsecutiveLimits 获取最新交易日的连板股票统计
|
||||
// GET /api/dashboard/consecutive
|
||||
func (h *DashboardHandler) GetConsecutiveLimits(c *gin.Context) {
|
||||
data, err := h.svc.GetConsecutiveLimits()
|
||||
if err != nil {
|
||||
@@ -47,6 +55,8 @@ func (h *DashboardHandler) GetConsecutiveLimits(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"data": data})
|
||||
}
|
||||
|
||||
// GetHigh100 获取百日新高股票列表
|
||||
// GET /api/dashboard/high100
|
||||
func (h *DashboardHandler) GetHigh100(c *gin.Context) {
|
||||
data, err := h.svc.GetHigh100()
|
||||
if err != nil {
|
||||
@@ -56,6 +66,8 @@ func (h *DashboardHandler) GetHigh100(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"data": data})
|
||||
}
|
||||
|
||||
// GetIndexKline 获取指数 K 线数据
|
||||
// GET /api/dashboard/index-kline?code=000001&days=60
|
||||
func (h *DashboardHandler) GetIndexKline(c *gin.Context) {
|
||||
code := c.DefaultQuery("code", "000001")
|
||||
days, _ := strconv.Atoi(c.DefaultQuery("days", "60"))
|
||||
@@ -69,3 +81,19 @@ func (h *DashboardHandler) GetIndexKline(c *gin.Context) {
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": data})
|
||||
}
|
||||
|
||||
// GetStockKline 获取个股 K 线数据(全部历史)
|
||||
// GET /api/dashboard/stock-kline?code=600498
|
||||
func (h *DashboardHandler) GetStockKline(c *gin.Context) {
|
||||
code := c.Query("code")
|
||||
if code == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "code is required"})
|
||||
return
|
||||
}
|
||||
data, err := h.svc.GetStockKline(code)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": data})
|
||||
}
|
||||
|
||||
+7
-1
@@ -1,3 +1,5 @@
|
||||
// AShareView 服务端入口
|
||||
// A股市场数据可视化平台后端,提供市场概览、指数行情、K线叠加对比等 API
|
||||
package main
|
||||
|
||||
import (
|
||||
@@ -16,15 +18,17 @@ import (
|
||||
)
|
||||
|
||||
func main() {
|
||||
// 加载配置
|
||||
cfg := config.Load()
|
||||
|
||||
// 连接 MySQL
|
||||
db, err := gorm.Open(mysql.Open(cfg.DB.DSN()), &gorm.Config{})
|
||||
if err != nil {
|
||||
log.Fatalf("failed to connect database: %v", err)
|
||||
}
|
||||
fmt.Println("Database connected successfully")
|
||||
|
||||
// Ensure indexes exist (skip if already exist)
|
||||
// 异步创建常用索引,避免首次查询慢
|
||||
go func() {
|
||||
time.Sleep(3 * time.Second)
|
||||
indexes := []string{
|
||||
@@ -40,10 +44,12 @@ func main() {
|
||||
}
|
||||
}()
|
||||
|
||||
// 分层初始化: Repository -> Service -> Handler
|
||||
stockRepo := repository.NewStockRepo(db)
|
||||
dashboardSvc := service.NewDashboardService(stockRepo)
|
||||
dashboardHandler := handler.NewDashboardHandler(dashboardSvc)
|
||||
|
||||
// 启动 HTTP 服务
|
||||
r := gin.Default()
|
||||
router.Setup(r, dashboardHandler)
|
||||
|
||||
|
||||
+66
-53
@@ -1,84 +1,97 @@
|
||||
// Package model 定义了A股数据相关的数据模型
|
||||
//
|
||||
// 数据源:
|
||||
// - stock_daily 表: 个股每日行情数据(开盘、收盘、最高、最低、成交量、成交额、涨跌幅等)
|
||||
// - stock_info 表: 个股基本信息(代码、名称、上市日期)
|
||||
// - index_daily 表: 指数每日行情数据
|
||||
package model
|
||||
|
||||
// StockDaily 个股每日行情记录
|
||||
type StockDaily struct {
|
||||
ID int `gorm:"primaryKey" json:"id"`
|
||||
Code string `gorm:"column:code;type:varchar(10)" json:"code"`
|
||||
Date string `gorm:"column:date;type:date" json:"date"`
|
||||
Open float64 `gorm:"column:open" json:"open"`
|
||||
Close float64 `gorm:"column:close" json:"close"`
|
||||
High float64 `gorm:"column:high" json:"high"`
|
||||
Low float64 `gorm:"column:low" json:"low"`
|
||||
Volume float64 `gorm:"column:volume" json:"volume"`
|
||||
Turnover float64 `gorm:"column:turnover" json:"turnover"`
|
||||
Amplitude float64 `gorm:"column:amplitude" json:"amplitude"`
|
||||
PctChange float64 `gorm:"column:pct_change" json:"pct_change"`
|
||||
Change float64 `gorm:"column:change" json:"change"`
|
||||
TurnoverRate float64 `gorm:"column:turnover_rate" json:"turnover_rate"`
|
||||
Code string `gorm:"column:code;type:varchar(10)" json:"code"` // 股票代码,如 "600498"
|
||||
Date string `gorm:"column:date;type:date" json:"date"` // 交易日期
|
||||
Open float64 `gorm:"column:open" json:"open"` // 开盘价
|
||||
Close float64 `gorm:"column:close" json:"close"` // 收盘价
|
||||
High float64 `gorm:"column:high" json:"high"` // 最高价
|
||||
Low float64 `gorm:"column:low" json:"low"` // 最低价
|
||||
Volume float64 `gorm:"column:volume" json:"volume"` // 成交量(股)
|
||||
Turnover float64 `gorm:"column:turnover" json:"turnover"` // 成交额(元)
|
||||
Amplitude float64 `gorm:"column:amplitude" json:"amplitude"` // 振幅(%)
|
||||
PctChange float64 `gorm:"column:pct_change" json:"pct_change"` // 涨跌幅(%)
|
||||
Change float64 `gorm:"column:change" json:"change"` // 涨跌额
|
||||
TurnoverRate float64 `gorm:"column:turnover_rate" json:"turnover_rate"` // 换手率(%)
|
||||
}
|
||||
|
||||
func (StockDaily) TableName() string {
|
||||
return "stock_daily"
|
||||
}
|
||||
|
||||
// StockInfo 个股基本信息
|
||||
type StockInfo struct {
|
||||
Code string `gorm:"primaryKey;column:code;type:varchar(10)" json:"code"`
|
||||
Name string `gorm:"column:name;type:varchar(50)" json:"name"`
|
||||
IpoDate string `gorm:"column:ipo_date;type:date" json:"ipo_date"`
|
||||
Code string `gorm:"primaryKey;column:code;type:varchar(10)" json:"code"` // 股票代码
|
||||
Name string `gorm:"column:name;type:varchar(50)" json:"name"` // 股票名称
|
||||
IpoDate string `gorm:"column:ipo_date;type:date" json:"ipo_date"` // 上市日期
|
||||
}
|
||||
|
||||
func (StockInfo) TableName() string {
|
||||
return "stock_info"
|
||||
}
|
||||
|
||||
// StockRank 涨停/跌停/百日新高排行
|
||||
type StockRank struct {
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
PctChange float64 `json:"pct_change"`
|
||||
Close float64 `json:"close"`
|
||||
Code string `json:"code"` // 股票代码
|
||||
Name string `json:"name"` // 股票名称
|
||||
PctChange float64 `json:"pct_change"` // 涨跌幅(%)
|
||||
Close float64 `json:"close"` // 收盘价
|
||||
}
|
||||
|
||||
// DailySummary 每日市场统计汇总
|
||||
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"`
|
||||
ConsecutiveLimitCount int `gorm:"column:consecutive_limit_count" json:"consecutive_limit_count"`
|
||||
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"` // 主板涨停数量(涨幅>=9.5%)
|
||||
LimitDown10 int `gorm:"column:limit_down_10" json:"limit_down_10"` // 主板跌停数量(跌幅<=-9.5%)
|
||||
LimitUp20 int `gorm:"column:limit_up_20" json:"limit_up_20"` // 创业板/科创板涨停数量(涨幅>=19.5%)
|
||||
LimitDown20 int `gorm:"column:limit_down_20" json:"limit_down_20"` // 创业板/科创板跌停数量(跌幅<=-19.5%)
|
||||
ConsecutiveLimitCount int `gorm:"column:consecutive_limit_count" json:"consecutive_limit_count"` // 连板股票数量(当日涨停且前一日也涨停)
|
||||
}
|
||||
|
||||
// DashboardSummary 市场概览数据
|
||||
type DashboardSummary struct {
|
||||
TotalStocks int64 `json:"total_stocks"`
|
||||
UpCount int64 `json:"up_count"`
|
||||
DownCount int64 `json:"down_count"`
|
||||
FlatCount int64 `json:"flat_count"`
|
||||
TotalTurnover float64 `json:"total_turnover"`
|
||||
AvgPctChange float64 `json:"avg_pct_change"`
|
||||
LimitUpList []StockRank `json:"limit_up_list"`
|
||||
LimitDownList []StockRank `json:"limit_down_list"`
|
||||
High100List []StockRank `json:"high_100_list"`
|
||||
TotalStocks int64 `json:"total_stocks"` // 当日交易股票总数
|
||||
UpCount int64 `json:"up_count"` // 上涨数量
|
||||
DownCount int64 `json:"down_count"` // 下跌数量
|
||||
FlatCount int64 `json:"flat_count"` // 平盘数量
|
||||
TotalTurnover float64 `json:"total_turnover"` // 总成交额
|
||||
AvgPctChange float64 `json:"avg_pct_change"` // 平均涨跌幅(%)
|
||||
LimitUpList []StockRank `json:"limit_up_list"` // 涨停股票列表
|
||||
LimitDownList []StockRank `json:"limit_down_list"` // 跌停股票列表
|
||||
High100List []StockRank `json:"high_100_list"` // 百日新高股票列表
|
||||
}
|
||||
|
||||
// ConsecutiveLimit 连板统计
|
||||
type ConsecutiveLimit struct {
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
Streak int `json:"streak"`
|
||||
Close float64 `json:"close"`
|
||||
PctChange float64 `json:"pct_change"`
|
||||
Code string `json:"code"` // 股票代码
|
||||
Name string `json:"name"` // 股票名称
|
||||
Streak int `json:"streak"` // 连板天数(连续涨停天数)
|
||||
Close float64 `json:"close"` // 收盘价
|
||||
PctChange float64 `json:"pct_change"` // 当日涨跌幅(%)
|
||||
}
|
||||
|
||||
// IndexKline K线数据(通用结构,同时用于指数和个股)
|
||||
type IndexKline struct {
|
||||
Date string `json:"date"`
|
||||
Open float64 `json:"open"`
|
||||
Close float64 `json:"close"`
|
||||
High float64 `json:"high"`
|
||||
Low float64 `json:"low"`
|
||||
Volume float64 `json:"volume"`
|
||||
Amount float64 `json:"amount"`
|
||||
PctChange float64 `json:"pct_change"`
|
||||
Date string `json:"date"` // 交易日期
|
||||
Open float64 `json:"open"` // 开盘价
|
||||
Close float64 `json:"close"` // 收盘价
|
||||
High float64 `json:"high"` // 最高价
|
||||
Low float64 `json:"low"` // 最低价
|
||||
Volume float64 `json:"volume"` // 成交量
|
||||
Amount float64 `json:"amount"` // 成交额
|
||||
PctChange float64 `json:"pct_change"` // 涨跌幅(%)
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Package repository 数据访问层,直接操作 MySQL 数据库执行 SQL 查询
|
||||
package repository
|
||||
|
||||
import (
|
||||
@@ -8,9 +9,10 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// StockRepo 个股与指数数据的数据访问对象
|
||||
type StockRepo struct {
|
||||
db *gorm.DB
|
||||
dateCache struct {
|
||||
dateCache struct { // 最新交易日期缓存,避免频繁查询
|
||||
mu sync.RWMutex
|
||||
date string
|
||||
expires time.Time
|
||||
@@ -21,7 +23,7 @@ func NewStockRepo(db *gorm.DB) *StockRepo {
|
||||
return &StockRepo{db: db}
|
||||
}
|
||||
|
||||
// getLatestDate returns the latest trade date, cached for 5 minutes
|
||||
// getLatestDate 获取最新交易日期,结果缓存 5 分钟
|
||||
func (r *StockRepo) getLatestDate() (string, error) {
|
||||
r.dateCache.mu.RLock()
|
||||
if r.dateCache.date != "" && time.Now().Before(r.dateCache.expires) {
|
||||
@@ -47,14 +49,18 @@ func (r *StockRepo) getLatestDate() (string, error) {
|
||||
return result.MaxDate, nil
|
||||
}
|
||||
|
||||
// GetLatestTradeDate 获取最新交易日期(公开方法)
|
||||
func (r *StockRepo) GetLatestTradeDate() (string, error) {
|
||||
return r.getLatestDate()
|
||||
}
|
||||
|
||||
// GetSummaryByDate 获取指定日期的市场概览数据
|
||||
// 包括: 涨跌统计、涨停跌停列表、百日新高列表
|
||||
// 使用单条聚合查询替代多次 COUNT 查询,提升性能
|
||||
func (r *StockRepo) GetSummaryByDate(date string) (*model.DashboardSummary, error) {
|
||||
summary := &model.DashboardSummary{}
|
||||
|
||||
// Single aggregation query instead of 6 separate queries
|
||||
// 聚合统计: 总数、上涨、下跌、平盘、总成交额、平均涨跌幅
|
||||
var agg struct {
|
||||
Total int64 `gorm:"column:total"`
|
||||
Up int64 `gorm:"column:up"`
|
||||
@@ -81,6 +87,7 @@ func (r *StockRepo) GetSummaryByDate(date string) (*model.DashboardSummary, erro
|
||||
summary.TotalTurnover = agg.Turnover
|
||||
summary.AvgPctChange = agg.AvgPct
|
||||
|
||||
// 涨停列表: 涨幅 >= 9.5%(覆盖主板10%和创业板/科创板20%)
|
||||
r.db.Raw(`
|
||||
SELECT sd.code, si.name, sd.pct_change, sd.close
|
||||
FROM stock_daily sd
|
||||
@@ -89,6 +96,7 @@ func (r *StockRepo) GetSummaryByDate(date string) (*model.DashboardSummary, erro
|
||||
ORDER BY sd.pct_change DESC
|
||||
`, date).Scan(&summary.LimitUpList)
|
||||
|
||||
// 跌停列表: 跌幅 <= -9.5%
|
||||
r.db.Raw(`
|
||||
SELECT sd.code, si.name, sd.pct_change, sd.close
|
||||
FROM stock_daily sd
|
||||
@@ -97,7 +105,7 @@ func (r *StockRepo) GetSummaryByDate(date string) (*model.DashboardSummary, erro
|
||||
ORDER BY sd.pct_change ASC
|
||||
`, date).Scan(&summary.LimitDownList)
|
||||
|
||||
// 100-day new high: pre-aggregate max_close with covering index
|
||||
// 百日新高: 当日收盘价等于近 150 个交易日最高收盘价的股票
|
||||
r.db.Raw(`
|
||||
SELECT t.code, si.name, t.pct_change, t.close
|
||||
FROM (
|
||||
@@ -118,6 +126,7 @@ func (r *StockRepo) GetSummaryByDate(date string) (*model.DashboardSummary, erro
|
||||
return summary, nil
|
||||
}
|
||||
|
||||
// GetHigh100 获取最新交易日创百日新高的股票列表
|
||||
func (r *StockRepo) GetHigh100() ([]model.StockRank, error) {
|
||||
date, err := r.getLatestDate()
|
||||
if err != nil {
|
||||
@@ -143,6 +152,8 @@ func (r *StockRepo) GetHigh100() ([]model.StockRank, error) {
|
||||
return list, nil
|
||||
}
|
||||
|
||||
// GetDailySummary 获取近 N 天的每日市场统计
|
||||
// 包含: 涨跌数量、成交额、涨停跌停数量、连板数量
|
||||
func (r *StockRepo) GetDailySummary(days int) ([]model.DailySummary, error) {
|
||||
latest, err := r.getLatestDate()
|
||||
if err != nil {
|
||||
@@ -169,7 +180,7 @@ func (r *StockRepo) GetDailySummary(days int) ([]model.DailySummary, error) {
|
||||
ORDER BY date ASC
|
||||
`, latest, days-1).Scan(&result)
|
||||
|
||||
// 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"`
|
||||
@@ -188,7 +199,7 @@ func (r *StockRepo) GetDailySummary(days int) ([]model.DailySummary, error) {
|
||||
}
|
||||
var consecResults []consecRow
|
||||
if len(pairs) > 0 {
|
||||
// Use UNION ALL for better index utilization than OR
|
||||
// 使用 UNION ALL 批量查询,利用索引优于 OR 条件
|
||||
unionSQL := ""
|
||||
args := []interface{}{}
|
||||
for _, p := range pairs {
|
||||
@@ -201,6 +212,7 @@ func (r *StockRepo) GetDailySummary(days int) ([]model.DailySummary, error) {
|
||||
r.db.Raw(unionSQL, args...).Scan(&consecResults)
|
||||
}
|
||||
|
||||
// 将连板数量映射到对应的日期
|
||||
consecMap := make(map[string]int)
|
||||
for _, row := range consecResults {
|
||||
consecMap[row.Date] = row.Cnt
|
||||
@@ -214,6 +226,8 @@ func (r *StockRepo) GetDailySummary(days int) ([]model.DailySummary, error) {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetConsecutiveLimits 获取最新交易日连板股票列表
|
||||
// 查询近 30 天的涨停记录,计算当日涨停股票的连续涨停天数
|
||||
func (r *StockRepo) GetConsecutiveLimits() ([]model.ConsecutiveLimit, error) {
|
||||
latest, err := r.getLatestDate()
|
||||
if err != nil {
|
||||
@@ -223,7 +237,7 @@ func (r *StockRepo) GetConsecutiveLimits() ([]model.ConsecutiveLimit, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Get recent trading dates with date range to use index
|
||||
// 获取近 20 个交易日(覆盖 30 天范围)
|
||||
var dates []string
|
||||
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 {
|
||||
@@ -231,7 +245,7 @@ func (r *StockRepo) GetConsecutiveLimits() ([]model.ConsecutiveLimit, error) {
|
||||
}
|
||||
earliest := dates[len(dates)-1]
|
||||
|
||||
// Get all limit-up records for recent dates
|
||||
// 加载近期的全部涨停记录到内存,用 set 快速判断
|
||||
type limitRow struct {
|
||||
Code string `gorm:"column:code"`
|
||||
D string `gorm:"column:date"`
|
||||
@@ -244,7 +258,7 @@ func (r *StockRepo) GetConsecutiveLimits() ([]model.ConsecutiveLimit, error) {
|
||||
limitSet[row.Code+"_"+row.D[:10]] = true
|
||||
}
|
||||
|
||||
// Get today's limit-up stocks with info
|
||||
// 获取当日涨停股票详情
|
||||
type stockInfo struct {
|
||||
Code string `gorm:"column:code"`
|
||||
Name string `gorm:"column:name"`
|
||||
@@ -260,6 +274,7 @@ func (r *StockRepo) GetConsecutiveLimits() ([]model.ConsecutiveLimit, error) {
|
||||
ORDER BY sd.pct_change DESC
|
||||
`, latest).Scan(&todayLimit)
|
||||
|
||||
// 逐只股票从最近交易日往前计算连续涨停天数
|
||||
var result []model.ConsecutiveLimit
|
||||
for _, s := range todayLimit {
|
||||
streak := 0
|
||||
@@ -280,7 +295,7 @@ func (r *StockRepo) GetConsecutiveLimits() ([]model.ConsecutiveLimit, error) {
|
||||
})
|
||||
}
|
||||
|
||||
// Sort by streak desc
|
||||
// 按连板天数降序排列
|
||||
for i := 0; i < len(result); i++ {
|
||||
for j := i + 1; j < len(result); j++ {
|
||||
if result[j].Streak > result[i].Streak || (result[j].Streak == result[i].Streak && result[j].PctChange > result[i].PctChange) {
|
||||
@@ -292,6 +307,7 @@ func (r *StockRepo) GetConsecutiveLimits() ([]model.ConsecutiveLimit, error) {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetIndexKline 获取指数 K 线数据(全部历史,来自 index_daily 表)
|
||||
func (r *StockRepo) GetIndexKline(code string, days int) ([]model.IndexKline, error) {
|
||||
var result []model.IndexKline
|
||||
r.db.Raw(`
|
||||
@@ -302,3 +318,22 @@ func (r *StockRepo) GetIndexKline(code string, days int) ([]model.IndexKline, er
|
||||
`, code).Scan(&result)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetStockKline 获取个股 K 线数据(全部历史,来自 stock_daily 表)
|
||||
func (r *StockRepo) GetStockKline(code string) ([]model.IndexKline, error) {
|
||||
var result []model.IndexKline
|
||||
r.db.Raw(`
|
||||
SELECT date, open, close, high, low, volume, turnover as amount, pct_change
|
||||
FROM stock_daily
|
||||
WHERE code = ?
|
||||
ORDER BY date ASC
|
||||
`, code).Scan(&result)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetStockName 根据 code 查询股票名称
|
||||
func (r *StockRepo) GetStockName(code string) (string, error) {
|
||||
var name string
|
||||
r.db.Raw(`SELECT name FROM stock_info WHERE code = ?`, code).Scan(&name)
|
||||
return name, nil
|
||||
}
|
||||
|
||||
+15
-8
@@ -1,3 +1,4 @@
|
||||
// Package router 路由配置,注册 API 路由和前端静态文件服务
|
||||
package router
|
||||
|
||||
import (
|
||||
@@ -9,20 +10,26 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Setup 注册所有路由
|
||||
// - /api/* : 后端 API 接口
|
||||
// - /assets/*, /vite.svg : 前端静态资源
|
||||
// - 其余路径: SPA fallback,返回 index.html
|
||||
func Setup(r *gin.Engine, dh *handler.DashboardHandler) {
|
||||
// 后端 API 路由组
|
||||
api := r.Group("/api")
|
||||
{
|
||||
api.GET("/dashboard/summary", dh.GetSummary)
|
||||
api.GET("/dashboard/daily", dh.GetDailySummary)
|
||||
api.GET("/dashboard/consecutive", dh.GetConsecutiveLimits)
|
||||
api.GET("/dashboard/high100", dh.GetHigh100)
|
||||
api.GET("/dashboard/index-kline", dh.GetIndexKline)
|
||||
api.GET("/dashboard/summary", dh.GetSummary) // 市场概览
|
||||
api.GET("/dashboard/daily", dh.GetDailySummary) // 每日统计
|
||||
api.GET("/dashboard/consecutive", dh.GetConsecutiveLimits) // 连板统计
|
||||
api.GET("/dashboard/high100", dh.GetHigh100) // 百日新高
|
||||
api.GET("/dashboard/index-kline", dh.GetIndexKline) // 指数K线
|
||||
api.GET("/dashboard/stock-kline", dh.GetStockKline) // 个股K线
|
||||
}
|
||||
|
||||
// Serve frontend static files
|
||||
// 前端静态文件服务
|
||||
staticDir := "static"
|
||||
if _, err := os.Stat(staticDir); os.IsNotExist(err) {
|
||||
// Try relative to executable
|
||||
// 当前目录不存在时,尝试可执行文件所在目录
|
||||
exePath, _ := os.Executable()
|
||||
staticDir = filepath.Join(filepath.Dir(exePath), "static")
|
||||
}
|
||||
@@ -30,7 +37,7 @@ func Setup(r *gin.Engine, dh *handler.DashboardHandler) {
|
||||
r.Static("/assets", filepath.Join(staticDir, "assets"))
|
||||
r.StaticFile("/vite.svg", filepath.Join(staticDir, "vite.svg"))
|
||||
|
||||
// SPA fallback: all non-API routes serve index.html
|
||||
// SPA fallback: 非 API 路径全部返回 index.html,支持前端路由
|
||||
r.NoRoute(func(c *gin.Context) {
|
||||
path := c.Request.URL.Path
|
||||
if len(path) < 4 || path[:4] != "/api" {
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
// Package service 业务逻辑层,封装数据查询并提供带 TTL 的内存缓存
|
||||
//
|
||||
// 缓存策略: 使用读写锁 + 泛型缓存条目,所有查询先检查缓存是否过期,
|
||||
// 未过期则直接返回缓存数据,否则从 Repository 层重新加载。
|
||||
// 缓存过期时间为 3 分钟,适合A股数据的更新频率(每日收盘后更新)。
|
||||
package service
|
||||
|
||||
import (
|
||||
@@ -7,19 +12,21 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// cacheEntry 带过期时间的泛型缓存条目
|
||||
type cacheEntry[T any] struct {
|
||||
data T
|
||||
expires time.Time
|
||||
}
|
||||
|
||||
// DashboardService 市场数据服务,包含各查询的独立缓存
|
||||
type DashboardService struct {
|
||||
repo *repository.StockRepo
|
||||
mu sync.RWMutex
|
||||
summary *cacheEntry[*model.DashboardSummary]
|
||||
daily map[int]*cacheEntry[[]model.DailySummary]
|
||||
consec *cacheEntry[[]model.ConsecutiveLimit]
|
||||
high100 *cacheEntry[[]model.StockRank]
|
||||
indexKline map[string]*cacheEntry[[]model.IndexKline]
|
||||
mu sync.RWMutex // 保护所有缓存字段的读写锁
|
||||
summary *cacheEntry[*model.DashboardSummary] // 市场概览缓存
|
||||
daily map[int]*cacheEntry[[]model.DailySummary] // 每日统计缓存,按天数分组
|
||||
consec *cacheEntry[[]model.ConsecutiveLimit] // 连板统计缓存
|
||||
high100 *cacheEntry[[]model.StockRank] // 百日新高缓存
|
||||
indexKline map[string]*cacheEntry[[]model.IndexKline] // K线缓存,指数用 code,个股用 "stock:"+code
|
||||
}
|
||||
|
||||
func NewDashboardService(repo *repository.StockRepo) *DashboardService {
|
||||
@@ -28,6 +35,7 @@ func NewDashboardService(repo *repository.StockRepo) *DashboardService {
|
||||
|
||||
const cacheTTL = 3 * time.Minute
|
||||
|
||||
// GetSummary 获取最新交易日市场概览(涨跌数量、涨停跌停、百日新高等)
|
||||
func (s *DashboardService) GetSummary() (*model.DashboardSummary, error) {
|
||||
s.mu.RLock()
|
||||
if s.summary != nil && time.Now().Before(s.summary.expires) {
|
||||
@@ -53,6 +61,7 @@ func (s *DashboardService) GetSummary() (*model.DashboardSummary, error) {
|
||||
return summary, nil
|
||||
}
|
||||
|
||||
// GetDailySummary 获取近 N 天每日市场统计
|
||||
func (s *DashboardService) GetDailySummary(days int) ([]model.DailySummary, error) {
|
||||
s.mu.RLock()
|
||||
if e, ok := s.daily[days]; ok && time.Now().Before(e.expires) {
|
||||
@@ -74,6 +83,7 @@ func (s *DashboardService) GetDailySummary(days int) ([]model.DailySummary, erro
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// GetConsecutiveLimits 获取最新交易日连板股票(连续多日涨停)
|
||||
func (s *DashboardService) GetConsecutiveLimits() ([]model.ConsecutiveLimit, error) {
|
||||
s.mu.RLock()
|
||||
if s.consec != nil && time.Now().Before(s.consec.expires) {
|
||||
@@ -95,6 +105,7 @@ func (s *DashboardService) GetConsecutiveLimits() ([]model.ConsecutiveLimit, err
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// GetHigh100 获取最新交易日创百日新高的股票列表
|
||||
func (s *DashboardService) GetHigh100() ([]model.StockRank, error) {
|
||||
s.mu.RLock()
|
||||
if s.high100 != nil && time.Now().Before(s.high100.expires) {
|
||||
@@ -116,6 +127,7 @@ func (s *DashboardService) GetHigh100() ([]model.StockRank, error) {
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// GetIndexKline 获取指数 K 线数据(全部历史)
|
||||
func (s *DashboardService) GetIndexKline(code string, days int) ([]model.IndexKline, error) {
|
||||
s.mu.RLock()
|
||||
if e, ok := s.indexKline[code]; ok && time.Now().Before(e.expires) {
|
||||
@@ -139,3 +151,38 @@ func (s *DashboardService) GetIndexKline(code string, days int) ([]model.IndexKl
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// StockKlineResult 个股 K 线查询结果,包含股票名称和 K 线数据
|
||||
type StockKlineResult struct {
|
||||
Name string `json:"name"` // 股票名称
|
||||
Data []model.IndexKline `json:"data"` // K 线数据列表
|
||||
}
|
||||
|
||||
// GetStockKline 获取个股 K 线数据(全部历史),缓存 key 为 "stock:"+code
|
||||
func (s *DashboardService) GetStockKline(code string) (*StockKlineResult, error) {
|
||||
cacheKey := "stock:" + code
|
||||
|
||||
s.mu.RLock()
|
||||
if e, ok := s.indexKline[cacheKey]; ok && time.Now().Before(e.expires) {
|
||||
data := e.data
|
||||
s.mu.RUnlock()
|
||||
name, _ := s.repo.GetStockName(code)
|
||||
return &StockKlineResult{Name: name, Data: data}, nil
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
|
||||
data, err := s.repo.GetStockKline(code)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
if s.indexKline == nil {
|
||||
s.indexKline = make(map[string]*cacheEntry[[]model.IndexKline])
|
||||
}
|
||||
s.indexKline[cacheKey] = &cacheEntry[[]model.IndexKline]{data: data, expires: time.Now().Add(cacheTTL)}
|
||||
s.mu.Unlock()
|
||||
|
||||
name, _ := s.repo.GetStockName(code)
|
||||
return &StockKlineResult{Name: name, Data: data}, nil
|
||||
}
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
/**
|
||||
* 应用入口组件
|
||||
* 使用 Ant Design Layout + react-router-dom 构建单页应用
|
||||
* 顶部导航栏切换各功能页面
|
||||
*/
|
||||
import { ConfigProvider, Layout, Menu } from 'antd';
|
||||
import { Routes, Route, useNavigate, useLocation } from 'react-router-dom';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
@@ -7,6 +12,7 @@ import {
|
||||
FundOutlined,
|
||||
LineChartOutlined,
|
||||
RiseOutlined,
|
||||
StockOutlined,
|
||||
ThunderboltOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import Dashboard from './pages/Dashboard';
|
||||
@@ -14,17 +20,21 @@ import TrendPage from './pages/TrendPage';
|
||||
import ConsecutivePage from './pages/ConsecutivePage';
|
||||
import High100Page from './pages/High100Page';
|
||||
import IndexPage from './pages/IndexPage';
|
||||
import OverlayPage from './pages/OverlayPage';
|
||||
|
||||
const { Header, Content } = Layout;
|
||||
|
||||
/** 顶部导航菜单配置 */
|
||||
const menuItems = [
|
||||
{ key: '/', icon: <DashboardOutlined />, label: '市场概览' },
|
||||
{ key: '/index', icon: <FundOutlined />, label: '指数行情' },
|
||||
{ key: '/overlay', icon: <StockOutlined />, label: 'K线叠加' },
|
||||
{ key: '/high100', icon: <RiseOutlined />, label: '百日新高' },
|
||||
{ key: '/consecutive', icon: <ThunderboltOutlined />, label: '连板高度' },
|
||||
{ key: '/trend', icon: <LineChartOutlined />, label: '每日趋势' },
|
||||
];
|
||||
|
||||
/** 布局组件: 顶部导航 + 内容区 */
|
||||
function AppLayout() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
@@ -48,6 +58,7 @@ function AppLayout() {
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/index" element={<IndexPage />} />
|
||||
<Route path="/overlay" element={<OverlayPage />} />
|
||||
<Route path="/high100" element={<High100Page />} />
|
||||
<Route path="/consecutive" element={<ConsecutivePage />} />
|
||||
<Route path="/trend" element={<TrendPage />} />
|
||||
@@ -57,6 +68,7 @@ function AppLayout() {
|
||||
);
|
||||
}
|
||||
|
||||
/** 根组件: Ant Design 中文国际化 + BrowserRouter */
|
||||
function App() {
|
||||
return (
|
||||
<ConfigProvider locale={zhCN}>
|
||||
|
||||
+41
-15
@@ -1,9 +1,14 @@
|
||||
/**
|
||||
* 后端 API 接口封装
|
||||
* 所有接口基于 /api 前缀,返回数据格式为 { data: T }
|
||||
*/
|
||||
import axios from 'axios';
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: '/api',
|
||||
});
|
||||
|
||||
/** 股票排行项(涨停、跌停、百日新高共用) */
|
||||
export interface StockRank {
|
||||
code: string;
|
||||
name: string;
|
||||
@@ -11,18 +16,20 @@ export interface StockRank {
|
||||
close: number;
|
||||
}
|
||||
|
||||
/** 市场概览数据 */
|
||||
export interface DashboardSummary {
|
||||
total_stocks: number;
|
||||
up_count: number;
|
||||
down_count: number;
|
||||
flat_count: number;
|
||||
total_turnover: number;
|
||||
avg_pct_change: number;
|
||||
limit_up_list: StockRank[];
|
||||
limit_down_list: StockRank[];
|
||||
high_100_list: StockRank[];
|
||||
total_stocks: number; // 当日交易股票总数
|
||||
up_count: number; // 上涨数量
|
||||
down_count: number; // 下跌数量
|
||||
flat_count: number; // 平盘数量
|
||||
total_turnover: number; // 总成交额
|
||||
avg_pct_change: number; // 平均涨跌幅
|
||||
limit_up_list: StockRank[]; // 涨停股票列表
|
||||
limit_down_list: StockRank[]; // 跌停股票列表
|
||||
high_100_list: StockRank[]; // 百日新高股票列表
|
||||
}
|
||||
|
||||
/** 每日市场统计 */
|
||||
export interface DailySummary {
|
||||
date: string;
|
||||
total_stocks: number;
|
||||
@@ -31,41 +38,47 @@ export interface DailySummary {
|
||||
flat_count: number;
|
||||
total_turnover: number;
|
||||
avg_pct_change: number;
|
||||
limit_up_10: number;
|
||||
limit_down_10: number;
|
||||
limit_up_20: number;
|
||||
limit_down_20: number;
|
||||
consecutive_limit_count: number;
|
||||
limit_up_10: number; // 主板涨停数量
|
||||
limit_down_10: number; // 主板跌停数量
|
||||
limit_up_20: number; // 创业板/科创板涨停数量
|
||||
limit_down_20: number; // 创业板/科创板跌停数量
|
||||
consecutive_limit_count: number; // 连板股票数量
|
||||
}
|
||||
|
||||
/** 获取市场概览数据 */
|
||||
export async function getDashboardSummary(): Promise<DashboardSummary> {
|
||||
const res = await api.get('/dashboard/summary');
|
||||
return res.data.data;
|
||||
}
|
||||
|
||||
/** 获取近 N 天每日市场统计 */
|
||||
export async function getDailySummary(days = 30): Promise<DailySummary[]> {
|
||||
const res = await api.get('/dashboard/daily', { params: { days } });
|
||||
return res.data.data;
|
||||
}
|
||||
|
||||
/** 连板统计项 */
|
||||
export interface ConsecutiveLimit {
|
||||
code: string;
|
||||
name: string;
|
||||
streak: number;
|
||||
streak: number; // 连续涨停天数
|
||||
close: number;
|
||||
pct_change: number;
|
||||
}
|
||||
|
||||
/** 获取连板股票统计 */
|
||||
export async function getConsecutiveLimits(): Promise<ConsecutiveLimit[]> {
|
||||
const res = await api.get('/dashboard/consecutive');
|
||||
return res.data.data;
|
||||
}
|
||||
|
||||
/** 获取百日新高股票列表 */
|
||||
export async function getHigh100(): Promise<StockRank[]> {
|
||||
const res = await api.get('/dashboard/high100');
|
||||
return res.data.data;
|
||||
}
|
||||
|
||||
/** K 线数据项(指数和个股通用) */
|
||||
export interface IndexKline {
|
||||
date: string;
|
||||
open: number;
|
||||
@@ -77,7 +90,20 @@ export interface IndexKline {
|
||||
pct_change: number;
|
||||
}
|
||||
|
||||
/** 获取指数 K 线数据 */
|
||||
export async function getIndexKline(code = '000001', days = 60): Promise<IndexKline[]> {
|
||||
const res = await api.get('/dashboard/index-kline', { params: { code, days } });
|
||||
return res.data.data;
|
||||
}
|
||||
|
||||
/** 个股 K 线查询结果 */
|
||||
export interface StockKlineResult {
|
||||
name: string;
|
||||
data: IndexKline[];
|
||||
}
|
||||
|
||||
/** 获取个股 K 线数据(全部历史) */
|
||||
export async function getStockKline(code: string): Promise<StockKlineResult> {
|
||||
const res = await api.get('/dashboard/stock-kline', { params: { code } });
|
||||
return res.data.data;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* 指数 K 线图组件
|
||||
* 使用 ECharts 渲染蜡烛图 + 成交量柱状图 + MA 均线
|
||||
* 支持鼠标滚轮缩放和拖拽平移
|
||||
*/
|
||||
import { useEffect, useRef } from 'react';
|
||||
import * as echarts from 'echarts';
|
||||
import type { IndexKline } from '../api/dashboard';
|
||||
|
||||
interface Props {
|
||||
data: IndexKline[];
|
||||
title?: string;
|
||||
}
|
||||
|
||||
/** 计算 N 日简单移动平均线 */
|
||||
function ma(data: number[], n: number): (number | null)[] {
|
||||
return data.map((_, i) => (i >= n - 1 ? data.slice(i - n + 1, i + 1).reduce((a, b) => a + b, 0) / n : null));
|
||||
}
|
||||
|
||||
export default function IndexKlineChart({ data, title = '上证指数' }: Props) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const chartRef = useRef<echarts.ECharts | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ref.current || data.length === 0) return;
|
||||
if (!chartRef.current) chartRef.current = echarts.init(ref.current);
|
||||
const chart = chartRef.current;
|
||||
|
||||
// 准备数据
|
||||
const dates = data.map((d) => d.date.slice(0, 10));
|
||||
const ohlc = data.map((d) => [d.open, d.close, d.low, d.high]);
|
||||
const volumes = data.map((d) => d.volume);
|
||||
const closes = data.map((d) => d.close);
|
||||
const ma5 = ma(closes, 5);
|
||||
const ma10 = ma(closes, 10);
|
||||
const ma20 = ma(closes, 20);
|
||||
|
||||
// 双面板布局: 上方 K 线图(55%),下方成交量(18%)
|
||||
chart.setOption({
|
||||
tooltip: { trigger: 'axis', axisPointer: { type: 'cross' } },
|
||||
legend: { top: 0, data: ['K线', 'MA5', 'MA10', 'MA20'] },
|
||||
grid: [
|
||||
{ left: 70, right: 30, top: 40, height: '55%' }, // K 线面板
|
||||
{ left: 70, right: 30, top: '74%', height: '18%' }, // 成交量面板
|
||||
],
|
||||
xAxis: [
|
||||
{ type: 'category', data: dates, gridIndex: 0, boundaryGap: true, axisLabel: { show: false } },
|
||||
{ type: 'category', data: dates, gridIndex: 1, boundaryGap: true },
|
||||
],
|
||||
yAxis: [
|
||||
{ type: 'value', gridIndex: 0, scale: true, splitArea: { show: true } },
|
||||
{ type: 'value', gridIndex: 1, splitLine: { show: false } },
|
||||
],
|
||||
// 默认显示最近 90 天,支持滚轮缩放
|
||||
dataZoom: [
|
||||
{ type: 'inside', xAxisIndex: [0, 1], startValue: dates.length - 90, endValue: dates.length - 1 },
|
||||
],
|
||||
series: [
|
||||
// K 线蜡烛图: 红(涨) 绿(跌)
|
||||
{
|
||||
name: 'K线',
|
||||
type: 'candlestick',
|
||||
xAxisIndex: 0,
|
||||
yAxisIndex: 0,
|
||||
data: ohlc,
|
||||
itemStyle: {
|
||||
color: '#ef5350',
|
||||
color0: '#26a69a',
|
||||
borderColor: '#ef5350',
|
||||
borderColor0: '#26a69a',
|
||||
},
|
||||
},
|
||||
// MA 均线叠加在 K 线面板上
|
||||
{
|
||||
name: 'MA5',
|
||||
type: 'line',
|
||||
xAxisIndex: 0,
|
||||
yAxisIndex: 0,
|
||||
data: ma5,
|
||||
smooth: true,
|
||||
lineStyle: { width: 1 },
|
||||
symbol: 'none',
|
||||
},
|
||||
{
|
||||
name: 'MA10',
|
||||
type: 'line',
|
||||
xAxisIndex: 0,
|
||||
yAxisIndex: 0,
|
||||
data: ma10,
|
||||
smooth: true,
|
||||
lineStyle: { width: 1 },
|
||||
symbol: 'none',
|
||||
},
|
||||
{
|
||||
name: 'MA20',
|
||||
type: 'line',
|
||||
xAxisIndex: 0,
|
||||
yAxisIndex: 0,
|
||||
data: ma20,
|
||||
smooth: true,
|
||||
lineStyle: { width: 1 },
|
||||
symbol: 'none',
|
||||
},
|
||||
// 成交量柱状图,颜色跟随涨跌
|
||||
{
|
||||
name: '成交量',
|
||||
type: 'bar',
|
||||
xAxisIndex: 1,
|
||||
yAxisIndex: 1,
|
||||
data: volumes.map((v, i) => ({
|
||||
value: v,
|
||||
itemStyle: { color: data[i].close >= data[i].open ? '#ef5350' : '#26a69a' },
|
||||
})),
|
||||
},
|
||||
],
|
||||
}, true);
|
||||
|
||||
const handleResize = () => chart.resize();
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, [data, title]);
|
||||
|
||||
return <div ref={ref} style={{ height: 400 }} />;
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* K 线叠加对比图组件
|
||||
* 多只股票的 K 线蜡烛图叠加在同一个面板上,共享 x 轴
|
||||
* 每只股票有独立的对数 y 轴,支持通过图例切换显示/隐藏
|
||||
*/
|
||||
import { useEffect, useRef } from 'react';
|
||||
import * as echarts from 'echarts';
|
||||
import type { IndexKline } from '../api/dashboard';
|
||||
|
||||
/** 单只股票的数据系列 */
|
||||
interface StockSeries {
|
||||
code: string;
|
||||
name: string;
|
||||
data: IndexKline[];
|
||||
}
|
||||
|
||||
interface Props {
|
||||
series: StockSeries[];
|
||||
}
|
||||
|
||||
// 不同股票使用不同颜色区分
|
||||
const COLOR_PAIRS = [
|
||||
{ up: '#ef5350', down: '#26a69a' },
|
||||
{ up: '#5470c6', down: '#73c0de' },
|
||||
{ up: '#91cc75', down: '#b8d4a3' },
|
||||
{ up: '#fac858', down: '#ffd97d' },
|
||||
{ up: '#fc8452', down: '#ffa97d' },
|
||||
{ up: '#9a60b4', down: '#b88cc8' },
|
||||
{ up: '#ea7ccc', down: '#f0a0dd' },
|
||||
{ up: '#3ba272', down: '#6dc7a0' },
|
||||
];
|
||||
|
||||
export default function OverlayKlineChart({ series }: Props) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const chartRef = useRef<echarts.ECharts | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ref.current) return;
|
||||
|
||||
if (series.length === 0) {
|
||||
if (chartRef.current) {
|
||||
chartRef.current.clear();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!chartRef.current) {
|
||||
chartRef.current = echarts.init(ref.current);
|
||||
}
|
||||
const chart = chartRef.current;
|
||||
|
||||
// 取所有股票日期的交集
|
||||
let startDate = series[0].data[0]?.date.slice(0, 10) || '';
|
||||
let endDate = series[0].data[series[0].data.length - 1]?.date.slice(0, 10) || '';
|
||||
for (const s of series) {
|
||||
const first = s.data[0]?.date.slice(0, 10) || '';
|
||||
const last = s.data[s.data.length - 1]?.date.slice(0, 10) || '';
|
||||
if (first > startDate) startDate = first;
|
||||
if (last < endDate) endDate = last;
|
||||
}
|
||||
|
||||
const dateSet = new Set<string>();
|
||||
for (const s of series) {
|
||||
for (const d of s.data) {
|
||||
const ds = d.date.slice(0, 10);
|
||||
if (ds >= startDate && ds <= endDate) dateSet.add(ds);
|
||||
}
|
||||
}
|
||||
const baseDates = [...dateSet].sort();
|
||||
|
||||
const yAxes: any[] = [];
|
||||
const echartsSeries: any[] = [];
|
||||
|
||||
// 只用最近 90 天的数据计算 y 轴范围,让 K 线更饱满
|
||||
const visibleStart = baseDates.length > 90 ? baseDates[baseDates.length - 90] : baseDates[0];
|
||||
|
||||
series.forEach((s, idx) => {
|
||||
const name = `${s.name}(${s.code})`;
|
||||
const colors = COLOR_PAIRS[idx % COLOR_PAIRS.length];
|
||||
|
||||
// 对数 y 轴: 第一只在左侧,其余在右侧,范围取可见区间
|
||||
let min = Infinity, max = -Infinity;
|
||||
for (const d of s.data) {
|
||||
const ds = d.date.slice(0, 10);
|
||||
if (ds >= visibleStart) {
|
||||
if (d.low < min) min = d.low;
|
||||
if (d.high > max) max = d.high;
|
||||
}
|
||||
}
|
||||
yAxes.push({
|
||||
type: 'log',
|
||||
min: min * 0.95,
|
||||
max: max * 1.05,
|
||||
logBase: 10,
|
||||
position: idx === 0 ? 'left' : 'right',
|
||||
offset: idx <= 1 ? 0 : (idx - 1) * 50,
|
||||
name: name,
|
||||
nameTextStyle: { fontSize: 11 },
|
||||
axisLabel: { fontSize: 11 },
|
||||
splitLine: { show: idx === 0 },
|
||||
show: idx < 4,
|
||||
});
|
||||
|
||||
// 将股票数据映射到基准日期
|
||||
const dateMap = new Map(s.data.map((d) => [d.date.slice(0, 10), d]));
|
||||
const klineData = baseDates.map((date) => {
|
||||
const d = dateMap.get(date);
|
||||
if (!d) return '-';
|
||||
return [d.open, d.close, d.low, d.high];
|
||||
});
|
||||
|
||||
echartsSeries.push({
|
||||
name,
|
||||
type: 'candlestick',
|
||||
data: klineData,
|
||||
yAxisIndex: idx,
|
||||
itemStyle: {
|
||||
color: colors.up,
|
||||
color0: colors.down,
|
||||
borderColor: colors.up,
|
||||
borderColor0: colors.down,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// 右侧预留空间随股票数量增加
|
||||
const rightSpace = series.length <= 1 ? 30 : Math.min(30 + (series.length - 1) * 50, 250);
|
||||
|
||||
chart.setOption({
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: { type: 'cross' },
|
||||
},
|
||||
legend: {
|
||||
top: 0,
|
||||
data: series.map((s) => `${s.name}(${s.code})`),
|
||||
},
|
||||
grid: { left: 70, right: rightSpace, top: 30, bottom: 70 },
|
||||
xAxis: { type: 'category', data: baseDates, boundaryGap: true },
|
||||
yAxis: yAxes,
|
||||
dataZoom: [
|
||||
{ type: 'inside', startValue: baseDates.length - 90, endValue: baseDates.length - 1 },
|
||||
{ type: 'slider', startValue: baseDates.length - 90, endValue: baseDates.length - 1, bottom: 10 },
|
||||
],
|
||||
series: echartsSeries,
|
||||
}, true);
|
||||
|
||||
const handleResize = () => chart.resize();
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, [series]);
|
||||
|
||||
return (
|
||||
<div ref={ref} style={{ width: '100%', height: '100%' }}>
|
||||
{series.length === 0 && (
|
||||
<div style={{ textAlign: 'center', padding: 60, color: '#999', position: 'absolute', width: '100%' }}>
|
||||
请输入股票代码添加对比
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* 指数行情页面
|
||||
* 左侧显示指数列表(上证指数、深证成指等),右侧显示选中指数的 K 线图
|
||||
*/
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Card, List, Spin, Typography } from 'antd';
|
||||
import { getIndexKline, type IndexKline } from '../api/dashboard';
|
||||
import IndexKlineChart from '../components/IndexKlineChart';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
/** 支持的指数列表 */
|
||||
const INDICES = [
|
||||
{ code: '000001', name: '上证指数' },
|
||||
{ code: '399001', name: '深证成指' },
|
||||
{ code: '000300', name: '沪深300' },
|
||||
{ code: '000905', name: '中证500' },
|
||||
{ code: '000852', name: '中证1000' },
|
||||
{ code: '399006', name: '创业板指' },
|
||||
];
|
||||
|
||||
export default function IndexPage() {
|
||||
const [selected, setSelected] = useState('000001');
|
||||
const [kline, setKline] = useState<IndexKline[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// 切换指数时重新加载 K 线数据
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
getIndexKline(selected, 120)
|
||||
.then(setKline)
|
||||
.finally(() => setLoading(false));
|
||||
}, [selected]);
|
||||
|
||||
const latest = kline.length > 0 ? kline[kline.length - 1] : null;
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 1200, margin: '0 auto' }}>
|
||||
<Title level={2} style={{ textAlign: 'center', marginBottom: 24 }}>
|
||||
指数行情
|
||||
</Title>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '160px 1fr', gap: 16 }}>
|
||||
{/* 左侧指数列表 */}
|
||||
<Card size="small" style={{ padding: 0 }}>
|
||||
<List
|
||||
size="small"
|
||||
dataSource={INDICES}
|
||||
renderItem={(item) => (
|
||||
<List.Item
|
||||
onClick={() => setSelected(item.code)}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
padding: '8px 12px',
|
||||
background: item.code === selected ? '#e6f7ff' : undefined,
|
||||
borderRadius: 4,
|
||||
}}
|
||||
>
|
||||
<Text strong={item.code === selected}>{item.name}</Text>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* 右侧 K 线图 */}
|
||||
<Card
|
||||
title={INDICES.find((i) => i.code === selected)?.name}
|
||||
size="small"
|
||||
>
|
||||
{/* 最新收盘价和涨跌幅 */}
|
||||
{latest && (
|
||||
<div style={{ marginBottom: 8, fontSize: 16 }}>
|
||||
<Text strong style={{ fontSize: 22 }}>{latest.close.toFixed(2)}</Text>
|
||||
<Text
|
||||
style={{ marginLeft: 12, color: latest.pct_change >= 0 ? '#cf1322' : '#3f8600' }}
|
||||
>
|
||||
{latest.pct_change >= 0 ? '+' : ''}{latest.pct_change.toFixed(2)}%
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
{loading ? (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: 100 }}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
) : (
|
||||
<IndexKlineChart data={kline} />
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* K 线叠加对比页面
|
||||
* 用户输入股票代码,添加多只股票到图表中叠加对比
|
||||
* 最多支持 8 只股票,每只股票以独立面板显示 K 线蜡烛图
|
||||
*/
|
||||
import { useState, useCallback } from 'react';
|
||||
import { Card, Input, Tag, Spin, message } from 'antd';
|
||||
import { getStockKline, type IndexKline } from '../api/dashboard';
|
||||
import OverlayKlineChart from '../components/OverlayKlineChart';
|
||||
|
||||
/** 单只股票的加载状态和数据 */
|
||||
interface StockEntry {
|
||||
code: string;
|
||||
name: string;
|
||||
data: IndexKline[];
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
export default function OverlayPage() {
|
||||
const [stocks, setStocks] = useState<StockEntry[]>([]);
|
||||
const [input, setInput] = useState('');
|
||||
|
||||
/** 添加股票: 输入代码回车后触发,先加入 loading 状态,异步加载数据后更新 */
|
||||
const addStock = useCallback(async (code: string) => {
|
||||
const trimmed = code.trim();
|
||||
if (!trimmed) return;
|
||||
if (stocks.some((s) => s.code === trimmed)) {
|
||||
message.warning(`${trimmed} 已添加`);
|
||||
return;
|
||||
}
|
||||
if (stocks.length >= 8) {
|
||||
message.warning('最多叠加8只');
|
||||
return;
|
||||
}
|
||||
|
||||
// 先加入 loading 占位
|
||||
const entry: StockEntry = { code: trimmed, name: trimmed, data: [], loading: true };
|
||||
setStocks((prev) => [...prev, entry]);
|
||||
|
||||
try {
|
||||
const res = await getStockKline(trimmed);
|
||||
if (!res.data || res.data.length === 0) {
|
||||
// 无数据时移除该条目
|
||||
setStocks((prev) => prev.filter((s) => s.code !== trimmed));
|
||||
message.error(trimmed + ' 无数据');
|
||||
return;
|
||||
}
|
||||
// 更新为已加载状态
|
||||
setStocks((prev) =>
|
||||
prev.map((s) => (s.code === trimmed ? { ...s, name: res.name || trimmed, data: res.data, loading: false } : s))
|
||||
);
|
||||
} catch {
|
||||
// 查询失败时移除该条目
|
||||
setStocks((prev) => prev.filter((s) => s.code !== trimmed));
|
||||
message.error(`${trimmed} 查询失败`);
|
||||
}
|
||||
}, [stocks]);
|
||||
|
||||
/** 移除股票 */
|
||||
const removeStock = useCallback((code: string) => {
|
||||
setStocks((prev) => prev.filter((s) => s.code !== code));
|
||||
}, []);
|
||||
|
||||
/** 回车添加 */
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
addStock(input);
|
||||
setInput('');
|
||||
}
|
||||
};
|
||||
|
||||
// 过滤掉仍在加载中的股票,只传已就绪的数据给图表
|
||||
const readyStocks = stocks.filter((s) => !s.loading && s.data.length > 0);
|
||||
|
||||
return (
|
||||
<div style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||
{/* 输入栏 + 股票标签 */}
|
||||
<Card size="small" style={{ marginBottom: 8, flexShrink: 0 }}>
|
||||
<Input
|
||||
placeholder="输入股票代码,回车添加(如 000001)"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
style={{ width: 300, marginRight: 16 }}
|
||||
/>
|
||||
{stocks.map((s) => (
|
||||
<Tag
|
||||
key={s.code}
|
||||
closable
|
||||
onClose={() => removeStock(s.code)}
|
||||
color={s.loading ? 'processing' : 'blue'}
|
||||
style={{ marginBottom: 4 }}
|
||||
>
|
||||
{s.name}({s.code}) {s.loading && <Spin size="small" />}
|
||||
</Tag>
|
||||
))}
|
||||
</Card>
|
||||
|
||||
{/* K 线叠加图表,直接填满剩余空间 */}
|
||||
<div style={{ flex: 1, minHeight: 0 }}>
|
||||
<OverlayKlineChart series={readyStocks} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user