Initial commit: 可转债轮动分析系统
- 实时行情:东财push2主源 + 腾讯批量报价备用源(自动切换) - 历史K线:东财push2his主源 + 腾讯web子域备用源(含熔断机制) - 增量同步:支持 -incr/-since 参数按存档日期增量入库 - 策略回测:净值曲线/10指标/基准对比,含基本面过滤 - 用户认证:用户名密码 + 邮箱验证码双登录方式 - 前端:三Tab SPA(实时行情/策略回测/数据管理)
This commit is contained in:
339
internal/controller/api.go
Normal file
339
internal/controller/api.go
Normal 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()})
|
||||
}
|
||||
Reference in New Issue
Block a user