SHA256
叠加显示
This commit is contained in:
+18
-10
@@ -1,3 +1,4 @@
|
|||||||
|
// Package config 读取应用配置,支持 YAML 配置文件和环境变量覆盖
|
||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -7,29 +8,34 @@ import (
|
|||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// DBConfig MySQL 数据库连接配置
|
||||||
type DBConfig struct {
|
type DBConfig struct {
|
||||||
Host string `yaml:"host"`
|
Host string `yaml:"host"` // 数据库主机地址
|
||||||
Port string `yaml:"port"`
|
Port string `yaml:"port"` // 数据库端口
|
||||||
User string `yaml:"user"`
|
User string `yaml:"user"` // 用户名
|
||||||
Password string `yaml:"password"`
|
Password string `yaml:"password"` // 密码
|
||||||
DBName string `yaml:"database"`
|
DBName string `yaml:"database"` // 数据库名
|
||||||
Charset string `yaml:"charset"`
|
Charset string `yaml:"charset"` // 字符集,默认 utf8mb4
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ServerConfig HTTP 服务器配置
|
||||||
type ServerConfig struct {
|
type ServerConfig struct {
|
||||||
Port string `yaml:"port"`
|
Port string `yaml:"port"` // 监听端口,默认 8080
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Config 应用总配置
|
||||||
type Config struct {
|
type Config struct {
|
||||||
DB DBConfig `yaml:"mysql"`
|
DB DBConfig `yaml:"mysql"` // MySQL 配置段
|
||||||
Server ServerConfig `yaml:"server"`
|
Server ServerConfig `yaml:"server"` // 服务器配置段
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load 加载配置,优先级: 环境变量 > config.yaml > 默认值
|
||||||
func Load() *Config {
|
func Load() *Config {
|
||||||
cfg := &Config{}
|
cfg := &Config{}
|
||||||
|
|
||||||
data, err := os.ReadFile("config.yaml")
|
data, err := os.ReadFile("config.yaml")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
// 配置文件不存在时使用环境变量或默认值
|
||||||
fmt.Println("config.yaml not found, using env defaults")
|
fmt.Println("config.yaml not found, using env defaults")
|
||||||
cfg.DB.Host = getEnv("DB_HOST", "127.0.0.1")
|
cfg.DB.Host = getEnv("DB_HOST", "127.0.0.1")
|
||||||
cfg.DB.Port = getEnv("DB_PORT", "3306")
|
cfg.DB.Port = getEnv("DB_PORT", "3306")
|
||||||
@@ -53,7 +59,7 @@ func Load() *Config {
|
|||||||
return cfg
|
return cfg
|
||||||
}
|
}
|
||||||
|
|
||||||
// Environment variables override config file
|
// 环境变量覆盖配置文件中的值
|
||||||
if v := os.Getenv("DB_HOST"); v != "" {
|
if v := os.Getenv("DB_HOST"); v != "" {
|
||||||
cfg.DB.Host = v
|
cfg.DB.Host = v
|
||||||
}
|
}
|
||||||
@@ -76,6 +82,7 @@ func Load() *Config {
|
|||||||
return cfg
|
return cfg
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DSN 生成 MySQL 连接字符串
|
||||||
func (c *DBConfig) DSN() string {
|
func (c *DBConfig) DSN() string {
|
||||||
charset := c.Charset
|
charset := c.Charset
|
||||||
if charset == "" {
|
if charset == "" {
|
||||||
@@ -85,6 +92,7 @@ func (c *DBConfig) DSN() string {
|
|||||||
c.User, c.Password, c.Host, c.Port, c.DBName, charset)
|
c.User, c.Password, c.Host, c.Port, c.DBName, charset)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getEnv 获取环境变量,不存在时返回默认值
|
||||||
func getEnv(key, fallback string) string {
|
func getEnv(key, fallback string) string {
|
||||||
if v := os.Getenv(key); v != "" {
|
if v := os.Getenv(key); v != "" {
|
||||||
return v
|
return v
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// Package handler HTTP 请求处理层,负责参数解析、调用 Service、返回 JSON 响应
|
||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -8,6 +9,7 @@ import (
|
|||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// DashboardHandler 市场概览相关的 HTTP handler
|
||||||
type DashboardHandler struct {
|
type DashboardHandler struct {
|
||||||
svc *service.DashboardService
|
svc *service.DashboardService
|
||||||
}
|
}
|
||||||
@@ -16,6 +18,8 @@ func NewDashboardHandler(svc *service.DashboardService) *DashboardHandler {
|
|||||||
return &DashboardHandler{svc: svc}
|
return &DashboardHandler{svc: svc}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetSummary 获取最新交易日的市场概览数据
|
||||||
|
// GET /api/dashboard/summary
|
||||||
func (h *DashboardHandler) GetSummary(c *gin.Context) {
|
func (h *DashboardHandler) GetSummary(c *gin.Context) {
|
||||||
summary, err := h.svc.GetSummary()
|
summary, err := h.svc.GetSummary()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -25,6 +29,8 @@ func (h *DashboardHandler) GetSummary(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"data": summary})
|
c.JSON(http.StatusOK, gin.H{"data": summary})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetDailySummary 获取近 N 天的每日市场统计数据
|
||||||
|
// GET /api/dashboard/daily?days=30
|
||||||
func (h *DashboardHandler) GetDailySummary(c *gin.Context) {
|
func (h *DashboardHandler) GetDailySummary(c *gin.Context) {
|
||||||
days, _ := strconv.Atoi(c.DefaultQuery("days", "30"))
|
days, _ := strconv.Atoi(c.DefaultQuery("days", "30"))
|
||||||
if days <= 0 || days > 365 {
|
if days <= 0 || days > 365 {
|
||||||
@@ -38,6 +44,8 @@ func (h *DashboardHandler) GetDailySummary(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"data": data})
|
c.JSON(http.StatusOK, gin.H{"data": data})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetConsecutiveLimits 获取最新交易日的连板股票统计
|
||||||
|
// GET /api/dashboard/consecutive
|
||||||
func (h *DashboardHandler) GetConsecutiveLimits(c *gin.Context) {
|
func (h *DashboardHandler) GetConsecutiveLimits(c *gin.Context) {
|
||||||
data, err := h.svc.GetConsecutiveLimits()
|
data, err := h.svc.GetConsecutiveLimits()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -47,6 +55,8 @@ func (h *DashboardHandler) GetConsecutiveLimits(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"data": data})
|
c.JSON(http.StatusOK, gin.H{"data": data})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetHigh100 获取百日新高股票列表
|
||||||
|
// GET /api/dashboard/high100
|
||||||
func (h *DashboardHandler) GetHigh100(c *gin.Context) {
|
func (h *DashboardHandler) GetHigh100(c *gin.Context) {
|
||||||
data, err := h.svc.GetHigh100()
|
data, err := h.svc.GetHigh100()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -56,6 +66,8 @@ func (h *DashboardHandler) GetHigh100(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"data": data})
|
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) {
|
func (h *DashboardHandler) GetIndexKline(c *gin.Context) {
|
||||||
code := c.DefaultQuery("code", "000001")
|
code := c.DefaultQuery("code", "000001")
|
||||||
days, _ := strconv.Atoi(c.DefaultQuery("days", "60"))
|
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})
|
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
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -16,15 +18,17 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
// 加载配置
|
||||||
cfg := config.Load()
|
cfg := config.Load()
|
||||||
|
|
||||||
|
// 连接 MySQL
|
||||||
db, err := gorm.Open(mysql.Open(cfg.DB.DSN()), &gorm.Config{})
|
db, err := gorm.Open(mysql.Open(cfg.DB.DSN()), &gorm.Config{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("failed to connect database: %v", err)
|
log.Fatalf("failed to connect database: %v", err)
|
||||||
}
|
}
|
||||||
fmt.Println("Database connected successfully")
|
fmt.Println("Database connected successfully")
|
||||||
|
|
||||||
// Ensure indexes exist (skip if already exist)
|
// 异步创建常用索引,避免首次查询慢
|
||||||
go func() {
|
go func() {
|
||||||
time.Sleep(3 * time.Second)
|
time.Sleep(3 * time.Second)
|
||||||
indexes := []string{
|
indexes := []string{
|
||||||
@@ -40,10 +44,12 @@ func main() {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
// 分层初始化: Repository -> Service -> Handler
|
||||||
stockRepo := repository.NewStockRepo(db)
|
stockRepo := repository.NewStockRepo(db)
|
||||||
dashboardSvc := service.NewDashboardService(stockRepo)
|
dashboardSvc := service.NewDashboardService(stockRepo)
|
||||||
dashboardHandler := handler.NewDashboardHandler(dashboardSvc)
|
dashboardHandler := handler.NewDashboardHandler(dashboardSvc)
|
||||||
|
|
||||||
|
// 启动 HTTP 服务
|
||||||
r := gin.Default()
|
r := gin.Default()
|
||||||
router.Setup(r, dashboardHandler)
|
router.Setup(r, dashboardHandler)
|
||||||
|
|
||||||
|
|||||||
+66
-53
@@ -1,84 +1,97 @@
|
|||||||
|
// Package model 定义了A股数据相关的数据模型
|
||||||
|
//
|
||||||
|
// 数据源:
|
||||||
|
// - stock_daily 表: 个股每日行情数据(开盘、收盘、最高、最低、成交量、成交额、涨跌幅等)
|
||||||
|
// - stock_info 表: 个股基本信息(代码、名称、上市日期)
|
||||||
|
// - index_daily 表: 指数每日行情数据
|
||||||
package model
|
package model
|
||||||
|
|
||||||
|
// StockDaily 个股每日行情记录
|
||||||
type StockDaily struct {
|
type StockDaily struct {
|
||||||
ID int `gorm:"primaryKey" json:"id"`
|
ID int `gorm:"primaryKey" json:"id"`
|
||||||
Code string `gorm:"column:code;type:varchar(10)" json:"code"`
|
Code string `gorm:"column:code;type:varchar(10)" json:"code"` // 股票代码,如 "600498"
|
||||||
Date string `gorm:"column:date;type:date" json:"date"`
|
Date string `gorm:"column:date;type:date" json:"date"` // 交易日期
|
||||||
Open float64 `gorm:"column:open" json:"open"`
|
Open float64 `gorm:"column:open" json:"open"` // 开盘价
|
||||||
Close float64 `gorm:"column:close" json:"close"`
|
Close float64 `gorm:"column:close" json:"close"` // 收盘价
|
||||||
High float64 `gorm:"column:high" json:"high"`
|
High float64 `gorm:"column:high" json:"high"` // 最高价
|
||||||
Low float64 `gorm:"column:low" json:"low"`
|
Low float64 `gorm:"column:low" json:"low"` // 最低价
|
||||||
Volume float64 `gorm:"column:volume" json:"volume"`
|
Volume float64 `gorm:"column:volume" json:"volume"` // 成交量(股)
|
||||||
Turnover float64 `gorm:"column:turnover" json:"turnover"`
|
Turnover float64 `gorm:"column:turnover" json:"turnover"` // 成交额(元)
|
||||||
Amplitude float64 `gorm:"column:amplitude" json:"amplitude"`
|
Amplitude float64 `gorm:"column:amplitude" json:"amplitude"` // 振幅(%)
|
||||||
PctChange float64 `gorm:"column:pct_change" json:"pct_change"`
|
PctChange float64 `gorm:"column:pct_change" json:"pct_change"` // 涨跌幅(%)
|
||||||
Change float64 `gorm:"column:change" json:"change"`
|
Change float64 `gorm:"column:change" json:"change"` // 涨跌额
|
||||||
TurnoverRate float64 `gorm:"column:turnover_rate" json:"turnover_rate"`
|
TurnoverRate float64 `gorm:"column:turnover_rate" json:"turnover_rate"` // 换手率(%)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (StockDaily) TableName() string {
|
func (StockDaily) TableName() string {
|
||||||
return "stock_daily"
|
return "stock_daily"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// StockInfo 个股基本信息
|
||||||
type StockInfo struct {
|
type StockInfo struct {
|
||||||
Code string `gorm:"primaryKey;column:code;type:varchar(10)" json:"code"`
|
Code string `gorm:"primaryKey;column:code;type:varchar(10)" json:"code"` // 股票代码
|
||||||
Name string `gorm:"column:name;type:varchar(50)" json:"name"`
|
Name string `gorm:"column:name;type:varchar(50)" json:"name"` // 股票名称
|
||||||
IpoDate string `gorm:"column:ipo_date;type:date" json:"ipo_date"`
|
IpoDate string `gorm:"column:ipo_date;type:date" json:"ipo_date"` // 上市日期
|
||||||
}
|
}
|
||||||
|
|
||||||
func (StockInfo) TableName() string {
|
func (StockInfo) TableName() string {
|
||||||
return "stock_info"
|
return "stock_info"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// StockRank 涨停/跌停/百日新高排行
|
||||||
type StockRank struct {
|
type StockRank struct {
|
||||||
Code string `json:"code"`
|
Code string `json:"code"` // 股票代码
|
||||||
Name string `json:"name"`
|
Name string `json:"name"` // 股票名称
|
||||||
PctChange float64 `json:"pct_change"`
|
PctChange float64 `json:"pct_change"` // 涨跌幅(%)
|
||||||
Close float64 `json:"close"`
|
Close float64 `json:"close"` // 收盘价
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DailySummary 每日市场统计汇总
|
||||||
type DailySummary struct {
|
type DailySummary struct {
|
||||||
Date string `gorm:"column:date" json:"date"`
|
Date string `gorm:"column:date" json:"date"` // 交易日期
|
||||||
TotalStocks int `gorm:"column:total_stocks" json:"total_stocks"`
|
TotalStocks int `gorm:"column:total_stocks" json:"total_stocks"` // 当日交易股票总数
|
||||||
UpCount int `gorm:"column:up_count" json:"up_count"`
|
UpCount int `gorm:"column:up_count" json:"up_count"` // 上涨数量
|
||||||
DownCount int `gorm:"column:down_count" json:"down_count"`
|
DownCount int `gorm:"column:down_count" json:"down_count"` // 下跌数量
|
||||||
FlatCount int `gorm:"column:flat_count" json:"flat_count"`
|
FlatCount int `gorm:"column:flat_count" json:"flat_count"` // 平盘数量
|
||||||
TotalTurnover float64 `gorm:"column:total_turnover" json:"total_turnover"`
|
TotalTurnover float64 `gorm:"column:total_turnover" json:"total_turnover"` // 总成交额
|
||||||
AvgPctChange float64 `gorm:"column:avg_pct_change" json:"avg_pct_change"`
|
AvgPctChange float64 `gorm:"column:avg_pct_change" json:"avg_pct_change"` // 平均涨跌幅(%)
|
||||||
LimitUp10 int `gorm:"column:limit_up_10" json:"limit_up_10"`
|
LimitUp10 int `gorm:"column:limit_up_10" json:"limit_up_10"` // 主板涨停数量(涨幅>=9.5%)
|
||||||
LimitDown10 int `gorm:"column:limit_down_10" json:"limit_down_10"`
|
LimitDown10 int `gorm:"column:limit_down_10" json:"limit_down_10"` // 主板跌停数量(跌幅<=-9.5%)
|
||||||
LimitUp20 int `gorm:"column:limit_up_20" json:"limit_up_20"`
|
LimitUp20 int `gorm:"column:limit_up_20" json:"limit_up_20"` // 创业板/科创板涨停数量(涨幅>=19.5%)
|
||||||
LimitDown20 int `gorm:"column:limit_down_20" json:"limit_down_20"`
|
LimitDown20 int `gorm:"column:limit_down_20" json:"limit_down_20"` // 创业板/科创板跌停数量(跌幅<=-19.5%)
|
||||||
ConsecutiveLimitCount int `gorm:"column:consecutive_limit_count" json:"consecutive_limit_count"`
|
ConsecutiveLimitCount int `gorm:"column:consecutive_limit_count" json:"consecutive_limit_count"` // 连板股票数量(当日涨停且前一日也涨停)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DashboardSummary 市场概览数据
|
||||||
type DashboardSummary struct {
|
type DashboardSummary struct {
|
||||||
TotalStocks int64 `json:"total_stocks"`
|
TotalStocks int64 `json:"total_stocks"` // 当日交易股票总数
|
||||||
UpCount int64 `json:"up_count"`
|
UpCount int64 `json:"up_count"` // 上涨数量
|
||||||
DownCount int64 `json:"down_count"`
|
DownCount int64 `json:"down_count"` // 下跌数量
|
||||||
FlatCount int64 `json:"flat_count"`
|
FlatCount int64 `json:"flat_count"` // 平盘数量
|
||||||
TotalTurnover float64 `json:"total_turnover"`
|
TotalTurnover float64 `json:"total_turnover"` // 总成交额
|
||||||
AvgPctChange float64 `json:"avg_pct_change"`
|
AvgPctChange float64 `json:"avg_pct_change"` // 平均涨跌幅(%)
|
||||||
LimitUpList []StockRank `json:"limit_up_list"`
|
LimitUpList []StockRank `json:"limit_up_list"` // 涨停股票列表
|
||||||
LimitDownList []StockRank `json:"limit_down_list"`
|
LimitDownList []StockRank `json:"limit_down_list"` // 跌停股票列表
|
||||||
High100List []StockRank `json:"high_100_list"`
|
High100List []StockRank `json:"high_100_list"` // 百日新高股票列表
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ConsecutiveLimit 连板统计
|
||||||
type ConsecutiveLimit struct {
|
type ConsecutiveLimit struct {
|
||||||
Code string `json:"code"`
|
Code string `json:"code"` // 股票代码
|
||||||
Name string `json:"name"`
|
Name string `json:"name"` // 股票名称
|
||||||
Streak int `json:"streak"`
|
Streak int `json:"streak"` // 连板天数(连续涨停天数)
|
||||||
Close float64 `json:"close"`
|
Close float64 `json:"close"` // 收盘价
|
||||||
PctChange float64 `json:"pct_change"`
|
PctChange float64 `json:"pct_change"` // 当日涨跌幅(%)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IndexKline K线数据(通用结构,同时用于指数和个股)
|
||||||
type IndexKline struct {
|
type IndexKline struct {
|
||||||
Date string `json:"date"`
|
Date string `json:"date"` // 交易日期
|
||||||
Open float64 `json:"open"`
|
Open float64 `json:"open"` // 开盘价
|
||||||
Close float64 `json:"close"`
|
Close float64 `json:"close"` // 收盘价
|
||||||
High float64 `json:"high"`
|
High float64 `json:"high"` // 最高价
|
||||||
Low float64 `json:"low"`
|
Low float64 `json:"low"` // 最低价
|
||||||
Volume float64 `json:"volume"`
|
Volume float64 `json:"volume"` // 成交量
|
||||||
Amount float64 `json:"amount"`
|
Amount float64 `json:"amount"` // 成交额
|
||||||
PctChange float64 `json:"pct_change"`
|
PctChange float64 `json:"pct_change"` // 涨跌幅(%)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// Package repository 数据访问层,直接操作 MySQL 数据库执行 SQL 查询
|
||||||
package repository
|
package repository
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -8,9 +9,10 @@ import (
|
|||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// StockRepo 个股与指数数据的数据访问对象
|
||||||
type StockRepo struct {
|
type StockRepo struct {
|
||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
dateCache struct {
|
dateCache struct { // 最新交易日期缓存,避免频繁查询
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
date string
|
date string
|
||||||
expires time.Time
|
expires time.Time
|
||||||
@@ -21,7 +23,7 @@ func NewStockRepo(db *gorm.DB) *StockRepo {
|
|||||||
return &StockRepo{db: db}
|
return &StockRepo{db: db}
|
||||||
}
|
}
|
||||||
|
|
||||||
// getLatestDate returns the latest trade date, cached for 5 minutes
|
// getLatestDate 获取最新交易日期,结果缓存 5 分钟
|
||||||
func (r *StockRepo) getLatestDate() (string, error) {
|
func (r *StockRepo) getLatestDate() (string, error) {
|
||||||
r.dateCache.mu.RLock()
|
r.dateCache.mu.RLock()
|
||||||
if r.dateCache.date != "" && time.Now().Before(r.dateCache.expires) {
|
if r.dateCache.date != "" && time.Now().Before(r.dateCache.expires) {
|
||||||
@@ -47,14 +49,18 @@ func (r *StockRepo) getLatestDate() (string, error) {
|
|||||||
return result.MaxDate, nil
|
return result.MaxDate, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetLatestTradeDate 获取最新交易日期(公开方法)
|
||||||
func (r *StockRepo) GetLatestTradeDate() (string, error) {
|
func (r *StockRepo) GetLatestTradeDate() (string, error) {
|
||||||
return r.getLatestDate()
|
return r.getLatestDate()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetSummaryByDate 获取指定日期的市场概览数据
|
||||||
|
// 包括: 涨跌统计、涨停跌停列表、百日新高列表
|
||||||
|
// 使用单条聚合查询替代多次 COUNT 查询,提升性能
|
||||||
func (r *StockRepo) GetSummaryByDate(date string) (*model.DashboardSummary, error) {
|
func (r *StockRepo) GetSummaryByDate(date string) (*model.DashboardSummary, error) {
|
||||||
summary := &model.DashboardSummary{}
|
summary := &model.DashboardSummary{}
|
||||||
|
|
||||||
// Single aggregation query instead of 6 separate queries
|
// 聚合统计: 总数、上涨、下跌、平盘、总成交额、平均涨跌幅
|
||||||
var agg struct {
|
var agg struct {
|
||||||
Total int64 `gorm:"column:total"`
|
Total int64 `gorm:"column:total"`
|
||||||
Up int64 `gorm:"column:up"`
|
Up int64 `gorm:"column:up"`
|
||||||
@@ -81,6 +87,7 @@ func (r *StockRepo) GetSummaryByDate(date string) (*model.DashboardSummary, erro
|
|||||||
summary.TotalTurnover = agg.Turnover
|
summary.TotalTurnover = agg.Turnover
|
||||||
summary.AvgPctChange = agg.AvgPct
|
summary.AvgPctChange = agg.AvgPct
|
||||||
|
|
||||||
|
// 涨停列表: 涨幅 >= 9.5%(覆盖主板10%和创业板/科创板20%)
|
||||||
r.db.Raw(`
|
r.db.Raw(`
|
||||||
SELECT sd.code, si.name, sd.pct_change, sd.close
|
SELECT sd.code, si.name, sd.pct_change, sd.close
|
||||||
FROM stock_daily sd
|
FROM stock_daily sd
|
||||||
@@ -89,6 +96,7 @@ func (r *StockRepo) GetSummaryByDate(date string) (*model.DashboardSummary, erro
|
|||||||
ORDER BY sd.pct_change DESC
|
ORDER BY sd.pct_change DESC
|
||||||
`, date).Scan(&summary.LimitUpList)
|
`, date).Scan(&summary.LimitUpList)
|
||||||
|
|
||||||
|
// 跌停列表: 跌幅 <= -9.5%
|
||||||
r.db.Raw(`
|
r.db.Raw(`
|
||||||
SELECT sd.code, si.name, sd.pct_change, sd.close
|
SELECT sd.code, si.name, sd.pct_change, sd.close
|
||||||
FROM stock_daily sd
|
FROM stock_daily sd
|
||||||
@@ -97,7 +105,7 @@ func (r *StockRepo) GetSummaryByDate(date string) (*model.DashboardSummary, erro
|
|||||||
ORDER BY sd.pct_change ASC
|
ORDER BY sd.pct_change ASC
|
||||||
`, date).Scan(&summary.LimitDownList)
|
`, date).Scan(&summary.LimitDownList)
|
||||||
|
|
||||||
// 100-day new high: pre-aggregate max_close with covering index
|
// 百日新高: 当日收盘价等于近 150 个交易日最高收盘价的股票
|
||||||
r.db.Raw(`
|
r.db.Raw(`
|
||||||
SELECT t.code, si.name, t.pct_change, t.close
|
SELECT t.code, si.name, t.pct_change, t.close
|
||||||
FROM (
|
FROM (
|
||||||
@@ -118,6 +126,7 @@ func (r *StockRepo) GetSummaryByDate(date string) (*model.DashboardSummary, erro
|
|||||||
return summary, nil
|
return summary, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetHigh100 获取最新交易日创百日新高的股票列表
|
||||||
func (r *StockRepo) GetHigh100() ([]model.StockRank, error) {
|
func (r *StockRepo) GetHigh100() ([]model.StockRank, error) {
|
||||||
date, err := r.getLatestDate()
|
date, err := r.getLatestDate()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -143,6 +152,8 @@ func (r *StockRepo) GetHigh100() ([]model.StockRank, error) {
|
|||||||
return list, nil
|
return list, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetDailySummary 获取近 N 天的每日市场统计
|
||||||
|
// 包含: 涨跌数量、成交额、涨停跌停数量、连板数量
|
||||||
func (r *StockRepo) GetDailySummary(days int) ([]model.DailySummary, error) {
|
func (r *StockRepo) GetDailySummary(days int) ([]model.DailySummary, error) {
|
||||||
latest, err := r.getLatestDate()
|
latest, err := r.getLatestDate()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -169,7 +180,7 @@ func (r *StockRepo) GetDailySummary(days int) ([]model.DailySummary, error) {
|
|||||||
ORDER BY date ASC
|
ORDER BY date ASC
|
||||||
`, latest, days-1).Scan(&result)
|
`, latest, days-1).Scan(&result)
|
||||||
|
|
||||||
// Consecutive limit-up: get trading date pairs, then batch query
|
// 计算连板数量: 获取相邻交易日对,批量查询当日和前一日都涨停的股票数量
|
||||||
type datePair struct {
|
type datePair struct {
|
||||||
CurDate string `gorm:"column:cur_date"`
|
CurDate string `gorm:"column:cur_date"`
|
||||||
PrevDate string `gorm:"column:prev_date"`
|
PrevDate string `gorm:"column:prev_date"`
|
||||||
@@ -188,7 +199,7 @@ func (r *StockRepo) GetDailySummary(days int) ([]model.DailySummary, error) {
|
|||||||
}
|
}
|
||||||
var consecResults []consecRow
|
var consecResults []consecRow
|
||||||
if len(pairs) > 0 {
|
if len(pairs) > 0 {
|
||||||
// Use UNION ALL for better index utilization than OR
|
// 使用 UNION ALL 批量查询,利用索引优于 OR 条件
|
||||||
unionSQL := ""
|
unionSQL := ""
|
||||||
args := []interface{}{}
|
args := []interface{}{}
|
||||||
for _, p := range pairs {
|
for _, p := range pairs {
|
||||||
@@ -201,6 +212,7 @@ func (r *StockRepo) GetDailySummary(days int) ([]model.DailySummary, error) {
|
|||||||
r.db.Raw(unionSQL, args...).Scan(&consecResults)
|
r.db.Raw(unionSQL, args...).Scan(&consecResults)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 将连板数量映射到对应的日期
|
||||||
consecMap := make(map[string]int)
|
consecMap := make(map[string]int)
|
||||||
for _, row := range consecResults {
|
for _, row := range consecResults {
|
||||||
consecMap[row.Date] = row.Cnt
|
consecMap[row.Date] = row.Cnt
|
||||||
@@ -214,6 +226,8 @@ func (r *StockRepo) GetDailySummary(days int) ([]model.DailySummary, error) {
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetConsecutiveLimits 获取最新交易日连板股票列表
|
||||||
|
// 查询近 30 天的涨停记录,计算当日涨停股票的连续涨停天数
|
||||||
func (r *StockRepo) GetConsecutiveLimits() ([]model.ConsecutiveLimit, error) {
|
func (r *StockRepo) GetConsecutiveLimits() ([]model.ConsecutiveLimit, error) {
|
||||||
latest, err := r.getLatestDate()
|
latest, err := r.getLatestDate()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -223,7 +237,7 @@ func (r *StockRepo) GetConsecutiveLimits() ([]model.ConsecutiveLimit, error) {
|
|||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get recent trading dates with date range to use index
|
// 获取近 20 个交易日(覆盖 30 天范围)
|
||||||
var dates []string
|
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)
|
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 {
|
if len(dates) == 0 {
|
||||||
@@ -231,7 +245,7 @@ func (r *StockRepo) GetConsecutiveLimits() ([]model.ConsecutiveLimit, error) {
|
|||||||
}
|
}
|
||||||
earliest := dates[len(dates)-1]
|
earliest := dates[len(dates)-1]
|
||||||
|
|
||||||
// Get all limit-up records for recent dates
|
// 加载近期的全部涨停记录到内存,用 set 快速判断
|
||||||
type limitRow struct {
|
type limitRow struct {
|
||||||
Code string `gorm:"column:code"`
|
Code string `gorm:"column:code"`
|
||||||
D string `gorm:"column:date"`
|
D string `gorm:"column:date"`
|
||||||
@@ -244,7 +258,7 @@ func (r *StockRepo) GetConsecutiveLimits() ([]model.ConsecutiveLimit, error) {
|
|||||||
limitSet[row.Code+"_"+row.D[:10]] = true
|
limitSet[row.Code+"_"+row.D[:10]] = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get today's limit-up stocks with info
|
// 获取当日涨停股票详情
|
||||||
type stockInfo struct {
|
type stockInfo struct {
|
||||||
Code string `gorm:"column:code"`
|
Code string `gorm:"column:code"`
|
||||||
Name string `gorm:"column:name"`
|
Name string `gorm:"column:name"`
|
||||||
@@ -260,6 +274,7 @@ func (r *StockRepo) GetConsecutiveLimits() ([]model.ConsecutiveLimit, error) {
|
|||||||
ORDER BY sd.pct_change DESC
|
ORDER BY sd.pct_change DESC
|
||||||
`, latest).Scan(&todayLimit)
|
`, latest).Scan(&todayLimit)
|
||||||
|
|
||||||
|
// 逐只股票从最近交易日往前计算连续涨停天数
|
||||||
var result []model.ConsecutiveLimit
|
var result []model.ConsecutiveLimit
|
||||||
for _, s := range todayLimit {
|
for _, s := range todayLimit {
|
||||||
streak := 0
|
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 i := 0; i < len(result); i++ {
|
||||||
for j := i + 1; j < len(result); j++ {
|
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) {
|
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
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetIndexKline 获取指数 K 线数据(全部历史,来自 index_daily 表)
|
||||||
func (r *StockRepo) GetIndexKline(code string, days int) ([]model.IndexKline, error) {
|
func (r *StockRepo) GetIndexKline(code string, days int) ([]model.IndexKline, error) {
|
||||||
var result []model.IndexKline
|
var result []model.IndexKline
|
||||||
r.db.Raw(`
|
r.db.Raw(`
|
||||||
@@ -302,3 +318,22 @@ func (r *StockRepo) GetIndexKline(code string, days int) ([]model.IndexKline, er
|
|||||||
`, code).Scan(&result)
|
`, code).Scan(&result)
|
||||||
return result, nil
|
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
|
package router
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -9,20 +10,26 @@ import (
|
|||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Setup 注册所有路由
|
||||||
|
// - /api/* : 后端 API 接口
|
||||||
|
// - /assets/*, /vite.svg : 前端静态资源
|
||||||
|
// - 其余路径: SPA fallback,返回 index.html
|
||||||
func Setup(r *gin.Engine, dh *handler.DashboardHandler) {
|
func Setup(r *gin.Engine, dh *handler.DashboardHandler) {
|
||||||
|
// 后端 API 路由组
|
||||||
api := r.Group("/api")
|
api := r.Group("/api")
|
||||||
{
|
{
|
||||||
api.GET("/dashboard/summary", dh.GetSummary)
|
api.GET("/dashboard/summary", dh.GetSummary) // 市场概览
|
||||||
api.GET("/dashboard/daily", dh.GetDailySummary)
|
api.GET("/dashboard/daily", dh.GetDailySummary) // 每日统计
|
||||||
api.GET("/dashboard/consecutive", dh.GetConsecutiveLimits)
|
api.GET("/dashboard/consecutive", dh.GetConsecutiveLimits) // 连板统计
|
||||||
api.GET("/dashboard/high100", dh.GetHigh100)
|
api.GET("/dashboard/high100", dh.GetHigh100) // 百日新高
|
||||||
api.GET("/dashboard/index-kline", dh.GetIndexKline)
|
api.GET("/dashboard/index-kline", dh.GetIndexKline) // 指数K线
|
||||||
|
api.GET("/dashboard/stock-kline", dh.GetStockKline) // 个股K线
|
||||||
}
|
}
|
||||||
|
|
||||||
// Serve frontend static files
|
// 前端静态文件服务
|
||||||
staticDir := "static"
|
staticDir := "static"
|
||||||
if _, err := os.Stat(staticDir); os.IsNotExist(err) {
|
if _, err := os.Stat(staticDir); os.IsNotExist(err) {
|
||||||
// Try relative to executable
|
// 当前目录不存在时,尝试可执行文件所在目录
|
||||||
exePath, _ := os.Executable()
|
exePath, _ := os.Executable()
|
||||||
staticDir = filepath.Join(filepath.Dir(exePath), "static")
|
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.Static("/assets", filepath.Join(staticDir, "assets"))
|
||||||
r.StaticFile("/vite.svg", filepath.Join(staticDir, "vite.svg"))
|
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) {
|
r.NoRoute(func(c *gin.Context) {
|
||||||
path := c.Request.URL.Path
|
path := c.Request.URL.Path
|
||||||
if len(path) < 4 || path[:4] != "/api" {
|
if len(path) < 4 || path[:4] != "/api" {
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
// Package service 业务逻辑层,封装数据查询并提供带 TTL 的内存缓存
|
||||||
|
//
|
||||||
|
// 缓存策略: 使用读写锁 + 泛型缓存条目,所有查询先检查缓存是否过期,
|
||||||
|
// 未过期则直接返回缓存数据,否则从 Repository 层重新加载。
|
||||||
|
// 缓存过期时间为 3 分钟,适合A股数据的更新频率(每日收盘后更新)。
|
||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -7,19 +12,21 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// cacheEntry 带过期时间的泛型缓存条目
|
||||||
type cacheEntry[T any] struct {
|
type cacheEntry[T any] struct {
|
||||||
data T
|
data T
|
||||||
expires time.Time
|
expires time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DashboardService 市场数据服务,包含各查询的独立缓存
|
||||||
type DashboardService struct {
|
type DashboardService struct {
|
||||||
repo *repository.StockRepo
|
repo *repository.StockRepo
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex // 保护所有缓存字段的读写锁
|
||||||
summary *cacheEntry[*model.DashboardSummary]
|
summary *cacheEntry[*model.DashboardSummary] // 市场概览缓存
|
||||||
daily map[int]*cacheEntry[[]model.DailySummary]
|
daily map[int]*cacheEntry[[]model.DailySummary] // 每日统计缓存,按天数分组
|
||||||
consec *cacheEntry[[]model.ConsecutiveLimit]
|
consec *cacheEntry[[]model.ConsecutiveLimit] // 连板统计缓存
|
||||||
high100 *cacheEntry[[]model.StockRank]
|
high100 *cacheEntry[[]model.StockRank] // 百日新高缓存
|
||||||
indexKline map[string]*cacheEntry[[]model.IndexKline]
|
indexKline map[string]*cacheEntry[[]model.IndexKline] // K线缓存,指数用 code,个股用 "stock:"+code
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewDashboardService(repo *repository.StockRepo) *DashboardService {
|
func NewDashboardService(repo *repository.StockRepo) *DashboardService {
|
||||||
@@ -28,6 +35,7 @@ func NewDashboardService(repo *repository.StockRepo) *DashboardService {
|
|||||||
|
|
||||||
const cacheTTL = 3 * time.Minute
|
const cacheTTL = 3 * time.Minute
|
||||||
|
|
||||||
|
// GetSummary 获取最新交易日市场概览(涨跌数量、涨停跌停、百日新高等)
|
||||||
func (s *DashboardService) GetSummary() (*model.DashboardSummary, error) {
|
func (s *DashboardService) GetSummary() (*model.DashboardSummary, error) {
|
||||||
s.mu.RLock()
|
s.mu.RLock()
|
||||||
if s.summary != nil && time.Now().Before(s.summary.expires) {
|
if s.summary != nil && time.Now().Before(s.summary.expires) {
|
||||||
@@ -53,6 +61,7 @@ func (s *DashboardService) GetSummary() (*model.DashboardSummary, error) {
|
|||||||
return summary, nil
|
return summary, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetDailySummary 获取近 N 天每日市场统计
|
||||||
func (s *DashboardService) GetDailySummary(days int) ([]model.DailySummary, error) {
|
func (s *DashboardService) GetDailySummary(days int) ([]model.DailySummary, error) {
|
||||||
s.mu.RLock()
|
s.mu.RLock()
|
||||||
if e, ok := s.daily[days]; ok && time.Now().Before(e.expires) {
|
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
|
return data, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetConsecutiveLimits 获取最新交易日连板股票(连续多日涨停)
|
||||||
func (s *DashboardService) GetConsecutiveLimits() ([]model.ConsecutiveLimit, error) {
|
func (s *DashboardService) GetConsecutiveLimits() ([]model.ConsecutiveLimit, error) {
|
||||||
s.mu.RLock()
|
s.mu.RLock()
|
||||||
if s.consec != nil && time.Now().Before(s.consec.expires) {
|
if s.consec != nil && time.Now().Before(s.consec.expires) {
|
||||||
@@ -95,6 +105,7 @@ func (s *DashboardService) GetConsecutiveLimits() ([]model.ConsecutiveLimit, err
|
|||||||
return data, nil
|
return data, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetHigh100 获取最新交易日创百日新高的股票列表
|
||||||
func (s *DashboardService) GetHigh100() ([]model.StockRank, error) {
|
func (s *DashboardService) GetHigh100() ([]model.StockRank, error) {
|
||||||
s.mu.RLock()
|
s.mu.RLock()
|
||||||
if s.high100 != nil && time.Now().Before(s.high100.expires) {
|
if s.high100 != nil && time.Now().Before(s.high100.expires) {
|
||||||
@@ -116,6 +127,7 @@ func (s *DashboardService) GetHigh100() ([]model.StockRank, error) {
|
|||||||
return data, nil
|
return data, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetIndexKline 获取指数 K 线数据(全部历史)
|
||||||
func (s *DashboardService) GetIndexKline(code string, days int) ([]model.IndexKline, error) {
|
func (s *DashboardService) GetIndexKline(code string, days int) ([]model.IndexKline, error) {
|
||||||
s.mu.RLock()
|
s.mu.RLock()
|
||||||
if e, ok := s.indexKline[code]; ok && time.Now().Before(e.expires) {
|
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
|
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 { ConfigProvider, Layout, Menu } from 'antd';
|
||||||
import { Routes, Route, useNavigate, useLocation } from 'react-router-dom';
|
import { Routes, Route, useNavigate, useLocation } from 'react-router-dom';
|
||||||
import { BrowserRouter } from 'react-router-dom';
|
import { BrowserRouter } from 'react-router-dom';
|
||||||
@@ -7,6 +12,7 @@ import {
|
|||||||
FundOutlined,
|
FundOutlined,
|
||||||
LineChartOutlined,
|
LineChartOutlined,
|
||||||
RiseOutlined,
|
RiseOutlined,
|
||||||
|
StockOutlined,
|
||||||
ThunderboltOutlined,
|
ThunderboltOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import Dashboard from './pages/Dashboard';
|
import Dashboard from './pages/Dashboard';
|
||||||
@@ -14,17 +20,21 @@ import TrendPage from './pages/TrendPage';
|
|||||||
import ConsecutivePage from './pages/ConsecutivePage';
|
import ConsecutivePage from './pages/ConsecutivePage';
|
||||||
import High100Page from './pages/High100Page';
|
import High100Page from './pages/High100Page';
|
||||||
import IndexPage from './pages/IndexPage';
|
import IndexPage from './pages/IndexPage';
|
||||||
|
import OverlayPage from './pages/OverlayPage';
|
||||||
|
|
||||||
const { Header, Content } = Layout;
|
const { Header, Content } = Layout;
|
||||||
|
|
||||||
|
/** 顶部导航菜单配置 */
|
||||||
const menuItems = [
|
const menuItems = [
|
||||||
{ key: '/', icon: <DashboardOutlined />, label: '市场概览' },
|
{ key: '/', icon: <DashboardOutlined />, label: '市场概览' },
|
||||||
{ key: '/index', icon: <FundOutlined />, label: '指数行情' },
|
{ key: '/index', icon: <FundOutlined />, label: '指数行情' },
|
||||||
|
{ key: '/overlay', icon: <StockOutlined />, label: 'K线叠加' },
|
||||||
{ key: '/high100', icon: <RiseOutlined />, label: '百日新高' },
|
{ key: '/high100', icon: <RiseOutlined />, label: '百日新高' },
|
||||||
{ key: '/consecutive', icon: <ThunderboltOutlined />, label: '连板高度' },
|
{ key: '/consecutive', icon: <ThunderboltOutlined />, label: '连板高度' },
|
||||||
{ key: '/trend', icon: <LineChartOutlined />, label: '每日趋势' },
|
{ key: '/trend', icon: <LineChartOutlined />, label: '每日趋势' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** 布局组件: 顶部导航 + 内容区 */
|
||||||
function AppLayout() {
|
function AppLayout() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
@@ -48,6 +58,7 @@ function AppLayout() {
|
|||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Dashboard />} />
|
<Route path="/" element={<Dashboard />} />
|
||||||
<Route path="/index" element={<IndexPage />} />
|
<Route path="/index" element={<IndexPage />} />
|
||||||
|
<Route path="/overlay" element={<OverlayPage />} />
|
||||||
<Route path="/high100" element={<High100Page />} />
|
<Route path="/high100" element={<High100Page />} />
|
||||||
<Route path="/consecutive" element={<ConsecutivePage />} />
|
<Route path="/consecutive" element={<ConsecutivePage />} />
|
||||||
<Route path="/trend" element={<TrendPage />} />
|
<Route path="/trend" element={<TrendPage />} />
|
||||||
@@ -57,6 +68,7 @@ function AppLayout() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 根组件: Ant Design 中文国际化 + BrowserRouter */
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<ConfigProvider locale={zhCN}>
|
<ConfigProvider locale={zhCN}>
|
||||||
|
|||||||
+41
-15
@@ -1,9 +1,14 @@
|
|||||||
|
/**
|
||||||
|
* 后端 API 接口封装
|
||||||
|
* 所有接口基于 /api 前缀,返回数据格式为 { data: T }
|
||||||
|
*/
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
|
||||||
const api = axios.create({
|
const api = axios.create({
|
||||||
baseURL: '/api',
|
baseURL: '/api',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** 股票排行项(涨停、跌停、百日新高共用) */
|
||||||
export interface StockRank {
|
export interface StockRank {
|
||||||
code: string;
|
code: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -11,18 +16,20 @@ export interface StockRank {
|
|||||||
close: number;
|
close: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 市场概览数据 */
|
||||||
export interface DashboardSummary {
|
export interface DashboardSummary {
|
||||||
total_stocks: number;
|
total_stocks: number; // 当日交易股票总数
|
||||||
up_count: number;
|
up_count: number; // 上涨数量
|
||||||
down_count: number;
|
down_count: number; // 下跌数量
|
||||||
flat_count: number;
|
flat_count: number; // 平盘数量
|
||||||
total_turnover: number;
|
total_turnover: number; // 总成交额
|
||||||
avg_pct_change: number;
|
avg_pct_change: number; // 平均涨跌幅
|
||||||
limit_up_list: StockRank[];
|
limit_up_list: StockRank[]; // 涨停股票列表
|
||||||
limit_down_list: StockRank[];
|
limit_down_list: StockRank[]; // 跌停股票列表
|
||||||
high_100_list: StockRank[];
|
high_100_list: StockRank[]; // 百日新高股票列表
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 每日市场统计 */
|
||||||
export interface DailySummary {
|
export interface DailySummary {
|
||||||
date: string;
|
date: string;
|
||||||
total_stocks: number;
|
total_stocks: number;
|
||||||
@@ -31,41 +38,47 @@ export interface DailySummary {
|
|||||||
flat_count: number;
|
flat_count: number;
|
||||||
total_turnover: number;
|
total_turnover: number;
|
||||||
avg_pct_change: number;
|
avg_pct_change: number;
|
||||||
limit_up_10: number;
|
limit_up_10: number; // 主板涨停数量
|
||||||
limit_down_10: number;
|
limit_down_10: number; // 主板跌停数量
|
||||||
limit_up_20: number;
|
limit_up_20: number; // 创业板/科创板涨停数量
|
||||||
limit_down_20: number;
|
limit_down_20: number; // 创业板/科创板跌停数量
|
||||||
consecutive_limit_count: number;
|
consecutive_limit_count: number; // 连板股票数量
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 获取市场概览数据 */
|
||||||
export async function getDashboardSummary(): Promise<DashboardSummary> {
|
export async function getDashboardSummary(): Promise<DashboardSummary> {
|
||||||
const res = await api.get('/dashboard/summary');
|
const res = await api.get('/dashboard/summary');
|
||||||
return res.data.data;
|
return res.data.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 获取近 N 天每日市场统计 */
|
||||||
export async function getDailySummary(days = 30): Promise<DailySummary[]> {
|
export async function getDailySummary(days = 30): Promise<DailySummary[]> {
|
||||||
const res = await api.get('/dashboard/daily', { params: { days } });
|
const res = await api.get('/dashboard/daily', { params: { days } });
|
||||||
return res.data.data;
|
return res.data.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 连板统计项 */
|
||||||
export interface ConsecutiveLimit {
|
export interface ConsecutiveLimit {
|
||||||
code: string;
|
code: string;
|
||||||
name: string;
|
name: string;
|
||||||
streak: number;
|
streak: number; // 连续涨停天数
|
||||||
close: number;
|
close: number;
|
||||||
pct_change: number;
|
pct_change: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 获取连板股票统计 */
|
||||||
export async function getConsecutiveLimits(): Promise<ConsecutiveLimit[]> {
|
export async function getConsecutiveLimits(): Promise<ConsecutiveLimit[]> {
|
||||||
const res = await api.get('/dashboard/consecutive');
|
const res = await api.get('/dashboard/consecutive');
|
||||||
return res.data.data;
|
return res.data.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 获取百日新高股票列表 */
|
||||||
export async function getHigh100(): Promise<StockRank[]> {
|
export async function getHigh100(): Promise<StockRank[]> {
|
||||||
const res = await api.get('/dashboard/high100');
|
const res = await api.get('/dashboard/high100');
|
||||||
return res.data.data;
|
return res.data.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** K 线数据项(指数和个股通用) */
|
||||||
export interface IndexKline {
|
export interface IndexKline {
|
||||||
date: string;
|
date: string;
|
||||||
open: number;
|
open: number;
|
||||||
@@ -77,7 +90,20 @@ export interface IndexKline {
|
|||||||
pct_change: number;
|
pct_change: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 获取指数 K 线数据 */
|
||||||
export async function getIndexKline(code = '000001', days = 60): Promise<IndexKline[]> {
|
export async function getIndexKline(code = '000001', days = 60): Promise<IndexKline[]> {
|
||||||
const res = await api.get('/dashboard/index-kline', { params: { code, days } });
|
const res = await api.get('/dashboard/index-kline', { params: { code, days } });
|
||||||
return res.data.data;
|
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