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

258
cmd/backtest/main.go Normal file
View File

@@ -0,0 +1,258 @@
// backtest 是可转债轮动策略回测命令。
//
// go run ./cmd/backtest [-db 库路径] [-from 2020-01-01] [-to 2025-12-31] \
// [-strategy lowprice|doublelow] [-topn 10] [-rebalance 21] \
// [-min-price 80] [-max-price 130] [-max-premium 30] [-cash 100000] [-out 曲线.csv]
//
// 从 SQLite 加载全量转债日K线,运行内置轮动策略(低价/双低),
// 输出绩效指标并与等权全转债基准对比。
package main
import (
"context"
"encoding/csv"
"flag"
"fmt"
"os"
"path/filepath"
"stock-analysis/internal/backtest"
"stock-analysis/internal/strategy"
"stock-analysis/internal/storage/sqlite"
)
func main() {
dbPath := flag.String("db", defaultDBPath(), "SQLite 数据库文件路径")
from := flag.String("from", "2020-01-01", "回测起始日(含)")
to := flag.String("to", "", "回测结束日(含,默认数据末日)")
topn := flag.Int("topn", 10, "持仓数量")
rebalance := flag.Int("rebalance", 21, "调仓周期(交易日)")
minPrice := flag.Float64("min-price", 80, "价格下限")
maxPrice := flag.Float64("max-price", 130, "价格上限")
strategyName := flag.String("strategy", "lowprice", "策略: lowprice(低价轮动)|doublelow(双低轮动)|premium(溢价率轮动)")
maxPremium := flag.Float64("max-premium", 30, "双低策略溢价率上限")
fundamental := flag.Bool("fundamental", false, "启用正股基本面过滤器(排除ST/高负债/亏损标的)")
cash := flag.Float64("cash", 100000, "初始资金(元)")
out := flag.String("out", "", "净值曲线 CSV 输出路径(可选)")
flag.Parse()
ctx := context.Background()
store, err := sqlite.Open(*dbPath)
if err != nil {
fatal("打开数据库失败: ", err)
}
defer store.Close()
feed, err := backtest.NewDataFeed(ctx, store)
if err != nil {
fatal("加载K线数据失败: ", err)
}
fmt.Printf("数据加载完成:%d 个交易日(%s ~ %s)\n\n",
len(feed.Dates()), feed.Dates()[0], feed.Dates()[len(feed.Dates())-1])
params := backtest.DefaultParams()
params.InitialCash = *cash
params.StartDate = *from
params.EndDate = *to
var strat backtest.Strategy
switch *strategyName {
case "lowprice":
strat = strategy.NewLowPrice(strategy.LowPriceParams{
TopN: *topn,
Rebalance: *rebalance,
MinPrice: *minPrice,
MaxPrice: *maxPrice,
})
case "doublelow":
strat = strategy.NewDoubleLow(strategy.DoubleLowParams{
TopN: *topn,
Rebalance: *rebalance,
MinPrice: *minPrice,
MaxPrice: *maxPrice,
MaxPremium: *maxPremium,
})
case "premium":
strat = strategy.NewPremium(strategy.PremiumParams{
TopN: *topn,
Rebalance: *rebalance,
MinPrice: *minPrice,
MaxPrice: *maxPrice,
})
default:
fatal("未知策略 ", fmt.Errorf("应为 lowprice、doublelow 或 premium"))
}
engine := backtest.NewEngine(params, strat)
if *fundamental {
// 加载正股基本面数据
fundamentals, err := store.LoadAllFundamentals(ctx)
if err != nil {
fatal("加载基本面数据失败: ", err)
}
// 获取正股-转债映射
stock2bond, err := store.GetStockBondMap(ctx)
if err != nil {
fatal("获取正股-转债映射失败: ", err)
}
filter := backtest.NewFundamentalFilter(stock2bond)
engine.SetFundamentalFilter(filter, fundamentals)
fmt.Printf("基本面过滤器已启用:%d 只正股数据, %d 条正股-转债映射\n", len(fundamentals), len(stock2bond))
}
res, err := engine.Run(ctx, feed)
if err != nil {
fatal("回测失败: ", err)
}
fmt.Printf("策略:%s(%s ~ %s)\n", res.Strategy, res.Start, res.End)
printMetrics("策略", res.Metrics)
bench := computeBenchmark(feed, *from, *to)
fmt.Println()
fmt.Printf("基准:等权全转债指数(%s ~ %s)\n", *from, *to)
printMetrics("基准", bench)
if *out != "" {
if err := writeCSV(*out, res, feed); err != nil {
fatal("写CSV失败: ", err)
}
fmt.Printf("\n净值曲线已输出到 %s\n", *out)
}
}
// computeBenchmark 计算等权全转债指数:每日对全部存续转债取等权平均日收益并复利。
func computeBenchmark(feed *backtest.DataFeed, from, to string) backtest.Metrics {
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 backtest.ComputeMetrics(curve)
}
// printMetrics 打印绩效指标表格。
func printMetrics(label string, m backtest.Metrics) {
fmt.Printf("%-14s 总收益 %9.2f%% 年化 %8.2f%% 最大回撤 %7.2f%% 夏普 %6.2f 波动率 %5.2f%% 胜率 %5.1f%%\n",
label,
m.TotalReturn*100, m.AnnualReturn*100, m.MaxDrawdown*100,
m.Sharpe, m.Volatility*100, m.WinRatio*100)
}
// writeCSV 输出策略与基准的净值曲线(含累计收益)到 CSV 文件。
func writeCSV(path string, res *backtest.Result, feed *backtest.DataFeed) error {
f, err := os.Create(path)
if err != nil {
return err
}
defer f.Close()
w := csv.NewWriter(f)
defer w.Flush()
if err := w.Write([]string{"date", "strategy_value", "strategy_return", "bench_value", "bench_return"}); err != nil {
return err
}
// 基准曲线独立重算,与策略日期对齐。
benchCurve := benchmarkCurve(feed, res.Start, res.End)
byDate := make(map[string]backtest.EquityPoint, len(benchCurve))
for _, p := range benchCurve {
byDate[p.Date] = p
}
for _, p := range res.Curve {
b, ok := byDate[p.Date]
bv, br := 1.0, 0.0
if ok {
bv, br = b.Value, b.Return
}
if err := w.Write([]string{
p.Date,
fmt.Sprintf("%.2f", p.Value),
fmt.Sprintf("%.6f", p.Return),
fmt.Sprintf("%.6f", bv),
fmt.Sprintf("%.6f", br),
}); err != nil {
return err
}
}
return nil
}
// benchmarkCurve 返回 from~to 区间的基准净值曲线。
func benchmarkCurve(feed *backtest.DataFeed, from, to string) []backtest.EquityPoint {
var (
idx float64 = 1
prev map[string]float64
curve []backtest.EquityPoint
hasPrev bool
)
for _, date := range feed.Dates() {
if date < from || (to != "" && date > to) {
continue
}
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 curve
}
// defaultDBPath 与 ingest 保持一致,返回默认数据库路径。
func defaultDBPath() string {
local := os.Getenv("LOCALAPPDATA")
if local == "" {
local = os.TempDir()
}
return filepath.Join(local, "stock-analysis", "stock.db")
}
// fatal 打印错误并退出。
func fatal(msg string, err error) {
fmt.Fprintln(os.Stderr, msg, err)
os.Exit(1)
}