diff --git a/internal/trading/backtest/account.go b/internal/trading/backtest/account.go index 0c1aaf2..85200d5 100644 --- a/internal/trading/backtest/account.go +++ b/internal/trading/backtest/account.go @@ -4,20 +4,30 @@ 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 + 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} + return &Account{ + Cash: cash, Simulator: sim, MaxPosPct: 1.0, MaxExposurePct: 1.0, + Stat: &TradeStat{}, + } } func (a *Account) SetRiskLimits(maxPosPct, maxExposurePct float64) { @@ -33,9 +43,10 @@ func (a *Account) SetRiskLimits(maxPosPct, maxExposurePct float64) { func (a *Account) CurrentEquity(price float64) float64 { equity := a.Cash for _, p := range a.Positions { - if p.Side == types.SideBuy { + switch p.Side { + case types.SideLong: equity += (price - p.EntryPx) * p.Qty - } else if p.Side == types.SideSell { + case types.SideShort: equity += (p.EntryPx - price) * p.Qty } } @@ -51,6 +62,14 @@ func (a *Account) CurrentExposure(price float64) float64 { 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 { @@ -77,66 +96,82 @@ func (a *Account) CanOpen(side types.Side, qty, price float64) bool { } // ApplyMarketOrder 直接用市价下单(简化),qty为基础货币数量 -func (a *Account) ApplyMarketOrder(side types.Side, qty float64, klineTs int64, kline types.Kline) (t Trade, ok bool) { - // risk check before executing - price := decimals.MustToFloat64(kline.Close) +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 := a.Simulator.ExecuteMarket(side, qty, kline, klineTs) - // apply cash/position - if side == types.SideBuy { - cost := trade.Price*qty + trade.Fee - if cost > a.Cash { - return trade, false - } - a.Cash -= cost - // open/add position - pos := &Position{Side: side, Qty: qty, EntryPx: trade.Price, EntryTs: klineTs, PeakPx: trade.Price} - a.Positions = append(a.Positions, pos) - } else if side == types.SideSell { - // simplify: allow short by increasing cash - receive := trade.Price*qty - trade.Fee - a.Cash += receive - pos := &Position{Side: side, Qty: qty, EntryPx: trade.Price, EntryTs: klineTs, PeakPx: trade.Price} - a.Positions = append(a.Positions, pos) + 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, kline types.Kline, ts int64, cause string) (t Trade, ok bool) { - if index < 0 || index >= len(a.Positions) { - return t, false - } - pos := a.Positions[index] - if pos == nil { - return t, false - } +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.SideBuy { - closeSide = types.SideSell + if pos.Side == types.SideLong { + closeSide = types.SideShort } else { - closeSide = types.SideBuy + closeSide = types.SideLong } - t = a.Simulator.ExecuteMarket(closeSide, pos.Qty, kline, ts) + 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 - if closeSide == types.SideSell { - // selling a long position -> receive cash - receive := t.Price*pos.Qty - t.Fee - a.Cash += receive + // 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 { - // buying to close a short -> pay cash - cost := t.Price*pos.Qty + t.Fee - a.Cash -= cost + a.Stat.LosingTrades++ } + a.Stat.Fee += t.Fee // remove position a.Positions = append(a.Positions[:index], a.Positions[index+1:]...) - a.Trades = append(a.Trades, t) + 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 } diff --git a/internal/trading/backtest/backtest.go b/internal/trading/backtest/backtest.go index a979e70..11fafd9 100644 --- a/internal/trading/backtest/backtest.go +++ b/internal/trading/backtest/backtest.go @@ -11,6 +11,7 @@ import ( "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" @@ -20,28 +21,19 @@ import ( type Backtest struct { exchangeClient pb.ExchangeServiceClient indReg *indicator.IndicatorRegistry - sigStrategyReg *strategy.SigStrategyRegistry - account *Account riskStrategy *trade.RiskStrategy - closeManager *CloseManager } func NewBacktest(exchangeClient pb.ExchangeServiceClient, indReg *indicator.IndicatorRegistry, sigStrategyReg *strategy.SigStrategyRegistry) *Backtest { - account := NewAccount(10000, NewSimulator(0.0005, 0.0008)) - cm := NewCloseManager(0.02, 0.03) return &Backtest{ exchangeClient: exchangeClient, indReg: indReg, riskStrategy: trade.NewRiskStrategy(), - account: account, - closeManager: cm, } } func (b *Backtest) RunTradingPlan(ctx context.Context, tradingPlan *sig.TradingPlan, stime, etime int64, sigKlineSeries *sig.KlineSeries) (err error) { - var sim *Simulator - _ = sim plan := tradingPlan.Plan exchange := pb.ExchangeType(plan.Exchange) interval := types.Interval(plan.Interval) @@ -54,6 +46,12 @@ func (b *Backtest) RunTradingPlan(ctx context.Context, tradingPlan *sig.TradingP 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, @@ -67,25 +65,31 @@ func (b *Backtest) RunTradingPlan(ctx context.Context, tradingPlan *sig.TradingP } // fetch history klines via stream req := &pb.ReqHistoryKlineStream{Series: seriesRange} - stream, err := b.exchangeClient.HistoryKlineStream(context.Background(), req, grpc.UseCompressor("snappy")) + stream, err := b.exchangeClient.HistoryKlineStream(ctx, req, grpc.UseCompressor("snappy")) if err != nil { return } - recvTimes, total := 0, 0 watch := times.NewWatch() + recvTimes, recvTotal := 0, 0 var lastK *types.Kline + var msg *pb.RspHistoryKlineStream for { - msg, err0 := stream.Recv() - if err0 == io.EOF { + select { + case <-ctx.Done(): + err = ctx.Err() + return + default: + } + msg, err = stream.Recv() + if err == io.EOF { break } - if err0 != nil { - err = err0 + if err != nil { return } recvTimes++ - total += len(msg.Klines) + recvTotal += len(msg.Klines) for _, k := range msg.Klines { kline := new(types.Kline) kline.ParsePBKline(seriesRange.Exchange, k) @@ -100,166 +104,35 @@ func (b *Backtest) RunTradingPlan(ctx context.Context, tradingPlan *sig.TradingP } // 平仓策略 - b.closeManager.OnKline(*kline, b.account) + closeManager.OnKline(*kline, account) sigSide := tradingPlan.Update(strategy.StrategyTypeSig) if sigSide.IsValid() { - b.onSigSideSignal(sigSide, *kline) + b.onSigSideSignalWithAccount(sigSide, *kline, account, closeManager) } } } - // build result - res := &BacktestResult{} - res.StartTs = 0 - res.EndTs = 0 - acct := b.account - res.Trades = acct.Trades - for _, p := range acct.Positions { - res.Positions = append(res.Positions, *p) - } - res.Cash = acct.Cash - // estimate equity using last close price - if lastK != nil { - last := decimals.MustToFloat64(lastK.Close) - equity := acct.Cash - // naive mark-to-market of positions - for _, p := range acct.Positions { - if p.Side == types.SideBuy { - equity += (last - p.EntryPx) * p.Qty - } else { - equity += (p.EntryPx - last) * p.Qty - } - } - res.Equity = equity - } - zlog.Debugf("recv=%d, total=%d, use %s, ret=%#v", recvTimes, total, watch.ElapsedFmt("."), res) + _ = 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 } -// onSigSideSignal 交易策略发出交易信号 -func (b *Backtest) onSigSideSignal(sigSide types.Side, k types.Kline) { +// onSigSideSignalWithAccount 交易策略发出交易信号(使用指定的账户和平仓管理器) +func (b *Backtest) onSigSideSignalWithAccount(sigSide types.Side, k types.Kline, account *Account, closeManager *CloseManager) { + // risk check before executing side := b.riskStrategy.SideAssess(sigSide) if !side.IsValid() { zlog.Debugf("risk strategy filter sig side: %s", sigSide.String()) return } - zlog.Debugf("apply market order: ts=%d, side=%s", k.Ts, side.String()) - b.account.ApplyMarketOrder(side, 0.01, k.Ts, k) - - b.closeManager.CloseBySignal(types.SideBuy, b.account, k) -} - -// Run 执行回测 -// seriesRange: 回测的交易产品/周期/时间区间 -// sigStrategy: 已创建的策略实例(将调用 New() 并 Init) -// params: 策略参数 -func (b *Backtest) Run0(ctx context.Context, seriesRange *pb.SeriesRange, sigStrategy strategy.ISigStrategy, params strategy.StrategyParam, initialCash float64) (res BacktestResult, err error) { - // prepare strategy - strat := sigStrategy.New() - if err = strat.Init(params); err != nil { - return - } - - // fetch history klines via stream - req := &pb.ReqHistoryKlineStream{Series: seriesRange} - stream, err := b.exchangeClient.HistoryKlineStream(context.Background(), req, grpc.UseCompressor("snappy")) - if err != nil { - return - } - - var klines []types.Kline - var firstTs, lastTs int64 - for { - msg, err0 := stream.Recv() - if err0 == io.EOF { - break - } - if err0 != nil { - err = err0 - return - } - for _, k := range msg.Klines { - kk := new(types.Kline) - kk.ParsePBKline(seriesRange.Exchange, k) - klines = append(klines, *kk) - if firstTs == 0 { - firstTs = kk.Ts - } - lastTs = kk.Ts - } - } - - // prepare account and set risk limits from params if provided - acct := NewAccount(initialCash, nil) - if v, ok := params.GetFloat64("max_pos_pct"); ok && v > 0 { - acct.MaxPosPct = v - } - if v, ok := params.GetFloat64("max_exposure_pct"); ok && v > 0 { - acct.MaxExposurePct = v - } - - // prepare close manager from params - var cm *CloseManager - if sl, ok := params.GetFloat64("stoploss_pct"); ok || true { - tp, _ := params.GetFloat64("takeprofit_pct") - cm = NewCloseManager(sl, tp) - } - - // iterate klines in chronological order - for i := 0; i < len(klines); i++ { - // build context with klines up to i - window := klines[:i+1] - ctxSig := NewSigStrategyContext(window, b.indReg) - // first, evaluate stoploss/takeprofit on this kline - if cm != nil { - cm.OnKline(klines[i], acct) - } - side := strat.Update(ctxSig) - // simple position sizing: param 'size' as fraction of cash; else use fixed qty 1 - sizePct, ok := params.GetFloat64("size") - var qty float64 - if ok && sizePct > 0 { - price := decimals.MustToFloat64(klines[i].Close) - qty = (acct.Cash * sizePct) / price - } else { - qty = 1 - } - - if side == types.SideBuy || side == types.SideSell { - // signal-based close: close opposite positions first - if cm != nil { - cm.CloseBySignal(types.SideBuy, acct, klines[i]) - } - if _, ok := acct.ApplyMarketOrder(types.SideBuy, qty, klines[i].Ts, klines[i]); ok { - // trade recorded - } else { - zlog.Debugf("order rejected or insufficient cash at ts=%d", klines[i].Ts) - } - } - } + // 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) - // build result - res.StartTs = firstTs - res.EndTs = lastTs - res.Trades = acct.Trades - for _, p := range acct.Positions { - res.Positions = append(res.Positions, *p) - } - res.Cash = acct.Cash - // estimate equity using last close price - if len(klines) > 0 { - last := decimals.MustToFloat64(klines[len(klines)-1].Close) - equity := acct.Cash - // naive mark-to-market of positions - for _, p := range acct.Positions { - if p.Side == types.SideBuy { - equity += (last - p.EntryPx) * p.Qty - } else { - equity += (p.EntryPx - last) * p.Qty - } - } - res.Equity = equity - } - return + // 根据信号方向平掉相反方向的仓位:如果信号是买入,平掉所有卖出仓位;如果信号是卖出,平掉所有买入仓位 + closeManager.CloseBySignal(sigSide, account, k) } diff --git a/internal/trading/backtest/close_manager.go b/internal/trading/backtest/close_manager.go index db800ee..515139c 100644 --- a/internal/trading/backtest/close_manager.go +++ b/internal/trading/backtest/close_manager.go @@ -1,11 +1,14 @@ 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 @@ -24,9 +27,13 @@ func (m *CloseManager) SetDynamicParams(trailingPct, minProfitToTrail, profitRet 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) { +func (m *CloseManager) OnKline(k types.Kline, acct *Account) (trades []*Trade) { if acct == nil { return } @@ -36,127 +43,140 @@ func (m *CloseManager) OnKline(k types.Kline, acct *Account) (trades []Trade) { // collect indices to close to avoid modifying slice during iteration type closeTask struct { - idx int - cause string + tradeId int64 + cause string } var toClose []closeTask - priceHigh := decimals.MustToFloat64(k.High) - priceLow := decimals.MustToFloat64(k.Low) + closePrice := decimals.MustToFloat64(k.Close) - for i, p := range acct.Positions { + for _, 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 == types.SideBuy { - if high > p.PeakPx { - p.PeakPx = high + switch p.Side { + case types.SideLong: + if closePrice > p.PeakPx { + p.PeakPx = closePrice } - } else if p.Side == types.SideSell { - if low < p.PeakPx { - p.PeakPx = low + case types.SideShort: + if closePrice < p.PeakPx { + p.PeakPx = closePrice } } - if p.Side == types.SideBuy { + // 固定止盈止损 + if p.Side == types.SideLong { // stoploss - if m.StopLossPct > 0 && priceLow <= entry*(1-m.StopLossPct) { - toClose = append(toClose, closeTask{idx: i, cause: "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) { - toClose = append(toClose, closeTask{idx: i, cause: "takeprofit"}) - continue - } + // 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 { + if peakProfit >= m.MinProfitToTrail { // 最高盈利百分比 // trailing level - trailLevel := p.PeakPx * (1 - m.TrailingPct) - if priceLow <= trailLevel { - toClose = append(toClose, closeTask{idx: i, cause: "trailing"}) + // 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 := (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 == types.SideSell { + // 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 && 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"}) + 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 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"}) + // 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-- { - idx := toClose[j].idx + tradeId := toClose[j].tradeId cause := toClose[j].cause - if idx < 0 || idx >= len(acct.Positions) { - continue + var pos *Position + var index int + for i, p := range acct.Positions { + if p.TradeId == tradeId { + pos = p + index = i + break + } } - // perform market close: side opposite - pos := acct.Positions[idx] - var closeSide types.Side - if pos.Side == types.SideBuy { - closeSide = types.SideSell - } else { - closeSide = types.SideBuy + if pos == nil { + return } - tr, ok := acct.ClosePosition(idx, k, k.Ts, cause) + + // perform market close: side opposite + tr, ok := acct.ClosePosition(index, pos, 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 types.Side, acct *Account, k types.Kline) (trades []Trade) { +func (m *CloseManager) CloseBySignal(sigSide types.Side, acct *Account, k types.Kline) (trades []*Trade) { if acct == nil { return } @@ -164,22 +184,23 @@ func (m *CloseManager) CloseBySignal(sigSide types.Side, acct *Account, k types. 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.SideBuy && p.Side == types.SideSell { - toClose = append(toClose, closeTask{idx: i, cause: "signal"}) - } else if sigSide == types.SideSell && p.Side == types.SideBuy { - toClose = append(toClose, closeTask{idx: i, cause: "signal"}) + 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, k, k.Ts, cause) + tr, ok := acct.ClosePosition(idx, toClose[j].pos, k, k.Ts, cause) if ok { trades = append(trades, tr) } diff --git a/internal/trading/backtest/context.go b/internal/trading/backtest/context.go deleted file mode 100644 index 5c133c7..0000000 --- a/internal/trading/backtest/context.go +++ /dev/null @@ -1,78 +0,0 @@ -package backtest - -import ( - "sig-pub/pkg/indicator" - "sig-pub/pkg/types" - "sig-pub/pkg/types/series" -) - -// SigStrategyContext 是一个轻量的策略上下文,用于回测时把历史k线提供给策略 -type SigStrategyContext struct { - klines []types.Kline // 时间升序: oldest ... newest - offset int16 // offset applied when indicators request - indReg *indicator.IndicatorRegistry -} - -func NewSigStrategyContext(klines []types.Kline, indReg *indicator.IndicatorRegistry) *SigStrategyContext { - return &SigStrategyContext{klines: klines, indReg: indReg} -} - -func (c *SigStrategyContext) Get(offset int16) (k types.Kline) { - // offset relative to current (0 = latest) - idx := len(c.klines) - 1 - int(offset+c.offset) - if idx < 0 { - // return zero kline if out of range - return types.Kline{} - } - return c.klines[idx] -} - -func (c *SigStrategyContext) Series(offset, count int16) (klines series.Klines) { - // return slice in descending time order as expected by series.Klines - var ret series.Klines - for i := int16(0); i < count; i++ { - k := c.Get(offset + i) - ret = append(ret, k) - } - return ret -} - -// WindowIndicatorSeriesLocal 实现 indicator.IIndicatorSeries -type WindowIndicatorSeriesLocal struct { - window int16 - ind indicator.IWindowIndicator - ctx *SigStrategyContext -} - -func NewWindowIndicatorSeriesLocal(window int16, ind indicator.IWindowIndicator, ctx *SigStrategyContext) *WindowIndicatorSeriesLocal { - return &WindowIndicatorSeriesLocal{window: window, ind: ind, ctx: ctx} -} - -func (w *WindowIndicatorSeriesLocal) Get(offset int16) (vector float64) { - // tell indicator to use offset by shifting internal offset then restore - prev := w.ctx.offset - w.ctx.offset += offset - vector = w.ind.Calculate(w.ctx, w.window) - w.ctx.offset = prev - return -} - -func (w *WindowIndicatorSeriesLocal) Series(offset, count int16) (matrix series.Floats) { - prev := w.ctx.offset - w.ctx.offset += offset - for i := int16(0); i < count; i++ { - v := w.ind.Calculate(w.ctx, w.window) - matrix.Push(v) - w.ctx.offset++ - } - w.ctx.offset = prev - return -} - -func (c *SigStrategyContext) IndicatorW(name string, window int16) indicator.IIndicatorSeries { - ind, ok := c.indReg.IndicatorW(name) - if !ok { - panic("indicator not found: " + name) - } - return NewWindowIndicatorSeriesLocal(window, ind, c) -} diff --git a/internal/trading/backtest/simulator.go b/internal/trading/backtest/simulator.go index 92b3dd0..8b05aed 100644 --- a/internal/trading/backtest/simulator.go +++ b/internal/trading/backtest/simulator.go @@ -9,6 +9,8 @@ import ( type Simulator 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 { @@ -16,42 +18,49 @@ func NewSimulator(feePct, slippagePct float64) *Simulator { } // ExecuteMarket 执行市价单,使用kline信息决定成交价(使用close以及滑点) -func (s *Simulator) ExecuteMarket(side types.Side, qty float64, k types.Kline, ts int64) (trade Trade) { +func (s *Simulator) ExecuteMarket(side types.Side, qty float64, closePrice float64, ts int64) (trade *Trade, ok bool) { // base price use close - base := decimals.MustToFloat64(k.Close) + base := closePrice slippage := s.SlippagePct - if side == types.SideSell { - // sell: worse price lower - base = base * (1 - slippage) - } else { + switch side { + case types.SideLong: // buy: worse price higher base = base * (1 + slippage) + case types.SideShort: + // sell: worse price lower + base = base * (1 - slippage) + default: + return } fee := math.Abs(base*qty) * s.FeePct - trade = Trade{Side: side, Qty: qty, Price: base, Fee: fee, Ts: ts} + trade = &Trade{Side: side, Qty: qty, Price: base, Fee: fee, Ts: ts} + s.TradeId++ + trade.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) (filled bool, trade Trade) { +func (s *Simulator) ExecuteLimit(side types.Side, qty float64, limitPx float64, k types.Kline, ts int64) (trade Trade, filled bool) { h := decimals.MustToFloat64(k.High) l := decimals.MustToFloat64(k.Low) - if side == types.SideBuy { + switch side { + case types.SideLong: // buy limit: filled if low <= price if l <= limitPx { // 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 true, trade + return trade, true } - } else if side == types.SideSell { + 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 true, trade + return trade, true } } - return false, trade + return trade, false } diff --git a/internal/trading/backtest/types.go b/internal/trading/backtest/types.go index ff7d6e5..c39f7c6 100644 --- a/internal/trading/backtest/types.go +++ b/internal/trading/backtest/types.go @@ -1,21 +1,27 @@ package backtest -import "sig-pub/pkg/types" +import ( + "sig-pub/pkg/types" +) type Side = types.Side type Position struct { - Side Side - Qty float64 - EntryPx float64 - EntryTs int64 + 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 // 交易方向 - Status int32 // 1.交易中 2.持仓中 3.已平仓 Qty float64 // 交易量 Price float64 // 开仓价格 Fee float64 // 开仓手续费 @@ -24,6 +30,8 @@ type Trade struct { 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 { @@ -34,3 +42,55 @@ type BacktestResult struct { Cash float64 Equity float64 } + +type TradeStat struct { + // 消耗 + Fee float64 // 总手续费 + StoplossTimes int + TakeprofitTimes int + TrailingTimes int + RetraceTimes int + + // Trade Statistics (交易统计) + WinRate float64 `json:"win_rate"` // 胜率 + TotalTrades int64 `json:"total_trades"` // 交易次数 + WinningTrades int64 `json:"winning_trades"` // 盈利次数 + LosingTrades int64 `json:"losing_trades"` // 亏损次数 + + // // Capital (本金) + // Principal float64 `json:"principal"` // 本金 + // MaxCapitalUsedAmount float64 `json:"max_capital_used_amount"` // 最大资金使用金额 + // MaxCapitalUsedAmountTime int64 `json:"max_capital_used_amount_time"` // 最大资金使用金额时间 + // MaxCapitalUsedAmountRatio float64 `json:"max_capital_used_amount_ratio"` // 最大资金使用金额比率 + // MaxCapitalUsedRatioTime string `json:"max_capital_used_ratio_time"` // 最大资金使用金额比率时间 + + // // Equity (权益) + // MaxEquityDuringBacktest float64 `json:"max_equity_during_backtest"` // 回测期间最大权益 + // MinEquityDuringBacktest float64 `json:"min_equity_during_backtest"` // 回测期间最小权益 + // FinalEquity float64 `json:"final_equity"` // 最终权益 + // Profit float64 `json:"profit"` // 收益 + + // // Returns (收益率) + // ReturnRate float64 `json:"return_rate"` // 收益率 + // CumulativeReturnRate float64 `json:"cumulative_return_rate"` // 累积收益率 + // AnnualizedSimpleReturnRate float64 `json:"annualized_simple_return_rate"` // 年化单利收益率 + // MonthlySimpleReturnRate float64 `json:"monthly_simple_return_rate"` // 月化单利收益率 + // AnnualizedCompoundReturnRate float64 `json:"annualized_compound_return_rate"` // 年化复利收益率 + // MonthlyCompoundReturnRate float64 `json:"monthly_compound_return_rate"` // 月化复利收益率 + + // // Risk Metrics (风险指标) + // SharpeRatio float64 `json:"sharpe_ratio"` // 夏普比率 + // SortinoRatio float64 `json:"sortino_ratio"` // 索提诺比率 + // EquityVolatility float64 `json:"equity_volatility"` // 权益离散度 + // EquityVolatilityCoefficient float64 `json:"equity_volatility_coefficient"` // 权益离散系数 + // PrincipalRiskRate float64 `json:"principal_risk_rate"` // 本金风险率 + // AnnualizedReturnRiskRatio float64 `json:"annualized_return_risk_ratio"` // 年化收益风险比率 + + // // Drawdown (回撤) + // MaxDrawdownAmount float64 `json:"max_drawdown_amount"` // 权益/盈亏最大回撤 + // MaxDrawdownTime string `json:"max_drawdown_time"` // 权益/盈亏最大回撤时间 + // MaxDrawdownRatio float64 `json:"max_drawdown_ratio"` // 权益/盈亏最大回撤比率 + // MaxDrawdownRatioTime string `json:"max_drawdown_ratio_time"` // 权益/盈亏最大回撤比率时间 + // LongestDaysWithoutNewHigh int64 `json:"longest_days_without_new_high"` // 权益/盈亏最长未创新高天数 + // LongestPeriodWithoutNewHigh string `json:"longest_period_without_new_high"` // 权益/盈亏最长未创新高时间段 +} diff --git a/internal/trading/sig/account_position.go b/internal/trading/sig/account_position.go deleted file mode 100644 index c7771e5..0000000 --- a/internal/trading/sig/account_position.go +++ /dev/null @@ -1,3 +0,0 @@ -package sig - -// 账户持仓管理 -> riskManager 风险管理 diff --git a/internal/trading/sig/trading_plan.go b/internal/trading/sig/trading_plan.go index ded1c78..366cefd 100644 --- a/internal/trading/sig/trading_plan.go +++ b/internal/trading/sig/trading_plan.go @@ -15,8 +15,6 @@ type TradingPlan struct { sigStrategy strategy.ISigStrategy sigStrategyContext strategy.ISigStrategyContext - // publisher publish.Publisher[int32, any] - // signalKey map[string]int32 } func NewTradingPlan(plan entity.TradePlan, indicatorReg *indicator.IndicatorRegistry) *TradingPlan { diff --git a/internal/trading/trading_service.go b/internal/trading/trading_service.go index f046c6f..5aff713 100644 --- a/internal/trading/trading_service.go +++ b/internal/trading/trading_service.go @@ -202,6 +202,7 @@ func (svc *TradingService) IndicatorSeries(indicatorName string, window uint32, } // StrategySeries 简单策略信号测试 +// todo 去掉 HistoryIndicatorContext, 像backtest使用stream来一个算一个 func (svc *TradingService) StrategySeries(req *pb.ReqStrategySeries, rsp *pb.RspStrategySeries) (err error) { // sigStrategy sigStrategy, ok := svc.strategyReg.NewSigStrategy(req.SigStrategy) @@ -227,7 +228,14 @@ func (svc *TradingService) StrategySeries(req *pb.ReqStrategySeries, rsp *pb.Rsp // return // } // recover todo out of range + sr := req.Series + exchange := sr.Exchange + series := sig.NewKlineSeries(exchange, sr.InstId, interval) + count, totalK := 0, 0 + indctx := sig.NewIndicatorContext(series) + _ = indctx + indicatorContext := sig.NewHistoryIndicatorContext(svc.exchangeClient) req.Series.Window += indicator.MaxWindow if totalK, err = indicatorContext.Init(req.Series); err != nil { @@ -239,8 +247,8 @@ func (svc *TradingService) StrategySeries(req *pb.ReqStrategySeries, rsp *pb.Rsp for i := count - 1; i >= 0; i-- { strategyContext.SetOffset(int16(i)) sigSide := sigStrategy.Update(strategyContext) - if sigSide == types.SideBuy || sigSide == types.SideSell { - side := lang.Ternary(sigSide == types.SideBuy, pb.Side_BUY, pb.Side_SELL) + if sigSide == types.SideLong || sigSide == types.SideShort { + side := lang.Ternary(sigSide == types.SideLong, pb.Side_BUY, pb.Side_SELL) signalK := strategyContext.Get(0) rsp.Signal = append(rsp.Signal, side) rsp.Times = append(rsp.Times, signalK.Ts) diff --git a/pkg/indicator/atr.go b/pkg/indicator/atr.go new file mode 100644 index 0000000..027c614 --- /dev/null +++ b/pkg/indicator/atr.go @@ -0,0 +1,35 @@ +package indicator + +import "sig-pub/pkg/types/series" + +// ATR = SMA(TR, N) +// 平均真实波幅 (ATR) atr define: https://www.investopedia.com/terms/a/atr.asp +type ATR struct { +} + +// indicator interface +func (c *ATR) Name() string { + return "atr" +} + +// Calculate 计算单根k线rsi指标 +func (c *ATR) Calculate(ctx IIndicatorContext, window int16) (vector float64) { + klineSeries := ctx.Series(0, int16(window)+1) + highs := klineSeries.High() + lows := klineSeries.Low() + closes := klineSeries.Close() + + trs := make([]float64, 0, window) + for i := range window { + high := highs[i] + low := lows[i] + close1 := closes[i+1] + // 计算TR + tr := max(high-low, high-close1, low-close1) + trs = append(trs, tr) + } + // 计算TR平均值得到ATR + seriesTR := series.NewFloats(trs...) + atr := seriesTR.Avg() + return atr +} diff --git a/pkg/indicator/indicator_registry.go b/pkg/indicator/indicator_registry.go index 122c671..84be087 100644 --- a/pkg/indicator/indicator_registry.go +++ b/pkg/indicator/indicator_registry.go @@ -20,6 +20,7 @@ func (r *IndicatorRegistry) Init() (err error) { // indicator regist r.MustRegistIndicatorW(&RSI{}) r.MustRegistIndicatorW(&SMA{}) + r.MustRegistIndicatorW(&ATR{}) return } diff --git a/pkg/strategy/cross_star.go b/pkg/strategy/cross_star.go new file mode 100644 index 0000000..d490642 --- /dev/null +++ b/pkg/strategy/cross_star.go @@ -0,0 +1,64 @@ +package strategy + +import ( + "math" + "sig-pub/pkg/types" + "sig-pub/pkg/zlog" +) + +// CrossStar +type CrossStar struct { + ISigStrategy + IIntervalSigStrategy + rate float64 + rate2 float64 +} + +func (s *CrossStar) New() ISigStrategy { + return &CrossStar{} +} + +func (s *CrossStar) Meta() StrategyMeta { + return StrategyMeta{ + Name: "CrossStar", + Desc: "十字星策略", + Args: []Param{ + {Name: "rate", Type: ParamTypeUFloat, Desc: "上线影线与基线比例"}, + {Name: "rate2", Type: ParamTypeUFloat, Desc: "上线影线之间比例"}, + }, + } +} + +func (s *CrossStar) Init(param StrategyParam) (err error) { // 校验参数, 并根据参数初始化策略 + if s.rate, err = param.GetFloat64E("rate"); err != nil { + return + } + if s.rate2, err = param.GetFloat64E("rate2"); err != nil { + return + } + return +} + +func (s *CrossStar) MaxWindow() int { + return int(1) +} + +func (s *CrossStar) Update(ctx ISigStrategyContext) (side types.Side) { + // O 109744.8 H 110600 L 109507.5 C 109686.8 + k0 := ctx.Get(0) + open, close, high, low := k0.OpenF64(), k0.CloseF64(), k0.HighF64(), k0.LowF64() + base := math.Abs(open - close) // 58 + rup := (high - max(open, close)) / base // 855.2 / 2 427.6 + rdown := (min(open, close) - low) / base // 179.3 / 2 89.65 + + if k0.Ts == 1761833700000 { + zlog.Debugf("base=%.4f, rup=%.4f, rdown=%.4f", base, rup, rdown) + } + if rup > s.rate && rup/rdown > s.rate2 { + return types.SideLong + } + if rdown > s.rate && rdown/rup > s.rate2 { + return types.SideShort + } + return +} diff --git a/pkg/strategy/gold_x.go b/pkg/strategy/gold_x.go index 07e711c..81b6763 100644 --- a/pkg/strategy/gold_x.go +++ b/pkg/strategy/gold_x.go @@ -54,10 +54,10 @@ func (s *GoldX) Update(ctx ISigStrategyContext) (side types.Side) { crossover := s14[0] > s28[0] && s14[1] < s28[1] // 上穿 crossunder := s14[0] < s28[0] && s14[1] > s28[1] // 下穿 if crossover { - return types.SideBuy + return types.SideLong } if crossunder { - return types.SideSell + return types.SideShort } return } diff --git a/pkg/strategy/sig_strategy_params.go b/pkg/strategy/sig_strategy_params.go index fa7361c..8b0720b 100644 --- a/pkg/strategy/sig_strategy_params.go +++ b/pkg/strategy/sig_strategy_params.go @@ -94,6 +94,18 @@ func (s *StrategyParam) Get(key string) (v string, ok bool) { return } +func (s *StrategyParam) GetE(key string) (v string, err error) { + if len(*s) == 0 { + return + } + v, ok := (*s)[key] + if !ok { + err = fmt.Errorf("param %s not provided", key) + return + } + return +} + func (s *StrategyParam) GetInt(key string) (r int, ok bool) { v, ok := s.Get(key) if !ok { @@ -107,9 +119,8 @@ func (s *StrategyParam) GetInt(key string) (r int, ok bool) { } func (s *StrategyParam) GetInt16E(key string) (r int16, err error) { - v, ok := s.Get(key) - if !ok { - err = fmt.Errorf("param %s not provided", key) + v, err := s.GetE(key) + if err != nil { return } r, err = cast.ToInt16E(v) @@ -117,14 +128,18 @@ func (s *StrategyParam) GetInt16E(key string) (r int16, err error) { } func (s *StrategyParam) GetFloat64(key string) (r float64, ok bool) { - v, ok := s.Get(key) - if !ok { + r, err := s.GetFloat64E(key) + if err != nil { return } - r, err := cast.ToFloat64E(v) - if ok = err == nil; !ok { + return r, true +} +func (s *StrategyParam) GetFloat64E(key string) (r float64, err error) { + v, err := s.GetE(key) + if err != nil { return } + r, err = cast.ToFloat64E(v) return } diff --git a/pkg/strategy/sig_strategy_registry.go b/pkg/strategy/sig_strategy_registry.go index da00444..eaa2729 100644 --- a/pkg/strategy/sig_strategy_registry.go +++ b/pkg/strategy/sig_strategy_registry.go @@ -21,6 +21,8 @@ func NewSigStrategyRegistry() *SigStrategyRegistry { func (r *SigStrategyRegistry) Init() (err error) { // indicator regist r.MustRegistStrategy(&GoldX{}) + r.MustRegistStrategy(&SupertrendBOSWaves{}) + r.MustRegistStrategy(&CrossStar{}) return } diff --git a/pkg/strategy/super_trend.go b/pkg/strategy/super_trend.go new file mode 100644 index 0000000..6904d58 --- /dev/null +++ b/pkg/strategy/super_trend.go @@ -0,0 +1,146 @@ +package strategy + +import ( + "sig-pub/pkg/types" + "sig-pub/pkg/utils/lang" +) + +type SupertrendBOSWaves struct { + ISigStrategy + + atrLength int16 + atrMult float64 + // radiusStrength float64 + // smoothness int16 + + prevDirection int + // anchorPrice float64 + // anchorBar int + // velocity float64 + // barCount int +} + +func (s *SupertrendBOSWaves) New() ISigStrategy { + return &SupertrendBOSWaves{} +} + +func (s *SupertrendBOSWaves) Meta() StrategyMeta { + return StrategyMeta{ + Name: "SupertrendBOSWaves", + Desc: "曲线半径超级趋势 [BOSWaves] https://www.tradingview.com/script/v0Fr7PAb-Curved-Radius-Supertrend-BOSWaves/", + Args: []Param{ + {Name: "atrLength", Type: ParamTypeUInt, Desc: "atr指标长度,14"}, + {Name: "atrMult", Type: ParamTypeUFloat, Desc: "atr倍数,2"}, + {Name: "radiusStrength", Type: ParamTypeUFloat, Desc: ` + Controls curve acceleration strength.\n\n" + + "Recommended values by timeframe:\n" + + "• 1-5min (Scalping): 0.08-0.12\n" + + "• 15min: 0.12-0.15\n" + + "• 1H: 0.15-0.18\n" + + "• 4H: 0.18-0.22\n" + + "• Daily: 0.20-0.25\n" + + "• Weekly: 0.25-0.30\n\n" + + "Lower = Tighter curves (responsive)\n" + + "Higher = Wider curves (smoother) + `}, + {Name: "smoothness", Type: ParamTypeUInt, Desc: "Smoothing applied to curved band. Higher = smoother curves, less noise."}, + }, + } +} + +func (s *SupertrendBOSWaves) Init(param StrategyParam) (err error) { // 校验参数, 并根据参数初始化策略 + if s.atrLength, err = param.GetInt16E("atrLength"); err != nil { + return + } + if s.atrMult, err = param.GetFloat64E("atrMult"); err != nil { + return + } + // if s.radiusStrength, err = param.GetFloat64E("radiusStrength"); err != nil { + // return + // } + // if s.smoothness, err = param.GetInt16E("smoothness"); err != nil { + // return + // } + return +} + +func (s *SupertrendBOSWaves) MaxWindow() int { + return int(s.atrLength + 1) +} + +func (s *SupertrendBOSWaves) Update(ctx ISigStrategyContext) (side types.Side) { + k0 := ctx.Get(0) + high, low, close := k0.HighF64(), k0.LowF64(), k0.CloseF64() + + atr := ctx.IndicatorW("atr", s.atrLength).Get(0) + src := (high + low) / 2 + // src := k0.HL2() + + upperBand := src + (s.atrMult * atr) + lowerBand := src - (s.atrMult * atr) + + supertrend := lowerBand + direction := 1 + + // Standard supertrend logic + prevSupertrend := supertrend + if direction == 1 { + supertrend = lang.Ternary(close < prevSupertrend, upperBand, max(lowerBand, prevSupertrend)) + } else { + supertrend = lang.Ternary(close > prevSupertrend, lowerBand, min(upperBand, prevSupertrend)) + } + + s.prevDirection = direction + if close < supertrend { + direction = -1 + } + if close > supertrend { + direction = 1 + } + + // ============================================================================ + // Curved Radius Implementation + // ============================================================================ + + // Detect trend change - set new anchor + trendChanged := s.prevDirection != 0 && direction != s.prevDirection + + buySignal := trendChanged && direction == 1 + sellSignal := trendChanged && direction == -1 + if buySignal { + return types.SideLong + } + if sellSignal { + return types.SideShort + } + + // if trendChanged { + // s.anchorPrice = supertrend + // s.anchorBar = 0 //bar_index + // s.velocity = 0.0 + // s.barCount = 0 + // } + + // // Increment bar counter + // s.barCount = s.barCount + 1 + + // // Calculate curved offset using acceleration creating a parabolic curve + // // todo if not na(anchorPrice) + // if trendChanged { + // // Acceleration increases with each bar (quadratic growth) + // s.velocity = s.velocity + (s.radiusStrength * float64(s.barCount)) + // } + + // // Apply velocity in direction of trend + // if direction == 1 { + // // Uptrend - curve upward with acceleration + // supertrend = s.anchorPrice + s.velocity + // } else { + // // Downtrend - curve downward with acceleration + // supertrend = s.anchorPrice - s.velocity + // } + + // Apply smoothing to create flowing curves + // curvedBand = ta.sma(supertrend, s.smoothness) + return +} diff --git a/pkg/strategy/super_trend_yin_yang.go b/pkg/strategy/super_trend_yin_yang.go new file mode 100644 index 0000000..7fa772f --- /dev/null +++ b/pkg/strategy/super_trend_yin_yang.go @@ -0,0 +1,6 @@ +package strategy + +// SuperTrendYinYang +// https://www.tradingview.com/script/9Mm3qKME-Machine-Learning-SuperTrend-Strategy-TP-SL-YinYangAlgorithms/ +type SuperTrendTpSlYinYang struct { +} diff --git a/pkg/trade/close_strategy.go b/pkg/trade/close_strategy.go new file mode 100644 index 0000000..3973486 --- /dev/null +++ b/pkg/trade/close_strategy.go @@ -0,0 +1,24 @@ +package trade + +// 平仓策略参数 +type CloseStrategyParam struct { + StopLossPct float64 // 固定止损 static stoploss + TakeProfitPct float64 // 固定止盈 static take profit + + TrailMinProfit float64 // 启动移动止损的最小盈利阈值(例如达到 1% 后才开始追踪) minimum profit (fraction) before trailing activates (e.g. 0.01 = 1%); + TrailingPct float64 // 移动止损百分比(例如 0.02 表示从最高价回撤 2% 时触发追踪止损) trailing stop percent (e.g. 0.02 = 2%); + ProfitRetracePct float64 // close when profit retraces more than this fraction of peak profit;基于最高利润回撤触发平仓(例如从最高利润回撤超过 30% 则平仓)。 +} + +type CloseStrategy struct { +} + +func NewCloseStrategy(param CloseStrategyParam) { + +} + +// OnPriceUpdate 当价格更新判断是否关闭仓位 +func (s *CloseStrategy) OnPriceUpdate(price float64, position *string) (closePos bool) { + + return +} diff --git a/pkg/trade/risk_strategy.go b/pkg/trade/risk_strategy.go index ab4473a..fb7a405 100644 --- a/pkg/trade/risk_strategy.go +++ b/pkg/trade/risk_strategy.go @@ -17,7 +17,9 @@ func NewRiskStrategy() *RiskStrategy { } // SideAssess 收到信号时进行评估, 返回过滤后的交易信号 -// todo 对交易方向进行信心分数评估, 后续开仓仓位 +// 对交易方向进行信心分数评估, 后续开仓仓位 +// 1.当前持有反方向单时, 不进行开仓 +// 2.当前持有同方向单时, 根据信心分数评估是否加仓 func (s *RiskStrategy) SideAssess(signalSide types.Side) (side types.Side) { return signalSide diff --git a/pkg/types/kline.go b/pkg/types/kline.go index 0c0032e..607132b 100644 --- a/pkg/types/kline.go +++ b/pkg/types/kline.go @@ -60,6 +60,34 @@ func (k *Kline) ToPBKline() (kline *pb.Kline) { return } +func (k *Kline) OpenF64() float64 { + return decimals.MustToFloat64(k.Open) +} + +func (k *Kline) CloseF64() float64 { + return decimals.MustToFloat64(k.Close) +} + +func (k *Kline) HighF64() float64 { + return decimals.MustToFloat64(k.High) +} + +func (k *Kline) LowF64() float64 { + return decimals.MustToFloat64(k.Low) +} + +func (k *Kline) VolF64() float64 { + return decimals.MustToFloat64(k.Vol) +} + +func (k *Kline) VolQtyF64() float64 { + return decimals.MustToFloat64(k.VolQuote) +} + +func (k *Kline) HL2() float64 { + return (k.HighF64() + k.LowF64()) / 2 +} + // ChannelKline k线订阅消息 type ChannelKline struct { ExgInstId string `json:"instId"` // 交易所交易产品id,如 BTC_USDT_SWAP diff --git a/pkg/types/signal.go b/pkg/types/signal.go index efe20b0..115fa88 100644 --- a/pkg/types/signal.go +++ b/pkg/types/signal.go @@ -3,21 +3,21 @@ package types type Side int32 const ( - SideBuy Side = 1 // BUY - SideSell Side = 2 // SELL + SideLong Side = 1 // LONG + SideShort Side = 2 // SHORT ) func (side Side) IsValid() bool { - return side == SideBuy || side == SideSell + return side == SideLong || side == SideShort } func (side Side) String() string { switch side { default: return "" - case SideBuy: - return "BUY" - case SideSell: - return "SELL" + case SideLong: + return "LONG" + case SideShort: + return "SHORT" } } diff --git a/pkg/utils/collect/collect.go b/pkg/utils/collect/collect.go index f76895c..da14d79 100644 --- a/pkg/utils/collect/collect.go +++ b/pkg/utils/collect/collect.go @@ -41,6 +41,15 @@ func Filter[T any](slice []T, predicate func(int, T) bool) []T { return res } +func Find[T any](slice []T, predicate func(T) bool) (r T, ok bool) { + for _, item := range slice { + if predicate(item) { + return item, true + } + } + return +} + // SortAsc 切片升序排序 func SortAsc[T any, C cmp.Ordered](slice []T, compare func(T) C) { if len(slice) < 2 {