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