8 changed files with 682 additions and 15 deletions
@ -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 |
||||
} |
||||
@ -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 |
||||
} |
||||
|
||||
@ -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 |
||||
} |
||||
@ -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) |
||||
} |
||||
@ -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 |
||||
} |
||||
@ -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 |
||||
} |
||||
Loading…
Reference in new issue