Initial commit: 可转债轮动分析系统

- 实时行情:东财push2主源 + 腾讯批量报价备用源(自动切换)
- 历史K线:东财push2his主源 + 腾讯web子域备用源(含熔断机制)
- 增量同步:支持 -incr/-since 参数按存档日期增量入库
- 策略回测:净值曲线/10指标/基准对比,含基本面过滤
- 用户认证:用户名密码 + 邮箱验证码双登录方式
- 前端:三Tab SPA(实时行情/策略回测/数据管理)
This commit is contained in:
2026-08-17 20:23:22 +08:00
commit e28bfec957
45 changed files with 7889 additions and 0 deletions

339
internal/controller/api.go Normal file
View File

@@ -0,0 +1,339 @@
// Package controller 定义 MVC 架构中的 Controller 层,
// 负责接收 HTTP 请求、调用 Service 层业务逻辑、返回响应。
package controller
import (
"encoding/json"
"fmt"
"log"
"net/http"
"stock-analysis/internal/backtest"
"stock-analysis/internal/service"
"stock-analysis/internal/storage/sqlite"
"stock-analysis/internal/strategy"
)
// MarketController 提供行情与回测相关的 HTTP 接口。
type MarketController struct {
svc *service.MarketService
dbPath string
}
// NewMarketController 创建行情控制器。
func NewMarketController(svc *service.MarketService, dbPath string) *MarketController {
return &MarketController{svc: svc, dbPath: dbPath}
}
// ListBonds 处理 GET /api/bonds,返回全量可转债实时行情。
func (c *MarketController) ListBonds(w http.ResponseWriter, r *http.Request) {
bonds, err := c.svc.ListConvertibles(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, err)
return
}
writeJSON(w, http.StatusOK, bonds)
}
// GetKline 处理 GET /api/bonds/{code}/kline,返回指定可转债日K线。
func (c *MarketController) GetKline(w http.ResponseWriter, r *http.Request) {
code := r.PathValue("code")
klines, err := c.svc.GetDailyKline(r.Context(), code, "20200101")
if err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
writeJSON(w, http.StatusOK, klines)
}
// BacktestRequest 回测请求参数。
type BacktestRequest struct {
Strategy string `json:"strategy"`
StartDate string `json:"startDate"`
EndDate string `json:"endDate"`
TopN int `json:"topN"`
Rebalance int `json:"rebalance"`
MinPrice float64 `json:"minPrice"`
MaxPrice float64 `json:"maxPrice"`
MaxPremium float64 `json:"maxPremium"`
Cash float64 `json:"cash"`
Codes []string `json:"codes"` // 指定参与回测的转债代码,空表示全部
Fundamental bool `json:"fundamental"` // 启用正股基本面过滤器
}
// RunBacktest 处理 POST /api/backtest,执行回测并返回结果。
func (c *MarketController) RunBacktest(w http.ResponseWriter, r *http.Request) {
var req BacktestRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
// 默认值填充。
if req.Strategy == "" {
req.Strategy = "lowprice"
}
if req.TopN <= 0 {
req.TopN = 10
}
if req.Rebalance <= 0 {
req.Rebalance = 21
}
if req.Cash <= 0 {
req.Cash = 100000
}
if req.MaxPrice <= 0 {
req.MaxPrice = 130
}
if req.MinPrice <= 0 {
req.MinPrice = 80
}
if req.MaxPremium <= 0 {
req.MaxPremium = 30
}
ctx := r.Context()
store, err := sqlite.Open(c.dbPath)
if err != nil {
writeError(w, http.StatusInternalServerError, err)
return
}
defer store.Close()
feed, err := backtest.NewDataFeed(ctx, store)
if err != nil {
writeError(w, http.StatusInternalServerError, err)
return
}
// 如果指定了 codes,过滤数据源。
if len(req.Codes) > 0 {
feed.Filter(req.Codes)
}
params := backtest.DefaultParams()
params.InitialCash = req.Cash
params.StartDate = req.StartDate
params.EndDate = req.EndDate
var strat backtest.Strategy
switch req.Strategy {
case "lowprice":
strat = strategy.NewLowPrice(strategy.LowPriceParams{
TopN: req.TopN,
Rebalance: req.Rebalance,
MinPrice: req.MinPrice,
MaxPrice: req.MaxPrice,
})
case "doublelow":
strat = strategy.NewDoubleLow(strategy.DoubleLowParams{
TopN: req.TopN,
Rebalance: req.Rebalance,
MinPrice: req.MinPrice,
MaxPrice: req.MaxPrice,
MaxPremium: req.MaxPremium,
})
case "premium":
strat = strategy.NewPremium(strategy.PremiumParams{
TopN: req.TopN,
Rebalance: req.Rebalance,
MinPrice: req.MinPrice,
MaxPrice: req.MaxPrice,
})
default:
writeError(w, http.StatusBadRequest, err)
return
}
engine := backtest.NewEngine(params, strat)
if req.Fundamental {
fundamentals, err := store.LoadAllFundamentals(ctx)
if err != nil {
writeError(w, http.StatusInternalServerError, fmt.Errorf("加载基本面数据: %w", err))
return
}
stock2bond, err := store.GetStockBondMap(ctx)
if err != nil {
writeError(w, http.StatusInternalServerError, fmt.Errorf("获取正股映射: %w", err))
return
}
filter := backtest.NewFundamentalFilter(stock2bond)
engine.SetFundamentalFilter(filter, fundamentals)
log.Printf("基本面过滤器已启用:%d 只正股数据", len(fundamentals))
}
res, err := engine.Run(ctx, feed)
if err != nil {
writeError(w, http.StatusInternalServerError, err)
return
}
log.Printf("backtest done: %s, start=%s, end=%s, totalReturn=%.2f%%, curvePoints=%d, tradeDays=%d",
res.Strategy, res.Start, res.End, res.Metrics.TotalReturn*100, len(res.Curve), res.Metrics.TradeDays)
bench := computeBenchmarkJSON(feed, req.StartDate, req.EndDate)
writeJSON(w, http.StatusOK, map[string]any{
"strategy": res.Strategy,
"start": res.Start,
"end": res.End,
"metrics": res.Metrics,
"curve": res.Curve,
"benchMetrics": bench.Metrics,
"benchCurve": bench.Curve,
})
}
// benchmarkResult 基准回测结果。
type benchmarkResult struct {
Metrics backtest.Metrics
Curve []backtest.EquityPoint
}
// computeBenchmarkJSON 在 Web API 中计算基准指数。
func computeBenchmarkJSON(feed *backtest.DataFeed, from, to string) benchmarkResult {
var (
idx float64 = 1
prev map[string]float64
curve []backtest.EquityPoint
hasPrev bool
)
for _, date := range feed.Dates() {
if from != "" && date < from {
continue
}
if to != "" && date > to {
break
}
cur := make(map[string]float64)
for _, q := range feed.QuotesOn(date) {
cur[q.Code] = q.Price
}
if hasPrev {
var sum, n float64
for code, p := range cur {
if pp, ok := prev[code]; ok && pp > 0 {
sum += p/pp - 1
n++
}
}
if n > 0 {
idx *= 1 + sum/n
}
}
curve = append(curve, backtest.EquityPoint{Date: date, Value: idx, Return: idx - 1})
prev = cur
hasPrev = true
}
return benchmarkResult{
Metrics: backtest.ComputeMetrics(curve),
Curve: curve,
}
}
// DataStatus 数据状态信息。
type DataStatus struct {
DBPath string `json:"dbPath"`
BondCount int `json:"bondCount"`
KlineCount int `json:"klineCount"`
DateRange string `json:"dateRange"`
}
// DBBond 数据库中转债简要信息。
type DBBond struct {
Code string `json:"Code"`
Name string `json:"Name"`
KlineCount int `json:"KlineCount"`
}
// ListDBBonds 处理 GET /api/db/bonds,返回数据库中已有的转债列表。
func (c *MarketController) ListDBBonds(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
store, err := sqlite.Open(c.dbPath)
if err != nil {
writeError(w, http.StatusInternalServerError, err)
return
}
defer store.Close()
codes, err := store.ListCodes(ctx)
if err != nil {
writeError(w, http.StatusInternalServerError, err)
return
}
// 加载转债名称映射。
allBonds, _ := store.ListBonds(ctx)
nameMap := make(map[string]string, len(allBonds))
for _, b := range allBonds {
nameMap[b.Code] = b.Name
}
bonds := make([]DBBond, 0, len(codes))
for _, code := range codes {
n, _ := store.CountKlines(ctx, code)
bonds = append(bonds, DBBond{Code: code, Name: nameMap[code], KlineCount: n})
}
writeJSON(w, http.StatusOK, bonds)
}
// GetDataStatus 处理 GET /api/status,返回数据库状态信息。
func (c *MarketController) GetDataStatus(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
status := DataStatus{DBPath: c.dbPath}
store, err := sqlite.Open(c.dbPath)
if err != nil {
writeJSON(w, http.StatusOK, status) // 返回部分信息
return
}
defer store.Close()
codes, err := store.ListCodes(ctx)
if err == nil {
status.BondCount = len(codes)
}
all, err := store.LoadAllKlines(ctx)
if err == nil {
for _, ks := range all {
status.KlineCount += len(ks)
}
}
// 取日期范围。
if len(all) > 0 {
var minDate, maxDate string
for _, ks := range all {
if len(ks) > 0 {
if minDate == "" || ks[0].Date < minDate {
minDate = ks[0].Date
}
last := ks[len(ks)-1].Date
if maxDate == "" || last > maxDate {
maxDate = last
}
}
}
status.DateRange = minDate + " ~ " + maxDate
}
writeJSON(w, http.StatusOK, status)
}
// writeJSON 写入 JSON 响应。
func writeJSON(w http.ResponseWriter, status int, data any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(data); err != nil {
log.Printf("write json: %v", err)
}
}
// writeError 写入 JSON 错误响应。
func writeError(w http.ResponseWriter, status int, err error) {
log.Printf("http %d: %v", status, err)
writeJSON(w, status, map[string]string{"error": err.Error()})
}

