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

View File

@@ -0,0 +1,132 @@
package backtest
// Account 模拟账户:现金 + 持仓,负责调仓成交与逐日市值结算。
// 可转债交易规则:面值 100 元/张,1 手 = 10 张,买入须为整手;
// T+0、无印花税,仅计单边佣金与可选滑点。
type Account struct {
initialCash float64
cash float64
holdings map[string]float64 // code → 持有张数
commission float64
slippage float64
curve []EquityPoint
equity float64 // 最近一次结算的净值
}
// NewAccount 创建初始账户。
func NewAccount(p Params) *Account {
return &Account{
initialCash: p.InitialCash,
cash: p.InitialCash,
holdings: make(map[string]float64),
commission: p.Commission,
slippage: p.Slippage,
equity: p.InitialCash, // 首个交易日即可建仓
}
}
// priceFn 返回标的指定日期(或最近)价格。封装 History.CloseOn 签名。
type priceFn func(code, date string) (float64, bool)
// Rebalance 按目标权重调仓:先卖后买,张数向下取整到整手(10 张)。
// 已退市/当日停牌标的按最后已知价结算。
func (a *Account) Rebalance(date string, targets []Target, price priceFn) {
total := a.equity
if total <= 0 {
return
}
// 目标持仓权重表。
want := make(map[string]float64)
for _, t := range targets {
if t.Weight > 0 {
want[t.Code] = t.Weight
}
}
// 先卖出:超出目标权重的部分。
for code, qty := range a.holdings {
p, ok := price(code, date)
if !ok || p <= 0 {
continue // 无价格信息,暂不处理该持仓
}
targetQty := lots(total*want[code]/(p*10)) * 10
if targetQty < qty {
sell := qty - targetQty
proceeds := sell * p * (1 - a.slippage)
a.cash += proceeds - proceeds*a.commission
if targetQty == 0 {
delete(a.holdings, code)
} else {
a.holdings[code] = targetQty
}
}
}
// 再买入:未达目标权重的部分。
for code, weight := range want {
p, ok := price(code, date)
if !ok || p <= 0 {
continue
}
targetQty := lots(total*weight/(p*10)) * 10
cur := a.holdings[code]
if targetQty <= cur {
continue
}
buy := targetQty - cur
if avail := a.buyableLots(p); avail < buy/10 {
buy = avail * 10
}
if buy <= 0 {
continue
}
cost := buy * p * (1 + a.slippage)
a.cash -= cost + cost*a.commission
a.holdings[code] = cur + buy
}
}
// buyableLots 返回当前现金可买的手数(整手向下取整)。
func (a *Account) buyableLots(price float64) float64 {
unitCost := price * 10 * (1 + a.slippage) * (1 + a.commission)
if unitCost <= 0 {
return 0
}
return lots(a.cash / unitCost)
}
// lots 向下取整到整数手。
func lots(v float64) float64 {
return float64(int(v))
}
// MarkToMarket 按收盘价结算当日净值并追加到净值曲线。
func (a *Account) MarkToMarket(date string, price priceFn) {
value := a.cash
for code, qty := range a.holdings {
if p, ok := price(code, date); ok {
value += qty * p
}
}
a.equity = value
init := a.initialCash
if init <= 0 {
init = 1
}
a.curve = append(a.curve, EquityPoint{
Date: date,
Value: value,
Return: value/init - 1,
})
}
// Equity 返回最近一次结算的净值。
func (a *Account) Equity() float64 {
return a.equity
}
// Curve 返回累计净值曲线。
func (a *Account) Curve() []EquityPoint {
return a.curve
}

View File

