Browse Source

backtest

main
strange 10 months ago
parent
commit
e5d051e446
  1. 48
      internal/exchange/exchange_service.go
  2. 143
      internal/trading/backtest/account.go
  3. 141
      internal/trading/backtest/backtest.go
  4. 189
      internal/trading/backtest/close_manager.go
  5. 78
      internal/trading/backtest/context.go
  6. 58
      internal/trading/backtest/simulator.go
  7. 33
      internal/trading/backtest/types.go
  8. 1
      pkg/strategy/sig_strategy.go

48
internal/exchange/exchange_service.go

@ -768,6 +768,54 @@ func (svc *ExchangeService) HistoryKline(ctx context.Context, arg *pb.SeriesRang
return
}
// 时间分段查询: 升降序, live, open
branch := int64(10)
before, after := beforeTs, afterTs
if arg.Desc {
before = intervalAdder(after, -branch)
if before > afterTs { // int64 越界
before = beforeTs
}
before = max(before, beforeTs)
} else {
after = intervalAdder(before, branch)
if after < beforeTs { // int64 越界
after = afterTs
}
after = min(after, afterTs)
}
for {
klines, err = svc.exchangeDataPersist.ListKline(*exchangeInst.Inst, interval, before, after)
if err != nil {
zlog.Error("list vmtsdb kline error: ", err)
return
}
if len(klines) == 0 {
break
}
if arg.Desc {
after = intervalAdder(klines[0].Ts, -1)
before = max(intervalAdder(after, -branch), beforeTs)
if after < KlineBefore0 || before > after {
break
}
} else {
before = intervalAdder(klines[0].Ts, 1)
after = min(intervalAdder(before, 1000), afterTs)
if before > time.Now().UnixMilli() || before > after {
break
}
}
if arg.Desc {
collect.Reverse(klines)
}
tss := collect.Mapping(klines, func(_ int, k *types.Kline) int64 { return k.Ts })
zlog.Info(tss)
}
klines, err = svc.exchangeDataPersist.ListKline(*exchangeInst.Inst, interval, beforeTs, afterTs)
if err != nil {
zlog.Error("list vmtsdb kline error: ", err)

143
internal/trading/backtest/account.go

@ -0,0 +1,143 @@
package backtest
import (
"math"
"sig-pub/api/pb"
"sig-pub/pkg/types"
"sig-pub/pkg/types/decimals"
)
type Account struct {
Cash float64
Positions []*Position
Trades []Trade
MaxPosPct float64 // 最大仓位占比
MaxExposurePct float64 // 最大总敞口占比
MaxLots float64 // 最大手数/数量 (optional)
Simulator *Simulator
}
func NewAccount(cash float64, sim *Simulator) *Account {
return &Account{Cash: cash, Simulator: sim, MaxPosPct: 1.0, MaxExposurePct: 1.0}
}
func (a *Account) SetRiskLimits(maxPosPct, maxExposurePct float64) {
if maxPosPct > 0 {
a.MaxPosPct = maxPosPct
}
if maxExposurePct > 0 {
a.MaxExposurePct = maxExposurePct
}
}
// CurrentEquity 根据当前价格对仓位进行 mark-to-market,返回账户净值
func (a *Account) CurrentEquity(price float64) float64 {
equity := a.Cash
for _, p := range a.Positions {
if p.Side == pb.Side_BUY {
equity += (price - p.EntryPx) * p.Qty
} else if p.Side == pb.Side_SELL {
equity += (p.EntryPx - price) * p.Qty
}
}
return equity
}
// CurrentExposure 返回当前仓位的名义总敞口(绝对值)
func (a *Account) CurrentExposure(price float64) float64 {
var sum float64
for _, p := range a.Positions {
sum += math.Abs(p.Qty * price)
}
return sum
}
// CanOpen 判断在给定价格下是否可以开仓(基于 MaxPosPct 和 MaxExposurePct)
func (a *Account) CanOpen(side pb.Side, qty, price float64) bool {
if qty <= 0 || price <= 0 {
return false
}
equity := a.CurrentEquity(price)
if equity <= 0 {
return false
}
notional := math.Abs(qty * price)
// 单仓位限制
if a.MaxPosPct > 0 {
if notional > a.MaxPosPct*equity {
return false
}
}
// 总敞口限制
if a.MaxExposurePct > 0 {
if a.CurrentExposure(price)+notional > a.MaxExposurePct*equity {
return false
}
}
return true
}
// ApplyMarketOrder 直接用市价下单(简化),qty为基础货币数量
func (a *Account) ApplyMarketOrder(side pb.Side, qty float64, klineTs int64, kline types.Kline) (t Trade, ok bool) {
// risk check before executing
price := decimals.MustToFloat64(kline.Close)
if !a.CanOpen(side, qty, price) {
return t, false
}
trade := a.Simulator.ExecuteMarket(side, qty, kline, klineTs)
// apply cash/position
if side == pb.Side_BUY {
cost := trade.Price*qty + trade.Fee
if cost > a.Cash {
return trade, false
}
a.Cash -= cost
// open/add position
pos := &Position{Side: side, Qty: qty, EntryPx: trade.Price, EntryTs: klineTs, PeakPx: trade.Price}
a.Positions = append(a.Positions, pos)
} else if side == pb.Side_SELL {
// simplify: allow short by increasing cash
receive := trade.Price*qty - trade.Fee
a.Cash += receive
pos := &Position{Side: side, Qty: qty, EntryPx: trade.Price, EntryTs: klineTs, PeakPx: trade.Price}
a.Positions = append(a.Positions, pos)
}
a.Trades = append(a.Trades, trade)
return trade, true
}
// ClosePosition 根据索引平仓(全部平仓该仓位)
func (a *Account) ClosePosition(index int, kline types.Kline, ts int64, cause string) (t Trade, ok bool) {
if index < 0 || index >= len(a.Positions) {
return t, false
}
pos := a.Positions[index]
if pos == nil {
return t, false
}
// determine close side (opposite)
var closeSide pb.Side
if pos.Side == pb.Side_BUY {
closeSide = pb.Side_SELL
} else {
closeSide = pb.Side_BUY
}
t = a.Simulator.ExecuteMarket(closeSide, pos.Qty, kline, ts)
t.Cause = cause
// apply cash change
if closeSide == pb.Side_SELL {
// selling a long position -> receive cash
receive := t.Price*pos.Qty - t.Fee
a.Cash += receive
} else {
// buying to close a short -> pay cash
cost := t.Price*pos.Qty + t.Fee
a.Cash -= cost
}
// remove position
a.Positions = append(a.Positions[:index], a.Positions[index+1:]...)
a.Trades = append(a.Trades, t)
return t, true
}

141
internal/trading/backtest/backtest.go

@ -1,21 +1,138 @@
package backtest
import (
"sig-pub/pkg/data/entity"
"context"
"io"
"sig-pub/api/pb"
"sig-pub/pkg/indicator"
"sig-pub/pkg/strategy"
"sig-pub/pkg/types"
"sig-pub/pkg/types/decimals"
"sig-pub/pkg/zlog"
"google.golang.org/grpc"
)
// 回测引擎
// sig strategy
// trade strategy
// close strategy
// 历史k线加载
type BacktestEngine struct {
start, end int64
plan entity.TradePlan // 交易计划
type Backtest struct {
exchangeClient pb.ExchangeServiceClient
indReg *indicator.IndicatorRegistry
sim *Simulator
}
func NewBacktest(exchangeClient pb.ExchangeServiceClient, indReg *indicator.IndicatorRegistry, sim *Simulator) *Backtest {
return &Backtest{exchangeClient: exchangeClient, indReg: indReg, sim: sim}
}
// 多周期策略回测引擎
type MultiIntervalBacktraceEngine struct {
strategy strategy.IIntervalSigStrategy
// Run 执行回测
// seriesRange: 回测的交易产品/周期/时间区间
// sigStrategy: 已创建的策略实例(将调用 New() 并 Init)
// params: 策略参数
func (b *Backtest) Run(ctx context.Context, seriesRange *pb.SeriesRange, sigStrategy strategy.ISigStrategy, params strategy.SigStrategyParam, initialCash float64) (res BacktestResult, err error) {
// prepare strategy
strat := sigStrategy.New()
if err = strat.Init(params); err != nil {
return
}
// fetch history klines via stream
req := &pb.ReqHistoryKlineStream{Series: seriesRange}
stream, err := b.exchangeClient.HistoryKlineStream(context.Background(), req, grpc.UseCompressor("snappy"))
if err != nil {
return
}
var klines []types.Kline
var firstTs, lastTs int64
for {
msg, err0 := stream.Recv()
if err0 == io.EOF {
break
}
if err0 != nil {
err = err0
return
}
for _, k := range msg.Klines {
kk := new(types.Kline)
kk.ParsePBKline(seriesRange.Exchange, k)
klines = append(klines, *kk)
if firstTs == 0 {
firstTs = kk.Ts
}
lastTs = kk.Ts
}
}
// prepare account and set risk limits from params if provided
acct := NewAccount(initialCash, b.sim)
if v, ok := params.GetFloat64("max_pos_pct"); ok && v > 0 {
acct.MaxPosPct = v
}
if v, ok := params.GetFloat64("max_exposure_pct"); ok && v > 0 {
acct.MaxExposurePct = v
}
// prepare close manager from params
var cm *CloseManager
if sl, ok := params.GetFloat64("stoploss_pct"); ok || true {
tp, _ := params.GetFloat64("takeprofit_pct")
cm = NewCloseManager(sl, tp)
}
// iterate klines in chronological order
for i := 0; i < len(klines); i++ {
// build context with klines up to i
window := klines[:i+1]
ctxSig := NewSigStrategyContext(window, b.indReg)
// first, evaluate stoploss/takeprofit on this kline
if cm != nil {
cm.OnKline(klines[i], acct)
}
side := strat.Update(ctxSig)
// simple position sizing: param 'size' as fraction of cash; else use fixed qty 1
sizePct, ok := params.GetFloat64("size")
var qty float64
if ok && sizePct > 0 {
price := decimals.MustToFloat64(klines[i].Close)
qty = (acct.Cash * sizePct) / price
} else {
qty = 1
}
if side == pb.Side_BUY || side == pb.Side_SELL {
// signal-based close: close opposite positions first
if cm != nil {
cm.CloseBySignal(side, acct, klines[i])
}
if _, ok := acct.ApplyMarketOrder(side, qty, klines[i].Ts, klines[i]); ok {
// trade recorded
} else {
zlog.Debugf("order rejected or insufficient cash at ts=%d", klines[i].Ts)
}
}
}
// build result
res.StartTs = firstTs
res.EndTs = lastTs
res.Trades = acct.Trades
for _, p := range acct.Positions {
res.Positions = append(res.Positions, *p)
}
res.Cash = acct.Cash
// estimate equity using last close price
if len(klines) > 0 {
last := decimals.MustToFloat64(klines[len(klines)-1].Close)
equity := acct.Cash
// naive mark-to-market of positions
for _, p := range acct.Positions {
if p.Side == pb.Side_BUY {
equity += (last - p.EntryPx) * p.Qty
} else {
equity += (p.EntryPx - last) * p.Qty
}
}
res.Equity = equity
}
return
}

189
internal/trading/backtest/close_manager.go

@ -0,0 +1,189 @@
package backtest
import (
"sig-pub/api/pb"
"sig-pub/pkg/types"
"sig-pub/pkg/types/decimals"
)
// CloseManager 管理持仓平仓逻辑:stoploss/takeprofit 与 基于信号的平仓
type CloseManager struct {
StopLossPct float64 // static stoploss
TakeProfitPct float64 // static take profit
TrailingPct float64 // trailing stop percent (e.g. 0.02 = 2%);移动止损百分比(例如 0.02 表示从最高价回撤 2% 时触发追踪止损)。
MinProfitToTrail float64 // minimum profit (fraction) before trailing activates (e.g. 0.01 = 1%);启动移动止损的最小盈利阈值(例如达到 1% 后才开始追踪)。
ProfitRetracePct float64 // close when profit retraces more than this fraction of peak profit;基于最高利润回撤触发平仓(例如从最高利润回撤超过 30% 则平仓)。
}
func NewCloseManager(stopLossPct, takeProfitPct float64) *CloseManager {
return &CloseManager{StopLossPct: stopLossPct, TakeProfitPct: takeProfitPct}
}
func (m *CloseManager) SetDynamicParams(trailingPct, minProfitToTrail, profitRetracePct float64) {
m.TrailingPct = trailingPct
m.MinProfitToTrail = minProfitToTrail
m.ProfitRetracePct = profitRetracePct
}
// OnKline 根据最新 kline 检查是否触发 stoploss 或 takeprofit,触发则平仓(市价)
// 返回发生的平仓成交记录
func (m *CloseManager) OnKline(k types.Kline, acct *Account) (trades []Trade) {
if acct == nil {
return
}
if (m.StopLossPct <= 0) && (m.TakeProfitPct <= 0) {
return
}
// collect indices to close to avoid modifying slice during iteration
type closeTask struct {
idx int
cause string
}
var toClose []closeTask
priceHigh := decimals.MustToFloat64(k.High)
priceLow := decimals.MustToFloat64(k.Low)
for i, p := range acct.Positions {
if p == nil {
continue
}
entry := p.EntryPx
// update peak px
high := decimals.MustToFloat64(k.High)
low := decimals.MustToFloat64(k.Low)
if p.Side == pb.Side_BUY {
if high > p.PeakPx {
p.PeakPx = high
}
} else if p.Side == pb.Side_SELL {
if low < p.PeakPx {
p.PeakPx = low
}
}
if p.Side == pb.Side_BUY {
// stoploss
if m.StopLossPct > 0 && priceLow <= entry*(1-m.StopLossPct) {
toClose = append(toClose, closeTask{idx: i, cause: "stoploss"})
continue
}
// takeprofit
if m.TakeProfitPct > 0 && priceHigh >= entry*(1+m.TakeProfitPct) {
toClose = append(toClose, closeTask{idx: i, cause: "takeprofit"})
continue
}
// dynamic trailing stop based on peak price
if m.TrailingPct > 0 && m.MinProfitToTrail > 0 {
// peak profit fraction
peakProfit := (p.PeakPx - entry) / entry
if peakProfit >= m.MinProfitToTrail {
// trailing level
trailLevel := p.PeakPx * (1 - m.TrailingPct)
if priceLow <= trailLevel {
toClose = append(toClose, closeTask{idx: i, cause: "trailing"})
continue
}
}
}
// profit retrace rule: if peakProfit>0 and current retrace > ProfitRetracePct
if m.ProfitRetracePct > 0 {
peakProfit := (p.PeakPx - entry) / entry
curProfit := (priceHigh - entry) / entry
if peakProfit > 0 {
retrace := (peakProfit - curProfit) / peakProfit
if retrace >= m.ProfitRetracePct {
toClose = append(toClose, closeTask{idx: i, cause: "retrace"})
continue
}
}
}
} else if p.Side == pb.Side_SELL {
// short: stoploss if high >= entry*(1+stop), takeprofit if low <= entry*(1-tp)
if m.StopLossPct > 0 && priceHigh >= entry*(1+m.StopLossPct) {
toClose = append(toClose, closeTask{idx: i, cause: "stoploss"})
continue
}
if m.TakeProfitPct > 0 && priceLow <= entry*(1-m.TakeProfitPct) {
toClose = append(toClose, closeTask{idx: i, cause: "takeprofit"})
continue
}
// update trailing for short based on PeakPx (lower is better for short)
if m.TrailingPct > 0 && m.MinProfitToTrail > 0 {
peakProfit := (entry - p.PeakPx) / entry
if peakProfit >= m.MinProfitToTrail {
trailLevel := p.PeakPx * (1 + m.TrailingPct)
if priceHigh >= trailLevel {
toClose = append(toClose, closeTask{idx: i, cause: "trailing"})
continue
}
}
}
if m.ProfitRetracePct > 0 {
peakProfit := (entry - p.PeakPx) / entry
curProfit := (entry - priceLow) / entry
if peakProfit > 0 {
retrace := (peakProfit - curProfit) / peakProfit
if retrace >= m.ProfitRetracePct {
toClose = append(toClose, closeTask{idx: i, cause: "retrace"})
continue
}
}
}
}
}
// close collected positions (process from high index to low to safely remove)
for j := len(toClose) - 1; j >= 0; j-- {
idx := toClose[j].idx
cause := toClose[j].cause
if idx < 0 || idx >= len(acct.Positions) {
continue
}
// perform market close: side opposite
pos := acct.Positions[idx]
var closeSide pb.Side
if pos.Side == pb.Side_BUY {
closeSide = pb.Side_SELL
} else {
closeSide = pb.Side_BUY
}
tr, ok := acct.ClosePosition(idx, k, k.Ts, cause)
if ok {
trades = append(trades, tr)
}
_ = closeSide // closeSide kept for clarity if we later need it
}
return
}
// CloseBySignal 根据策略信号尝试平掉相反方向的仓位。例如策略返回 SELL 时,尝试平掉所有 BUY 持仓
func (m *CloseManager) CloseBySignal(sigSide pb.Side, acct *Account, k types.Kline) (trades []Trade) {
if acct == nil {
return
}
// determine which positions to close: positions with opposite side to sigSide
type closeTask struct {
idx int
cause string
}
var toClose []closeTask
for i, p := range acct.Positions {
if p == nil {
continue
}
if sigSide == pb.Side_BUY && p.Side == pb.Side_SELL {
toClose = append(toClose, closeTask{idx: i, cause: "signal"})
} else if sigSide == pb.Side_SELL && p.Side == pb.Side_BUY {
toClose = append(toClose, closeTask{idx: i, cause: "signal"})
}
}
for j := len(toClose) - 1; j >= 0; j-- {
idx := toClose[j].idx
cause := toClose[j].cause
tr, ok := acct.ClosePosition(idx, k, k.Ts, cause)
if ok {
trades = append(trades, tr)
}
}
return
}

78
internal/trading/backtest/context.go

@ -0,0 +1,78 @@
package backtest
import (
"sig-pub/pkg/indicator"
"sig-pub/pkg/types"
"sig-pub/pkg/types/series"
)
// SigStrategyContext 是一个轻量的策略上下文,用于回测时把历史k线提供给策略
type SigStrategyContext struct {
klines []types.Kline // 时间升序: oldest ... newest
offset int16 // offset applied when indicators request
indReg *indicator.IndicatorRegistry
}
func NewSigStrategyContext(klines []types.Kline, indReg *indicator.IndicatorRegistry) *SigStrategyContext {
return &SigStrategyContext{klines: klines, indReg: indReg}
}
func (c *SigStrategyContext) Get(offset int16) (k types.Kline) {
// offset relative to current (0 = latest)
idx := len(c.klines) - 1 - int(offset+c.offset)
if idx < 0 {
// return zero kline if out of range
return types.Kline{}
}
return c.klines[idx]
}
func (c *SigStrategyContext) Series(offset, count int16) (klines series.Klines) {
// return slice in descending time order as expected by series.Klines
var ret series.Klines
for i := int16(0); i < count; i++ {
k := c.Get(offset + i)
ret = append(ret, k)
}
return ret
}
// WindowIndicatorSeriesLocal 实现 indicator.IIndicatorSeries
type WindowIndicatorSeriesLocal struct {
window int16
ind indicator.IWindowIndicator
ctx *SigStrategyContext
}
func NewWindowIndicatorSeriesLocal(window int16, ind indicator.IWindowIndicator, ctx *SigStrategyContext) *WindowIndicatorSeriesLocal {
return &WindowIndicatorSeriesLocal{window: window, ind: ind, ctx: ctx}
}
func (w *WindowIndicatorSeriesLocal) Get(offset int16) (vector float64) {
// tell indicator to use offset by shifting internal offset then restore
prev := w.ctx.offset
w.ctx.offset += offset
vector = w.ind.Calculate(w.ctx, w.window)
w.ctx.offset = prev
return
}
func (w *WindowIndicatorSeriesLocal) Series(offset, count int16) (matrix series.Floats) {
prev := w.ctx.offset
w.ctx.offset += offset
for i := int16(0); i < count; i++ {
v := w.ind.Calculate(w.ctx, w.window)
matrix.Push(v)
w.ctx.offset++
}
w.ctx.offset = prev
return
}
func (c *SigStrategyContext) IndicatorW(name string, window int16) indicator.IIndicatorSeries {
ind, ok := c.indReg.IndicatorW(name)
if !ok {
panic("indicator not found: " + name)
}
return NewWindowIndicatorSeriesLocal(window, ind, c)
}

58
internal/trading/backtest/simulator.go

@ -0,0 +1,58 @@
package backtest
import (
"math"
"sig-pub/api/pb"
"sig-pub/pkg/types"
"sig-pub/pkg/types/decimals"
)
type Simulator struct {
FeePct float64 // e.g. 0.0005 = 0.05%
SlippagePct float64 // e.g. 0.001 = 0.1%
}
func NewSimulator(feePct, slippagePct float64) *Simulator {
return &Simulator{FeePct: feePct, SlippagePct: slippagePct}
}
// ExecuteMarket 执行市价单,使用kline信息决定成交价(使用close以及滑点)
func (s *Simulator) ExecuteMarket(side pb.Side, qty float64, k types.Kline, ts int64) (trade Trade) {
// base price use close
base := decimals.MustToFloat64(k.Close)
slippage := s.SlippagePct
if side == pb.Side_SELL {
// sell: worse price lower
base = base * (1 - slippage)
} else {
// buy: worse price higher
base = base * (1 + slippage)
}
fee := math.Abs(base*qty) * s.FeePct
trade = Trade{Side: side, Qty: qty, Price: base, Fee: fee, Ts: ts}
return
}
// ExecuteLimit 简单实现: 如果limit价格被kline的high/low包含则成交
func (s *Simulator) ExecuteLimit(side pb.Side, qty float64, limitPx float64, k types.Kline, ts int64) (filled bool, trade Trade) {
h := decimals.MustToFloat64(k.High)
l := decimals.MustToFloat64(k.Low)
if side == pb.Side_BUY {
// buy limit: filled if low <= price
if l <= limitPx {
// assume filled at min(limitPx, open)
px := math.Min(limitPx, decimals.MustToFloat64(k.Open))
fee := math.Abs(px*qty) * s.FeePct
trade = Trade{Side: side, Qty: qty, Price: px * (1 + s.SlippagePct), Fee: fee, Ts: ts}
return true, trade
}
} else if side == pb.Side_SELL {
if h >= limitPx {
px := math.Max(limitPx, decimals.MustToFloat64(k.Open))
fee := math.Abs(px*qty) * s.FeePct
trade = Trade{Side: side, Qty: qty, Price: px * (1 - s.SlippagePct), Fee: fee, Ts: ts}
return true, trade
}
}
return false, trade
}

33
internal/trading/backtest/types.go

@ -0,0 +1,33 @@
package backtest
import (
"sig-pub/api/pb"
)
type Side = pb.Side
type Position struct {
Side Side
Qty float64
EntryPx float64
EntryTs int64
PeakPx float64 // highest (for long) or lowest (for short) observed price since entry
}
type Trade struct {
Side Side
Qty float64
Price float64
Fee float64
Ts int64
Cause string // close reason or 'open' for open trades, ["stoploss", "takeprofit", "trailing", "retrace", "signal"](“止损”、“止盈”、“动态跟踪”、“回撤”、“信号”)
}
type BacktestResult struct {
StartTs int64
EndTs int64
Trades []Trade
Positions []Position
Cash float64
Equity float64
}

1
pkg/strategy/sig_strategy.go

@ -18,6 +18,7 @@ type ISigStrategy interface {
type StrategyMeta struct {
Name string `json:"name"`
Desc string `json:"desc"`
MaxWindow int `json:"maxWindow"` // 需要的最大窗口数, 回测时用, 若不定义则取最大窗口值
Args []Param `json:"args"` // 参数定义
}

Loading…
Cancel
Save