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 |
package backtest |
||||||
|
|
||||||
import ( |
import ( |
||||||
"sig-pub/pkg/data/entity" |
"context" |
||||||
|
"io" |
||||||
|
"sig-pub/api/pb" |
||||||
|
"sig-pub/pkg/indicator" |
||||||
"sig-pub/pkg/strategy" |
"sig-pub/pkg/strategy" |
||||||
|
"sig-pub/pkg/types" |
||||||
|
"sig-pub/pkg/types/decimals" |
||||||
|
"sig-pub/pkg/zlog" |
||||||
|
|
||||||
|
"google.golang.org/grpc" |
||||||
) |
) |
||||||
|
|
||||||
// 回测引擎
|
type Backtest struct { |
||||||
// sig strategy
|
exchangeClient pb.ExchangeServiceClient |
||||||
// trade strategy
|
indReg *indicator.IndicatorRegistry |
||||||
// close strategy
|
sim *Simulator |
||||||
// 历史k线加载
|
} |
||||||
type BacktestEngine struct { |
|
||||||
start, end int64 |
func NewBacktest(exchangeClient pb.ExchangeServiceClient, indReg *indicator.IndicatorRegistry, sim *Simulator) *Backtest { |
||||||
plan entity.TradePlan // 交易计划
|
return &Backtest{exchangeClient: exchangeClient, indReg: indReg, sim: sim} |
||||||
} |
} |
||||||
|
|
||||||
// 多周期策略回测引擎
|
// Run 执行回测
|
||||||
type MultiIntervalBacktraceEngine struct { |
// seriesRange: 回测的交易产品/周期/时间区间
|
||||||
strategy strategy.IIntervalSigStrategy |
// 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