@@ -0,0 +1,159 @@
package backtest
import (
"math"
"testing"
)
// mockPrice 返回一个简单的价格函数,所有标的固定价格。
func mockPrice(price float64) priceFn {
return func(code, date string) (float64, bool) {
return price, true
}
}
// mockPriceMap 返回按 code 返回不同价格的价格函数。
func mockPriceMap(prices map[string]float64) priceFn {
return func(code, date string) (float64, bool) {
p, ok := prices[code]
return p, ok
}
}
func TestNewAccount(t *testing.T) {
params := DefaultParams()
params.InitialCash = 200000
a := NewAccount(params)
if a.cash != 200000 {
t.Errorf("cash: expected 200000, got %f", a.cash)
}
if a.equity != 200000 {
t.Errorf("equity: expected 200000, got %f", a.equity)
}
if len(a.holdings) != 0 {
t.Errorf("holdings: expected empty, got %d", len(a.holdings))
}
}
func TestMarkToMarket_NoHoldings(t *testing.T) {
a := NewAccount(DefaultParams())
a.MarkToMarket("2020-01-02", mockPrice(100))
if a.equity != 100000 {
t.Errorf("equity: expected 100000, got %f", a.equity)
}
if len(a.curve) != 1 {
t.Errorf("curve: expected 1 point, got %d", len(a.curve))
}
if a.curve[0].Return != 0 {
t.Errorf("return: expected 0, got %f", a.curve[0].Return)
}
}
func TestMarkToMarket_WithHoldings(t *testing.T) {
a := NewAccount(DefaultParams())
// 手动设置持仓:100 张,每张 100 元
a.holdings["113001"] = 100
a.MarkToMarket("2020-01-02", mockPrice(100))
// equity = cash(100000) + 100*100 = 110000
expected := 100000.0 + 100*100
if math.Abs(a.equity-expected) > 0.001 {
t.Errorf("equity: expected %f, got %f", expected, a.equity)
}
// return = 110000/100000 - 1 = 0.1
if math.Abs(a.curve[0].Return-0.1) > 0.001 {
t.Errorf("return: expected 0.1, got %f", a.curve[0].Return)
}
}
func TestRebalance_BuyOnly(t *testing.T) {
params := Params{
InitialCash: 100000,
Commission: 0,
Slippage: 0,
}
a := NewAccount(params)
targets := []Target{
{Code: "113001", Weight: 0.5},
{Code: "113002", Weight: 0.5},
}
prices := map[string]float64{"113001": 100, "113002": 120}
a.Rebalance("2020-01-02", targets, mockPriceMap(prices))
// 总权益 100000
// 113001: 50000 / (100*10) = 50 手 = 500 张, cost = 500*100 = 50000
// 113002: 50000 / (120*10) = 41.67 → 41 手 = 410 张, cost = 410*120 = 49200
// 剩余现金 = 100000 - 50000 - 49200 = 800
if a.holdings["113001"] != 500 {
t.Errorf("113001 holdings: expected 500, got %f", a.holdings["113001"])
}
if a.holdings["113002"] != 410 {
t.Errorf("113002 holdings: expected 410, got %f", a.holdings["113002"])
}
if math.Abs(a.cash-800) > 0.01 {
t.Errorf("cash: expected 800, got %f", a.cash)
}
}
func TestRebalance_NoTargets(t *testing.T) {
a := NewAccount(DefaultParams())
a.holdings["113001"] = 100
a.Rebalance("2020-01-02", nil, mockPrice(100))
// 无目标 → 全部卖出
if a.holdings["113001"] != 0 {
t.Errorf("holdings should be sold, got %f", a.holdings["113001"])
}
}
func TestRebalance_SellOnly(t *testing.T) {
params := Params{
InitialCash: 100000,
Commission: 0,
Slippage: 0,
}
a := NewAccount(params)
a.holdings["113001"] = 500
// 目标:只持有 113002,权重 1.0
targets := []Target{{Code: "113002", Weight: 1.0}}
prices := map[string]float64{"113001": 100, "113002": 100}
a.Rebalance("2020-01-02", targets, mockPriceMap(prices))
// 113001 应被全部卖出
if _, ok := a.holdings["113001"]; ok {
t.Errorf("113001 should be sold")
}
// 113002 应被买入
if a.holdings["113002"] <= 0 {
t.Errorf("113002 should be bought")
}
}
func TestLots(t *testing.T) {
if lots(5.7) != 5 {
t.Errorf("lots(5.7): expected 5, got %f", lots(5.7))
}
if lots(10.0) != 10 {
t.Errorf("lots(10.0): expected 10, got %f", lots(10.0))
}
if lots(0.3) != 0 {
t.Errorf("lots(0.3): expected 0, got %f", lots(0.3))
}
}
func TestEquity_InitialValue(t *testing.T) {
a := NewAccount(DefaultParams())
if a.Equity() != 100000 {
t.Errorf("initial equity: expected 100000, got %f", a.Equity())
}
}
func TestCurve_Accumulation(t *testing.T) {
a := NewAccount(DefaultParams())
a.MarkToMarket("2020-01-02", mockPrice(100))
a.MarkToMarket("2020-01-03", mockPrice(100))
c := a.Curve()
if len(c) != 2 {
t.Errorf("curve length: expected 2, got %d", len(c))
}
}

