- 实时行情:东财push2主源 + 腾讯批量报价备用源(自动切换) - 历史K线:东财push2his主源 + 腾讯web子域备用源(含熔断机制) - 增量同步:支持 -incr/-since 参数按存档日期增量入库 - 策略回测:净值曲线/10指标/基准对比,含基本面过滤 - 用户认证:用户名密码 + 邮箱验证码双登录方式 - 前端:三Tab SPA(实时行情/策略回测/数据管理)
340 lines
8.7 KiB
Go
340 lines
8.7 KiB
Go
// 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()})
|
||
}
|