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.
 
 

189 lines
5.9 KiB

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
}