186
internal/backtest/engine.go Normal file
View File

@@ -0,0 +1,186 @@
package backtest
import (
"context"
"fmt"
"sort"
"stock-analysis/internal/model"
"stock-analysis/internal/storage"
)
// Params 回测运行参数。
type Params struct {
InitialCash float64 // 初始资金(元)
Commission float64 // 单边佣金比例(含佣金,默认 0.00025)
Slippage float64 // 滑点比例(默认 0)
StartDate string // 回测起始日(含),空则使用数据首日
EndDate string // 回测结束日(含),空则使用数据末日
}
// DefaultParams 返回推荐默认参数。
func DefaultParams() Params {
return Params{
InitialCash: 100000,
Commission: 0.00025,
}
}
// DataFeed 从存储加载全量K线,构建按日期索引的行情数据。
type DataFeed struct {
quotes map[string][]Quote // date → 当日全部转债行情
dates []string // 升序交易日列表
hist *History
}
// NewDataFeed 从存储加载全部转债K线并构建回测数据源。
func NewDataFeed(ctx context.Context, store storage.KlineStore) (*DataFeed, error) {
all, err := store.LoadAllKlines(ctx)
if err != nil {
return nil, fmt.Errorf("load klines: %w", err)
}
quotes := make(map[string][]Quote)
for code, ks := range all {
for _, k := range ks {
quotes[k.Date] = append(quotes[k.Date], Quote{
Code: code,
Price: k.Close,
Open: k.Open,
High: k.High,
Low: k.Low,
Volume: k.Volume,
ConvertValue: k.ConvertValue,
PremiumRate: k.PremiumRate,
})
}
}
dates := make([]string, 0, len(quotes))
for d := range quotes {
dates = append(dates, d)
}
sort.Strings(dates)
return &DataFeed{quotes: quotes, dates: dates, hist: &History{klines: all}}, nil
}
// Dates 返回升序交易日列表。
func (d *DataFeed) Dates() []string {
return d.dates
}
// QuotesOn 返回指定交易日的全部转债行情。
func (d *DataFeed) QuotesOn(date string) []Quote {
return d.quotes[date]
}
// History 返回全量历史数据访问器。
func (d *DataFeed) History() *History {
return d.hist
}
// Filter 只保留指定代码的数据,用于按用户选择的转债子集回测。
func (d *DataFeed) Filter(codes []string) {
keep := make(map[string]bool, len(codes))
for _, c := range codes {
keep[c] = true
}
// 过滤 quotes。
for date, qs := range d.quotes {
var filtered []Quote
for _, q := range qs {
if keep[q.Code] {
filtered = append(filtered, q)
}
}
d.quotes[date] = filtered
}
// 过滤 history.klines。
filteredKlines := make(map[string][]model.Kline)
for code, ks := range d.hist.klines {
if keep[code] {
filteredKlines[code] = ks
}
}
d.hist.klines = filteredKlines
}
// Engine 事件驱动回测引擎。
type Engine struct {
params Params
strategy Strategy
account *Account
filter *FundamentalFilter
fundMap map[string]*model.StockFundamental
}
// NewEngine 创建回测引擎。
func NewEngine(params Params, strategy Strategy) *Engine {
return &Engine{params: params, strategy: strategy}
}
// SetFundamentalFilter 设置基本面过滤器(可选)。
// stock2bond 为正股代码到转债代码的映射;fundamentals 为正股代码到基本面数据的映射。
func (e *Engine) SetFundamentalFilter(filter *FundamentalFilter, fundamentals map[string]*model.StockFundamental) {
e.filter = filter
e.fundMap = fundamentals
}
// Run 执行回测,返回结果(含净值曲线与绩效指标)。
func (e *Engine) Run(ctx context.Context, feed *DataFeed) (*Result, error) {
e.account = NewAccount(e.params)
for _, date := range feed.dates {
if e.params.StartDate != "" && date < e.params.StartDate {
continue
}
if e.params.EndDate != "" && date > e.params.EndDate {
break
}
universe := feed.quotes[date]
if e.filter != nil {
universe = e.filter.Apply(ctx, universe, e.fundMap)
}
targets, err := e.strategy.OnBar(ctx, date, universe, feed.hist)
if err != nil {
return nil, fmt.Errorf("strategy %s on %s: %w", e.strategy.Name(), date, err)
}
if targets != nil {
e.account.Rebalance(date, targets, feed.hist.CloseOn)
}
e.account.MarkToMarket(date, feed.hist.CloseOn)
}
curve := e.account.Curve()
start, end := "", ""
if len(curve) > 0 {
start, end = curve[0].Date, curve[len(curve)-1].Date
}
return &Result{
Strategy: e.strategy.Name(),
Start: start,
End: end,
Metrics: ComputeMetrics(curve),
Curve: curve,
}, nil
}
// Result 回测输出。
type Result struct {
Strategy string
Start string
End string
Metrics Metrics
Curve []EquityPoint
}
// EquityPoint 净值曲线点。
type EquityPoint struct {
Date string
Value float64 // 组合总市值
Return float64 // 累计收益率
}