168
internal/controller/auth.go Normal file
View File

@@ -0,0 +1,168 @@
// Package controller 定义 MVC 架构中的 Controller 层。
package controller
import (
"encoding/json"
"fmt"
"net/http"
"strings"
"stock-analysis/internal/model"
"stock-analysis/internal/service"
)
// AuthController 提供用户认证相关的 HTTP 接口。
type AuthController struct {
svc *service.AuthService
}
// NewAuthController 创建认证控制器。
func NewAuthController(svc *service.AuthService) *AuthController {
return &AuthController{svc: svc}
}
// CurrentUser 返回当前请求对应的登录用户,未登录返回错误。
func (c *AuthController) CurrentUser(r *http.Request) (*model.User, error) {
token := extractToken(r)
if token == "" {
return nil, fmt.Errorf("未登录")
}
return c.svc.ValidateToken(r.Context(), token)
}
// sendCodeRequest 发送验证码请求。
type sendCodeRequest struct {
Email string `json:"email"`
Purpose string `json:"purpose"` // register 或 login
}
// SendCode 处理 POST /api/auth/send-code,发送邮箱验证码。
func (c *AuthController) SendCode(w http.ResponseWriter, r *http.Request) {
var req sendCodeRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
if req.Purpose == "" {
req.Purpose = "register"
}
if err := c.svc.SendVerifyCode(r.Context(), req.Email, req.Purpose); err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
writeJSON(w, http.StatusOK, map[string]string{"message": "验证码已发送"})
}
// registerRequest 注册请求。
type registerRequest struct {
Username string `json:"username"`
Email string `json:"email"`
Code string `json:"code"`
Password string `json:"password"`
}
// Register 处理 POST /api/auth/register,邮箱+验证码注册。
func (c *AuthController) Register(w http.ResponseWriter, r *http.Request) {
var req registerRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
user, err := c.svc.Register(r.Context(), req.Username, req.Email, req.Code, req.Password)
if err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{
"id": user.ID,
"username": user.Username,
"email": user.Email,
})
}
// loginRequest 登录请求。
type loginRequest struct {
Username string `json:"username"`
Email string `json:"email"`
Code string `json:"code"`
Password string `json:"password"`
}
// Login 处理 POST /api/auth/login。
// 支持两种登录方式:
// - 用户名+密码:{ username, password }
// - 邮箱+验证码:{ email, code }
func (c *AuthController) Login(w http.ResponseWriter, r *http.Request) {
var req loginRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
var user *model.User
var token string
var err error
if req.Username != "" {
// 用户名+密码登录
user, token, err = c.svc.LoginByUsername(r.Context(), req.Username, req.Password)
} else if req.Code != "" {
// 邮箱+验证码登录
user, token, err = c.svc.Login(r.Context(), req.Email, req.Code)
} else {
writeError(w, http.StatusBadRequest, fmt.Errorf("请输入用户名密码或邮箱验证码"))
return
}
if err != nil {
writeError(w, http.StatusUnauthorized, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{
"id": user.ID,
"username": user.Username,
"email": user.Email,
"token": token,
})
}
// Me 处理 GET /api/auth/me,返回当前登录用户信息。
func (c *AuthController) Me(w http.ResponseWriter, r *http.Request) {
token := extractToken(r)
if token == "" {
writeError(w, http.StatusUnauthorized, fmt.Errorf("未登录"))
return
}
u, err := c.svc.ValidateToken(r.Context(), token)
if err != nil {
writeError(w, http.StatusUnauthorized, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{
"id": u.ID,
"username": u.Username,
"email": u.Email,
})
}
// Logout 处理 POST /api/auth/logout,注销当前会话。
func (c *AuthController) Logout(w http.ResponseWriter, r *http.Request) {
token := extractToken(r)
if token != "" {
c.svc.Logout(token)
}
writeJSON(w, http.StatusOK, map[string]string{"message": "已退出"})
}
// extractToken 从 Cookie 或 Authorization 头中提取 token。
func extractToken(r *http.Request) string {
if c, err := r.Cookie("auth_token"); err == nil && c.Value != "" {
return c.Value
}
if h := r.Header.Get("Authorization"); h != "" {
if strings.HasPrefix(h, "Bearer ") {
return strings.TrimPrefix(h, "Bearer ")
}
}
return ""
}