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 }