View File

@@ -0,0 +1,83 @@
package backtest
import (
"context"
"stock-analysis/internal/model"
)
// FundamentalFilter 正股基本面过滤器,用于排除信用风险过高的转债。
// 过滤规则:
// - ST / *ST 正股对应的转债一律排除
// - 资产负债率超过阈值的转债排除
// - 净利润为负(连续亏损)的转债排除
type FundamentalFilter struct {
MaxDebtRatio float64 // 资产负债率上限(%),默认 70
stock2bond map[string]string
enabled bool
}
// NewFundamentalFilter 创建基本面过滤器。
// stock2bond 为正股代码到转债代码的映射(一只正股可能对应多只转债,取任意一只即可)。
func NewFundamentalFilter(stock2bond map[string]string) *FundamentalFilter {
return &FundamentalFilter{
MaxDebtRatio: 70,
stock2bond: stock2bond,
enabled: true,
}
}
// SetEnabled 启用/禁用过滤器。
func (f *FundamentalFilter) SetEnabled(v bool) { f.enabled = v }
// Apply 给定当日 universe 行情和基本面数据,返回过滤后的行情列表。
// fundamentals 的 key 为正股代码。
func (f *FundamentalFilter) Apply(ctx context.Context, universe []Quote, fundamentals map[string]*model.StockFundamental) []Quote {
if !f.enabled || len(fundamentals) == 0 {
return universe
}
// 构建转债代码到正股代码的映射(反向查找)。
bond2stock := make(map[string]string, len(f.stock2bond))
for stock, bond := range f.stock2bond {
bond2stock[bond] = stock
}
filtered := make([]Quote, 0, len(universe))
for _, q := range universe {
stockCode, ok := bond2stock[q.Code]
if !ok {
// 无正股映射,允许通过(不因数据缺失误杀)。
filtered = append(filtered, q)
continue
}
fund, ok := fundamentals[stockCode]
if !ok {
// 无基本面数据,允许通过。
filtered = append(filtered, q)
continue
}
if f.isBlocked(fund) {
continue
}
filtered = append(filtered, q)
}
return filtered
}
// isBlocked 判断正股基本面是否触发过滤条件。
func (f *FundamentalFilter) isBlocked(fund *model.StockFundamental) bool {
// ST / *ST 一律排除。
if fund.Status >= 2 {
return true
}
// 资产负债率超过阈值。
if f.MaxDebtRatio > 0 && fund.DebtRatio > f.MaxDebtRatio {
return true
}
// 净利润为负(连续亏损)。
if fund.NetProfit < 0 {
return true
}
return false
}

View File

