package backtest import ( "context" "fmt" "io" "sig-pub/api/pb" "sig-pub/internal/trading/sig" "sig-pub/pkg/indicator" "sig-pub/pkg/strategy" "sig-pub/pkg/trade" "sig-pub/pkg/types" "sig-pub/pkg/types/decimals" "sig-pub/pkg/utils/times" "sig-pub/pkg/zlog" "google.golang.org/grpc" ) type Backtest struct { exchangeClient pb.ExchangeServiceClient indReg *indicator.IndicatorRegistry sigStrategyReg *strategy.SigStrategyRegistry account *Account riskStrategy *trade.RiskStrategy closeManager *CloseManager } func NewBacktest(exchangeClient pb.ExchangeServiceClient, indReg *indicator.IndicatorRegistry, sigStrategyReg *strategy.SigStrategyRegistry) *Backtest { account := NewAccount(10000, NewSimulator(0.0005, 0.0008)) cm := NewCloseManager(0.02, 0.03) return &Backtest{ exchangeClient: exchangeClient, indReg: indReg, riskStrategy: trade.NewRiskStrategy(), account: account, closeManager: cm, } } func (b *Backtest) RunTradingPlan(ctx context.Context, tradingPlan *sig.TradingPlan, stime, etime int64, sigKlineSeries *sig.KlineSeries) (err error) { var sim *Simulator _ = sim plan := tradingPlan.Plan exchange := pb.ExchangeType(plan.Exchange) interval := types.Interval(plan.Interval) instId := plan.InstId sigStrategy := tradingPlan.GetSigStrategy() maxWindow := sigStrategy.MaxWindow() if maxWindow < 0 || maxWindow > indicator.MaxWindow { err = fmt.Errorf("invalid window %d 0-%d, planId=%d", maxWindow, indicator.MaxWindow, plan.Id) return } seriesRange := &pb.SeriesRange{ Exchange: exchange, InstId: instId, Interval: string(interval), Before: stime, After: etime, Open: false, Live: false, Desc: false, Window: uint32(maxWindow), } // 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 } recvTimes, total := 0, 0 watch := times.NewWatch() var lastK *types.Kline for { msg, err0 := stream.Recv() if err0 == io.EOF { break } if err0 != nil { err = err0 return } recvTimes++ total += len(msg.Klines) for _, k := range msg.Klines { kline := new(types.Kline) kline.ParsePBKline(seriesRange.Exchange, k) lastK = kline if lastTs, serial := sigKlineSeries.Update(kline); !serial { err = fmt.Errorf("kline not series: %s(%s), interval=%s, lastTs=%d", instId, exchange, interval, lastTs) return } length := sigKlineSeries.Length() if length <= maxWindow { continue } // 平仓策略 b.closeManager.OnKline(*kline, b.account) sigSide := tradingPlan.Update(strategy.StrategyTypeSig) if sigSide.IsValid() { b.onSigSideSignal(sigSide, *kline) } } } // build result res := &BacktestResult{} res.StartTs = 0 res.EndTs = 0 acct := b.account 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 lastK != nil { last := decimals.MustToFloat64(lastK.Close) equity := acct.Cash // naive mark-to-market of positions for _, p := range acct.Positions { if p.Side == types.SideBuy { equity += (last - p.EntryPx) * p.Qty } else { equity += (p.EntryPx - last) * p.Qty } } res.Equity = equity } zlog.Debugf("recv=%d, total=%d, use %s, ret=%#v", recvTimes, total, watch.ElapsedFmt("."), res) return } // onSigSideSignal 交易策略发出交易信号 func (b *Backtest) onSigSideSignal(sigSide types.Side, k types.Kline) { side := b.riskStrategy.SideAssess(sigSide) if !side.IsValid() { zlog.Debugf("risk strategy filter sig side: %s", sigSide.String()) return } zlog.Debugf("apply market order: ts=%d, side=%s", k.Ts, side.String()) b.account.ApplyMarketOrder(side, 0.01, k.Ts, k) b.closeManager.CloseBySignal(types.SideBuy, b.account, k) } // Run 执行回测 // seriesRange: 回测的交易产品/周期/时间区间 // sigStrategy: 已创建的策略实例(将调用 New() 并 Init) // params: 策略参数 func (b *Backtest) Run0(ctx context.Context, seriesRange *pb.SeriesRange, sigStrategy strategy.ISigStrategy, params strategy.StrategyParam, 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, nil) 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 == types.SideBuy || side == types.SideSell { // signal-based close: close opposite positions first if cm != nil { cm.CloseBySignal(types.SideBuy, acct, klines[i]) } if _, ok := acct.ApplyMarketOrder(types.SideBuy, 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 == types.SideBuy { equity += (last - p.EntryPx) * p.Qty } else { equity += (p.EntryPx - last) * p.Qty } } res.Equity = equity } return }