Merge pull request '更新' (#1) from main into master

Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
2026-04-26 21:31:44 +08:00
10 changed files with 390 additions and 193 deletions
+17 -48
View File
@@ -1,68 +1,37 @@
package main package main
import ( import (
"fmt"
"github.com/gin-gonic/gin"
"gorm.io/driver/mysql" "gorm.io/driver/mysql"
"gorm.io/gorm" "gorm.io/gorm"
"level_go/internal/api"
"level_go/internal/router"
"level_go/internal/service" "level_go/internal/service"
"level_go/pkg/config" "level_go/pkg/config" // 引入配置包
"log"
) )
func main() { func main() {
// 1. 加载配置 // 1. 初始化配置
config.InitConfig() 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{}) db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
if err != nil { if err != nil {
panic("数据库连接失败: " + err.Error()) log.Fatal("数据库连接失败:", err)
} }
// 3. 注入 Service // 3. 依赖注入
stockSvc := &service.StockService{DB: db} commonSrv := &service.CommonService{DB: db}
stockSrv := &service.StockService{DB: db}
// 4. 路由设置 commonHandler := &api.CommonHandler{Service: commonSrv}
r := gin.Default() stockHandler := &api.StockHandler{Service: stockSrv}
// 跨域中间件 // 4. 启动路由
r.Use(func(c *gin.Context) { r := router.NewRouter(commonHandler, stockHandler)
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()
})
api := r.Group("/api/v1") port := config.GetServerPort()
{ log.Printf("服务启动于端口 %s", port)
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)
r.Run(port) r.Run(port)
} }
+20
View File
@@ -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)
}
+48
View File
@@ -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)
}
+17
View File
@@ -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"`
}
+26 -16
View File
@@ -2,25 +2,35 @@ package model
import "time" import "time"
// StockDaily 对应数据库中的 stock_daily 表
type StockDaily struct { type StockDaily struct {
TsCode string `gorm:"column:ts_code"` TsCode string `gorm:"column:ts_code" json:"ts_code"`
TradeDate time.Time `gorm:"column:trade_date"` TradeDate time.Time `gorm:"column:trade_date" json:"trade_date"`
Open float64 `gorm:"column:open"` Open float64 `gorm:"column:open" json:"open"`
High float64 `gorm:"column:high"` High float64 `gorm:"column:high" json:"high"`
Low float64 `gorm:"column:low"` Low float64 `gorm:"column:low" json:"low"`
Close float64 `gorm:"column:close"` 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 { type AnalysisResponse struct {
Code string `json:"code"` Code string `json:"code"`
Name string `json:"name"` Name string `json:"name"`
DayOpen float64 `json:"day_open"` DayOpen float64 `json:"day_open"`
DayHigh float64 `json:"day_high"` DayHigh float64 `json:"day_high"`
MaxHigh float64 `json:"max_high"` MaxHigh float64 `json:"max_high"` // 逻辑最高点(用于计算降幅)
MaxDate string `json:"max_date"` MaxDate string `json:"max_date"` // 逻辑最高点日期
PostMinLow float64 `json:"post_min_low"` OverallMaxHigh float64 `json:"overall_max_high"` // 区间绝对最高点
DropPct *float64 `json:"drop_pct"` // 改为指针以支持 null OverallMaxDate string `json:"overall_max_date"` // 区间绝对最高点日期
ReboundPct *float64 `json:"rebound_pct"` // 改为指针以支持 null PostMinLow float64 `json:"post_min_low"` // 涨停日后的最低点
DropPct *float64 `json:"drop_pct"` // 降幅 (跌破涨停价才有)
ReboundPct *float64 `json:"rebound_pct"` // 涨幅/反弹
ClosePrices []float64 `json:"close_prices"` // 用于前端画图的收盘价序列
} }
+30
View File
@@ -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
}
-128
View File
@@ -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
}
+32
View File
@@ -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
}
+186
View File
@@ -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
}
+14 -1
View File
@@ -24,7 +24,8 @@ var AppConfig *Config
func InitConfig() { func InitConfig() {
viper.SetConfigName("config") viper.SetConfigName("config")
viper.SetConfigType("yaml") viper.SetConfigType("yaml")
viper.AddConfigPath(".") // 在当前目录查找 config.yaml viper.AddConfigPath(".") // 在当前目录查找 config.yaml
viper.AddConfigPath("./config") // 兼容多一层目录的情况
if err := viper.ReadInConfig(); err != nil { if err := viper.ReadInConfig(); err != nil {
panic(fmt.Errorf("无法读取配置文件: %w", err)) panic(fmt.Errorf("无法读取配置文件: %w", err))
@@ -34,3 +35,15 @@ func InitConfig() {
panic(fmt.Errorf("无法解析配置结构体: %w", err)) 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)
}