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