You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
285 lines
7.5 KiB
285 lines
7.5 KiB
package backtest |
|
|
|
import ( |
|
"fmt" |
|
"math" |
|
"sig-pub/pkg/trade" |
|
"sig-pub/pkg/types" |
|
"sig-pub/pkg/types/decimals" |
|
"sig-pub/pkg/utils/conver" |
|
"sig-pub/pkg/utils/lang" |
|
"sort" |
|
"time" |
|
) |
|
|
|
type BacktestAccount struct { |
|
trade.ITradeAccount |
|
cash float64 |
|
initialCash float64 |
|
positions map[int64]*trade.Position |
|
trades map[int64]*Trade |
|
closeTrades map[int64]*Trade |
|
simulator *TradeSimulator |
|
|
|
profit float64 |
|
winningTrades int |
|
losingTrades int |
|
fee float64 |
|
maxDrawdown [4]float64 // 最大回撤 |
|
} |
|
|
|
func NewBacktestAccount(cash float64, simulator *TradeSimulator) *BacktestAccount { |
|
return &BacktestAccount{ |
|
// keep initial cash for return calculations |
|
initialCash: cash, |
|
cash: cash, |
|
positions: make(map[int64]*trade.Position), |
|
trades: make(map[int64]*Trade), |
|
closeTrades: make(map[int64]*Trade), |
|
simulator: simulator, |
|
maxDrawdown: [4]float64{cash, cash, cash, cash}, |
|
} |
|
} |
|
|
|
// initialCash stores the starting capital for return calculations |
|
// (placed here to avoid changing exported API) |
|
func (a *BacktestAccount) InitialCash() float64 { return a.initialCash } |
|
|
|
// SharpeRatio computes an annualized Sharpe ratio based on closed trades. |
|
// rfAnnual is the annual risk-free rate expressed as a decimal (e.g. 0.01 for 1%). |
|
// Method: |
|
// - For each closed trade, we compute a period return = trade.Pnl / initialCash. |
|
// - Period lengths are derived from successive trade close timestamps (ms). |
|
// - Excess returns = periodReturn - rfAnnual * periodYears. |
|
// - Sharpe = mean(excess) / stddev(excess) * sqrt(periodsPerYear) |
|
// This provides a reasonable approximation when equity snapshots are not available. |
|
func (a *BacktestAccount) SharpeRatio(rfAnnual float64) float64 { |
|
if a.initialCash <= 0 { |
|
return 0 |
|
} |
|
n := len(a.closeTrades) |
|
if n < 2 { |
|
return 0 |
|
} |
|
|
|
trades := make([]*Trade, 0, n) |
|
for _, t := range a.closeTrades { |
|
trades = append(trades, t) |
|
} |
|
sort.Slice(trades, func(i, j int) bool { return trades[i].CloseTime < trades[j].CloseTime }) |
|
|
|
// returns per closed trade (relative to initial capital) |
|
returns := make([]float64, 0, n) |
|
// periods in seconds between closes; length will be n-1 initially |
|
periodsSec := make([]float64, 0, n-1) |
|
for i, t := range trades { |
|
returns = append(returns, t.Pnl/a.initialCash) |
|
if i > 0 { |
|
// CloseTs is in milliseconds in this codebase |
|
dtSec := float64(t.CloseTime-trades[i-1].CloseTime) / 1000.0 |
|
if dtSec <= 0 { |
|
dtSec = 1.0 |
|
} |
|
periodsSec = append(periodsSec, dtSec) |
|
} |
|
} |
|
if len(periodsSec) == 0 { |
|
return 0 |
|
} |
|
// average period (seconds) used to approximate period length for the first return |
|
sumDt := 0.0 |
|
for _, d := range periodsSec { |
|
sumDt += d |
|
} |
|
avgDt := sumDt / float64(len(periodsSec)) |
|
|
|
// Build final periods slice aligned with returns length |
|
periods := make([]float64, 0, n) |
|
periods = append(periods, avgDt) |
|
periods = append(periods, periodsSec...) |
|
|
|
const secsYear = 365.0 * 24.0 * 3600.0 |
|
excess := make([]float64, len(returns)) |
|
for i := range returns { |
|
years := periods[i] / secsYear |
|
excess[i] = returns[i] - rfAnnual*years |
|
} |
|
|
|
meanEx := mean(excess) |
|
sd := stddev(excess) |
|
if sd == 0 { |
|
return 0 |
|
} |
|
// approximate number of periods per year |
|
periodsPerYear := secsYear / avgDt |
|
return meanEx / sd * math.Sqrt(periodsPerYear) |
|
} |
|
|
|
func mean(x []float64) float64 { |
|
if len(x) == 0 { |
|
return 0 |
|
} |
|
s := 0.0 |
|
for _, v := range x { |
|
s += v |
|
} |
|
return s / float64(len(x)) |
|
} |
|
|
|
func stddev(x []float64) float64 { |
|
if len(x) <= 1 { |
|
return 0 |
|
} |
|
m := mean(x) |
|
s := 0.0 |
|
for _, v := range x { |
|
d := v - m |
|
s += d * d |
|
} |
|
// population or sample? use sample (n-1) |
|
return math.Sqrt(s / float64(len(x)-1)) |
|
} |
|
|
|
// CurrentEquity 根据当前价格对仓位进行 mark-to-market,返回账户净值 |
|
func (a *BacktestAccount) CurrentEquity(price float64) float64 { |
|
equity := a.cash |
|
for _, p := range a.positions { |
|
switch p.Side { |
|
case types.SideLong: |
|
// equity += (price - p.EntryPx) * p.Qty |
|
equity += price * p.Qty |
|
case types.SideShort: |
|
// equity += (p.EntryPx - price) * p.Qty |
|
equity += (p.EntryPx - price + p.EntryPx) * p.Qty |
|
} |
|
} |
|
return equity |
|
} |
|
|
|
// CurrentExposure 返回当前仓位的名义总敞口(绝对值) |
|
func (a *BacktestAccount) CurrentExposure() float64 { |
|
var sum float64 |
|
for _, p := range a.positions { |
|
sum += (p.Qty * p.EntryPx) |
|
} |
|
return sum |
|
} |
|
|
|
// CanOpen 判断在给定价格下是否可以开仓(基于 MaxPosPct 和 MaxExposurePct) |
|
// func (a *BacktestAccount) CanOpen(side types.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 |
|
// } |
|
|
|
// 获取未平仓交易单 |
|
func (a *BacktestAccount) OpenPositions() map[int64]*trade.Position { |
|
return a.positions |
|
} |
|
|
|
// 获取未平仓交易单数 |
|
func (a *BacktestAccount) CountOpenPositions() int { |
|
return len(a.positions) |
|
} |
|
|
|
// 订单下单 |
|
func (a *BacktestAccount) TradeOrder(ta trade.TradeArg) (ok bool, cause trade.Cause, err error) { |
|
order, ok := a.simulator.ExecuteMarket(ta.Side, ta.Qty, ta.Price, ta.Time) |
|
if !ok { |
|
return |
|
} |
|
|
|
// apply cash/position, short side also need earnest money |
|
cost := order.Price*order.Qty + order.Fee |
|
if cost > a.cash { |
|
ok = false |
|
return |
|
} |
|
a.cash -= cost |
|
// open/add position |
|
pos := &trade.Position{TradeId: order.Id, Side: order.Side, Qty: order.Qty, EntryPx: order.Price, EntryTs: order.Time, PeakPx: order.Price, Fee: order.Fee} |
|
a.positions[order.Id] = pos |
|
a.trades[order.Id] = order |
|
ok = true |
|
return |
|
} |
|
|
|
// 将仓位进行平仓 |
|
func (a *BacktestAccount) ClosePosition(pos *trade.Position, kline types.Kline, cause trade.Cause) (err error) { |
|
closeSide := lang.Ternary(pos.Side == types.SideLong, types.SideShort, types.SideLong) |
|
|
|
closePrice := decimals.MustToFloat64(kline.Close) |
|
t, ok := a.simulator.ExecuteMarket(closeSide, pos.Qty, closePrice, kline.Ts) |
|
if !ok { |
|
err = fmt.Errorf("close position error: %#v", pos) |
|
return |
|
} |
|
t.CloseCause = cause |
|
|
|
// apply cash change |
|
// calc profit |
|
var receive, profit float64 |
|
if pos.Side == types.SideLong { |
|
profit = t.Qty*(t.Price-pos.EntryPx) - t.Fee - pos.Fee |
|
receive = t.Price*pos.Qty - t.Fee |
|
} else { |
|
profit = (pos.EntryPx - t.Price) * t.Qty |
|
receive = pos.EntryPx*pos.Qty + profit - t.Fee |
|
profit = profit - t.Fee - pos.Fee |
|
} |
|
a.cash += receive |
|
a.profit += profit |
|
|
|
if profit > 0 { |
|
a.winningTrades++ |
|
} else { |
|
a.losingTrades++ |
|
} |
|
a.fee += t.Fee |
|
|
|
// remove position |
|
delete(a.positions, pos.TradeId) |
|
a.closeTrades[t.Id] = t |
|
|
|
if trade, ok := a.trades[pos.TradeId]; ok { |
|
trade.ClosePrice = closePrice |
|
trade.CloseFee = t.Fee |
|
trade.CloseTime = kline.Ts |
|
trade.CloseCause = cause |
|
trade.Pnl = profit |
|
trade.HoldTime = conver.TimeDurationFormat(time.Duration(trade.CloseTime-trade.Time)*time.Millisecond, ".") |
|
trade.PeakPx = pos.PeakPx |
|
} |
|
|
|
// 记录最大回撤 [high, low, high, low] |
|
currentEquity := a.CurrentEquity(closePrice) |
|
if currentEquity < a.maxDrawdown[1] { |
|
a.maxDrawdown[1] = currentEquity |
|
} |
|
if currentEquity > a.maxDrawdown[0] { |
|
if a.maxDrawdown[0]-a.maxDrawdown[1] > a.maxDrawdown[2]-a.maxDrawdown[3] { |
|
a.maxDrawdown[2], a.maxDrawdown[3] = a.maxDrawdown[0], a.maxDrawdown[1] |
|
} |
|
a.maxDrawdown[0] = currentEquity |
|
a.maxDrawdown[1] = currentEquity |
|
} |
|
return |
|
}
|
|
|