Files
stock-analysis/internal/controller/api.go
WarrenZhou 273fc90a95 feat: 添加异步数据入库任务系统,支持全量/增量更新与实时进度展示
新增完整的异步任务管理体系:
1. 新增Redis缓存层实现任务状态实时存储与降级机制
2. 实现并发拉取+批量写入的高性能入库流程
3. 添加任务管理HTTP接口与路由
4. 重构行情控制器使用共享SQLite存储
5. 配套添加Docker Compose Redis启动配置
6. 前端新增任务管理面板支持操作与进度查看
2026-08-18 10:09:52 +08:00

324 lines
8.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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 接口。
// store 为共享 SQLite 存储,与 job.Manager 共用同一连接池,避免每次请求重复开关连接。
type MarketController struct {
svc *service.MarketService
store *sqlite.Store
}
// NewMarketController 创建行情控制器。
func NewMarketController(svc *service.MarketService, store *sqlite.Store) *MarketController {
return &MarketController{svc: svc, store: store}
}
// 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 := c.store
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 := c.store
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()
store := c.store
status := DataStatus{DBPath: c.store.Path()}
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()})
}