From 3d7a91eb71b81ef7c691c09091ee1e2009d728fe Mon Sep 17 00:00:00 2001 From: strange Date: Mon, 10 Nov 2025 22:04:10 +0800 Subject: [PATCH] backtest trade --- config/exchange.toml | 4 +- internal/trading/backtest/account.go | 190 ++++++------- internal/trading/backtest/close_manager.go | 209 -------------- .../backtest/sig_strategy_backtester.go | 42 ++- .../{simulator.go => trade_simulator.go} | 25 +- .../backtest/trading_plan_backtester.go | 257 ++++++++---------- internal/trading/backtest/types.go | 42 +-- internal/trading/trading_grpc_server.go | 2 +- internal/trading/trading_service.go | 29 +- pkg/data/entity/trade_plan.go | 26 +- pkg/trade/close_strategy.go | 103 +++++-- pkg/trade/risk_strategy.go | 5 +- pkg/trade/trade_account.go | 44 ++- pkg/trade/trade_strategy.go | 45 ++- pkg/trade/types.go | 94 +++++-- pkg/types/interval.go | 36 ++- 16 files changed, 511 insertions(+), 642 deletions(-) delete mode 100644 internal/trading/backtest/close_manager.go rename internal/trading/backtest/{simulator.go => trade_simulator.go} (59%) diff --git a/config/exchange.toml b/config/exchange.toml index 07f7bca..662a8bd 100644 --- a/config/exchange.toml +++ b/config/exchange.toml @@ -18,8 +18,8 @@ receiveBuffer = 4096 marketSubscribeLimit = 16 consumeBatch = 1024 consumeLater = 2000 # 时间到达later或者数据累计到batch触发consume -httpProxy = "http://192.168.1.5:7890" -# httpProxy = "http://10.255.183.209:7890" +# httpProxy = "http://192.168.1.5:7890" +httpProxy = "http://10.255.183.209:7890" # 模拟盘API交易地址如下: # REST:https://www.okx.com diff --git a/internal/trading/backtest/account.go b/internal/trading/backtest/account.go index 85200d5..821b429 100644 --- a/internal/trading/backtest/account.go +++ b/internal/trading/backtest/account.go @@ -1,48 +1,43 @@ package backtest import ( - "math" + "fmt" + "sig-pub/pkg/trade" "sig-pub/pkg/types" "sig-pub/pkg/types/decimals" - "sig-pub/pkg/utils/collect" "sig-pub/pkg/utils/conver" + "sig-pub/pkg/utils/lang" "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 +type BacktestAccount struct { + trade.ITradeAccount + cash float64 + positions map[int64]*trade.Position + trades map[int64]*trade.Trade + closeTrades map[int64]*trade.Trade + simulator *TradeSimulator + + profit float64 + winningTrades int + losingTrades int + fee 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 +func NewBacktestAccount(cash float64, simulator *TradeSimulator) *BacktestAccount { + return &BacktestAccount{ + cash: cash, + positions: make(map[int64]*trade.Position), + trades: make(map[int64]*trade.Trade), + closeTrades: make(map[int64]*trade.Trade), + simulator: simulator, } } // CurrentEquity 根据当前价格对仓位进行 mark-to-market,返回账户净值 -func (a *Account) CurrentEquity(price float64) float64 { - equity := a.Cash - for _, p := range a.Positions { +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 @@ -54,88 +49,80 @@ func (a *Account) CurrentEquity(price float64) float64 { } // CurrentExposure 返回当前仓位的名义总敞口(绝对值) -func (a *Account) CurrentExposure(price float64) float64 { +func (a *BacktestAccount) CurrentExposure() float64 { var sum float64 - for _, p := range a.Positions { - sum += math.Abs(p.Qty * price) + for _, p := range a.positions { + sum += (p.Qty * p.EntryPx) } 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 *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 } -// 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 +// 获取未平仓交易单数 +func (a *BacktestAccount) CountOpenPositions() int { + return len(a.positions) } -// 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) +// 订单下单 +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 := trade.Price*qty + trade.Fee - if cost > a.Cash { - return trade, false + cost := order.Price*order.Qty + order.Fee + if cost > a.cash { + ok = false + return } - a.Cash -= cost + 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 + 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 } -// 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 - } +// 将仓位进行平仓 +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, ts) + t, ok := a.simulator.ExecuteMarket(closeSide, pos.Qty, closePrice, kline.Ts) if !ok { - return t, false + err = fmt.Errorf("close position error: %#v", pos) + return } t.CloseCause = cause @@ -150,28 +137,27 @@ func (a *Account) ClosePosition(index int, pos *Position, kline types.Kline, ts receive = pos.EntryPx*pos.Qty + profit - t.Fee profit = profit - t.Fee - pos.Fee } - a.Cash += receive - a.Profit += profit + a.cash += receive + a.profit += profit if profit > 0 { - a.Stat.WinningTrades++ + a.winningTrades++ } else { - a.Stat.LosingTrades++ + a.losingTrades++ } - a.Stat.Fee += t.Fee + a.fee += t.Fee // remove position - a.Positions = append(a.Positions[:index], a.Positions[index+1:]...) - a.CloseTrades = append(a.CloseTrades, t) + delete(a.positions, pos.TradeId) + a.closeTrades[t.Id] = t - trade, ok := collect.Find(a.Trades, func(t *Trade) bool { return t.Id == pos.TradeId }) - if ok { + if trade, ok := a.trades[pos.TradeId]; 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, ".") + trade.HoldTime = conver.TimeDurationFormat(time.Duration(trade.CloseTs-trade.Time)*time.Millisecond, ".") } - return t, true + return } diff --git a/internal/trading/backtest/close_manager.go b/internal/trading/backtest/close_manager.go deleted file mode 100644 index 515139c..0000000 --- a/internal/trading/backtest/close_manager.go +++ /dev/null @@ -1,209 +0,0 @@ -package backtest - -import ( - "sig-pub/pkg/trade" - "sig-pub/pkg/types" - "sig-pub/pkg/types/decimals" -) - -// CloseManager 管理持仓平仓逻辑:stoploss/takeprofit 与 基于信号的平仓 -// 1.交易信号和持单方向相反时是否进行平仓 -// 2.计算止盈止损时是否包含手续费 -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 -} - -func (m *CloseManager) Init(param trade.CloseStrategyParam) { - -} - -// 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 { - tradeId int64 - cause string - } - var toClose []closeTask - closePrice := decimals.MustToFloat64(k.Close) - - for _, p := range acct.Positions { - if p == nil { - continue - } - entry := p.EntryPx - // update peak px - switch p.Side { - case types.SideLong: - if closePrice > p.PeakPx { - p.PeakPx = closePrice - } - case types.SideShort: - if closePrice < p.PeakPx { - p.PeakPx = closePrice - } - } - // 固定止盈止损 - if p.Side == types.SideLong { - // stoploss - if m.StopLossPct > 0 && closePrice <= entry*(1-m.StopLossPct) { - acct.Stat.StoplossTimes++ - toClose = append(toClose, closeTask{tradeId: p.TradeId, cause: "stoploss"}) - continue - } - // takeprofit - // if m.TakeProfitPct > 0 && priceHigh >= entry*(1+m.TakeProfitPct) { - // acct.Stat.TakeprofitTimes++ - // toClose = append(toClose, closeTask{tradeId: p.TradeId, 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 closePrice <= trailLevel { - trail := (p.PeakPx - entry) * (1 - m.TrailingPct) - if (closePrice - entry) < trail { - acct.Stat.TrailingTimes++ - toClose = append(toClose, closeTask{tradeId: p.TradeId, cause: "trailing"}) - continue - } - } - } - // profit retrace rule: if peakProfit>0 and current retrace > ProfitRetracePct - // if m.ProfitRetracePct > 0 { - // peakProfit := (p.PeakPx - entry) / entry - // curProfit := (closePrice - entry) / entry - // if peakProfit > 0 { - // retrace := (peakProfit - curProfit) / peakProfit - // if retrace >= m.ProfitRetracePct { - // acct.Stat.RetraceTimes++ - // toClose = append(toClose, closeTask{tradeId: p.TradeId, cause: "retrace"}) - // continue - // } - // } - // } - } else if p.Side == types.SideShort { - // short: stoploss if high >= entry*(1+stop), takeprofit if low <= entry*(1-tp) - if m.StopLossPct > 0 && closePrice >= entry*(1+m.StopLossPct) { - acct.Stat.StoplossTimes++ - toClose = append(toClose, closeTask{tradeId: p.TradeId, cause: "stoploss"}) - continue - } - // if m.TakeProfitPct > 0 && priceLow <= entry*(1-m.TakeProfitPct) { - // acct.Stat.TakeprofitTimes++ - // toClose = append(toClose, closeTask{tradeId: p.TradeId, 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 closePrice >= trailLevel { - trail := (entry - p.PeakPx) * (1 - m.TrailingPct) - if (entry - closePrice) < trail { - acct.Stat.TrailingTimes++ - toClose = append(toClose, closeTask{tradeId: p.TradeId, cause: "trailing"}) - continue - } - } - } - // if m.ProfitRetracePct > 0 { - // peakProfit := (entry - p.PeakPx) / entry - // curProfit := (entry - closePrice) / entry - // if peakProfit > 0 { - // retrace := (peakProfit - curProfit) / peakProfit - // if retrace >= m.ProfitRetracePct { - // acct.Stat.RetraceTimes++ - // toClose = append(toClose, closeTask{tradeId: p.TradeId, cause: "retrace"}) - // continue - // } - // } - // } - } - } - - // close collected positions (process from high index to low to safely remove) - for j := len(toClose) - 1; j >= 0; j-- { - tradeId := toClose[j].tradeId - cause := toClose[j].cause - var pos *Position - var index int - for i, p := range acct.Positions { - if p.TradeId == tradeId { - pos = p - index = i - break - } - } - if pos == nil { - return - } - - // perform market close: side opposite - tr, ok := acct.ClosePosition(index, pos, k, k.Ts, cause) - if ok { - trades = append(trades, tr) - } - } - return -} - -// CloseBySignal 根据策略信号尝试平掉相反方向的仓位。例如策略返回 SELL 时,尝试平掉所有 BUY 持仓 -func (m *CloseManager) CloseBySignal(sigSide types.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 - pos *Position - } - var toClose []closeTask - for i, p := range acct.Positions { - if p == nil { - continue - } - if sigSide == types.SideLong && p.Side == types.SideShort { - toClose = append(toClose, closeTask{idx: i, cause: "signal", pos: p}) - } else if sigSide == types.SideShort && p.Side == types.SideLong { - toClose = append(toClose, closeTask{idx: i, cause: "signal", pos: p}) - } - } - for j := len(toClose) - 1; j >= 0; j-- { - idx := toClose[j].idx - cause := toClose[j].cause - tr, ok := acct.ClosePosition(idx, toClose[j].pos, k, k.Ts, cause) - if ok { - trades = append(trades, tr) - } - } - return -} diff --git a/internal/trading/backtest/sig_strategy_backtester.go b/internal/trading/backtest/sig_strategy_backtester.go index 6084f44..472d9e0 100644 --- a/internal/trading/backtest/sig_strategy_backtester.go +++ b/internal/trading/backtest/sig_strategy_backtester.go @@ -25,7 +25,7 @@ type SigStrategyBacktester struct { exchangeClient pb.ExchangeServiceClient // 回测过程中订阅k线 - intervalSubscribe map[types.Interval][]func(interval types.Interval, k *types.Kline) (err error) + intervalSubscribe map[types.Interval][]func(interval types.Interval, k types.Kline) (err error) } func NewSigStrategyBacktester( @@ -39,22 +39,25 @@ func NewSigStrategyBacktester( sigStrategy: sigStrategy, indicatorReg: indicatorReg, exchangeClient: exchangeServiceClient, - intervalSubscribe: make(map[types.Interval][]func(interval types.Interval, k *types.Kline) (err error)), + intervalSubscribe: make(map[types.Interval][]func(interval types.Interval, k types.Kline) (err error)), } } // SubKline 在回测过程中订阅k线 -func (b *SigStrategyBacktester) SubKline(interval types.Interval, recv func(interval types.Interval, k *types.Kline) (err error)) { +func (b *SigStrategyBacktester) SubKline(interval types.Interval, recv func(interval types.Interval, k types.Kline) (err error)) { b.intervalSubscribe[interval] = append(b.intervalSubscribe[interval], recv) } // Backtest 基于历史数据回测信号策略 -func (b *SigStrategyBacktester) Backtest(ctx context.Context, sr *pb.SeriesRange, recvSignal func(sigSide types.Side, k types.Kline) (err error)) (err error) { +func (b *SigStrategyBacktester) Backtest(ctx context.Context, sr *pb.SeriesRange, cleanIntervalSeries *types.IntervalState[*sig.KlineSeries], recvSignal func(sigSide types.Side, k types.Kline) (err error)) (err error) { + if cleanIntervalSeries == nil { + cleanIntervalSeries = types.NewIntervalState[*sig.KlineSeries]() + } switch b.sigStrategyType { case strategy.SigStrategyTypeSingle: - err = b.singleStrategySeries(ctx, b.sigStrategy.(strategy.ISingleSigStrategy), sr, recvSignal) + err = b.singleStrategySeries(ctx, b.sigStrategy.(strategy.ISingleSigStrategy), sr, cleanIntervalSeries, recvSignal) case strategy.SigStrategyTypeInterval: - err = b.intervalStrategySeries(ctx, b.sigStrategy.(strategy.IIntervalSigStrategy), sr, recvSignal) + err = b.intervalStrategySeries(ctx, b.sigStrategy.(strategy.IIntervalSigStrategy), sr, cleanIntervalSeries, recvSignal) default: err = fmt.Errorf("unknown sig strategy type %v", b.sigStrategyType) } @@ -62,9 +65,9 @@ func (b *SigStrategyBacktester) Backtest(ctx context.Context, sr *pb.SeriesRange } // singleStrategySeries 单周期策略 -func (b *SigStrategyBacktester) singleStrategySeries(ctx context.Context, sigStrategy strategy.ISingleSigStrategy, sr *pb.SeriesRange, recvSignal func(sigSide types.Side, k types.Kline) (err error)) (err error) { +func (b *SigStrategyBacktester) singleStrategySeries(ctx context.Context, sigStrategy strategy.ISingleSigStrategy, sr *pb.SeriesRange, intervalKlineSeries *types.IntervalState[*sig.KlineSeries], recvSignal func(sigSide types.Side, k types.Kline) (err error)) (err error) { interval := types.Interval(sr.Interval) - kSeries := sig.NewKlineSeries(sr.Exchange, sr.InstId, interval) + kSeries := intervalKlineSeries.ComputeIfAbsent(interval, func() *sig.KlineSeries { return sig.NewKlineSeries(sr.Exchange, sr.InstId, interval) }) indicatorContext := sig.NewIndicatorContext(kSeries) strategyContext := sig.NewStrategyContext(indicatorContext, b.indicatorReg) requiredSeries := int(sigStrategy.RequiredSeries()) @@ -72,9 +75,6 @@ func (b *SigStrategyBacktester) singleStrategySeries(ctx context.Context, sigStr requiredIntervalSeries := types.NewIntervalState[int16]() requiredIntervalSeries.Set(interval, int16(max(1, requiredSeries))) - intervalKlineSeries := types.NewIntervalState[*sig.KlineSeries]() - intervalKlineSeries.Set(interval, kSeries) - err = b.multiIntervalSeries(ctx, sr, requiredIntervalSeries, intervalKlineSeries, func(driver bool, interval types.Interval, k *types.Kline) (err error) { if !driver { return @@ -95,11 +95,9 @@ func (b *SigStrategyBacktester) singleStrategySeries(ctx context.Context, sigStr } // intervalStrategySeries 多周期策略 -func (b *SigStrategyBacktester) intervalStrategySeries(ctx context.Context, intervalSigStrategy strategy.IIntervalSigStrategy, sr *pb.SeriesRange, recvSignal func(sigSide types.Side, k types.Kline) (err error)) (err error) { +func (b *SigStrategyBacktester) intervalStrategySeries(ctx context.Context, intervalSigStrategy strategy.IIntervalSigStrategy, sr *pb.SeriesRange, intervalKlineSeries *types.IntervalState[*sig.KlineSeries], recvSignal func(sigSide types.Side, k types.Kline) (err error)) (err error) { // 各周期所需k线数量 requiredIntervalSeries := intervalSigStrategy.RequiredIntervalSeries() - // 各周期 series - intervalKlineSeries := types.NewIntervalState[*sig.KlineSeries]() // 策略上下文 intervalStrategyContext := sig.NewIntervalStrategyContext(intervalKlineSeries, b.indicatorReg) err = b.multiIntervalSeries(ctx, sr, requiredIntervalSeries, intervalKlineSeries, func(driver bool, interval types.Interval, k *types.Kline) (err error) { @@ -166,11 +164,7 @@ func (b *SigStrategyBacktester) multiIntervalSeries(ctx context.Context, sr *pb. stopCh := make(chan struct{}) stopChClosed := atomic.Bool{} for _, interval := range otherIntervals { - kSeries := intervalKlineSeries.Get(interval) - if kSeries == nil { - kSeries = sig.NewKlineSeries(sr.Exchange, sr.InstId, interval) - intervalKlineSeries.Set(interval, kSeries) - } + kSeries := intervalKlineSeries.ComputeIfAbsent(interval, func() *sig.KlineSeries { return sig.NewKlineSeries(sr.Exchange, sr.InstId, interval) }) go func(interval types.Interval, kSeries *sig.KlineSeries) { syncCh := otherIntervalSyncCh.Get(interval) intervalAdder := types.SupportedIntervals[interval] @@ -208,7 +202,7 @@ func (b *SigStrategyBacktester) multiIntervalSeries(ctx context.Context, sr *pb. // 回调周期订阅 if subs, ok := b.intervalSubscribe[interval]; ok { for _, subFn := range subs { - if err = subFn(interval, k); err != nil { + if err = subFn(interval, *k); err != nil { return } } @@ -232,11 +226,7 @@ func (b *SigStrategyBacktester) multiIntervalSeries(ctx context.Context, sr *pb. }(interval, kSeries) } // 驱动周期数据拉取 - driverSeries := intervalKlineSeries.Get(driverInterval) - if driverSeries == nil { - driverSeries = sig.NewKlineSeries(sr.Exchange, sr.InstId, driverInterval) - intervalKlineSeries.Set(driverInterval, driverSeries) - } + driverSeries := intervalKlineSeries.ComputeIfAbsent(driverInterval, func() *sig.KlineSeries { return sig.NewKlineSeries(sr.Exchange, sr.InstId, driverInterval) }) sr.WindowExtra = max(sr.WindowExtra, uint32(max(0, requiredIntervalSeries.Get(driverInterval)-1))) err0 := b.fetchHistoryKlineSeries(ctx, sr, func(k *types.Kline) (err error) { if lastTs, serial := driverSeries.Update(k); !serial { @@ -270,7 +260,7 @@ func (b *SigStrategyBacktester) multiIntervalSeries(ctx context.Context, sr *pb. // 回调周期订阅 if subs, ok := b.intervalSubscribe[driverInterval]; ok { for _, subFn := range subs { - if err = subFn(driverInterval, k); err != nil { + if err = subFn(driverInterval, *k); err != nil { return } } diff --git a/internal/trading/backtest/simulator.go b/internal/trading/backtest/trade_simulator.go similarity index 59% rename from internal/trading/backtest/simulator.go rename to internal/trading/backtest/trade_simulator.go index 8b05aed..4d2cfb4 100644 --- a/internal/trading/backtest/simulator.go +++ b/internal/trading/backtest/trade_simulator.go @@ -2,23 +2,24 @@ package backtest import ( "math" + "sig-pub/pkg/trade" "sig-pub/pkg/types" "sig-pub/pkg/types/decimals" ) -type Simulator struct { +type TradeSimulator struct { FeePct float64 // e.g. 0.0005 = 0.05% SlippagePct float64 // e.g. 0.001 = 0.1% TradeId int64 } -func NewSimulator(feePct, slippagePct float64) *Simulator { - return &Simulator{FeePct: feePct, SlippagePct: slippagePct} +func NewTradeSimulator(feePct, slippagePct float64) *TradeSimulator { + return &TradeSimulator{FeePct: feePct, SlippagePct: slippagePct} } // ExecuteMarket 执行市价单,使用kline信息决定成交价(使用close以及滑点) -func (s *Simulator) ExecuteMarket(side types.Side, qty float64, closePrice float64, ts int64) (trade *Trade, ok bool) { +func (s *TradeSimulator) ExecuteMarket(side types.Side, qty float64, closePrice float64, ts int64) (trd *trade.Trade, ok bool) { // base price use close base := closePrice slippage := s.SlippagePct @@ -33,15 +34,15 @@ func (s *Simulator) ExecuteMarket(side types.Side, qty float64, closePrice float return } fee := math.Abs(base*qty) * s.FeePct - trade = &Trade{Side: side, Qty: qty, Price: base, Fee: fee, Ts: ts} + trd = &trade.Trade{Side: side, Qty: qty, Price: base, Fee: fee, Time: ts} s.TradeId++ - trade.Id = s.TradeId + trd.Id = s.TradeId ok = true return } // ExecuteLimit 简单实现: 如果limit价格被kline的high/low包含则成交 -func (s *Simulator) ExecuteLimit(side types.Side, qty float64, limitPx float64, k types.Kline, ts int64) (trade Trade, filled bool) { +func (s *TradeSimulator) ExecuteLimit(side types.Side, qty float64, limitPx float64, k types.Kline, ts int64) (trd *trade.Trade, filled bool) { h := decimals.MustToFloat64(k.High) l := decimals.MustToFloat64(k.Low) switch side { @@ -51,16 +52,16 @@ func (s *Simulator) ExecuteLimit(side types.Side, qty float64, limitPx float64, // 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 trade, true + trd = &trade.Trade{Side: side, Qty: qty, Price: px * (1 + s.SlippagePct), Fee: fee, Time: ts} + return trd, true } case types.SideShort: 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 trade, true + trd = &trade.Trade{Side: side, Qty: qty, Price: px * (1 - s.SlippagePct), Fee: fee, Time: ts} + return trd, true } } - return trade, false + return nil, false } diff --git a/internal/trading/backtest/trading_plan_backtester.go b/internal/trading/backtest/trading_plan_backtester.go index da3ad0c..d109920 100644 --- a/internal/trading/backtest/trading_plan_backtester.go +++ b/internal/trading/backtest/trading_plan_backtester.go @@ -2,61 +2,48 @@ package backtest import ( "context" + "errors" "fmt" - "io" "sig-pub/api/pb" - "sig-pub/internal/trading/sig" "sig-pub/pkg/data/entity" "sig-pub/pkg/indicator" "sig-pub/pkg/strategy" "sig-pub/pkg/trade" "sig-pub/pkg/types" - "sig-pub/pkg/types/decimals" "sig-pub/pkg/utils/collect" - "sig-pub/pkg/utils/times" "sig-pub/pkg/zlog" "github.com/bytedance/sonic" - "google.golang.org/grpc" ) -type BacktestStat struct { - // total_return 年化收益 - // sharpe_ratio 夏普比率 - // max_drawdown 最大回撤 - // num_trades 单数 - // win_rate 胜率 -} - -type TradeAccount struct { - trade.ITradeAccount - cash float64 -} - -func NewTradeAccount(cash float64) *TradeAccount { - return &TradeAccount{ - cash: cash, - } -} - type TradingPlanBacktester struct { - exchangeClient pb.ExchangeServiceClient indicatorReg *indicator.IndicatorRegistry sigStrategyReg *strategy.SigStrategyRegistry + exchangeClient pb.ExchangeServiceClient + + account *BacktestAccount + sigStrategyType strategy.SigStrategyType + sigStrategy strategy.ISigStrategy + closeStrategy *trade.CloseStrategy + riskStrategy *trade.RiskStrategy + tradeStrategy *trade.TradeStrategy } -func NewTradingPlanBacktester(exchangeClient pb.ExchangeServiceClient, indicatorReg *indicator.IndicatorRegistry, sigStrategyReg *strategy.SigStrategyRegistry) *TradingPlanBacktester { +func NewTradingPlanBacktester(indicatorReg *indicator.IndicatorRegistry, sigStrategyReg *strategy.SigStrategyRegistry, exchangeClient pb.ExchangeServiceClient) *TradingPlanBacktester { return &TradingPlanBacktester{ - exchangeClient: exchangeClient, indicatorReg: indicatorReg, sigStrategyReg: sigStrategyReg, + exchangeClient: exchangeClient, } } -// 核心引擎,模拟交易、持仓跟踪、费用计算 -func (b *TradingPlanBacktester) Backtest(ctx context.Context, cash float64, plan entity.TradePlan, sr *pb.SeriesRange) (err error) { +func (b *TradingPlanBacktester) Init(cash float64, plan entity.TradePlan) (err error) { + // trade account + simulator := NewTradeSimulator(0.0005, 0.0008) + b.account = NewBacktestAccount(cash, simulator) // sig strategy - sigStrategyType, sigStrategy, ok := b.sigStrategyReg.NewSigStrategy(plan.SigStrategy) + ok := false + b.sigStrategyType, b.sigStrategy, ok = b.sigStrategyReg.NewSigStrategy(plan.SigStrategy) if !ok { err = fmt.Errorf("sig strategy not exists %s", plan.SigStrategy) return @@ -65,7 +52,7 @@ func (b *TradingPlanBacktester) Backtest(ctx context.Context, cash float64, plan if err = sonic.UnmarshalString(plan.SigStrategyParam, &sigStrategyParam); err != nil { return } - if err = sigStrategy.Init(sigStrategyParam); err != nil { + if err = b.sigStrategy.Init(sigStrategyParam); err != nil { return } closeStrategyParam, tradeStrategyParam, riskStrategyParam := new(trade.CloseStrategyParam), @@ -80,146 +67,124 @@ func (b *TradingPlanBacktester) Backtest(ctx context.Context, cash float64, plan return } // 平仓策略 - closeStrategy, err := trade.NewCloseStrategy(*closeStrategyParam) + b.closeStrategy, err = trade.NewCloseStrategy(*closeStrategyParam) if err != nil { return } // 风险管理策略 - riskStrategy, err := trade.NewRiskStrategy(*riskStrategyParam) + b.riskStrategy, err = trade.NewRiskStrategy(*riskStrategyParam) if err != nil { return } - - tradeAccount := NewTradeAccount(cash) - _ = tradeAccount - - // 平仓管理器 - closeManager := NewCloseManager(0.02, 0) - closeManager.SetDynamicParams(0.1, 0.02, 0) - - // 回测账户 - account := NewAccount(10000, NewSimulator(0.0005, 0.0008)) - - sigStrategyBacktester := NewSigStrategyBacktester(sigStrategyType, sigStrategy, b.indicatorReg, b.exchangeClient) - // 平仓策略订阅1分钟曲线 - sigStrategyBacktester.SubKline(types.Interval1m, func(interval types.Interval, k *types.Kline) (err error) { - closeManager.OnKline(*k, account) + // 交易策略 + b.tradeStrategy, err = trade.NewTradeStrategy(*tradeStrategyParam) + if err != nil { return - }) - // 下单 - err = sigStrategyBacktester.Backtest(ctx, sr, func(sigSide types.Side, k types.Kline) (err error) { - b.onSigSideSignalWithAccount(sigSide, k, account, closeManager, riskStrategy) - return b.onSideSingal(sigSide, k, closeStrategy) - }) + } return } -// onSideSingal 出现买卖信号 -func (b *TradingPlanBacktester) onSideSingal(sigSide types.Side, k types.Kline, closeStrategy *trade.CloseStrategy) (err error) { +// 核心引擎,模拟交易、持仓跟踪、费用计算 +func (b *TradingPlanBacktester) Backtest(ctx context.Context, sr *pb.SeriesRange) (err error) { + // 交易信号回测器 + sigStrategyBacktester := NewSigStrategyBacktester(b.sigStrategyType, b.sigStrategy, b.indicatorReg, b.exchangeClient) + + onInterval1m, sigTimes := 0, 0 + sigStrategyBacktester.SubKline(types.Interval1m, func(interval types.Interval, k types.Kline) (err error) { + onInterval1m++ + // 检查仓位平仓 + return b.closeByKlineInterval1m(k) + }) + err = sigStrategyBacktester.Backtest(ctx, sr, nil, func(sigSide types.Side, k types.Kline) (err error) { + sigTimes++ + // 根据交易信号检查仓位平仓 + if err = b.closeBySigSingal(sigSide, k); err != nil { + return + } + // 交易下单 + return b.onSideSingal(sigSide, k) + }) + if err != nil { + return + } + + // todo 回测报告 + var trades []*trade.Trade + for _, trade := range b.account.trades { + if trade.ClosePrice > 0 { + trades = append(trades, trade) + } + } + collect.SortDesc(trades, func(t *trade.Trade) float64 { return t.Pnl }) + exposure := b.account.cash + b.account.CurrentExposure() + _ = exposure return } -// onSigSideSignalWithAccount 交易策略发出交易信号(使用指定的账户和平仓管理器) -func (b *TradingPlanBacktester) onSigSideSignalWithAccount(sigSide types.Side, k types.Kline, account *Account, closeManager *CloseManager, riskStrategy *trade.RiskStrategy) { - // risk check before executing - side := riskStrategy.SideAssess(sigSide) - if !side.IsValid() { - zlog.Debugf("risk strategy filter sig side: %s", sigSide.String()) +// closeByKlineInterval1m k线更新时检查平仓 +func (b *TradingPlanBacktester) closeByKlineInterval1m(k types.Kline) (err error) { + var posErrs []error + positions := b.account.OpenPositions() + for _, pos := range positions { + closePos, cause := b.closeStrategy.OnKline(k, pos) + if closePos { + errc := b.account.ClosePosition(pos, k, cause) + if errc != nil { + posErrs = append(posErrs, errc) + zlog.Errorf("close position error: k=%#v, err=%v", k, errc) + } + } + } + if len(posErrs) > 0 { + err = errors.Join(posErrs...) return } - // zlog.Debugf("apply market order: ts=%d, side=%s", k.Ts, side.String()) - price := decimals.MustToFloat64(k.Close) - account.ApplyMarketOrder(side, 0.01, price, k.Ts) - - // 根据信号方向平掉相反方向的仓位:如果信号是买入,平掉所有卖出仓位;如果信号是卖出,平掉所有买入仓位 - closeManager.CloseBySignal(sigSide, account, k) + return } -func (b *TradingPlanBacktester) RunTradingPlan(ctx context.Context, tradingPlan *sig.TradingPlan, stime, etime int64, sigKlineSeries *sig.KlineSeries) (err error) { - plan := tradingPlan.Plan - exchange := pb.ExchangeType(plan.Exchange) - interval := types.Interval(plan.Interval) - instId := plan.InstId - - sigStrategy := tradingPlan.GetSigStrategy() - maxWindow := int(sigStrategy.(strategy.ISingleSigStrategy).RequiredSeries()) - if maxWindow < 0 || maxWindow > indicator.MaxWindow { - err = fmt.Errorf("invalid window %d 0-%d, planId=%d", maxWindow, indicator.MaxWindow, plan.Id) +// closeBySigSingal 交易信号出现时检查平仓 +func (b *TradingPlanBacktester) closeBySigSingal(sigSide types.Side, kline types.Kline) (err error) { + var posErrs []error + positions := b.account.OpenPositions() + for _, pos := range positions { + closePos, cause := b.closeStrategy.OnSigStrategySingal(sigSide, pos) + if closePos { + errc := b.account.ClosePosition(pos, kline, cause) + if errc != nil { + posErrs = append(posErrs, errc) + zlog.Error("close position error: ", errc) + } + } + } + if len(posErrs) > 0 { + err = errors.Join(posErrs...) return } + return +} - // 回测账户 - account := NewAccount(10000, NewSimulator(0.0005, 0.0008)) - // 平仓管理器 - closeManager := NewCloseManager(0.02, 0) - closeManager.SetDynamicParams(0.1, 0.02, 0) - - seriesRange := &pb.SeriesRange{ - Exchange: exchange, - InstId: instId, - Interval: string(interval), - Before: stime, - After: etime, - Open: false, - Live: false, - Desc: false, - WindowExtra: uint32(maxWindow), - } - // fetch history klines via stream - req := &pb.ReqHistoryKlineStream{Series: seriesRange} - stream, err := b.exchangeClient.HistoryKlineStream(ctx, req, grpc.UseCompressor("snappy")) +// onSideSingal 出现买卖信号 +func (b *TradingPlanBacktester) onSideSingal(sigSide types.Side, k types.Kline) (err error) { + // 买卖信号交易风险分析 + doTrade, causes, err := b.riskStrategy.SigRiskAnalyze(sigSide) if err != nil { return } - - watch := times.NewWatch() - recvTimes, recvTotal := 0, 0 - var lastK *types.Kline - var msg *pb.RspHistoryKlineStream - for { - select { - case <-ctx.Done(): - err = ctx.Err() - return - default: - } - msg, err = stream.Recv() - if err == io.EOF { - break - } - if err != nil { - return - } - recvTimes++ - recvTotal += len(msg.Klines) - for _, k := range msg.Klines { - kline := new(types.Kline) - kline.ParsePBKline(seriesRange.Exchange, k) - lastK = kline - if lastTs, serial := sigKlineSeries.Update(kline); !serial { - err = fmt.Errorf("kline not series: %s(%s), interval=%s, lastTs=%d", instId, exchange, interval, lastTs) - return - } - length := sigKlineSeries.Length() - if length <= maxWindow { - continue - } - - // 平仓策略 - closeManager.OnKline(*kline, account) - - sigSide := tradingPlan.Update(strategy.StrategyTypeSig) - if sigSide.IsValid() { - b.onSigSideSignalWithAccount(sigSide, *kline, account, closeManager, nil) - } - } + if !doTrade { + _ = causes // todo 记录信号不交易原因分析 log db analyze + return + } + tradeArg, err := b.tradeStrategy.SigTrade(sigSide, k) + if err != nil { + return + } + ok, cause, err := b.account.TradeOrder(tradeArg) + if err != nil { + return + } + if !ok { + _ = cause // todo 记录不交易原因 } - - _ = lastK - zlog.Debugf("recv=%d, total=%d, use %s", recvTimes, recvTotal, watch.ElapsedFmt(".")) - collect.SortDesc(account.Trades, func(t *Trade) float64 { return t.Pnl }) - exposure := account.Cash + account.PositionCost() - _ = exposure return } diff --git a/internal/trading/backtest/types.go b/internal/trading/backtest/types.go index c39f7c6..e1da4f0 100644 --- a/internal/trading/backtest/types.go +++ b/internal/trading/backtest/types.go @@ -1,46 +1,22 @@ package backtest import ( - "sig-pub/pkg/types" + "sig-pub/pkg/trade" ) -type Side = types.Side - -type Position struct { - TradeId int64 - Side Side // 交易方向 - Qty float64 // 交易量 - EntryPx float64 // 入场价格 - EntryTs int64 // 入场时间 - PeakPx float64 // highest (for long) or lowest (for short) observed price since entry - Status int32 // 1.交易中 2.持仓中 3.已平仓 - Fee float64 // 手续费 - - Close bool // 是否已平仓 -} - -type Trade struct { - Id int64 // 交易id - Side types.Side // 交易方向 - Qty float64 // 交易量 - Price float64 // 开仓价格 - Fee float64 // 开仓手续费 - Ts int64 // 开仓时间 - ClosePrice float64 // 平仓价格 - CloseFee float64 // 平仓手续费 - CloseTs int64 // 平仓时间 - CloseCause string // 平仓原因 ["stoploss", "takeprofit", "trailing", "retrace", "signal"](“止损”、“止盈”、“动态跟踪”、“回撤”、“信号”) - Pnl float64 // 盈利/亏损 pnl = (t.ClosePrice-t.Price)*t.Qty - t.Fee - t.CloseFee - HoldTime string // 持仓时间 -} - type BacktestResult struct { StartTs int64 EndTs int64 - Trades []Trade - Positions []Position + Trades []*trade.Trade + Positions []*trade.Position Cash float64 Equity float64 + + // total_return 年化收益 + // sharpe_ratio 夏普比率 + // max_drawdown 最大回撤 + // num_trades 单数 + // win_rate 胜率 } type TradeStat struct { diff --git a/internal/trading/trading_grpc_server.go b/internal/trading/trading_grpc_server.go index 6c7aa69..89015c6 100644 --- a/internal/trading/trading_grpc_server.go +++ b/internal/trading/trading_grpc_server.go @@ -47,7 +47,7 @@ func (svr *TradingGrpcServer) Backtest(ctx context.Context, req *pb.ReqBacktest) if err != nil { return } - err = svr.tradingService.Backtest(req.PlanId, stime.UnixMilli(), etime.UnixMilli()) + err = svr.tradingService.Backtest(ctx, req.PlanId, stime.UnixMilli(), etime.UnixMilli()) if err != nil { return } diff --git a/internal/trading/trading_service.go b/internal/trading/trading_service.go index a0d01c3..344bb1d 100644 --- a/internal/trading/trading_service.go +++ b/internal/trading/trading_service.go @@ -104,7 +104,7 @@ func (svc *TradingService) getTradingPlan(plan *entity.TradePlan, sigKlineSeries return } if _, ok := types.SupportedIntervals[interval]; !ok { - err = fmt.Errorf("unsupport interval %d", plan.Exchange) + err = fmt.Errorf("unsupport interval %s", plan.Interval) return } @@ -260,7 +260,7 @@ func (svc *TradingService) StrategySeries(ctx context.Context, req *pb.ReqStrate // 使用回测器回测信号 backtester := backtest.NewSigStrategyBacktester(sigStrategyType, sigStrategy, svc.indicatorReg, svc.exchangeClient) - err = backtester.Backtest(ctx, req.Series, func(sigSide types.Side, k types.Kline) (err error) { + err = backtester.Backtest(ctx, req.Series, nil, func(sigSide types.Side, k types.Kline) (err error) { side := lang.Ternary(sigSide == types.SideLong, pb.Side_BUY, pb.Side_SELL) rsp.Signal = append(rsp.Signal, side) rsp.Times = append(rsp.Times, k.Ts) @@ -270,24 +270,33 @@ func (svc *TradingService) StrategySeries(ctx context.Context, req *pb.ReqStrate } // Backtest 回测交易计划 -func (svc *TradingService) Backtest(planId, stime, etime int64) (err error) { +func (svc *TradingService) Backtest(ctx context.Context, planId, stime, etime int64) (err error) { plan, err := svc.tradingDataPersist.GetTradePlanById(planId) if err != nil { return } exchange := pb.ExchangeType(plan.Exchange) interval := types.Interval(plan.Interval) - - sigKlineSeries := sig.NewKlineSeries(exchange, plan.InstId, interval) - tradingPlan, err := svc.getTradingPlan(plan, sigKlineSeries) - if err != nil { + if _, ok := types.SupportedIntervals[interval]; !ok { + err = fmt.Errorf("unsupport interval %s", interval) return } - test := backtest.NewTradingPlanBacktester(svc.exchangeClient, svc.indicatorReg, svc.strategyReg) - err = test.RunTradingPlan(context.Background(), tradingPlan, stime, etime, sigKlineSeries) - if err != nil { + sr := &pb.SeriesRange{ + Exchange: exchange, + InstId: plan.InstId, + Interval: plan.Interval, + Before: stime, + After: etime, + Open: false, + Live: false, + Desc: false, + } + + tester := backtest.NewTradingPlanBacktester(svc.indicatorReg, svc.strategyReg, svc.exchangeClient) + if err = tester.Init(10000, *plan); err != nil { return } + err = tester.Backtest(ctx, sr) return } diff --git a/pkg/data/entity/trade_plan.go b/pkg/data/entity/trade_plan.go index 36a8b90..067a187 100644 --- a/pkg/data/entity/trade_plan.go +++ b/pkg/data/entity/trade_plan.go @@ -2,19 +2,19 @@ package entity // TradePlan 交易计划 type TradePlan struct { - Id int64 `gorm:"column:id;primaryKey" json:"id"` // id - UserId int64 `gorm:"column:user_id" json:"userId"` // 用户id - Status int8 `gorm:"column:status" json:"status"` // 状态:0禁用,1启用 - Exchange int8 `gorm:"column:exchange" json:"exchange"` // 交易所 - InstId string `gorm:"column:inst_id" json:"instId"` // 交易产品id - Interval string `gorm:"column:interval" json:"interval"` // 交易周期 - SigStrategy string `gorm:"column:sig_strategy" json:"sigStrategy"` // 交易信号策略 - SigStrategyParam string `gorm:"column:sig_strategy_param" json:"sigStrategyParam"` // 交易信号策略参数 - CloseStrategyParam string `gorm:"column:close_strategy_param" json:"closeStrategyParam"` // 退出策略名称参数 - TradeStrategyParam string `gorm:"column:trade_strategy_param" json:"tradeStrategyParam"` // 下单仓位管理策略参数 - RiskStrategyParam string `gorm:"column:risk_strategy_param" json:"riskStrategyParam"` // 风险管理策略参数 - UpdateBy string `gorm:"column:update_by" json:"updateBy"` // 更新人 - UpdateTime int64 `gorm:"column:update_time" json:"updateTime"` // 更新时间戳毫秒 + Id int64 ` json:"id" gorm:"column:id;primaryKey"` // id + UserId int64 ` json:"userId" gorm:"column:user_id"` // 用户id + Status int8 ` json:"status" gorm:"column:status"` // 状态:0禁用,1启用 + Exchange int8 ` json:"exchange" gorm:"column:exchange"` // 交易所 + InstId string ` json:"instId" gorm:"column:inst_id"` // 交易产品id + Interval string ` json:"interval" gorm:"column:interval"` // 交易周期 + SigStrategy string ` json:"sigStrategy" gorm:"column:sig_strategy"` // 交易信号策略 + SigStrategyParam string ` json:"sigStrategyParam" gorm:"column:sig_strategy_param"` // 交易信号策略参数 + CloseStrategyParam string ` json:"closeStrategyParam" gorm:"column:close_strategy_param"` // 退出策略名称参数 + TradeStrategyParam string ` json:"tradeStrategyParam" gorm:"column:trade_strategy_param"` // 下单仓位管理策略参数 + RiskStrategyParam string ` json:"riskStrategyParam" gorm:"column:risk_strategy_param"` // 风险管理策略参数 + UpdateBy string ` json:"updateBy" gorm:"column:update_by"` // 更新人 + UpdateTime int64 ` json:"updateTime" gorm:"column:update_time"` // 更新时间戳毫秒 } func (TradePlan) TableName() string { diff --git a/pkg/trade/close_strategy.go b/pkg/trade/close_strategy.go index f2682b2..2413743 100644 --- a/pkg/trade/close_strategy.go +++ b/pkg/trade/close_strategy.go @@ -15,13 +15,11 @@ type ICloseStrategy interface { // 平仓策略参数 type CloseStrategyParam struct { - StopLossPct float64 `json:"stopLossPct"` // 固定止损 static stoploss - TakeProfitPct float64 `json:"takeProfitPct"` // 固定止盈 static take profit - TrailMinProfit float64 `json:"trailMinProfit"` // 启动移动止损的最小盈利阈值(例如达到 1% 后才开始追踪) minimum profit (fraction) before trailing activates (e.g. 0.01 = 1%); - TrailingPct float64 `json:"trailingPct"` // 移动止损百分比(例如 0.02 表示从最高价回撤 2% 时触发追踪止损) trailing stop percent (e.g. 0.02 = 2%); - ProfitRetracePct float64 `json:"profitRetracePct"` // close when profit retraces more than this fraction of peak profit;基于最高利润回撤触发平仓(例如从最高利润回撤超过 30% 则平仓)。 - CloseOnSideReverse bool `json:"closeOnSideReverse"` // 交易信号和持单方向相反时是否进行平仓 - Fee bool `json:"fee"` // 计算止盈止损时是否包含手续费 + StopLossPct float64 `json:"stopLossPct"` // 固定止损 static stoploss + TakeProfitPct float64 `json:"takeProfitPct"` // 固定止盈 static take profit + ProfitRetracePcts [][]float64 `json:"profitRetracePcts"` // 基于最高利润回撤触发平仓 (例如 [[0.01, 0.3], [0.02, 0.2]] 最高利润超过1%时30%回撤则触发平仓,最高利润超过2%时20%回撤就触发平仓) + CloseOnSideReverse bool `json:"closeOnSideReverse"` // 交易信号和持单方向相反时是否进行平仓 + Fee bool `json:"fee"` // 计算止盈止损时是否包含手续费 } // CloseStrategy 平仓策略 @@ -41,13 +39,6 @@ func NewCloseStrategy(param CloseStrategyParam) (cs *CloseStrategy, err error) { // Update 当k线更新判断是否关闭仓位 func (s *CloseStrategy) OnKline(k types.Kline, pos *Position) (closePos bool, cause Cause) { closePrice := decimals.MustToFloat64(k.Close) - // update peak px - // if pos.Side == types.SideLong && closePrice > pos.PeakPx { - // pos.PeakPx = closePrice - // } - // if pos.Side == types.SideShort && closePrice < pos.PeakPx { - // pos.PeakPx = closePrice - // } return s.OnPrice(closePrice, pos) } @@ -56,26 +47,92 @@ func (s *CloseStrategy) OnPrice(price float64, pos *Position) (closePos bool, ca if !pos.Side.IsValid() { return } + // update peak px + peakPx, ok := pos.GetStateF64("peakPx") + updatePeakPx := false + if !ok { + updatePeakPx = true + } else { + if pos.Side == types.SideLong && price > peakPx { + updatePeakPx = true + } + if pos.Side == types.SideShort && price < peakPx { + updatePeakPx = true + } + } + if updatePeakPx { + peakPx = price + pos.SetState("peakPx", price) + } + + entry := pos.EntryPx // side long: if pos.Side == types.SideLong { // 固定止损 - if s.StopLossPct > 0 && price <= pos.EntryPx*(1-s.StopLossPct) { - return true, CauseStoploss + if s.StopLossPct > 0 && price <= entry*(1-s.StopLossPct) { + return true, CauseCloseStoploss } // 固定止盈 - if s.TakeProfitPct > 0 && price >= pos.EntryPx*(1+s.TakeProfitPct) { - return true, CauseTakeprofit + if s.TakeProfitPct > 0 && price >= entry*(1+s.TakeProfitPct) { + return true, CauseCloseTakeprofit + } + // 基于最高利润动态止盈 + if len(s.ProfitRetracePcts) > 0 { + // peak profit fraction + peakProfit := (peakPx - entry) / entry + minProfitToTrail, trailingPct := float64(0), float64(0) + for _, profit := range s.ProfitRetracePcts { + if len(profit) != 2 { + continue + } + _minProfitToTrail := profit[0] // 启动最高利润回撤的最小盈利阈值 + _trailingPct := profit[1] // 基于最高利润回撤触发平仓 + if peakProfit >= _minProfitToTrail { + if _minProfitToTrail > minProfitToTrail { + minProfitToTrail = _minProfitToTrail + trailingPct = _trailingPct + } + } + } + if minProfitToTrail > 0 && trailingPct > 0 { + trail := peakPx * (1 - trailingPct) + if price <= trail { + return true, CauseCloseTrailing + } + } } - // todo dynamic trailing - return } + // side short: if s.StopLossPct > 0 && price >= pos.EntryPx*(1+s.StopLossPct) { - return true, CauseStoploss + return true, CauseCloseStoploss } if s.TakeProfitPct > 0 && price <= pos.EntryPx*(1-s.TakeProfitPct) { - return true, CauseTakeprofit + return true, CauseCloseTakeprofit + } + // 基于最高利润动态止盈 + if len(s.ProfitRetracePcts) > 0 { + // peak profit fraction + peakProfit := (entry - peakPx) / entry + minProfitToTrail, trailingPct := float64(0), float64(0) + for _, profit := range s.ProfitRetracePcts { + if len(profit) != 2 { + continue + } + _minProfitToTrail := profit[0] // 启动最高利润回撤的最小盈利阈值 + _trailingPct := profit[1] // 基于最高利润回撤触发平仓 + if peakProfit >= _minProfitToTrail && _minProfitToTrail >= minProfitToTrail { + minProfitToTrail = _minProfitToTrail + trailingPct = _trailingPct + } + } + if minProfitToTrail > 0 && trailingPct > 0 { + trail := peakPx * (1 + trailingPct) + if price >= trail { + return true, CauseCloseTrailing + } + } } return } @@ -85,5 +142,5 @@ func (s *CloseStrategy) OnSigStrategySingal(sigSide types.Side, pos *Position) ( if !s.CloseOnSideReverse { return } - return sigSide != pos.Side, CauseStoploss + return sigSide != pos.Side, CauseCloseReverseSingal } diff --git a/pkg/trade/risk_strategy.go b/pkg/trade/risk_strategy.go index f5281f5..e1514ac 100644 --- a/pkg/trade/risk_strategy.go +++ b/pkg/trade/risk_strategy.go @@ -11,6 +11,7 @@ type IRickStrategy interface { type RiskStrategyParam struct { SkipOnSideOpposite bool // 当前持有反方向单时 SkipOnSideSame bool // 当前持有相同方向单时 + } // RiskStrategy 风险管理策略 @@ -30,7 +31,7 @@ func NewRiskStrategy(param RiskStrategyParam) (rs *RiskStrategy, err error) { // 对交易方向进行信心分数评估, 后续开仓仓位 // 1.当前持有反方向单时, 不进行开仓 // 2.当前持有同方向单时, 根据信心分数评估是否加仓 -func (s *RiskStrategy) SideAssess(signalSide types.Side) (side types.Side) { +func (s *RiskStrategy) SigRiskAnalyze(signalSide types.Side) (doTrade bool, causes []Cause, err error) { - return signalSide + return true, nil, nil } diff --git a/pkg/trade/trade_account.go b/pkg/trade/trade_account.go index 621fa1f..cc1dcc2 100644 --- a/pkg/trade/trade_account.go +++ b/pkg/trade/trade_account.go @@ -1,38 +1,32 @@ package trade -import ( - "sig-pub/pkg/data/entity" - "sig-pub/pkg/types" - - "github.com/govalues/decimal" -) - // sig -> close strategy // sig -> risk strategy -> trade strategy -> tarde account type ITradeAccount interface { - // 根据当前价格对仓位进行 mark-to-market,返回账户净值 - GetCurrentEquity() decimal.Decimal - - // 返回当前仓位的名义总敞口(绝对值) - GetCurrentExposure() decimal.Decimal - - // 获取可交易空闲资金 - GetCash() decimal.Decimal - // 获取未平仓交易单 - ListOpenPosition() []*Position - - // 获取交易订单 - GetTradeOrder(tradeId int64) Trade + OpenPositions() []*Position - // 判断在给定价格下是否可以开仓(基于 MaxPosPct 和 MaxExposurePct) - CanOpen(instId string, side types.Side, qty, price float64) bool + // 获取未平仓交易单数 + CountOpenPositions() int - // 直接用市价下单(简化),qty为基础货币数量 - ApplyMarketOrder(instId string, side types.Side, qty float64, price float64, ts int64) (t *entity.TradeOrder, ok bool) + // 订单下单 + TradeOrder(ta TradeArg) (ok bool, cause Cause, err error) // 将仓位进行平仓 - ClosePosition() + ClosePosition(*Position, Cause) (err error) + + // // 根据当前价格对仓位进行 mark-to-market,返回账户净值 + // GetCurrentEquity() decimal.Decimal + // // 返回当前仓位的名义总敞口(绝对值) + // GetCurrentExposure() decimal.Decimal + // // 获取可交易空闲资金 + // GetCash() decimal.Decimal + // // 获取交易订单 + // GetTradeOrder(tradeId int64) Trade + // // 判断在给定价格下是否可以开仓(基于 MaxPosPct 和 MaxExposurePct) + // CanOpen(instId string, side types.Side, qty, price float64) bool + // // 直接用市价下单(简化),qty为基础货币数量 + // ApplyMarketOrder(instId string, side types.Side, qty float64, price float64, ts int64) (t *entity.TradeOrder, ok bool) } type OkxTradeAccount struct { diff --git a/pkg/trade/trade_strategy.go b/pkg/trade/trade_strategy.go index 9fc8016..35cb1cb 100644 --- a/pkg/trade/trade_strategy.go +++ b/pkg/trade/trade_strategy.go @@ -1,6 +1,11 @@ package trade -import "github.com/govalues/decimal" +import ( + "sig-pub/pkg/types" + "sig-pub/pkg/types/decimals" + + "github.com/govalues/decimal" +) // ITradeStrategy 下单策略 // 根据购买信号和账户信息生成下单参数 @@ -19,4 +24,42 @@ type ITradeStrategyContext interface { } type TradeStrategyParam struct { + MaxPosPct float64 // 单笔交易最大仓位占比 + MaxExposurePct float64 // 最大总敞口占比 + MaxLots float64 // 最大手数/数量 (optional) +} + +type TradeStrategy struct { + param TradeStrategyParam +} + +func NewTradeStrategy(param TradeStrategyParam) (*TradeStrategy, error) { + return &TradeStrategy{ + param: param, + }, nil +} + +func (s *TradeStrategy) SigTrade(side types.Side, k types.Kline) (ta TradeArg, err error) { + price := decimals.MustToFloat64(k.Close) + time := k.Interval.MustAddMul(k.Ts, 1) + ta = TradeArg{ + Side: side, + Price: price, + Leverage: 1, + Qty: 0.01, + KInterval: string(k.Interval), + KTime: k.Ts, + Time: time, + } + return +} + +type TradeArg struct { + Side types.Side // 开仓方向 + Price float64 // 开仓价格 + Leverage int32 // 杠杆倍数 + Qty float64 // 交易量 qty为基础货币数量 + KInterval string // 交易k线周期 + KTime int64 // 交易k线时间 + Time int64 // 交易时间 } diff --git a/pkg/trade/types.go b/pkg/trade/types.go index 2acd09e..984406a 100644 --- a/pkg/trade/types.go +++ b/pkg/trade/types.go @@ -1,18 +1,32 @@ package trade -import "sig-pub/pkg/types" +import ( + "sig-pub/pkg/types" -// Position 持仓仓位 -type Position struct { - TradeId int64 // 交易订单id - Status int32 // 1.交易中 2.持仓中 3.已平仓 - Side types.Side // 交易方向 - Qty float64 // 交易量 - EntryPx float64 // 入场价格 - EntryTs int64 // 入场时间 - PeakPx float64 // highest (for long) or lowest (for short) observed price since entry - Fee float64 // 手续费 - FeeRate float64 // 手续费率 + "github.com/spf13/cast" +) + +type Cause int32 + +const ( + // _ Cause = iota + CauseCloseStoploss Cause = 1001 // 平仓:固定止损 + CauseCloseTakeprofit Cause = 1002 // 平仓:固定止盈 + CauseCloseReverseSingal Cause = 1003 // 平仓:策略反向信号 + CauseCloseTrailing Cause = 1004 // 平仓:移动止损基于最高利润点回撤百分比 + CauseRiskAlreadyTrade Cause = 2001 // 风控:已有持仓 + CauseRiskSideAlreadyTrade Cause = 2002 // 风控:相同方向已有持仓 +) + +func (c Cause) String() string { + switch c { + default: + return "" + case CauseCloseStoploss: + return "stoploss" + case CauseCloseTakeprofit: + return "takeprofit" + } } type Trade struct { @@ -21,31 +35,53 @@ type Trade struct { Qty float64 // 交易量 Price float64 // 开仓价格 Fee float64 // 开仓手续费 - Ts int64 // 开仓时间 + Time int64 // 开仓时间 ClosePrice float64 // 平仓价格 CloseFee float64 // 平仓手续费 CloseTs int64 // 平仓时间 - CloseCause string // 平仓原因 ["stoploss", "takeprofit", "trailing", "retrace", "signal"](“止损”、“止盈”、“动态跟踪”、“回撤”、“信号”) + CloseCause Cause // 平仓原因 ["stoploss", "takeprofit", "trailing", "retrace", "signal"](“止损”、“止盈”、“动态跟踪”、“回撤”、“信号”) Pnl float64 // 盈利/亏损 pnl = (t.ClosePrice-t.Price)*t.Qty - t.Fee - t.CloseFee HoldTime string // 持仓时间 } -type Cause int32 +// Position 持仓仓位 +type Position struct { + TradeId int64 // 交易订单id + Status int32 // 1.交易中 2.持仓中 3.已平仓 + Side types.Side // 交易方向 + Qty float64 // 交易量 + EntryPx float64 // 入场价格 + EntryTs int64 // 入场时间 + PeakPx float64 // highest (for long) or lowest (for short) observed price since entry + Fee float64 // 手续费 + FeeRate float64 // 手续费率 + State map[string]any // 持仓持久化状态 +} -const ( - _ Cause = iota - CauseStoploss // 固定止损 - CauseTakeprofit // 固定止盈 - CauseReverseSingal // 策略反向信号 -) +func (pos *Position) SetState(k string, v any) { + if pos.State == nil { + pos.State = make(map[string]any) + } + pos.State[k] = v +} -func (c Cause) String() string { - switch c { - default: - return "" - case CauseStoploss: - return "stoploss" - case CauseTakeprofit: - return "takeprofit" +func (pos *Position) GetState(k string) (v any, ok bool) { + if pos.State == nil { + return + } + v, ok = pos.State[k] + return +} + +func (pos *Position) GetStateF64(k string) (f float64, ok bool) { + if pos.State == nil { + return + } + v, ok := pos.State[k] + if !ok { + return } + f, err := cast.ToFloat64E(v) + ok = err == nil + return } diff --git a/pkg/types/interval.go b/pkg/types/interval.go index a2937a5..7f5cc57 100644 --- a/pkg/types/interval.go +++ b/pkg/types/interval.go @@ -127,30 +127,50 @@ func IntervalsSort(intervals []Interval) { } type IntervalState[T any] struct { - state []T + state []intervalStateEntry[T] +} + +type intervalStateEntry[T any] struct { + V T + Exists bool } func NewIntervalState[T any]() *IntervalState[T] { return &IntervalState[T]{ - state: make([]T, intervalIotaMax+1), + state: make([]intervalStateEntry[T], intervalIotaMax+1), } } func (s *IntervalState[T]) Get(interval Interval) T { i := intervalIotas[interval] - return s.state[i] + return s.state[i].V +} + +func (s *IntervalState[T]) setI(i int, v T) { + s.state[i].V = v + s.state[i].Exists = true } func (s *IntervalState[T]) Set(interval Interval, v T) { i := intervalIotas[interval] - s.state[i] = v + s.setI(i, v) +} + +func (s *IntervalState[T]) ComputeIfAbsent(interval Interval, computeV func() T) (r T) { + i := intervalIotas[interval] + if s.state[i].Exists { + return s.state[i].V + } + v := computeV() + s.setI(i, v) + return v } func (s *IntervalState[T]) Range(f func(interval Interval, v T)) { for i, interval := range iotasIntervals { index := i + 1 // 0保留 v := s.state[index] - f(interval, v) + f(interval, v.V) } } @@ -158,7 +178,7 @@ func (s *IntervalState[T]) RangeBreak(f func(interval Interval, v T) bool) { for i, interval := range iotasIntervals { index := i + 1 // 0保留 v := s.state[index] - if !f(interval, v) { + if !f(interval, v.V) { break } } @@ -167,7 +187,7 @@ func (s *IntervalState[T]) RangeBreak(f func(interval Interval, v T) bool) { func (s *IntervalState[T]) SetIf(interval Interval, v T, cond func(old T) bool) { i := intervalIotas[interval] old := s.state[i] - if cond(old) { - s.state[i] = v + if cond(old.V) { + s.setI(i, v) } }