From e5d051e446e5b037626b6655815c7e37b04ef89a Mon Sep 17 00:00:00 2001 From: strange Date: Wed, 29 Oct 2025 18:39:34 +0800 Subject: [PATCH] backtest --- internal/exchange/exchange_service.go | 48 ++++++ internal/trading/backtest/account.go | 143 ++++++++++++++++ internal/trading/backtest/backtest.go | 141 +++++++++++++-- internal/trading/backtest/close_manager.go | 189 +++++++++++++++++++++ internal/trading/backtest/context.go | 78 +++++++++ internal/trading/backtest/simulator.go | 58 +++++++ internal/trading/backtest/types.go | 33 ++++ pkg/strategy/sig_strategy.go | 7 +- 8 files changed, 682 insertions(+), 15 deletions(-) create mode 100644 internal/trading/backtest/account.go create mode 100644 internal/trading/backtest/close_manager.go create mode 100644 internal/trading/backtest/context.go create mode 100644 internal/trading/backtest/simulator.go create mode 100644 internal/trading/backtest/types.go diff --git a/internal/exchange/exchange_service.go b/internal/exchange/exchange_service.go index de1cb99..947f365 100644 --- a/internal/exchange/exchange_service.go +++ b/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) diff --git a/internal/trading/backtest/account.go b/internal/trading/backtest/account.go new file mode 100644 index 0000000..1e24301 --- /dev/null +++ b/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 +} diff --git a/internal/trading/backtest/backtest.go b/internal/trading/backtest/backtest.go index 6d36611..150a332 100644 --- a/internal/trading/backtest/backtest.go +++ b/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 } diff --git a/internal/trading/backtest/close_manager.go b/internal/trading/backtest/close_manager.go new file mode 100644 index 0000000..002c883 --- /dev/null +++ b/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 +} diff --git a/internal/trading/backtest/context.go b/internal/trading/backtest/context.go new file mode 100644 index 0000000..5c133c7 --- /dev/null +++ b/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) +} diff --git a/internal/trading/backtest/simulator.go b/internal/trading/backtest/simulator.go new file mode 100644 index 0000000..b2af07b --- /dev/null +++ b/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 +} diff --git a/internal/trading/backtest/types.go b/internal/trading/backtest/types.go new file mode 100644 index 0000000..1c65fac --- /dev/null +++ b/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 +} diff --git a/pkg/strategy/sig_strategy.go b/pkg/strategy/sig_strategy.go index 36c2a38..cc85922 100644 --- a/pkg/strategy/sig_strategy.go +++ b/pkg/strategy/sig_strategy.go @@ -16,9 +16,10 @@ type ISigStrategy interface { } type StrategyMeta struct { - Name string `json:"name"` - Desc string `json:"desc"` - Args []Param `json:"args"` // 参数定义 + Name string `json:"name"` + Desc string `json:"desc"` + MaxWindow int `json:"maxWindow"` // 需要的最大窗口数, 回测时用, 若不定义则取最大窗口值 + Args []Param `json:"args"` // 参数定义 } // ISigStrategyContext 策略外部访问能力