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.
177 lines
4.5 KiB
177 lines
4.5 KiB
package backtest |
|
|
|
import ( |
|
"math" |
|
"sig-pub/pkg/types" |
|
"sig-pub/pkg/types/decimals" |
|
"sig-pub/pkg/utils/collect" |
|
"sig-pub/pkg/utils/conver" |
|
"time" |
|
) |
|
|
|
type Account struct { |
|
Cash float64 |
|
Positions []*Position |
|
Trades []*Trade // 交易订单利润分布,回测结果echart折线显示,评估止盈止损策略效果 |
|
CloseTrades []*Trade |
|
MaxPosPct float64 // 最大仓位占比 |
|
MaxExposurePct float64 // 最大总敞口占比 |
|
MaxLots float64 // 最大手数/数量 (optional) |
|
Simulator *Simulator |
|
|
|
Stat *TradeStat // 交易统计 |
|
Profit float64 |
|
} |
|
|
|
func NewAccount(cash float64, sim *Simulator) *Account { |
|
return &Account{ |
|
Cash: cash, Simulator: sim, MaxPosPct: 1.0, MaxExposurePct: 1.0, |
|
Stat: &TradeStat{}, |
|
} |
|
} |
|
|
|
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 { |
|
switch p.Side { |
|
case types.SideLong: |
|
equity += (price - p.EntryPx) * p.Qty |
|
case types.SideShort: |
|
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 |
|
} |
|
|
|
func (a *Account) PositionCost() float64 { |
|
var sum float64 |
|
for _, p := range a.Positions { |
|
sum += math.Abs(p.Qty * p.EntryPx) |
|
} |
|
return sum |
|
} |
|
|
|
// CanOpen 判断在给定价格下是否可以开仓(基于 MaxPosPct 和 MaxExposurePct) |
|
func (a *Account) 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 |
|
} |
|
|
|
// ApplyMarketOrder 直接用市价下单(简化),qty为基础货币数量 |
|
func (a *Account) ApplyMarketOrder(side types.Side, qty float64, price float64, ts int64) (t *Trade, ok bool) { |
|
if !a.CanOpen(side, qty, price) { |
|
return t, false |
|
} |
|
trade, ok := a.Simulator.ExecuteMarket(side, qty, price, ts) |
|
if !ok { |
|
return |
|
} |
|
|
|
// apply cash/position, short side also need earnest money |
|
cost := trade.Price*qty + trade.Fee |
|
if cost > a.Cash { |
|
return trade, false |
|
} |
|
a.Cash -= cost |
|
// open/add position |
|
pos := &Position{TradeId: trade.Id, Side: side, Qty: qty, EntryPx: trade.Price, EntryTs: ts, PeakPx: trade.Price, Fee: trade.Fee} |
|
a.Positions = append(a.Positions, pos) |
|
a.Trades = append(a.Trades, trade) |
|
{ |
|
a.Stat.TotalTrades++ |
|
a.Stat.Fee += trade.Fee |
|
} |
|
return trade, true |
|
} |
|
|
|
// ClosePosition 根据索引平仓(全部平仓该仓位) |
|
func (a *Account) ClosePosition(index int, pos *Position, kline types.Kline, ts int64, cause string) (t *Trade, ok bool) { |
|
// determine close side (opposite) |
|
var closeSide types.Side |
|
if pos.Side == types.SideLong { |
|
closeSide = types.SideShort |
|
} else { |
|
closeSide = types.SideLong |
|
} |
|
|
|
closePrice := decimals.MustToFloat64(kline.Close) |
|
t, ok = a.Simulator.ExecuteMarket(closeSide, pos.Qty, closePrice, ts) |
|
if !ok { |
|
return t, false |
|
} |
|
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.Stat.WinningTrades++ |
|
} else { |
|
a.Stat.LosingTrades++ |
|
} |
|
a.Stat.Fee += t.Fee |
|
|
|
// remove position |
|
a.Positions = append(a.Positions[:index], a.Positions[index+1:]...) |
|
a.CloseTrades = append(a.CloseTrades, t) |
|
|
|
trade, ok := collect.Find(a.Trades, func(t *Trade) bool { return t.Id == pos.TradeId }) |
|
if ok { |
|
trade.Pnl = profit |
|
trade.ClosePrice = closePrice |
|
trade.CloseFee = t.Fee |
|
trade.CloseTs = kline.Ts |
|
trade.CloseCause = cause |
|
trade.HoldTime = conver.TimeDurationFormat(time.Duration(trade.CloseTs-trade.Ts)*time.Millisecond, ".") |
|
} |
|
return t, true |
|
}
|
|
|