diff --git a/cmd/server/main.go b/cmd/server/main.go index abcc02a..7043d43 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -1,68 +1,37 @@ package main import ( - "fmt" - "github.com/gin-gonic/gin" "gorm.io/driver/mysql" "gorm.io/gorm" + "level_go/internal/api" + "level_go/internal/router" "level_go/internal/service" - "level_go/pkg/config" + "level_go/pkg/config" // 引入配置包 + "log" ) func main() { - // 1. 加载配置 + // 1. 初始化配置 config.InitConfig() - dbConf := config.AppConfig.Database - - // 2. 连接数据库 - dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=%s&parseTime=True&loc=Local", - dbConf.User, dbConf.Password, dbConf.Host, dbConf.Port, dbConf.Dbname, dbConf.Charset) + // 2. 使用配置中的 DSN 连接数据库 + dsn := config.GetDSN() db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{}) if err != nil { - panic("数据库连接失败: " + err.Error()) + log.Fatal("数据库连接失败:", err) } - // 3. 注入 Service - stockSvc := &service.StockService{DB: db} + // 3. 依赖注入 + commonSrv := &service.CommonService{DB: db} + stockSrv := &service.StockService{DB: db} - // 4. 路由设置 - r := gin.Default() + commonHandler := &api.CommonHandler{Service: commonSrv} + stockHandler := &api.StockHandler{Service: stockSrv} - // 跨域中间件 - r.Use(func(c *gin.Context) { - c.Writer.Header().Set("Access-Control-Allow-Origin", "*") - c.Writer.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS") - c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type") - if c.Request.Method == "OPTIONS" { - c.AbortWithStatus(204) - return - } - c.Next() - }) + // 4. 启动路由 + r := router.NewRouter(commonHandler, stockHandler) - api := r.Group("/api/v1") - { - api.GET("/stock/analyze", func(c *gin.Context) { - start := c.Query("start_date") - end := c.Query("end_date") - - if start == "" || end == "" { - c.JSON(400, gin.H{"error": "参数缺失"}) - return - } - - data, err := stockSvc.AnalyzePerformance(start, end) - if err != nil { - c.JSON(500, gin.H{"error": err.Error()}) - return - } - c.JSON(200, data) - }) - } - - // 5. 启动 - port := fmt.Sprintf(":%d", config.AppConfig.Server.Port) - fmt.Printf("服务已启动,监听端口 %s\n", port) + port := config.GetServerPort() + log.Printf("服务启动于端口 %s", port) r.Run(port) } diff --git a/internal/api/common_handler.go b/internal/api/common_handler.go new file mode 100644 index 0000000..1ac2eb2 --- /dev/null +++ b/internal/api/common_handler.go @@ -0,0 +1,20 @@ +package api + +import ( + "github.com/gin-gonic/gin" + "level_go/internal/service" + "net/http" +) + +type CommonHandler struct { + Service *service.CommonService +} + +func (h *CommonHandler) GetTradeCalendar(c *gin.Context) { + resp, err := h.Service.GetTradeCalendar() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, resp) +} diff --git a/internal/api/stock_handler.go b/internal/api/stock_handler.go new file mode 100644 index 0000000..725a26a --- /dev/null +++ b/internal/api/stock_handler.go @@ -0,0 +1,48 @@ +package api + +import ( + "github.com/gin-gonic/gin" + "level_go/internal/model" + "level_go/internal/service" + "net/http" +) + +type StockHandler struct { + Service *service.StockService +} + +func NewStockHandler(s *service.StockService) *StockHandler { + return &StockHandler{Service: s} +} + +// GetKlineData 获取指定股票从2024-09-24至今的K线 +func (h *StockHandler) GetKlineData(c *gin.Context) { + code := c.Param("code") + if code == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "股票代码不能为空"}) + return + } + + data, err := h.Service.GetHistoryKline(code) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, data) +} + +func (h *StockHandler) Analyze(c *gin.Context) { + var req model.AnalyzeRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"}) + return + } + + results, err := h.Service.AnalyzePerformance(req.StartDate, req.EndDate) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, results) +} diff --git a/internal/model/calendar.go b/internal/model/calendar.go new file mode 100644 index 0000000..e613a31 --- /dev/null +++ b/internal/model/calendar.go @@ -0,0 +1,17 @@ +package model + +import "time" + +type TradeCalendar struct { + CalDate time.Time `gorm:"column:cal_date;primaryKey"` + Exchange string `gorm:"column:exchange;primaryKey"` + IsOpen int `gorm:"column:is_open"` + PretradeDate time.Time `gorm:"column:pretrade_date"` +} + +func (TradeCalendar) TableName() string { return "trade_calendar" } + +type CalendarResponse struct { + OpenDates []string `json:"open_dates"` + MaxDate string `json:"max_date"` +} diff --git a/internal/model/stock.go b/internal/model/stock.go index 354c661..7c2f217 100644 --- a/internal/model/stock.go +++ b/internal/model/stock.go @@ -2,25 +2,35 @@ package model import "time" +// StockDaily 对应数据库中的 stock_daily 表 type StockDaily struct { - TsCode string `gorm:"column:ts_code"` - TradeDate time.Time `gorm:"column:trade_date"` - Open float64 `gorm:"column:open"` - High float64 `gorm:"column:high"` - Low float64 `gorm:"column:low"` - Close float64 `gorm:"column:close"` + TsCode string `gorm:"column:ts_code" json:"ts_code"` + TradeDate time.Time `gorm:"column:trade_date" json:"trade_date"` + Open float64 `gorm:"column:open" json:"open"` + High float64 `gorm:"column:high" json:"high"` + Low float64 `gorm:"column:low" json:"low"` + Close float64 `gorm:"column:close" json:"close"` + PctChg float64 `gorm:"column:pct_chg" json:"pct_chg"` } -func (StockDaily) TableName() string { return "stock_daily" } +// AnalyzeRequest 用于接收前端 POST 请求的参数 +type AnalyzeRequest struct { + StartDate string `json:"start_date"` + EndDate string `json:"end_date"` +} +// AnalysisResponse 用于向前端返回分析结果 type AnalysisResponse struct { - Code string `json:"code"` - Name string `json:"name"` - DayOpen float64 `json:"day_open"` - DayHigh float64 `json:"day_high"` - MaxHigh float64 `json:"max_high"` - MaxDate string `json:"max_date"` - PostMinLow float64 `json:"post_min_low"` - DropPct *float64 `json:"drop_pct"` // 改为指针以支持 null - ReboundPct *float64 `json:"rebound_pct"` // 改为指针以支持 null + Code string `json:"code"` + Name string `json:"name"` + DayOpen float64 `json:"day_open"` + DayHigh float64 `json:"day_high"` + MaxHigh float64 `json:"max_high"` // 逻辑最高点(用于计算降幅) + MaxDate string `json:"max_date"` // 逻辑最高点日期 + OverallMaxHigh float64 `json:"overall_max_high"` // 区间绝对最高点 + OverallMaxDate string `json:"overall_max_date"` // 区间绝对最高点日期 + PostMinLow float64 `json:"post_min_low"` // 涨停日后的最低点 + DropPct *float64 `json:"drop_pct"` // 降幅 (跌破涨停价才有) + ReboundPct *float64 `json:"rebound_pct"` // 涨幅/反弹 + ClosePrices []float64 `json:"close_prices"` // 用于前端画图的收盘价序列 } diff --git a/internal/router/router.go b/internal/router/router.go new file mode 100644 index 0000000..ad82e4b --- /dev/null +++ b/internal/router/router.go @@ -0,0 +1,30 @@ +package router + +import ( + "github.com/gin-gonic/gin" + "level_go/internal/api" +) + +func NewRouter(commonApi *api.CommonHandler, stockApi *api.StockHandler) *gin.Engine { + r := gin.Default() + + // 跨域中间件 (根据需要开启) + r.Use(func(c *gin.Context) { + c.Writer.Header().Set("Access-Control-Allow-Origin", "*") + c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, GET, OPTIONS") + c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type") + if c.Request.Method == "OPTIONS" { + c.AbortWithStatus(204) + return + } + c.Next() + }) + + v1 := r.Group("/api/v1") + { + v1.GET("/common/trade-calendar", commonApi.GetTradeCalendar) + v1.POST("/stock/analyze", stockApi.Analyze) + v1.GET("/stock/kline/:code", stockApi.GetKlineData) + } + return r +} diff --git a/internal/service/analyzer.go b/internal/service/analyzer.go deleted file mode 100644 index d977d82..0000000 --- a/internal/service/analyzer.go +++ /dev/null @@ -1,128 +0,0 @@ -package service - -import ( - "level_go/internal/model" - "math" - "sort" - - "gorm.io/gorm" -) - -type StockService struct { - DB *gorm.DB -} - -func roundTo4(val float64) float64 { - return math.Round(val*10000) / 10000 -} - -func (s *StockService) AnalyzePerformance(startDate, endDate string) ([]model.AnalysisResponse, error) { - var results []model.AnalysisResponse - var limits []struct { - TsCode string - Name string - Open float64 - High float64 - } - - // 1. 获取选定日期当天涨停的股票 - query := ` - SELECT l.ts_code, l.name, d.open, d.high - FROM stock_limit_data l - LEFT JOIN stock_daily d ON l.ts_code = d.ts_code AND l.trade_date = d.trade_date - WHERE l.trade_date = ? AND l.status = 1` - - if err := s.DB.Raw(query, startDate).Scan(&limits).Error; err != nil { - return nil, err - } - - for _, stock := range limits { - var dailies []model.StockDaily - s.DB.Where("ts_code = ? AND trade_date BETWEEN ? AND ?", stock.TsCode, startDate, endDate). - Order("trade_date ASC").Find(&dailies) - - if len(dailies) == 0 { - continue - } - - // --- 步骤 1:寻找全区间的最高点 (MaxHigh) --- - maxPrice := -1.0 - maxIdx := 0 - for i, d := range dailies { - if d.High > maxPrice { - maxPrice = d.High - maxIdx = i - } - } - - var dropPct, reboundPct *float64 - var postMinLow float64 - maxDateStr := dailies[maxIdx].TradeDate.Format("2006-01-02") - - // --- 步骤 2:寻找最高点之后的最低点 (PostMinLow) --- - if maxIdx < len(dailies)-1 { - postMaxData := dailies[maxIdx+1:] - minLow := postMaxData[0].Low - minLowIdx := 0 - - for i, d := range postMaxData { - if d.Low < minLow { - minLow = d.Low - minLowIdx = i - } - } - postMinLow = minLow - - // 只要出现比 MaxHigh 低的价格,就计算回落幅度 - if minLow < maxPrice { - dVal := roundTo4(((maxPrice - minLow) / maxPrice) * 100) - dropPct = &dVal - - // --- 步骤 3:寻找最低点之后的反弹 --- - if minLowIdx < len(postMaxData)-1 { - lastClose := postMaxData[len(postMaxData)-1].Close - if lastClose > minLow { - rVal := roundTo4(((lastClose - minLow) / minLow) * 100) - reboundPct = &rVal - } - } - } - } - - // --- 步骤 4:处理特殊形态(如持续上涨无回落) --- - if dropPct == nil { - lastClose := dailies[len(dailies)-1].Close - firstDayHigh := dailies[0].High - if lastClose > firstDayHigh { - rVal := roundTo4(((lastClose - firstDayHigh) / firstDayHigh) * 100) - reboundPct = &rVal - } - } - - results = append(results, model.AnalysisResponse{ - Code: stock.TsCode, - Name: stock.Name, - DayOpen: stock.Open, - DayHigh: stock.High, - MaxHigh: maxPrice, - MaxDate: maxDateStr, - PostMinLow: postMinLow, - DropPct: dropPct, - ReboundPct: reboundPct, - }) - } - - // 5. 基础排序(仅按回落幅度降序,不处理 null 特殊逻辑) - sort.Slice(results, func(i, j int) bool { - valI, valJ := -1.0, -1.0 - if results[i].DropPct != nil { - valI = *results[i].DropPct - } - if results[j].DropPct != nil { - valJ = *results[j].DropPct - } - return valI > valJ - }) - - return results, nil -} diff --git a/internal/service/common_service.go b/internal/service/common_service.go new file mode 100644 index 0000000..e07ec48 --- /dev/null +++ b/internal/service/common_service.go @@ -0,0 +1,32 @@ +package service + +import ( + "gorm.io/gorm" + "level_go/internal/model" +) + +type CommonService struct { + DB *gorm.DB +} + +func (s *CommonService) GetTradeCalendar() (model.CalendarResponse, error) { + var results []struct { + DateStr string `gorm:"column:ds"` + } + var resp model.CalendarResponse + + err := s.DB.Model(&model.TradeCalendar{}). + Select("DATE_FORMAT(cal_date, '%Y-%m-%d') as ds"). + Where("is_open = ? AND exchange = ?", 1, "SSE"). + Order("cal_date ASC").Scan(&results).Error + + if err != nil { + return resp, err + } + + for _, r := range results { + resp.OpenDates = append(resp.OpenDates, r.DateStr) + resp.MaxDate = r.DateStr + } + return resp, nil +} diff --git a/internal/service/stock_service.go b/internal/service/stock_service.go new file mode 100644 index 0000000..20549cf --- /dev/null +++ b/internal/service/stock_service.go @@ -0,0 +1,186 @@ +package service + +import ( + "gorm.io/gorm" + "level_go/internal/model" + "math" + "sort" +) + +type StockService struct { + DB *gorm.DB +} + +func (s *StockService) GetHistoryKline(code string) ([]model.StockDaily, error) { + var dailies []model.StockDaily + startDate := "2024-09-24" + + err := s.DB.Table("stock_daily"). + Where("ts_code = ? AND trade_date >= ?", code, startDate). + Order("trade_date ASC"). + Find(&dailies).Error + + return dailies, err +} + +func roundTo2(val float64) float64 { + return math.Round(val*100) / 100 +} + +func (s *StockService) AnalyzePerformance(startDate, endDate string) ([]model.AnalysisResponse, error) { + var results []model.AnalysisResponse + var limits []struct { + TsCode string + Name string + Open float64 + High float64 + } + + // 查询涨停股 + query := `SELECT l.ts_code, l.name, d.open, d.high FROM stock_limit_data l + LEFT JOIN stock_daily d ON l.ts_code = d.ts_code AND l.trade_date = d.trade_date + WHERE l.trade_date = ? AND l.status = 1` + + if err := s.DB.Raw(query, startDate).Scan(&limits).Error; err != nil { + return nil, err + } + + if len(limits) == 0 { + return results, nil + } + + codes := make([]string, 0, len(limits)) + for _, stock := range limits { + codes = append(codes, stock.TsCode) + } + + var allDailies []model.StockDaily + if err := s.DB.Table("stock_daily"). + Select("ts_code", "trade_date", "open", "high", "low", "close"). + Where("ts_code IN ? AND trade_date BETWEEN ? AND ?", codes, startDate, endDate). + Order("ts_code ASC, trade_date ASC"). + Find(&allDailies).Error; err != nil { + return nil, err + } + + dailiesByCode := make(map[string][]model.StockDaily, len(codes)) + for _, daily := range allDailies { + dailiesByCode[daily.TsCode] = append(dailiesByCode[daily.TsCode], daily) + } + + for _, stock := range limits { + dailies := dailiesByCode[stock.TsCode] + if len(dailies) <= 1 { + continue + } + + // A. 计算区间绝对最高价 + overallMax := dailies[0].High + overallMaxDate := dailies[0].TradeDate.Format("2006-01-02") + for _, d := range dailies { + if d.High > overallMax { + overallMax = d.High + overallMaxDate = d.TradeDate.Format("2006-01-02") + } + } + + // B. 逻辑计算 + limitHigh := stock.High + postDailies := dailies[1:] + + // 找出涨停后的全局最低 + minLowAfterLimit := postDailies[0].Low + for _, d := range postDailies { + if d.Low < minLowAfterLimit { + minLowAfterLimit = d.Low + } + } + + var dropPct, reboundPct *float64 + var logicMaxHigh float64 + var logicMaxDate string + + if minLowAfterLimit >= limitHigh { + // 形态 1:强势。计算涨幅 + currMax := limitHigh + maxIdx := -1 + for i, d := range postDailies { + if d.High > currMax { + currMax = d.High + maxIdx = i + } + } + logicMaxHigh = currMax + logicMaxDate = startDate + if maxIdx != -1 { + logicMaxDate = postDailies[maxIdx].TradeDate.Format("2006-01-02") + val := roundTo2(((logicMaxHigh - limitHigh) / limitHigh) * 100) + reboundPct = &val + } + dropPct = nil + } else { + // 形态 2:破位。计算降幅和反弹 + absMinLow := postDailies[0].Low + minIdx := 0 + for i, d := range postDailies { + if d.Low < absMinLow { + absMinLow = d.Low + minIdx = i + } + } + // 找到涨停到该最低点之间的最高 + rangeDailies := dailies[:minIdx+2] + rangeMax := dailies[0].High + maxD := startDate + for _, d := range rangeDailies { + if d.High > rangeMax { + rangeMax = d.High + maxD = d.TradeDate.Format("2006-01-02") + } + } + logicMaxHigh = rangeMax + logicMaxDate = maxD + + dVal := roundTo2(((rangeMax - absMinLow) / rangeMax) * 100) + dropPct = &dVal + + lastClose := postDailies[len(postDailies)-1].Close + rVal := roundTo2(((lastClose - absMinLow) / absMinLow) * 100) + reboundPct = &rVal + } + + var closePrices []float64 + for _, d := range dailies { + closePrices = append(closePrices, d.Close) + } + + results = append(results, model.AnalysisResponse{ + Code: stock.TsCode, + Name: stock.Name, + DayOpen: stock.Open, + DayHigh: stock.High, + MaxHigh: logicMaxHigh, + MaxDate: logicMaxDate, + OverallMaxHigh: overallMax, + OverallMaxDate: overallMaxDate, + PostMinLow: minLowAfterLimit, + DropPct: dropPct, + ReboundPct: reboundPct, + ClosePrices: closePrices, + }) + } + + sort.Slice(results, func(i, j int) bool { + if results[i].DropPct == nil && results[j].DropPct != nil { + return false + } + if results[i].DropPct != nil && results[j].DropPct == nil { + return true + } + if results[i].DropPct != nil && results[j].DropPct != nil { + return *results[i].DropPct > *results[j].DropPct + } + return false + }) + return results, nil +} diff --git a/pkg/config/config.go b/pkg/config/config.go index c087065..614c3ad 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -24,7 +24,8 @@ var AppConfig *Config func InitConfig() { viper.SetConfigName("config") viper.SetConfigType("yaml") - viper.AddConfigPath(".") // 在当前目录查找 config.yaml + viper.AddConfigPath(".") // 在当前目录查找 config.yaml + viper.AddConfigPath("./config") // 兼容多一层目录的情况 if err := viper.ReadInConfig(); err != nil { panic(fmt.Errorf("无法读取配置文件: %w", err)) @@ -34,3 +35,15 @@ func InitConfig() { panic(fmt.Errorf("无法解析配置结构体: %w", err)) } } + +// GetDSN 生成 MySQL 连接字符串 +func GetDSN() string { + db := AppConfig.Database + return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=%s&parseTime=True&loc=Local", + db.User, db.Password, db.Host, db.Port, db.Dbname, db.Charset) +} + +// GetServerPort 获取服务器端口字符串 +func GetServerPort() string { + return fmt.Sprintf(":%d", AppConfig.Server.Port) +}