@@ -0,0 +1,85 @@
package backtest
import "math"
// Metrics 回测绩效指标。
type Metrics struct {
TotalReturn float64 // 累计收益率
AnnualReturn float64 // 年化收益率
MaxDrawdown float64 // 最大回撤(正值)
Sharpe float64 // 年化夏普比率(无风险利率为 0)
Volatility float64 // 年化波动率
WinRatio float64 // 正收益交易日占比
TradeDays int // 回测交易日数
}
const tradingDaysPerYear = 252.0
// ComputeMetrics 从净值曲线计算绩效指标。
func ComputeMetrics(curve []EquityPoint) Metrics {
m := Metrics{TradeDays: len(curve)}
if len(curve) == 0 {
return m
}
m.TotalReturn = curve[len(curve)-1].Return
// 日收益率序列。
rets := make([]float64, 0, len(curve)-1)
for i := 1; i < len(curve); i++ {
if curve[i-1].Value > 0 {
rets = append(rets, curve[i].Value/curve[i-1].Value-1)
}
}
// 最大回撤:遍历峰值。
var peak, maxDD float64
for _, p := range curve {
if p.Value > peak {
peak = p.Value
}
if dd := peak - p.Value; dd > maxDD {
maxDD = dd
}
}
if peak > 0 {
m.MaxDrawdown = maxDD / peak
}
n := len(rets)
if n == 0 {
return m
}
// 年化收益率。
years := float64(n) / tradingDaysPerYear
if years > 0 && 1+m.TotalReturn > 0 {
m.AnnualReturn = math.Pow(1+m.TotalReturn, 1/years) - 1
}
// 波动率与夏普。
mean := 0.0
for _, r := range rets {
mean += r
}
mean /= float64(n)
var variance float64
for _, r := range rets {
variance += (r - mean) * (r - mean)
}
variance /= float64(n - 1)
m.Volatility = math.Sqrt(variance) * math.Sqrt(tradingDaysPerYear)
if m.Volatility > 0 {
m.Sharpe = mean * tradingDaysPerYear / m.Volatility
}
// 正收益日占比。
win := 0
for _, r := range rets {
if r > 0 {
win++
}
}
m.WinRatio = float64(win) / float64(n)
return m
}

View File

