Files
WarrenZhou e28bfec957 Initial commit: 可转债轮动分析系统
- 实时行情:东财push2主源 + 腾讯批量报价备用源(自动切换)
- 历史K线:东财push2his主源 + 腾讯web子域备用源(含熔断机制)
- 增量同步:支持 -incr/-since 参数按存档日期增量入库
- 策略回测:净值曲线/10指标/基准对比,含基本面过滤
- 用户认证:用户名密码 + 邮箱验证码双登录方式
- 前端:三Tab SPA(实时行情/策略回测/数据管理)
2026-08-17 20:23:22 +08:00

259 lines
7.2 KiB
Go
Raw Permalink 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.

// 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)
}