@@ -0,0 +1,98 @@
package backtest
import (
"math"
"testing"
)
func TestComputeMetrics_Empty(t *testing.T) {
m := ComputeMetrics(nil)
if m.TradeDays != 0 {
t.Errorf("expected 0 trade days, got %d", m.TradeDays)
}
}
func TestComputeMetrics_SinglePoint(t *testing.T) {
curve := []EquityPoint{{Date: "2020-01-02", Value: 100000, Return: 0}}
m := ComputeMetrics(curve)
if m.TotalReturn != 0 {
t.Errorf("expected 0 total return, got %f", m.TotalReturn)
}
}
func TestComputeMetrics_SteadyGrowth(t *testing.T) {
// 10 个交易日,每天涨 1%
curve := make([]EquityPoint, 11)
curve[0] = EquityPoint{Date: "2020-01-02", Value: 100000, Return: 0}
for i := 1; i <= 10; i++ {
val := curve[i-1].Value * 1.01
curve[i] = EquityPoint{
Date: "2020-01-02",
Value: val,
Return: val/100000 - 1,
}
}
m := ComputeMetrics(curve)
expectedReturn := 100000*math.Pow(1.01, 10)/100000 - 1
if math.Abs(m.TotalReturn-expectedReturn) > 0.0001 {
t.Errorf("total return: expected %f, got %f", expectedReturn, m.TotalReturn)
}
if m.WinRatio != 1.0 {
t.Errorf("win ratio: expected 1.0, got %f", m.WinRatio)
}
if m.MaxDrawdown > 0.0001 {
t.Errorf("max drawdown: expected 0, got %f", m.MaxDrawdown)
}
if m.TradeDays != 11 {
t.Errorf("trade days: expected 11, got %d", m.TradeDays)
}
}
func TestComputeMetrics_Drawdown(t *testing.T) {
// 先涨后跌:100000 → 110000 → 99000
curve := []EquityPoint{
{Date: "2020-01-02", Value: 100000, Return: 0},
{Date: "2020-01-03", Value: 110000, Return: 0.1},
{Date: "2020-01-06", Value: 99000, Return: -0.01},
}
m := ComputeMetrics(curve)
if m.TotalReturn != -0.01 {
t.Errorf("total return: expected -0.01, got %f", m.TotalReturn)
}
// 峰值 110000, 谷 99000, 回撤 = (110000-99000)/110000 = 0.1
expectedDD := 0.1
if math.Abs(m.MaxDrawdown-expectedDD) > 0.0001 {
t.Errorf("max drawdown: expected %f, got %f", expectedDD, m.MaxDrawdown)
}
}
func TestComputeMetrics_MixedPerformance(t *testing.T) {
// 模拟实际场景:混合涨跌
curve := []EquityPoint{
{Date: "2020-01-02", Value: 100000, Return: 0},
{Date: "2020-01-03", Value: 101000, Return: 0.01}, // +1%
{Date: "2020-01-06", Value: 100500, Return: 0.005}, // -0.5%
{Date: "2020-01-07", Value: 102000, Return: 0.02}, // +1.5%
{Date: "2020-01-08", Value: 101500, Return: 0.015}, // -0.5%
{Date: "2020-01-09", Value: 103000, Return: 0.03}, // +1.5%
}
m := ComputeMetrics(curve)
if m.TotalReturn != 0.03 {
t.Errorf("total return: expected 0.03, got %f", m.TotalReturn)
}
if m.TradeDays != 6 {
t.Errorf("trade days: expected 6, got %d", m.TradeDays)
}
// 3 个正收益日 / 5 个交易日
if math.Abs(m.WinRatio-0.6) > 0.0001 {
t.Errorf("win ratio: expected 0.6, got %f", m.WinRatio)
}
// 年化收益应 > 0
if m.AnnualReturn <= 0 {
t.Errorf("annual return should be positive, got %f", m.AnnualReturn)
}
// 波动率应 > 0
if m.Volatility <= 0 {
t.Errorf("volatility should be positive, got %f", m.Volatility)
}
}

View File

@@ -0,0 +1,60 @@
// Package backtest 提供可转债量化回测引擎(MVC Service 层)。
// 采用事件驱动模式:按交易日推进,策略在任意交易日返回目标持仓,
// 引擎以当日收盘价成交并逐日记录组合净值。
package backtest
import (
"context"
"sort"
"stock-analysis/internal/model"
)
// Strategy 定义可插拔的回测策略接口。
// 用户实现该接口即可自定义轮动/网格等策略,无需关心成交与持仓细节。
type Strategy interface {
// Name 返回策略名称(用于结果标识)。
Name() string
// OnBar 每个交易日调用。返回 nil 表示当日不调仓;
// 返回目标持仓时,引擎按各标的权重以当日收盘价调仓。
OnBar(ctx context.Context, date string, universe []Quote, hist *History) ([]Target, error)
}
// Quote 某转债在某一交易日的基本行情快照。
// 溢价率数据来自基本面入库(cmd/fundamentals 步骤3),未计算时为 0。
type Quote struct {
Code string
Price float64 // 收盘价
Open float64
High float64
Low float64
Volume int64
ConvertValue float64 // 转股价值(元)
PremiumRate float64 // 转股溢价率(%)
}
// Target 目标持仓:转债代码 → 组合权重(0~1,各权重之和应为 1)。
type Target struct {
Code string
Weight float64
}
// History 提供全量历史K线查询,策略可访问任意代码任意日期的历史行情。
type History struct {
klines map[string][]model.Kline // code → 按日期升序的K线
}
// CloseOn 返回 code 在 date 当日(或之前最近交易日)的收盘价。
// 用于处理调仓时个别持仓当日停牌/退市等无报价的情况。
func (h *History) CloseOn(code, date string) (float64, bool) {
ks, ok := h.klines[code]
if !ok {
return 0, false
}
// 二分查找最近一根 <= date 的K线。
i := sort.Search(len(ks), func(i int) bool { return ks[i].Date > date })
if i == 0 {
return 0, false
}
return ks[i-1].Close, true
}